diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e6f997f01..74baade6c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -288,7 +288,11 @@ jobs: test/sandbox/path-canonicalization.test.ts \ test/sandbox/credential-deny.test.ts \ test/sandbox/symlink-boundary.test.ts \ - test/sandbox/stale-mount-point.test.ts + test/sandbox/mount-point-manifests.test.ts \ + test/sandbox/mount-point-liveness.test.ts \ + test/sandbox/mount-point-passes.test.ts \ + test/sandbox/stale-mount-point.test.ts \ + test/sandbox/linux-bwrap-args-file.test.ts - name: Run Node.js fallback tests if: matrix.os != 'windows' diff --git a/README.md b/README.md index 61714c9a1..ca34c10be 100644 --- a/README.md +++ b/README.md @@ -683,7 +683,7 @@ Watchman accesses files outside the sandbox boundaries, which will trigger permi **Linux requires:** -- `bubblewrap` - Container runtime +- `bubblewrap`, 0.3.1 or later - Container runtime - Ubuntu/Debian: `apt-get install bubblewrap` - Fedora: `dnf install bubblewrap` - Arch: `pacman -S bubblewrap` @@ -1125,19 +1125,36 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. 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"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. +**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. A manifest counts for as long as such a process runs, and for as long as the process that wrote it runs and has not let go of it, since the string may be run again until then. 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), which has to know `local`, as dash, ash and mksh do, 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. A cleanup decides that on a reading of the manifests and removes afterwards, and its process can be held up between the two (stopped, paused with its container, starved) for any length of time. So it puts itself on record first, in an empty file beside the manifests that is named for its process, and takes that away when it has removed what it set out to; and a wrap that has published its manifest lists those records, waits for every cleanup that is on it to end, and only then looks at the host again and hands its command out. Whichever of the two came second has seen the other: either the cleanup has read the manifest and keeps what it names, or no sandbox starts under that manifest until the cleanup is over. A wrap waits five seconds, and then gives up with `mount_points_changed`, naming the 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, one that has exited and is merely not waited for yet (a zombie, which under a process 1 that reaps nothing stays for good), 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 those of processes that are gone or have let go of them, 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. At a `commondir` or a `config.worktree`, `/dev/null` is what git cannot read: what stands there, on the host and in the sandbox, is a placeholder git accepts, and a file holding exactly that is a mount point like an empty one (see **The mount point for an absent `commondir` or `config.worktree`** below). 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 in every release but 0.0.77 to 0.0.79: see "Mount points with no manifest" under the known limits. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. -- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. - -**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. +- On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. It is made once the manifests have been read, so a wrap that finds them unreadable from the start makes nothing, and before the wrap's own manifest is published: a wrap that comes to nothing in between has it removed by the first clean-up with none of the process's commands outstanding, or at its exit, and a process killed in between leaves it with no manifest to name it. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. + +- **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. One that a read deny already hides is left hidden: the command cannot reach it, and bound on top of the deny it could read it. 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. While one of them cannot be looked at (an error of the moment, not "not there"), no command that restricts writes is handed out (`mount_points_unreadable`): left unbound, it would be believed again a moment later. 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. Every call, with or without an argument, lets go of the commands of this process that have run and ended, whatever else is still running, and collects what they relied on. What a call cannot tell is which command it is for, so with no argument it lets go of nothing else until it has been called once for every wrap handed out: not of the manifest of a command that is wrapped and not yet started (bubblewrap refuses to start without it), nor of 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 may be run again (a server started again from a kept string) until this process next calls `cleanupAfterCommand()` with none of its runs under way: no other process collects its manifest while this one runs. After that a run 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, or a `commondir` that holds what the library writes there (`.` and a newline). So what a caller spares on the word of the set is never more than a placeholder, and a manifest that is out of date or forged cannot make it spare a file with content of its own. 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 `/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. A cleanup that can ask says what it found, for those that cannot: once it has claimed a manifest and found no process behind it, it renames the claim once more (to `.finished`), before the reading it decides on, and a process that cannot ask keeps no path for a manifest at that name (one that goes by a manifest's age does not rename it so: whoever can ask goes on asking). Without that, two cleanups in two namespaces that each finish with a manifest naming the same path at the same moment would each keep the path for the other's manifest and drop their own, and the path would stay with nothing left to name it. What such a manifest names alone is still its own namespace's to remove, or anybody's after the seven days. 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 cleanup that cannot be asked after._ A wrap waits for a cleanup for as long as that one's process runs, which it can ask only of a process in its own PID namespace. The record of any other (a container that shares `/var/tmp` with the host) counts for a minute, and a cleanup removes nothing more two seconds after it set out to go on record, by the wall clock as well as by the one that stands still while the host is suspended. So a cleanup in another PID namespace that is held up for over a minute in the moment between its look at the clock and the removal after it still removes a path from under a sandbox that has started since. Within one PID namespace there is no such moment. + - _A cleanup that is stopped._ While a process is stopped half way through a cleanup, which takes milliseconds, every wrap of that user that names a mount point waits its five seconds and is refused. The error names the record, and that begins with the pid: continue or end that process. + - _A directory made again._ 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.81 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 to 0.0.79 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 any other release up to 0.0.81 removes at its cleanup what it made itself, whoever relies on it by then. None of them writes a manifest, reads one, or puts its cleanup on record. 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, or have exited, 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 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. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. -- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. +- The 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, and at any call made once a run of it has ended). 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. Until then it may be run again and again; each run adds its line to the record, and every run on it is asked after. A record past 4 MiB, some fourteen thousand runs, vouches like any that cannot be read: what its manifest names then stays until the next boot. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. -- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. +- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes six mounts (its `hooks`, `config`, `commondir` and `config.worktree`, and the two directories above them), so some 495 repositories reach the limit by themselves. A repository's submodule denies are degraded until the profile fits rather than refused at that point — see **Which git directories are covered**. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart (`too_many_arguments`, `nul_in_path`, `args_file_unavailable`, `command_too_long`, `too_many_mount_points`, `mount_points_unreadable`, `mount_points_changed`, `deny_scan_failed`, `deny_git_metadata_unreadable`, `deny_unresolvable_git_entry`, `deny_placeholder_unavailable`); 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, the two that are about other sandboxes, and the four `deny_` codes, which are about what the working directory holds (see **Refusals a wrapped command can plant**). `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; or one of their directories could not be looked at, so it cannot be kept out of the command's reach. It refuses every wrap of that user that restricts writes, and ends by itself, but for a file system that has gone from under `TMPDIR` or `XDG_RUNTIME_DIR` and stays gone: this process is out of descriptors or memory, until it is not; a file system gives an error, until it answers again; 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 reason, and the file where there is one. `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, or another process's cleanup is held up half way and what it set out to remove may go at any moment (`.cause` is its record, whose name begins with its pid); 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) @@ -1153,7 +1170,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config`. Where the working directory is a worktree or a submodule, its `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. **Which git directories are covered** below says how each is found and what is denied where one cannot be. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -1165,15 +1182,88 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. +**Which git directories are covered.** + +- **What is denied in one:** `hooks/` and `commondir`, and unless `allowGitConfig` is on `config` and `config.worktree`. `commondir` moves the hooks and config git uses to another directory, and `config.worktree` is read on top of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). In a linked worktree's git directory that is reached from the repository that keeps it, it is `commondir`, `gitdir` and `config.worktree`: git reads no hooks and no `config` there while a `commondir` stands. +- **Which ones:** the working directory's own repository and, on Linux, each nested repository the scan finds. For each of them every submodule git directory under its `modules`, however deep, and every linked worktree's under its `worktrees`, whether or not the checkout is anywhere the command can write. Each of those with what it keeps in turn: a submodule keeps linked worktrees (`.git/modules//worktrees//`) and a linked worktree keeps submodules (`.git/worktrees//modules//`). What an existing `commondir` names is denied with it. +- **Through a `.git` file.** An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The git directory it names, and the one that directory's `commondir` names, are covered like any other. So is what they keep, where the command may write any of it: a session in a linked worktree that may write the repository's git directory, in order to commit, has that repository's submodules and its sibling worktrees denied too. A git directory it names that does not exist yet cannot be filled in, and a directory that is no git directory is not followed. The file is read the way git reads it: whole, `\n` and `\r` stripped from its end, the path ending at the first NUL, and not at all past the 1 MiB git accepts. Where a `..` in the path follows a symlink, the path is denied as written and where the kernel lands, which is the one git opens. +- **What makes a directory a git directory** is what it holds: any one of a `HEAD`, `config` or `commondir` that is no directory, or a `hooks` or `objects` that is no file. `HEAD` and `objects` can be renamed aside by a command; the other three are denied, so from the first command that saw the directory on, one that is there stays. The `modules` of a git directory is one by the three files alone: a directory in it is a submodule's name, `hooks` and `objects` too. A directory a pointer or a `commondir` names is also one by where it really is: `.git/worktrees/`, and anything under a `.git/modules`, whatever it holds. +- **What cannot be read is denied whole:** a directory a pointer or a `commondir` names, a directory under `.git/modules` or `.git/worktrees` that cannot be listed, or that holds a name that is not valid UTF-8 or a link to nothing whose target is not, what an entry there leads to and cannot be looked at. A linked worktree's git directory, reached from the repository that keeps it, goes by what git needs, which opens `commondir` by name: one that cannot be searched is denied whole, and one that can be searched and not listed is read as git reads it. What is denied whole is the deepest directory that can be reached towards what could not be read, behind any link on the way and not around it. Where the directory a `.git` file names cannot be listed and sits at `/worktrees/`, `` is covered as the repository that keeps it, so a `chmod` of the one takes no deny off the other. Everything under a whole deny is read-only inside the sandbox, so git writes in such a tree fail. A symlink loop reaches nothing, and nothing is denied for it. A pointer's or a `commondir`'s value that reaches nothing is denied by its file and the links it goes through alone, with an `SRT_DEBUG` warning: git opens nothing through it, and failing closed on the nearest directory, often the checkout, would let one `.git` file lock every later command out. +- **Symlinks.** `hooks`, `config`, `commondir`, `config.worktree`, `modules`, `worktrees` and an entry of a `.git/modules` or a `.git/worktrees` can each be a link. What it leads to is denied, also where that is not there yet. The link itself is held, so that a command cannot put a directory of its own in its place for the host's git to use: on macOS by its own name, and on Linux, where a mount at a link lands on what it leads to, by denying WHOLE the directory that holds it. For an entry of a git directory that is the git directory: `git status`, `log` and `diff` go on working there, while `git add`, `git commit` and anything else that writes the index or an object fail read-only until the link is gone. **A command can arrange that.** An absent `.git/modules` is not denied, since a mount there would stop `git submodule add`, so one `ln -s` costs the commands after it their git writes in that repository until the link is removed from outside the sandbox. +- **Links on the way.** A chain can go through more links: `hooks -> ../hooks-link -> .githooks`, a symlinked directory on the way, a link in the path a pointer or a `commondir` names. A command that retargets one moves where the chain leads without touching either end, so each is held the same way: by its own name on macOS, and on Linux by denying whole the directory that holds it, never what it leads to. An `SRT_DEBUG` line names each directory bound for that and the link it holds. A command can arrange this too, in a directory of its choosing. A link above the repository is not one of these: that is where the repository lives. +- **Known limit (Linux): a link that the working directory or a write root holds**, as with `.git/hooks -> ../hooks-link` and `hooks-link` in the repository root. Binding that directory read-only would take the whole tree, so the link is not held: both ends of the chain stay denied, and one `SRT_DEBUG` warning per wrap names the link, which the command can retarget. Moving the link inside the git directory closes that, and so does a `denyWrite` entry naming the directory that holds it, at the cost of that directory. Where such a chain reaches nothing (a loop) there is no far end to deny: for an entry of the working directory's own repository the command is refused (`deny_unresolvable_git_entry`), and for anything a command may have made itself there is the warning. +- **Time is what bounds the walks.** None has a depth bound. A directory is walked once from where it really is and at most once more through a link, for each thing it can be read as, on Linux for all the repositories of a command together. That ends a link that leads back up, and keeps many links or `.git` files into one directory from costing as many walks of it. What a link costs by itself is paid for each: the length of its chain, the depth of the directory holding it, and one listing of what it leads to where it has a name git keeps for itself (`objects`, `refs`). A link that has a directory visited first, under another name, takes nothing from the walk that comes to it from where it is. What is left out is what a second name alone would add: where a `commondir` with a `..` in it, read under that name, folds to on paper. Where the kernel lands, which is what git opens, is denied whichever name led there. Each gets as long as the `ripgrep` scan gets, and running out refuses the command (`deny_scan_failed` on Linux, a `SubmoduleWalkBudgetError` on macOS) rather than sandboxing on what was reached. That covers the walks of `.git/modules` and `.git/worktrees`, of a symlink chain, and of the path a pointer or a `commondir` names. On Linux the budgets add up: at worst one for the working directory's own repository, the scan's own, and then one each for the walk after a scan that could not read a directory, the walk `allowGitConfig` adds, all the `.git` files together, and all the nested repositories together. On macOS there is one for everything. +- **A directory that looks like a git directory does not end the `.git/modules` walk.** A segment of a submodule's name (`vendor` of `vendor/lib`) is a directory a command can write, and a `HEAD` left in it would otherwise take the denies off `vendor/lib`. It is denied as a git directory and what else it holds is walked as well, its `modules` included. Only what git itself keeps there (`objects`, `refs`, `logs`, `hooks`, `info`, `worktrees`, `lfs`, `rr-cache`) is left at one listing each, and is walked after all where that listing shows a `config` or a `hooks`. **Not covered:** a submodule with one of those names in the MIDDLE of its own (`vendor/refs/lib`), once a command has left a `HEAD` in `vendor`, unless its checkout is where the scan finds its `.git` file. Walking those directories to the bottom would take `refs/heads` for a git directory wherever a branch is named `config`. +- **Past what bubblewrap takes arguments for, the submodule denies are degraded (Linux).** A mount takes three of bubblewrap's 9000 words, and a submodule git directory asks for five mounts, seven where it is checked out. Rather than refuse every command in a repository with hundreds of them, the wrap counts what the profile comes to and, while that is past the cap, degrades from the end: the working directory's own repository is served first, then the nested ones in sorted order, and within a repository the linked worktrees go before the submodules. + + 1. precise denies while the profile fits; + 2. a git directory that does not fit is denied WHOLE, one read-only bind: git writes inside that submodule fail read-only. What leads out of it (a `.git` file that names it, what an entry that is a symlink leads to) keeps its own deny; + 3. where one bind each does not fit, the enclosing `.git/modules` or `.git/worktrees` is denied whole, with everything really under it. + + With one write root and no network restrictions that starts at about 590 submodule git directories, or about 425 where each is checked out. An `SRT_DEBUG` warning says what was degraded. `too_many_arguments` is what is left for a profile that does not fit with everything degraded: measured at about 1485 CHECKED-OUT submodules, whose `.git` files no degrading saves, and at about 495 separate nested repositories. The violation monitor works the same denies out once for the session, with nothing degraded, so the path it reports can be one inside a git directory that is by then denied whole. + +- On Linux, the scan never looks inside a `node_modules`: a repository under one is not found and its hooks stay writable. +- On Linux, a directory the scan cannot list is denied whole, which covers what is under it and not what a `.git` file in there names. Where a nested checkout's repository lies outside the working directory and the command may write it, a `chmod` that takes the leave to list the checkout, or a directory above it, takes the denies off that repository for the commands after it. +- On macOS a whole deny that lands on a write root is dropped whether or not the directory can be looked into, where Linux refuses: after a `chmod` that takes the leave to list a git directory the command may write, that directory has no deny. An entry that is a link, in a git directory that is itself reached through a link, is named by the path it was reached by, which is not the name a removal of it uses. +- On macOS only the working directory's own `.git` file is followed, and only its own repository is enumerated. A nested repository is matched by pattern, as `.git/` and `.git/modules//`, which covers a single-segment submodule name. Nothing is degraded there. A Seatbelt profile travels on `sandbox-exec`'s command line and is bounded by `ARG_MAX` (1 MiB): enough entries under a `.git/modules`, which a command can write, and every later command fails at spawn with `E2BIG`, with no code from this library. + +**The mount point for an absent `commondir` or `config.worktree` (Linux).** Denying either where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap makes the mount point itself, once the manifest that names it is published, holding what git concludes with no file there (`.` for `commondir`, nothing for `config.worktree`), and binds it read-only onto itself. + +- The file is a mount point like any other (see "Write denies on paths that do not exist yet (Linux)" above): named in the manifest of every wrap that relies on it, and removed once no running sandbox does, while it still holds what was written there. Only a manifest makes it one. A `commondir` or a `config.worktree` that no manifest names is somebody's own whatever it holds: it is bound read-only where it is and is never removed. +- For as long as the file is there, git inside the sandbox and git on the host both read a redirect git accepts, but not what they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. +- The git directory can be one a command already running writes. So nothing found there is ever written over, and the file is made only in the directory the wrap looked at, never wherever its path leads by then. Where the name has been taken meanwhile (a link, another file, a FIFO), or the directory swapped for a link, that is left as found and the wrap plans its mounts again on what is there by then. +- Where the git directory takes no file from this process (its mode, a read-only or immutable file system, a full disk), it is bound read-only whole instead, and the command can write nothing in it (`git add`, `git commit`). The same goes for one that holds an empty `commondir` which a running process's manifest names, which is what bubblewrap makes where the file went from under a command about to start. One that a killed process left is collected, and made anew. That bind is the only mount the wrap puts inside the directory, since a mount point for another absent entry could not be made there, or could not be removed again. The directory's mode is asked first and does not always tell: where the write itself is refused, the wrap plans its mounts again and goes by the refusal. One git directory is never bound whole: one that holds the working directory or a path the command may write (`x/.git` holding `gitdir: ..`). That bind would take the work tree with it, so the command is refused instead (`deny_placeholder_unavailable`). + +**Refusals a wrapped command can plant (Linux).** These follow from what a sandboxed command is allowed to write, and the command that would put one right is refused as well. Each names the offending path, or the line the scan printed, in its message, and **each is lifted only by a command run outside the sandbox**: + +- `deny_git_metadata_unreadable`: a `.git` file or a `commondir` whose target cannot be worked out the way git works it out (bytes that are not valid UTF-8, a `commondir` larger than 1 MiB, a file that is there and cannot be read for a reason that would not stop git, a `commondir` in a directory a `.git` file names that can be listed and not searched), or a symlink whose target is not valid UTF-8 that is one of the denied entries of a git directory, or lies on the way a pointer or a `commondir` names, or is an entry under `.git/modules` or `.git/worktrees` with a directory behind it, or something that cannot be looked at. On macOS a `GitMetadataError`, exported from the package root. +- `deny_unresolvable_git_entry`: an entry of the working directory's OWN repository is a symlink whose chain reaches nothing, through a link that the working directory or a write root holds (see the known limit above). Repairing the chain, or moving the links inside the git directory, lifts it. Those entries are denied from the first wrap that saw the repository, so what is left to plant is a repository created this way in a directory that had none. +- `deny_placeholder_unavailable`: a `.git` file names the working directory, a directory above it or one holding a write root as its git directory, and that directory takes no file from this process (`chmod a-w .`). +- `deny_scan_failed`: the scan failed for a reason no deny stands in for; or a walk ran out of its time, over a tree wide or slow enough (on macOS a `SubmoduleWalkBudgetError`); or a directory that cannot be listed, or cannot be searched, is the working directory or a write root, or holds one (a git directory the command may write, after a `chmod` that takes the leave to list it). +- `too_many_arguments`: enough CHECKED-OUT submodules, or nested repositories, that the profile does not fit with everything degraded. + +Any other directory a command makes unreadable, but for the one case under `deny_git_metadata_unreadable`, refuses nothing: it is denied whole. Neither does a wide `.git/modules`: what that costs the commands after it is degraded denies. + +**No whole-directory deny is emitted for the work tree.** Each of them stands in for something read off paths a command can write (`gitdir: .` in a `.git` file, a `.git/modules` entry linked at `../..`, an entry of a git directory linked at the checkout), and one that lands on the working directory, a write root or a directory holding either would leave every later command unable to write the project. Judged by where it lands, such a deny is dropped with a warning where the directory can be listed and searched: what it was holding keeps the deny paths of its own two ends. Where it cannot the command is refused (`deny_scan_failed`). A write root that is a file holds no tree, and a deny that lands on it stands. So does one of the denied names where it is spelled (`.vscode`, the `hooks` of a git directory) with a write root configured at or beneath it: the deny wins. + +**Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: -**Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) 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. +- removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason. `git worktree prune`, and the prune `git gc` runs, prints one `failed to delete` line for a STALE worktree entry, exits 0 and leaves the rest of it for the next prune outside the sandbox; +- `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; +- from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; +- on macOS, `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. On Linux both succeed, since nothing is mounted where no `.git` was when the command was wrapped, and the repository they make is not covered until the next wrap; +- on Linux, every write inside a directory that is denied whole because it holds a link: a git directory one of whose entries is a symlink (`git add`, `git commit`, `git checkout -b`), a `.git/modules` one of whose entries is, and with `hooks -> ../links/hop` or `gitdir: links/hop/gd` the `links` directory. See **Which git directories are covered**. -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`). 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. +**Known limit: a `core.hooksPath` or `include.path` into the work tree.** The denied `config` is never read. Where the repository's own config already sets `core.hooksPath` to a directory in the work tree (husky's `.husky/_`, generated and gitignored), git never looks in `.git/hooks`, and what it does run is ordinary writable content; the same goes for a file an `include.path` names. A sandboxed command cannot create that state, since `config` is denied, so it matters where the caller already has it. + +**Known limit: a bare-repository layout in a subdirectory.** `HEAD`, `objects/`, `refs/` and `config` directly in a directory make it a repository git finds before the parent's `.git` when it is run from there (`safe.bareRepository` defaults to `all`). Nothing looks for that layout, so none of these denies apply to it. + +**Known limit: sequencer state.** `/rebase-merge/git-rebase-todo` takes `exec` lines that `git rebase --continue` runs, and it stays writable. It needs that specific command run on the host afterwards. + +**Known limit: a link that IS the git directory.** What a `.git` that is itself a symlink leads to is denied as any git directory is. The link sits in the work tree, with nothing to hold it by but the work tree's root, so a command can replace it. The next command's scan covers what it then leads to, but a git run on the host between the two follows the new link. + +**Known limit: a hook FILE that is a symlink into the work tree.** With `ln -s ../../scripts/pre-commit .git/hooks/pre-commit` no name can be added to `hooks` and none re-linked, but the script is ordinary project content and stays writable. A `denyWrite` entry naming it is the remedy where that matters. + +**Known limit: a pre-existing HARD link of a denied file.** A bind protects a path, not an inode, so an append through a second name for `.git/config` elsewhere in the work tree lands. A sandboxed command cannot make such a link to a bound file (the bind is its own mount, and `link(2)` across one fails `EXDEV`), so it has to pre-exist. + +**Known limit (macOS).** A path matched by pattern is protected where it is: a command may still rename the directory _holding_ it aside and make a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). The literal denies (the working directory's own repository and what it keeps) are held against that. So is everything the scan reached on Linux, where the ancestors of every denied path are pinned (see **Pinned directories** below). + +**Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` once no running sandbox relies on them — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those, and is run with `--no-config` so that a `RIPGREP_CONFIG_PATH` pointing inside the project cannot change what it lists. It fails closed, and which failures it can carry on from is decided by the `(os error N)` codes the run printed — never by the paths printed beside them, which are directory names whoever created them chose: + +- every code `ENOENT`: entries that went away while the scan walked. Nothing is at those paths to deny, and the rest of the listing still counts. +- any code `EACCES`: a directory of the user's own that the scan could not read holds an unknown tree, so it is denied whole, and the rest of the listing still counts. Another user's is left alone. WHICH directories is settled by a bounded walk of the working directory, to the scan's own depth and with a time budget of the scan's length. A directory that can be listed and not searched (mode 0400) is itself the one denied. +- anything else, no code at all, or an `EACCES` the walk finds no directory for: the command is refused, with the raw stderr (truncated) in the message. + +That is for a run that exited on its own. One that was killed (the timeout, an OOM kill, any signal) is refused whatever its stderr held, because its listing stops wherever the kill arrived, and so is one that could not be run at all. Each refusal is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. + +**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 `/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. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance: a dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`: +**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories of the working directory. Every mandatory deny is anchored there: a repository under a second allowed write path outside it gets none of them. By default, it searches up to 3 levels deep for performance: a dangerous file, or a `.git` pointer file, is found down to `a/b/.bashrc`, and a dangerous directory, or a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it. A dangerous directory is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`: ```json { @@ -1189,6 +1279,8 @@ A wrap that carries no write restrictions at all — `filesystem.disabled` with - Higher values provide more protection but slower performance - Files in CWD (depth 0) are always protected regardless of this setting +A nested repository is recognised by any regular file directly inside its `.git`, which is why it is found at the depth the repository sits at rather than one level further down where the hook files are. With `filesystem.allowGitConfig` off — the default — the deny of `config` keeps such a file there and it cannot be removed from inside the sandbox, so one command cannot hide the repository from the next command's scan. With `allowGitConfig` on it could, so each command additionally walks the working directory to the same depth, skipping `node_modules` as the scan does, and denies the `.git` directories it finds; that walk is the cost of allowing config writes and is not run otherwise. + ### Unix Socket Restrictions (Linux) On Linux, the sandbox uses **seccomp BPF (Berkeley Packet Filter)** to block Unix domain socket creation at the syscall level. This provides an additional layer of security to prevent processes from creating new Unix domain sockets for local IPC (unless explicitly allowed). diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..95adbec07 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +[test] +# Gives the run directories of its own for the mount point manifests, before any +# test file is loaded: see test/preload.ts. +preload = ["./test/preload.ts"] diff --git a/eslint.config.js b/eslint.config.js index 821860cec..75d7ac845 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,7 @@ export default [ 'eslint.config.js', 'test/fixtures/tunnel-ends.mjs', '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/index.ts b/src/index.ts index c763cdd6a..4def95d42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,20 @@ export { LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, } from './sandbox/linux-sandbox-utils.js' +// The macOS profile generator's own wrap-time refusal: a `.git` pointer or a +// `commondir` whose target cannot be worked out the way git works it out. On +// Linux the same condition arrives as a LinuxSandboxProfileError carrying +// `deny_git_metadata_unreadable`. +// The other refusal the shared producers raise: the `.git/modules` walk ran +// out of the time it was given, which on Linux arrives as a +// LinuxSandboxProfileError carrying `deny_scan_failed`. +export { + GitMetadataError, + SubmoduleWalkBudgetError, +} from './sandbox/mandatory-deny-paths.js' +// The mount points a running sandbox relies on (Linux), as a snapshot, for an +// embedder that removes paths itself after a command to leave out. +export { liveMountPoints } from './sandbox/bwrap-mount-manifests.js' // Windows install/status API export { diff --git a/src/sandbox/body-substitution.ts b/src/sandbox/body-substitution.ts index 268cf31d9..f280adacd 100644 --- a/src/sandbox/body-substitution.ts +++ b/src/sandbox/body-substitution.ts @@ -135,8 +135,8 @@ function substitute( for (;;) { // Earliest match across all pairs (typically 1–3 sentinels per host). // Matching is earliest-position-then-registration-order, not - // longest-match — sound because no sentinel is a substring of another - // (registerWithSentinel's caller contract). + // longest-match — sound because no sentinel is a substring of another, + // which registerWithSentinel refuses to register. let matchIdx = -1 let matchPair: SentinelBufferPair | undefined for (const p of pairs) { diff --git a/src/sandbox/bwrap-mount-manifests.ts b/src/sandbox/bwrap-mount-manifests.ts new file mode 100644 index 000000000..bf3a50d3f --- /dev/null +++ b/src/sandbox/bwrap-mount-manifests.ts @@ -0,0 +1,1916 @@ +/** + * Which mount points on the host a running sandbox still relies on (Linux). + * + * A deny on a path that does not exist needs a mount point there: bwrap makes + * an empty file or directory on the host and binds over it. Unlinking it while + * a sandbox is bound over it detaches the mount inside that sandbox, and the + * denied path can then be created on the host. So a mount point may be removed + * only when no running sandbox relies on it, and no process can decide that by + * counting for itself. README.md, "Write denies on paths that do not exist yet", + * tells it in full. + * What is kept in a per-user directory, and where its rules are: + * + * - `.json`, a manifest: what a wrap relies on, written before its command + * is handed out and bound by bubblewrap before it makes anything ({@link + * publishMountPointManifest}). + * - `.started`, its record: the command line appends its own + * /proc/self/stat line and then execs bubblewrap, so the sandbox vouches for + * itself ({@link recordVouches}, {@link isLive}). + * - `.claimed` and `.finished`, the names a clean-up gives a manifest + * before it removes what that names, and `.pass`, the clean-up's own + * record, which wraps wait for ({@link collect}, which holds the invariants). + * + * Whatever cannot be read or asked (a manifest, a record, /proc) counts as live. + * A reading in doubt refuses every wrap of this user that restricts writes, so + * NO LASTING STATE OF THE DIRECTORY REFUSES WRAPS FOR EVER, AND NONE KEEPS WHAT + * IS LEFT FOR EVER: every source of doubt is a condition of this process that + * clears by itself, or a property of what is at a name, which has an end (an + * hour, seven days, the process on a record, the boot). + * + * 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 (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. + */ + +import { randomBytes } from 'node:crypto' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { z } from 'zod/v3' +import { logForDebugging } from '../utils/debug.js' +import { gitRedirectPlaceholder } from './mandatory-deny-paths.js' +import { isAbsenceErrno } from './sandbox-utils.js' + +/** + * The manifest layout this version writes. It reads this one and any later one, + * by the fields it knows (see {@link ManifestSchema}). + */ +const MANIFEST_VERSION = 1 +const MANIFEST_SUFFIX = '.json' +const STARTED_SUFFIX = '.started' +const CLAIMED_SUFFIX = '.claimed' +const FINISHED_SUFFIX = '.finished' +const PASS_SUFFIX = '.pass' +/** Where a manifest is found, in the order it goes through them. */ +const MANIFEST_SUFFIXES = [MANIFEST_SUFFIX, CLAIMED_SUFFIX, FINISHED_SUFFIX] + +/** + * A manifest this young counts as live with no started record yet, so that a + * command is not refused its start because the process that wrapped it was + * killed a moment before. + */ +const MANIFEST_GRACE_MS = 500 + +/** + * 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. + */ +const LISTS_BEFORE_EACH_REMOVAL_UP_TO = 64 + +/** + * For how long one listing is good in a larger pass: well under the time a + * starting sandbox needs to reach its binds, and a time so a directory of + * thousands is not listed thousands of times. + */ +const LISTING_GOOD_FOR_MS = 0.25 + +/** + * How often a reading starts over because a listed manifest was gone when it + * was opened (claimed, given back or collected meanwhile) before it gives up. + */ +const LISTING_ATTEMPTS = 8 + +/** + * What is at a manifest's name and is not one this version reads counts as + * live until it is this old, and is dropped then: long enough for another + * version of this library to keep its own format. + */ +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 + +/** + * A pass removes nothing more this long after it set out to go on record, and + * leaves the rest to the next: an ordinary one takes milliseconds. + */ +const PASS_GOOD_FOR_MS = 2 * 1000 + +/** + * Both clocks. Whoever cannot ask after a pass goes by the age of its record, + * which is wall-clock time; the other stands still while the host is suspended + * (a laptop asleep, a virtual machine paused), and this one can be set back. + */ +const bothClocks = (): readonly [number, number] => [ + performance.now(), + Date.now(), +] + +/** + * A pass that cannot be asked after (another PID namespace's) counts as under + * way until its record is this old: well past {@link PASS_GOOD_FOR_MS}. + */ +const PASS_MAX_AGE_MS = 60 * 1000 + +/** + * For how long a wrap waits for the passes under way before it gives up: past + * {@link PASS_GOOD_FOR_MS}, so that only one that is held up outlasts it. + */ +const WAITS_FOR_PASSES_MS = 5 * 1000 + +/** The largest file read as a manifest; a real one is a few kilobytes. */ +const MANIFEST_MAX_BYTES = 1024 * 1024 + +/** + * The largest file read as a started record: a line of some 300 bytes for each + * run, so fourteen thousand runs of one command line. + */ +const RECORD_MAX_BYTES = 4 * 1024 * 1024 + +/** + * Suffix of a manifest before it is moved into place. One left by a killed + * process is removed at {@link UNREADABLE_MANIFEST_MAX_AGE_MS}. + */ +const TEMPORARY_SUFFIX = '.tmp' + +const ManifestSchema = z.object({ + /** + * INVARIANT for every later version: it may add fields, and keeps these, the + * five file names and what they mean, so a reader takes a later version by + * the fields it knows. A manifest that is not read puts everything in doubt, + * which would refuse every wrap of an older process for as long as a newer + * one is at work beside it. A change that cannot keep to this takes another + * directory name. + */ + version: z.number().int().min(MANIFEST_VERSION), + /** The process that wrapped, and its start time, to tell a recycled pid. */ + pid: z.number().int().nonnegative(), + start: z.string(), + /** + * The writer's PID namespace (`readlink /proc/self/ns/pid`): a pid can only + * 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. */ + paths: z.array(z.string()), + /** + * The empty directories those mount points bind from. A live bind's source + * must stay, so they are collected the same way (see {@link + * removeMountSource}). + */ + sources: z.array(z.string()), +}) + +type Manifest = z.infer & { + /** Where it is: at its own name, `.json`, or at one of a claim's two. */ + file: string + claimed: boolean + /** Claimed, and by its name no process was left on its record after that. */ + finished: boolean + /** Its started record, `.started`. */ + record: string + /** This process wrote it and the caller says its command is over. */ + released: boolean +} + +/** + * Manifests this process wrote that are still on disk, by their own name, with + * the command key the caller gave and whether that command is over. Remembered + * rather than acted on at once: a manifest must stay on disk until a pass + * removes what it names. + */ +const ownManifests = new Map< + string, + { commandKey: string | undefined; over: boolean } +>() + +/** + * The mount points, and the directories they bind from, that no manifest names: + * of wraps that could record them nowhere, and what a plan has made on the host + * itself (`madeByAPlan`, which are among the other two) until a manifest is + * published that names it, which takes it out of here. No other process knows of + * them, so they are this process's to remove once none of its wraps is + * outstanding, and kept until then: in a pass like any other where there is a + * directory by then, so that a path another process's manifest names is kept, + * and on its own word where there is none. + */ +const unrecorded = { + paths: new Set(), + sources: new Set(), + madeByAPlan: new Set(), +} + +function keepTrackOf(made: string, among: Set): void { + // Else a wrap that could record it nowhere relies on it, whoever makes it now. + if (!among.has(made)) unrecorded.madeByAPlan.add(made) + among.add(made) +} + +/** + * Has what a plan has made on the host itself removed at this process's own + * clean-up, from then until a manifest names it: the plan may come to nothing + * before that. + */ +export function keepTrackOfMountSource(source: string): void { + keepTrackOf(source, unrecorded.sources) +} + +export function keepTrackOfMountPoint(mountPoint: string): void { + keepTrackOf(mountPoint, unrecorded.paths) +} + +/** + * Which of this process's own manifests a collect may release. Only the caller + * knows that a command is over, and will not be run again: + * + * - `all`: no wrap of this process is outstanding, or the process is ending; + * - `ended`: some are, and the caller says one is over and not which, so those + * that have run and ended are; + * - one command: that command, whatever else is running, and those that have + * run and ended; + * - `none`: the caller has said nothing, so only what other processes have + * finished with is collected. + * + * A released manifest is collected once no process on its record runs, and its + * command line is refused its start from then on. + */ +export type OwnManifestRelease = + | 'all' + | 'ended' + | 'none' + | { commandKey: string } + +let manifestDirectory: string | undefined +// Whether that is the last resort below, which no other process knows of. +let manifestDirectoryIsPrivate = false +// Set once the last resort has been tried and found wanting, so a process on a +// temp dir that keeps no modes does not make a fresh directory on every call. +let manifestDirectoryUnavailable = false + +/** Everything here is for bubblewrap, which is Linux's. */ +const onLinux = (): boolean => process.platform === 'linux' + +/** + * Field 22 of a /proc/PID/stat line, the start time in clock ticks, counted + * from the last ')' because the comm field can hold spaces and parentheses. + * `undefined` for a line that is cut short or is not one. + */ +function startTimeFromProcStat(stat: string): string | undefined { + const comm = stat.lastIndexOf(')') + const start = stat + .slice(comm + 1) + .trim() + .split(' ')[19] + return comm > 0 && start !== undefined && /^\d+$/.test(start) + ? start + : undefined +} + +/** + * This process as the /proc it sees numbers it, which is what a pass will ask + * after. `process.pid` is another number where the process has a PID namespace + * of its own under an outer /proc (a nested sandbox): nobody's there, or an + * unrelated process's. + */ +function ownProcess(): { pid: number; start: string } { + try { + const stat = fs.readFileSync('/proc/self/stat', 'utf8') + const pid = /^\d+/.exec(stat)?.[0] + if (pid !== undefined) { + return { pid: Number(pid), start: startTimeFromProcStat(stat) ?? '' } + } + } catch { + // No /proc: every manifest counts as live then (see isLive). + } + return { pid: process.pid, start: '' } +} + +/** + * Whether the process with that pid and start time is running. Only "no such + * process", a process that has exited and a start time that differs say no: a + * sandboxed command can use up what this user may open, and a /proc that cannot + * be read must not make a live sandbox's mount points collectable. An unknown + * `start` leaves the pid alone to go by. + * + * One that has exited stays in /proc until its parent asks after it. Where that + * is a process 1 that never asks, as in many a container, it stays for good, + * and so would what its sandbox relied on. It has let go of its namespaces and + * sent its children their signal by then, like one that is gone. + */ +function isRunning(pid: number, start: string): boolean { + let stat: string + try { + stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8') + } catch (e) { + const code = (e as NodeJS.ErrnoException).code + return code !== 'ENOENT' && code !== 'ESRCH' + } + // Field 3, the first after the comm: Z is a zombie, X and x are dead. + if (/^ [ZXx] /.test(stat.slice(stat.lastIndexOf(')') + 1))) { + return false + } + const found = startTimeFromProcStat(stat) + return found === undefined || !/^\d+$/.test(start) || found === start +} + +let pidNamespace: string | undefined | null = null + +/** This process's PID namespace as the kernel names it, or `undefined`. */ +function ownPidNamespace(): string | undefined { + if (pidNamespace === null) { + try { + pidNamespace = fs.readlinkSync('/proc/self/ns/pid') + } catch { + pidNamespace = 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, 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, toWrite = true, alone = true } = {}, +): boolean { + try { + const stat = fs.lstatSync(dir) + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + (alone && (stat.mode & 0o077) !== 0) + ) { + return false + } + // 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 + if ( + orThrow && + !isAbsenceErrno(e) && + code !== 'EACCES' && + code !== 'EROFS' + ) { + throw e + } + return false + } +} + +/** + * Make `dir` a directory of ours alone, or say it cannot be. The name is + * predictable and sandboxed commands can often write beside it, so it is opened + * without following a link and its mode is set on the descriptor: a symlink + * planted at the name is refused, not followed. + */ +function makeOurPrivateDirectory(dir: string): boolean { + try { + fs.mkdirSync(dir, { mode: 0o700 }) + } catch { + // There already, or cannot be made: the look below decides. + } + let fd: number | undefined + try { + fd = fs.openSync( + dir, + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW, + ) + const stat = fs.fstatSync(fd) + if (!stat.isDirectory() || stat.uid !== process.getuid?.()) { + return false + } + // mkdir asks for 0700 but the umask applies, and a directory an earlier + // run left keeps the mode it was made with. + if ((stat.mode & 0o777) !== 0o700) { + fs.fchmodSync(fd, 0o700) + } + } catch { + return false + } finally { + if (fd !== undefined) fs.closeSync(fd) + } + return isOurPrivateDirectory(dir) +} + +/** + * Whether bubblewrap could not reach a manifest under `dir`: the wrap mounts a + * fresh /dev and /proc over whatever was bound beneath them. + */ +function isBuriedBySandboxMounts(dir: string): boolean { + return ['/dev', '/proc', '/sys'].some( + root => dir === root || dir.startsWith(`${root}/`), + ) +} + +// 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, + toWrite: false, + alone: 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: {@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( + orThrow = false, + toWrite = false, +): string[] { + const shared = fixedManifestDirectoryNames(orThrow, toWrite) + const runtimeDir = process.env['XDG_RUNTIME_DIR'] + 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 [...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. + * + * Each directory once, whatever it is called: where /var/tmp is a link to /tmp, + * or a bind mount of it, two of the names are one directory. Every manifest in + * it would be read at two names, and no pass could go on record in it twice. + */ +function believedManifestDirectories(): string[] { + const names = fixedManifestDirectoryNames(true, true).some(dir => + isOurPrivateDirectory(dir, { orThrow: true }), + ) + ? fixedManifestDirectoryNames(true) + : sharedManifestDirectoryNames(true) + if (manifestDirectory !== undefined) names.push(manifestDirectory) + const byIdentity = new Map() + for (const dir of names) { + if (!isOurPrivateDirectory(dir, { orThrow: true, toWrite: false })) continue + let identity: string + try { + // As big integers: a number does not hold every inode number apart. + const { dev, ino } = fs.lstatSync(dir, { bigint: true }) + identity = `${dev}:${ino}` + } catch (e) { + if (isAbsenceErrno(e)) continue + throw e + } + // By the name this process writes under, which it knows its own manifests + // by; else by one it can be written at (the other may be a read-only bind). + const sofar = byIdentity.get(identity) + if ( + sofar === undefined || + dir === manifestDirectory || + (sofar !== manifestDirectory && + !isOurPrivateDirectory(sofar) && + isOurPrivateDirectory(dir)) + ) { + byIdentity.set(identity, dir) + } + } + return [...byIdentity.values()] +} + +/** + * The directory manifests live in: the first shared name that can be made ours + * alone, else a private directory only this process knows, which keeps the + * guarantee within this process. Revalidated on every use, because a directory + * swapped for a symlink between two wraps would send manifests elsewhere. + */ +function ensureManifestDirectory(): string | undefined { + if ( + manifestDirectory !== undefined && + isOurPrivateDirectory(manifestDirectory) + ) { + return manifestDirectory + } + manifestDirectoryIsPrivate = false + for (const candidate of sharedManifestDirectoryNames(false, true)) { + if (makeOurPrivateDirectory(candidate)) { + manifestDirectory = candidate + return candidate + } + } + manifestDirectory = undefined + if (manifestDirectoryUnavailable || isBuriedBySandboxMounts(tmpdir())) { + return undefined + } + try { + const private_ = fs.mkdtempSync(path.join(tmpdir(), 'srt-mount-points-')) + if (!makeOurPrivateDirectory(private_)) { + // A temp dir that keeps no modes or ownership: nothing made in it can be + // told from somebody else's, now or on the next call. + try { + fs.rmdirSync(private_) + } catch { + // Left; it is empty. + } + throw new Error(`${private_} cannot be made private`) + } + manifestDirectory = private_ + manifestDirectoryIsPrivate = true + logForDebugging( + `[Sandbox Linux] No shared directory for mount point manifests, using one only this process knows: ${private_}`, + { level: 'warn' }, + ) + return private_ + } catch (e) { + manifestDirectoryUnavailable = true + logForDebugging( + `[Sandbox Linux] No directory for mount point manifests (${String(e)}) - the mount points this process makes are kept in memory and removed at its own clean-up`, + { level: 'warn' }, + ) + return undefined + } +} + +/** + * Every directory a wrap must bind read-only because some process of this user + * keeps manifests in it and a collect believes what it finds there: every + * shared name, 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: + * + * - 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. Throws + * where it cannot tell whether one of them is there. + */ +export function mountPointManifestDirectories(recording = false): string[] { + if (!onLinux()) { + return [] + } + const dirs = new Set() + // A real directory, not a link to one, and the user's. An error of the moment + // is not "not there": left unbound on that, it is believed again a moment + // later, by this process and by every other. + const isToBeBound = (dir: string): boolean => + isOurPrivateDirectory(dir, { orThrow: true, toWrite: false, alone: false }) + for (const candidate of sharedManifestDirectoryNames(true)) { + makeOurPrivateDirectory(candidate) + if (isToBeBound(candidate)) { + dirs.add(candidate) + } + } + if (recording) { + ensureManifestDirectory() + } + if (manifestDirectory !== undefined && isToBeBound(manifestDirectory)) { + dirs.add(manifestDirectory) + } + return [...dirs] +} + +/** + * Remove the directory only this process knows, once it is empty, when the + * process or its session ends. It stays while a manifest is in it. + */ +export function removePrivateManifestDirectory(): void { + if (manifestDirectory === undefined || !manifestDirectoryIsPrivate) { + return + } + try { + fs.rmdirSync(manifestDirectory) + } catch { + // Not empty, or gone already. + return + } + manifestDirectory = undefined + manifestDirectoryIsPrivate = false +} + +/** 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. + */ +function isOwnReadableFile(file: string): boolean { + try { + const stat = fs.lstatSync(file) + return ( + stat.isFile() && + stat.uid === process.getuid?.() && + (stat.mode & 0o400) !== 0 + ) + } catch { + return true + } +} + +/** + * What `file` holds, or `undefined` where nothing is there. Only a regular file + * of the user's, no larger than `max`, is read, through a descriptor opened + * without following a link and without blocking. Throws {@link NotOurs} for + * anything else that is there, and the error itself where that cannot be told. + */ +function readOwnFile(file: string, max: number): string | undefined { + let fd: number + try { + fd = fs.openSync( + file, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined + // A link, a socket, a file of mode 000: that lasts. Anything else passes. + throw isOwnReadableFile(file) + ? e + : new NotOurs(`${file} is not a readable regular file of this user's`) + } + try { + const stat = fs.fstatSync(fd) + if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.size > max) { + throw new NotOurs(`${file} is not a small regular file of this user's`) + } + return fs.readFileSync(fd, 'utf8') + } finally { + fs.closeSync(fd) + } +} + +/** `/` of a manifest, its claim or its started record. */ +const idOf = (file: string): string => file.slice(0, file.lastIndexOf('.')) + +/** + * The manifest at `file`, which is its own name or a claim's, or `undefined` + * where nothing is there. Throws like {@link readOwnFile}, and {@link NotOurs} + * for what does not hold a manifest's fields. + */ +function readManifest(file: string): Manifest | undefined { + const text = readOwnFile(file, MANIFEST_MAX_BYTES) + if (text === undefined) { + return undefined + } + let json: unknown + try { + json = JSON.parse(text) + } catch { + throw new NotJson(`${file} is not JSON`) + } + const parsed = ManifestSchema.safeParse(json) + if (!parsed.success) { + throw new NotOurs(`${file} is not a manifest this version can read`) + } + const id = idOf(file) + return { + ...parsed.data, + file, + claimed: !file.endsWith(MANIFEST_SUFFIX), + finished: file.endsWith(FINISHED_SUFFIX), + record: `${id}${STARTED_SUFFIX}`, + released: ownManifests.get(`${id}${MANIFEST_SUFFIX}`)?.over === true, + } +} + +/** + * Whether a process on the started record is running, or `undefined` with no + * whole line on record. A record that cannot be read, is not the user's regular + * file, or holds a line that is no /proc/PID/stat line vouches: only a process + * that is gone says its sandbox has ended. + * + * A command line may be run again and again, and each run adds its line. Every + * one of them is asked after, the last first: the first run may outlast all that + * follow it. + * + * Not so an `orphan`, which has no manifest that can be read: it names no path, + * so it protects none. Only a process that can be asked after vouches there, and + * an error that says nothing of the file. + * + * What follows the last newline counts as not written, an empty record + * included. The command line execs bubblewrap only once its whole line is + * written, so no sandbox is behind it yet, and one that follows is refused its + * start by a claim like any that has not put itself on record. Taking it for a + * running sandbox would keep what a shell killed there named for ever. + */ +function recordVouches(record: string, orphan = false): boolean | undefined { + let lines: string[] + try { + const text = readOwnFile(record, RECORD_MAX_BYTES) + if (text === undefined) { + return undefined + } + lines = text.split('\n').slice(0, -1).reverse() + } catch (e) { + return !(orphan && e instanceof NotOurs) + } + if (lines.length === 0) { + return undefined + } + return lines.some(line => { + const pid = /^(\d+) \(/.exec(line)?.[1] + const start = startTimeFromProcStat(line) + return pid === undefined || start === undefined + ? !orphan + : isRunning(Number(pid), start) + }) +} + +const isOfAnotherBoot = (manifest: Manifest): boolean => + manifest.boot !== undefined && + manifest.boot !== (ownBootId() ?? manifest.boot) + +/** + * Whether this process can tell if the processes behind a manifest run: none of + * another boot does, and of this one it can ask after those of its own PID + * namespace. What {@link isLive} says of any other goes by its age alone. + */ +function canBeAskedAfter(manifest: Manifest): boolean { + const here = ownPidNamespace() + return ( + isOfAnotherBoot(manifest) || (here !== undefined && manifest.ns === here) + ) +} + +/** + * Whether a sandbox that named this manifest may be running, or may yet start. + * 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}. A process on its started record that runs keeps + * it live, whatever else. With none, no sandbox is past a claim, and one at its + * own name that the caller has not released is live while its writer runs (its + * command line may be run yet, for the first time or again), or while it is + * young and has no record. + */ +function isLive(manifest: Manifest): boolean { + if (isOfAnotherBoot(manifest)) { + return false + } + if (!canBeAskedAfter(manifest)) { + return ( + ownPidNamespace() === undefined || + !( + olderThan(manifest.file, OTHER_NAMESPACE_MAX_AGE_MS) && + olderThan(manifest.record, OTHER_NAMESPACE_MAX_AGE_MS, true) + ) + ) + } + const vouched = recordVouches(manifest.record) + if (vouched === true) { + return true + } + return ( + !manifest.claimed && + !manifest.released && + ((vouched === undefined && + Date.now() - manifest.created < MANIFEST_GRACE_MS) || + isRunning(manifest.pid, manifest.start)) + ) +} + +/** + * What became of a path a pass set out to remove: `left` when it is gone or no + * longer what bubblewrap or this library made, `occupied` when it is a directory + * with something in it, `failed` when it is still a mount point and could not be + * removed from here. + */ +type Removal = 'removed' | 'left' | 'occupied' | 'failed' + +/** What a removal that threw says of the path. */ +function afterFailedRemoval(e: unknown, what: string): Removal { + const code = (e as NodeJS.ErrnoException | undefined)?.code + // Not empty after all: something was put into it meanwhile. + if (code === 'ENOTEMPTY' || code === 'EEXIST') { + return 'occupied' + } + if (isAbsenceErrno(e)) { + return 'left' + } + logForDebugging(`[Sandbox Linux] Could not remove ${what} (${String(e)})`) + return 'failed' +} + +/** + * Remove an empty directory a placeholder bound from, if it is still our own. + * It sits where sandboxed commands can often write, so anything but a private + * empty directory of ours is left exactly as found. + */ +function removeMountSource(source: string): Removal { + try { + const stat = fs.lstatSync(source) + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 || + fs.readdirSync(source).length > 0 + ) { + logForDebugging( + `[Sandbox Linux] Left the empty-directory mount source behind, it is no longer our own empty directory: ${source}`, + ) + return 'left' + } + fs.rmdirSync(source) + logForDebugging( + `[Sandbox Linux] Cleaned up the empty-directory mount source: ${source}`, + ) + return 'removed' + } catch (e) { + return afterFailedRemoval(e, `the empty-directory mount source ${source}`) + } +} + +/** {@link kindOfMountPoint}, throwing what the look at `p` throws. */ +function shapeOf(p: string): 'file' | 'directory' | undefined { + const stat = fs.lstatSync(p) + if (stat.uid !== process.getuid?.()) { + return undefined + } + if (stat.isDirectory()) { + return 'directory' + } + if (!stat.isFile() || stat.nlink !== 1) { + return undefined + } + if (stat.size === 0) { + return 'file' + } + const written = gitRedirectPlaceholder(p) + if (stat.size !== written?.length) { + return undefined + } + try { + return readOwnFile(p, stat.size) === written ? 'file' : undefined + } catch (e) { + // Something else has the name by now. + if (e instanceof NotOurs) return undefined + throw e + } +} + +/** + * What kind of mount point `p` can still be, by what is there now: an empty + * regular file (not a link) under one name, or a directory, and the user's; + * `undefined` for anything else. A manifest is only a claim about a path, so + * nothing is covered, spared or removed on its word that does not look like + * what bubblewrap leaves; and the look proves nothing without a manifest. + * + * The mount point of a file git reads back is the one that is not left to + * bubblewrap and is not empty (see writeGitRedirectMountPoint in + * linux-sandbox-utils.ts): it holds what follows from its name, and a file of + * that name holding exactly that is a `file` too. + * + * Not by its mode: bubblewrap makes the file 0444 from 0.5.0 and 0666 less the + * umask before, and one taken for the user's own on its write bits is never + * removed. + */ +export function kindOfMountPoint(p: string): 'file' | 'directory' | undefined { + try { + return shapeOf(p) + } catch { + return undefined + } +} + +/** + * Remove a mount point if it is still the empty file or directory bwrap made, + * or the file this library wrote; anything else is left where it is. + */ +function removeMountPoint(mountPoint: string): Removal { + try { + const kind = shapeOf(mountPoint) + if (kind === undefined) { + logForDebugging( + `[Sandbox Linux] Left a path a manifest names where it is, it no longer looks like a bwrap mount point: ${mountPoint}`, + ) + return 'left' + } + if (kind === 'file') { + fs.unlinkSync(mountPoint) + } else { + if (fs.readdirSync(mountPoint).length > 0) { + return 'occupied' + } + // rmdir, not a recursive remove: it neither follows a symlink nor + // descends, so a path that is no longer ours is left exactly as found. + fs.rmdirSync(mountPoint) + } + logForDebugging( + `[Sandbox Linux] Cleaned up bwrap mount point (${kind}): ${mountPoint}`, + ) + return 'removed' + } catch (e) { + return afterFailedRemoval(e, `the bwrap mount point ${mountPoint}`) + } +} + +/** What {@link publishMountPointManifest} wrote, for the bwrap invocation. */ +export type MountPointManifest = { + /** The directory to bind read-only inside the sandbox. */ + dir: string + /** The manifest, for bubblewrap to bind before it makes a mount point. */ + file: string + /** Its started record, for the command line to append to before bubblewrap. */ + started: string +} + +/** + * Record the mount points this wrap relies on, and the empty directories they + * bind from, before the sandbox can start. + * + * `undefined` when there is nothing to record or no manifest could be written. + * What could not be recorded is kept in memory and removed at this process's + * own clean-up, since no other process will know of it. `too-large`, with + * nothing written or added to that, for one its own reader would refuse: every pass of + * every process would turn back at it. + */ +export function publishMountPointManifest( + mountPoints: readonly string[], + sources: readonly string[], + commandKey?: string, +): MountPointManifest | 'too-large' | undefined { + if (!onLinux() || (mountPoints.length === 0 && sources.length === 0)) { + return undefined + } + const dir = ensureManifestDirectory() + if (dir !== undefined) { + const id = path.join( + dir, + `${process.pid}-${randomBytes(8).toString('hex')}`, + ) + const file = `${id}${MANIFEST_SUFFIX}` + const temporary = `${file}${TEMPORARY_SUFFIX}` + const body: z.infer = { + version: MANIFEST_VERSION, + ...ownProcess(), + ns: ownPidNamespace(), + boot: ownBootId(), + created: Date.now(), + paths: [...new Set(mountPoints)], + sources: [...new Set(sources)], + } + const text = JSON.stringify(body) + if (Buffer.byteLength(text) > MANIFEST_MAX_BYTES) { + return 'too-large' + } + // Written beside the manifest and renamed onto it, so that it is read whole + // or not at all. + try { + fs.writeFileSync(temporary, text, { mode: 0o600 }) + fs.renameSync(temporary, file) + ownManifests.set(file, { commandKey, over: false }) + // Not what an earlier wrap could record nowhere and this one names too: + // that wrap's sandbox may outlast this manifest. + for (const named of [...mountPoints, ...sources]) { + if (unrecorded.madeByAPlan.delete(named)) { + unrecorded.paths.delete(named) + unrecorded.sources.delete(named) + } + } + return { dir, file, started: `${id}${STARTED_SUFFIX}` } + } catch (e) { + try { + fs.unlinkSync(temporary) + } catch { + // Never made. + } + logForDebugging( + `[Sandbox Linux] Could not record the mount points this command relies on (${String(e)}) - they are kept in memory and removed at this process's own clean-up`, + { level: 'warn' }, + ) + } + } + for (const mountPoint of mountPoints) unrecorded.paths.add(mountPoint) + for (const source of sources) unrecorded.sources.add(source) + for (const named of [...mountPoints, ...sources]) { + unrecorded.madeByAPlan.delete(named) + } + return undefined +} + +/** + * Let go of the manifest of a wrap that produced no command: released, and + * collected by a pass like any other. Not unlinked: a pass elsewhere may have + * spared a path on its word and dropped the manifest that named the path till + * then, and what nothing names any more stays on the host for good. + */ +export function discardMountPointManifest(file: string): void { + const own = ownManifests.get(file) + if (own !== undefined) own.over = true + collectMountPoints('none') +} + +function drop(file: string): boolean { + try { + fs.unlinkSync(file) + return true + } catch { + // Gone already, or not a file. + return false + } +} + +/** 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 file in them. */ + files: string[] + /** The manifests that could be read, claimed ones among them. */ + manifests: Manifest[] + /** + * Why a sandbox may be running on paths that this reading cannot list, if one + * may: nothing may then be removed on it. + */ + inDoubt?: string + /** All that doubt is, is that manifests kept moving while they were read. */ + keptChanging?: boolean + /** What is there and is of no use to anybody any more. */ + spent: string[] +} + +/** + * 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, to give it back and to say that it is + * finished, which can hide it + * from a listing under way and from the open that follows one, and one published + * while the directory is listed can be missed with its record shown. A record is + * never renamed. So the reading starts over for a listed manifest that is gone + * when opened, and for a record with no manifest listed beside it whose manifest + * is there when asked for by name. + * + * A record with no manifest at any of its names puts the reading in doubt only while + * a process on it runs (see {@link recordVouches}) and is spent otherwise, one + * with no whole line past {@link MANIFEST_GRACE_MS} (its shell may be about to + * write to it). + * + * Whatever is at a manifest's name and cannot be read puts the reading in doubt + * 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 MANIFEST_GRACE_MS} for a {@link + * NotJson}). + */ +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 { + fs.lstatSync(file) + return true + } catch (e) { + return !isAbsenceErrno(e) + } + } + for (let attempt = 1; ; attempt++) { + let files: string[] + try { + files = filesIn(dirs) + } catch (e) { + return { files: [], manifests: [], spent: [], inDoubt: String(e) } + } + const reading: Reading = { files, manifests: [], spent: [] } + const listed = new Set() + let moved = false + for (const file of files) { + if (!MANIFEST_SUFFIXES.some(suffix => file.endsWith(suffix))) { + continue + } + listed.add(idOf(file)) + try { + const manifest = readManifest(file) + 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, + e instanceof NotJson + ? MANIFEST_GRACE_MS + : UNREADABLE_MANIFEST_MAX_AGE_MS, + ) + ) { + reading.spent.push(file) + } else { + const why = e instanceof Error ? e.message : String(e) + reading.inDoubt ??= why.includes(file) ? why : `${file}: ${why}` + } + } + } + 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 (file.endsWith(PASS_SUFFIX)) { + // Left by a process killed during its pass. One that is only overdue + // may be under way yet, to those who can ask after it. + const state = stateOfPass(file, oneKernel.includes(path.dirname(file))) + if ( + state === 'over' || + (state === 'overdue' && olderThan(file, OTHER_NAMESPACE_MAX_AGE_MS)) + ) { + reading.spent.push(file) + } + } else if (file.endsWith(STARTED_SUFFIX) && !listed.has(idOf(file))) { + // Asked for in the order it goes through its names, and the record read + // last. Only a claim given back can then pass between them unseen, and + // a pass gives back only once it has found a process on the record, + // which the read below finds too. + if ( + MANIFEST_SUFFIXES.some(suffix => isThere(`${idOf(file)}${suffix}`)) + ) { + moved = true + continue + } + const vouched = recordVouches(file, true) + if (vouched === true) { + reading.inDoubt ??= `${file} has no manifest, and a process on it may be running` + } else if (vouched === false || olderThan(file, MANIFEST_GRACE_MS)) { + reading.spent.push(file) + } + } + } + if (moved && attempt < LISTING_ATTEMPTS) { + continue + } + if (moved) { + reading.keptChanging = reading.inDoubt === undefined + reading.inDoubt ??= `${dirs.join(', ')} kept changing` + } + return reading + } +} + +/** What the manifests name, on one reading, and what that reading listed. */ +export type NamedMountPoints = { + paths: ReadonlySet + listed: ReadonlySet +} + +/** + * The mount points that any manifest names, a finished or claimed one included + * (what only those name is a leftover the next pass removes), and what this + * process could record nowhere. Makes nothing on the host. + * + * `inDoubt`, which says what could not be read and why, where the reading is in + * 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 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 directories list nothing it did not, + * `own` (a manifest the caller has published since) apart. + */ +export function namedMountPoints( + earlier?: NamedMountPoints, + own?: string, +): NamedMountPoints | { inDoubt: string; keptChanging?: boolean } { + try { + const dirs = onLinux() ? believedManifestDirectories() : [] + if ( + earlier !== undefined && + // What a plan has made itself since then is named from that moment. + [...unrecorded.paths].every(kept => earlier.paths.has(kept)) && + filesIn(dirs).every(file => earlier.listed.has(file) || file === own) + ) { + return earlier + } + const reading = readManifests(dirs) + if (reading.inDoubt !== undefined) { + return { inDoubt: reading.inDoubt, keptChanging: reading.keptChanging } + } + return { + paths: new Set([ + ...unrecorded.paths, + ...reading.manifests.flatMap(manifest => manifest.paths), + ]), + listed: new Set(reading.files), + } + } catch (e) { + // A directory cannot be looked at, or listed. + return { inDoubt: String(e) } + } +} + +/** + * Whether `p` is still something a clean-up would remove on a manifest's word: + * an empty file or one this library wrote (see {@link kindOfMountPoint}), or an + * empty directory. One that cannot be looked at may be. + */ +function isEmptyMountPoint(p: string): boolean { + try { + const kind = shapeOf(p) + return kind === 'directory' + ? fs.readdirSync(p).length === 0 + : kind === 'file' + } catch (e) { + return !isAbsenceErrno(e) + } +} + +/** + * The mount points a sandbox that may still be running relies on, as the + * manifests say at the moment of the call. For a caller that removes paths of + * its own accord after a command: removing one from under a running sandbox + * lifts the deny there. README.md, "Removing a path yourself", has the contract. + * + * `undefined` when it cannot tell: the caller must then skip every removal of an + * empty file or an empty directory. It does not throw. A snapshot, whose only + * safe use is to SKIP a removal. + * + * A path is in the set only while a live manifest names it (or this process + * could record it nowhere) AND what is there is still a placeholder of the + * user's, or cannot be looked at: empty, or a `commondir` that holds `.` and a + * newline, which is what stands at that name in a git directory while a command + * runs. A stale or forged manifest never makes a caller spare a file with + * content of its own. Makes nothing on the host. Empty off Linux. + */ +export function liveMountPoints(): ReadonlySet | undefined { + const spared = new Set() + if (!onLinux()) { + return spared + } + const named = new Set(unrecorded.paths) + try { + const reading = readManifests(believedManifestDirectories()) + if (reading.inDoubt !== undefined) { + throw new Error(reading.inDoubt) + } + for (const manifest of reading.manifests) { + if ( + isLive(manifest) && + !(manifest.finished && !canBeAskedAfter(manifest)) + ) { + for (const mountPoint of manifest.paths) named.add(mountPoint) + } + } + } catch (e) { + logForDebugging( + `[Sandbox Linux] Which mount points are live cannot be told (${String(e)})`, + { level: 'warn' }, + ) + return undefined + } + for (const mountPoint of named) { + if (isEmptyMountPoint(mountPoint)) { + spared.add(mountPoint) + } + } + return spared +} + +/** In the name of every pass of this copy of the library, to tell its own. */ +const ownPassMark = randomBytes(8).toString('hex') + +/** The records of the pass this copy of the library is making, while it is. */ +let ownPassOnRecord: readonly string[] = [] + +/** The number in `pid:[4026531836]`, and the boot id without its hyphens. */ +const inAPassName = { + ns: (): string => /\d+/.exec(ownPidNamespace() ?? '')?.[0] ?? '', + boot: (): string => (ownBootId() ?? '').replace(/-/g, ''), +} + +/** + * Puts a pass of this process on record in each of `dirs`, until {@link + * takePassOffRecord}. `false`, with nothing left there, where it could not be + * in one of them: nothing may then be removed on a manifest's word. + * + * The name says whose it is: the pid, its start time, its PID namespace and the + * boot, each left empty where it cannot be read. In the name, so that a pass is + * on record whole or not at all and a listing is all it takes to judge one. + */ +function putPassOnRecord(dirs: readonly string[]): boolean { + const { pid, start } = ownProcess() + const name = `${pid}-${start}-${inAPassName.ns()}-${inAPassName.boot()}-${ownPassMark}${randomBytes(4).toString('hex')}${PASS_SUFFIX}` + ownPassOnRecord = dirs.map(dir => path.join(dir, name)) + try { + for (const file of ownPassOnRecord) { + fs.closeSync(fs.openSync(file, 'wx', 0o600)) + } + return true + } catch (e) { + takePassOffRecord() + logForDebugging( + `[Sandbox Linux] The clean-up of mount points could not be put on record (${String(e)}) - leaving what the manifests name where it is`, + { level: 'warn' }, + ) + return false + } +} + +/** One that stays behind is dropped by the next pass, of any process. */ +function takePassOffRecord(): void { + const files = ownPassOnRecord + ownPassOnRecord = [] + files.forEach(drop) +} + +/** + * Whether the pass on record at `file` may still remove something. + * + * A process of this kernel and PID namespace is asked after, and only one that + * is gone says `over`. One of an earlier boot is, where only this kernel reaches + * the directory (`oneKernel`), and so is one of this copy of the library that + * it is not making now: a pass does not give the thread back. + * + * Any other, and a name that is none of ours, goes by its age, and is `overdue` + * past {@link PASS_MAX_AGE_MS}: not to be waited for from here, and not known to + * be over either. Its record is left for those who can ask. + */ +function stateOfPass( + file: string, + oneKernel: boolean, +): 'under way' | 'over' | 'overdue' { + const byItsAge = (): 'under way' | 'over' | 'overdue' => { + try { + return Date.now() - fs.lstatSync(file).mtimeMs > PASS_MAX_AGE_MS + ? 'overdue' + : 'under way' + } catch (e) { + return isAbsenceErrno(e) ? 'over' : 'under way' + } + } + const [pid, start, ns, boot, mark] = + /^(\d+)-(\d*)-(\d*)-([0-9a-f]*)-([0-9a-f]+)\.pass$/ + .exec(path.basename(file)) + ?.slice(1) ?? [] + if (pid === undefined || start === undefined || mark === undefined) { + return byItsAge() + } + if (mark.startsWith(ownPassMark)) { + return ownPassOnRecord.includes(file) ? 'under way' : 'over' + } + if (boot === '' || boot !== inAPassName.boot()) { + if (!oneKernel) return byItsAge() + if (boot !== '' && inAPassName.boot() !== '') return 'over' + } + if (ns === '' || ns !== inAPassName.ns()) { + return byItsAge() + } + return isRunning(Number(pid), start) ? 'under way' : 'over' +} + +/** + * The passes on record that may be under way. Throws where the directories + * cannot be told or listed. + */ +export function passesUnderWay(): string[] { + if (!onLinux()) { + return [] + } + const dirs = believedManifestDirectories() + const oneKernel = dirs.filter(isReachedByOneKernel) + return filesIn(dirs).filter( + file => + file.endsWith(PASS_SUFFIX) && + stateOfPass(file, oneKernel.includes(path.dirname(file))) === 'under way', + ) +} + +/** + * Resolves once none of `passes` is under way any more, with `undefined`, or + * with one that still is after `waitMs`: its process is held up (stopped, or + * starved), and what it set out to remove may go at any moment after. + * + * For a wrap that has published its manifest, with what {@link passesUnderWay} + * listed after that. Every pass that did not read the manifest is among them + * (see {@link collect}); one that goes on record later has read it, and is not + * waited for. Rejects like {@link passesUnderWay}, and with `signal`'s reason + * once that is aborted. + */ +export async function passesToEnd( + passes: readonly string[], + signal?: AbortSignal, + waitMs = WAITS_FOR_PASSES_MS, +): Promise { + const deadline = performance.now() + waitMs + let left = passes + for (let pause = 1; ; pause = Math.min(pause * 2, 50)) { + if (left.length === 0 || performance.now() >= deadline) { + return left[0] + } + await new Promise(resolve => setTimeout(resolve, pause)) + signal?.throwIfAborted() + // By its own name, which no other has: a directory that has two may be + // listed by the other one this time. + const underWay = new Set(passesUnderWay().map(pass => path.basename(pass))) + left = left.filter(pass => underWay.has(path.basename(pass))) + } +} + +/** + * Whether the manifest this process published at `file` is still its to start a + * command under: not once a clean-up has been told that its command is over. + */ +export function holdsMountPointManifest(file: string): boolean { + return ownManifests.get(file)?.over === false +} + +/** + * Release the manifests of the wraps of this process that `release` says are + * over, and remove every mount point no live manifest names, from any process, + * at any time, any number of times. Returns the mount points removed. Does + * nothing anywhere but on Linux. + */ +export function collectMountPoints(release: OwnManifestRelease): string[] { + if (!onLinux()) { + return [] + } + // Recorded first and kept whatever becomes of this pass, so a later pass + // knows these commands are over even when told to release nothing. + for (const [file, own] of ownManifests) { + own.over ||= + release === 'all' || + (release !== 'none' && + ((release !== 'ended' && own.commandKey === release.commandKey) || + recordVouches(`${idOf(file)}${STARTED_SUFFIX}`) === false)) + } + // 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') { + for (const mountPoint of childrenFirst(unrecorded.paths)) { + if (removeMountPoint(mountPoint) === 'removed') removed.push(mountPoint) + } + for (const source of unrecorded.sources) { + if (removeMountSource(source) === 'removed') removed.push(source) + } + Object.values(unrecorded).forEach(named => named.clear()) + } + return removed +} + +/** A path sorts after the directories it is in: a directory goes empty. */ +const childrenFirst = (paths: Iterable): string[] => + [...paths].sort().reverse() + +/** + * 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, 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 + * own name keeps what it names, live or not, since a start under it can succeed + * at any moment. Acting on a reading made before the claims would remove a + * mount point from under a sandbox that started in between. + * + * A pass gives back the claims it made itself, and only when that reading finds + * a process on the record, which every reading after the claim finds too. So + * what remains is a manifest published after the reading, whose sandbox would + * reach its bind on a path a claimed manifest names too. + * + * INVARIANT: a pass drops a manifest whose processes it can ask after only at + * the name `.finished`, which such a pass gives a claim it finds no process + * behind, before that reading; and a pass that cannot ask keeps nothing on the + * word of a manifest at that name. A path that is kept is dropped from the + * manifests of this pass and left to the one it is kept for. To a pass that + * cannot ask (one in another PID namespace) a claim is live like any other, so + * without the name each of two such passes would leave the path to the other's + * manifest. With it, whichever reads second finds the other's finished, and of + * all the manifests that name a path the pass over the last to be finished + * keeps it for none. What such a manifest names alone is still its own + * namespace's to remove, or anybody's once it is old. A pass that goes by the + * age of a manifest does not give it the name: whoever can ask goes on asking. + * + * INVARIANT: the pass is on record in every directory it can write from before + * that reading until after its last removal, and removes nothing where it could + * not be put there. A wrap lists the passes once it has published, and hands no + * command out while one is under way (see {@link passesToEnd}). So the wrap of + * a manifest that the reading missed finds the pass on record, and no sandbox + * starts under that manifest until the pass is over, for however long the pass + * is held up between its reading and a removal. + * + * INVARIANT: 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. The record above does not make + * this redundant. A wrap sees no record of a pass that could go on none, or only + * in a directory the wrap does not list (one inside another sandbox, which reads + * the shared places and cannot write them); and a pass is on no record in a + * directory that was made after it worked out where to go. Where the wrap does + * wait, it then finds what it planned on and need not plan again. + * + * A manifest is all that names its mount points, so its claim is dropped last, + * and stays, for a later pass, when the pass turns back, a removal is refused, + * or a directory holds what another manifest names. Only content that no + * manifest names makes a directory nobody's. + */ +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.files.map(idOf)) + for (const file of ownManifests.keys()) { + if (dirs.includes(path.dirname(file)) && !there.has(idOf(file))) { + ownManifests.delete(file) + } + } + for (const manifest of before.manifests) { + const claim = manifest.claimed + ? manifest.file + : `${idOf(manifest.file)}${CLAIMED_SUFFIX}` + if (!manifest.claimed) { + if (isLive(manifest)) continue + try { + fs.renameSync(manifest.file, claim) + own.add(claim) + } catch { + // Another pass has it, or the directory cannot be written. + continue + } + } + // Asked again, now that no sandbox can start under it. + const over = !isLive({ ...manifest, file: claim, claimed: true }) + claims ||= over || own.has(claim) + // Not of one that is only old: those who can ask go on asking. + if (over && !manifest.finished && canBeAskedAfter(manifest)) { + try { + fs.renameSync(claim, `${idOf(claim)}${FINISHED_SUFFIX}`) + own.delete(claim) + } catch { + // Another pass has said so, or has dropped it. + } + } + } + } + // Nothing to remove, or nothing that may be: not worth a record, which every + // wrap that is about to hand its command out would wait for. + if ( + before.inDoubt !== undefined || + (!claims && + !(unrecordedToo && unrecorded.paths.size + unrecorded.sources.size > 0)) + ) { + if (before.inDoubt !== undefined) { + logForDebugging( + `[Sandbox Linux] A sandbox may be running on mount points that cannot be listed - leaving every mount point where it is (${before.inDoubt})`, + { level: 'warn' }, + ) + } + return [] + } + const writable = dirs.filter(dir => isOurPrivateDirectory(dir)) + // From before its record is made, whose age others go by. + const since = bothClocks() + try { + // Off the record it removes nothing on a manifest's word. What is left to + // remove is what this process could record nowhere, which no wrap elsewhere + // knows for a mount point or waits for. + return removeWhatIsFinished( + dirs, + putPassOnRecord(writable) ? writable : [], + own, + unrecordedToo, + since, + ) + } finally { + takePassOffRecord() + } +} + +/** + * The rest of {@link collect}, for as long as the pass is on record, which it + * has been `since`: `own` are the claims it has made, `writable` the directories + * it can claim in. + */ +function removeWhatIsFinished( + dirs: readonly string[], + writable: readonly string[], + own: ReadonlySet, + unrecordedToo: boolean, + since: readonly [number, number], +): string[] { + const reading = readManifests(dirs) + 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})`, + { level: 'warn' }, + ) + return [] + } + + const kept = new Set() + const keep = ( + manifest: Record<'paths' | 'sources', Iterable>, + ): void => { + for (const named of [...manifest.paths, ...manifest.sources]) { + kept.add(named) + } + } + // A wrap of this process is outstanding, and may be the one that relies on it. + if (!unrecordedToo) keep(unrecorded) + const finished: Manifest[] = [] + for (const manifest of reading.manifests) { + const asked = canBeAskedAfter(manifest) + if ( + manifest.claimed && + !isLive(manifest) && + // Else its last process has ended, or the renaming has failed, since the + // claims: it is the next pass's, which says so before it reads. + (manifest.finished || !asked) && + writable.includes(path.dirname(manifest.file)) + ) { + finished.push(manifest) + continue + } + // Not for this pass to remove, and nothing to keep anything for. + if (manifest.finished && !asked) { + continue + } + keep(manifest) + if (own.has(manifest.file)) { + try { + fs.renameSync(manifest.file, `${idOf(manifest.file)}${MANIFEST_SUFFIX}`) + } catch { + // Stays claimed, and kept while a process on its record runs. + } + } + } + + // Each path once, and as a mount point where a manifest names it as one. + const candidates = new Map() + for (const manifest of unrecordedToo ? [...finished, unrecorded] : finished) { + for (const mountPoint of manifest.paths) { + candidates.set(mountPoint, false) + } + for (const source of manifest.sources) { + if (!candidates.has(source)) { + candidates.set(source, true) + } + } + } + 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 { + // 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(file) + if (arrived === undefined) continue + keep(arrived) + known.add(file) + } + } catch (e) { + logForDebugging( + `[Sandbox Linux] The mount point manifests could not be read again (${String(e)}) - leaving the rest where it is`, + { level: 'warn' }, + ) + return false + } + listedAt = performance.now() + return true + } + + const removed: string[] = [] + const notRemoved = new Set() + for (const candidate of childrenFirst(candidates.keys())) { + if ( + performance.now() - listedAt >= goodFor && + !newcomersKeepWhatTheyName() + ) { + return removed + } + if (kept.has(candidate)) { + continue + } + // To a process that cannot ask after this one the pass is over by its age. + // The last thing before a removal, after all that may have taken time. + if (bothClocks().some((now, i) => now - since[i]! > PASS_GOOD_FOR_MS)) { + logForDebugging( + '[Sandbox Linux] The clean-up of mount points has taken too long - leaving the rest to the next', + { level: 'warn' }, + ) + return removed + } + const outcome = candidates.get(candidate) + ? removeMountSource(candidate) + : removeMountPoint(candidate) + if (outcome === 'removed') { + removed.push(candidate) + } else if ( + outcome === 'failed' || + (outcome === 'occupied' && + [...kept, ...notRemoved].some(p => p.startsWith(`${candidate}/`))) + ) { + notRemoved.add(candidate) + } + } + if (unrecordedToo) { + for (const named of Object.values(unrecorded)) { + for (const one of named) if (!notRemoved.has(one)) named.delete(one) + } + } + for (const manifest of finished) { + // A mount point that could not be removed from here is still somebody's to + // remove, and the manifest is all that says so. The record goes only with + // the claim: a manifest given back since may have a sandbox on its record. + if ( + ![...manifest.paths, ...manifest.sources].some(named => + notRemoved.has(named), + ) && + drop(manifest.file) + ) { + drop(manifest.record) + ownManifests.delete(`${idOf(manifest.file)}${MANIFEST_SUFFIX}`) + } + } + 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 + * 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/credential-sentinel.ts b/src/sandbox/credential-sentinel.ts index 52c7c1c0e..0410b63cf 100644 --- a/src/sandbox/credential-sentinel.ts +++ b/src/sandbox/credential-sentinel.ts @@ -105,12 +105,8 @@ export class SentinelRegistry { * registered, the EXISTING sentinel is returned (and `sentinel` discarded) * so a re-register never invalidates a fake the sandboxed process has * already read. The caller must mint sentinels with enough entropy that - * collisions with real content are negligible (embed a uuid4). No - * registered sentinel may be a substring of any other registered sentinel: - * the body-substitution scan matches earliest-position-then-registration- - * order, not longest-match, so nested sentinels would make replacement - * chunk-boundary-dependent (still fail-safe — worst case a wrong fake - * reaches upstream, never a secret). + * collisions with real content are negligible (embed a uuid4). A sentinel + * that nests with one already registered is refused (see the check below). * * Caller-minted sentinels are used verbatim — never length-padded: a * shaped fake (e.g. a JWT) must keep its structure. They are therefore @@ -129,6 +125,35 @@ export class SentinelRegistry { existing.injectHosts = injectHosts return existing.sentinel } + // No registered sentinel may be a substring of another. The + // body-substitution scan matches earliest-position-then-registration- + // order, not longest-match, so a nested pair replaces the wrong span, and + // which one wins depends on where the streaming transform's chunk + // boundaries fall. Minted sentinels each carry their own uuid4 and cannot + // nest; a caller-minted one (a shaped fake) can, and is refused here + // rather than at the first body that carries it. Registration is once per + // credential at wrap time, so the scan costs nothing that matters. + // + // The empty one nests with every other, and is found at every position of + // every body. Registered first it would meet nothing to nest with. + if (sentinel === '') { + throw new Error( + `Credential "${name}" was given an empty sentinel. Mint sentinels ` + + `that embed a uuid4.`, + ) + } + for (const registered of this.bySentinel.values()) { + if ( + registered.sentinel.includes(sentinel) || + sentinel.includes(registered.sentinel) + ) { + throw new Error( + `Credential "${name}" was given a sentinel that nests with the one ` + + `already registered for "${registered.name}": body substitution ` + + `would replace the wrong span. Mint sentinels that embed a uuid4.`, + ) + } + } if (!sentinel.startsWith(SENTINEL_PREFIX)) { this.allSentinelsPrefixed = false } diff --git a/src/sandbox/linux-deny-collapse.ts b/src/sandbox/linux-deny-collapse.ts new file mode 100644 index 000000000..e748913be --- /dev/null +++ b/src/sandbox/linux-deny-collapse.ts @@ -0,0 +1,367 @@ +import { realPathOrSelf } from '../utils/real-path.js' +import type { + GitDirDenies, + GitDirTreeDenies, + GitChainHop, +} from './mandatory-deny-paths.js' +import { isAtOrUnder } from './sandbox-utils.js' + +/** + * The git directories one repository keeps under its `.git/modules`, or its + * `.git/worktrees`. Their deny paths are ones the plan's list carries, and + * collapsing one drops them from wherever they came from: a checked-out + * submodule's `.git` file names the same git directory as the walk does, in the + * same strings. + */ +export interface RepositorySubmodules { + modulesDir: string + /** Sorted by path, which is the order a collapse works back through. */ + gitDirs: Array<{ gitDir: string } & GitDirDenies> + /** + * The whole-directory denies UNDER `modulesDir`: what the walk could not see + * through, and the directories holding an entry that is a symlink. A bind of + * `modulesDir` covers the ones really under it. + */ + wholeDirDenies: string[] + /** + * Set where these are linked worktrees' git directories and `modulesDir` is a + * `.git/worktrees`: degraded the same way, and named for what they are in the + * wrap's warning. + */ + linkedWorktrees?: true +} + +/** What one wrap denies, and what of that can be degraded. */ +export interface SubmoduleDenyPlan { + /** Every deny path the wrap found, in the order it found them. */ + denyPaths: string[] + /** + * In the order they were served: the working directory's own repository + * first, then the nested ones, sorted. See {@link degradableGitDirs}. + */ + repositories: RepositorySubmodules[] + /** + * Every symlink a git directory is reached THROUGH. Which of the directories + * holding one a wrap can bind depends on the write roots, so that is decided + * there and nothing collapses them: `chainHopDenies` in + * src/sandbox/linux-sandbox-utils.ts. + */ + chainHops: GitChainHop[] + /** + * The entries of the git directory the working directory's own `.git` is or + * names. Each is denied from the first wrap that saw the repository, so a + * link found at one is the user's layout, where any other git directory may + * be one an earlier command made. + */ + ownEntries?: string[] +} + +/** + * How far the denies have been degraded, counted from the END of the order + * above, so that two wraps of one tree degrade the same entries. + */ +export interface CollapseLevel { + /** Submodule git directories denied whole, one read-only bind each. */ + wholeGitDirs: number + /** `.git/modules` directories denied whole, taking what is under them. */ + wholeModulesDirs: number +} + +/** Nothing degraded: every submodule keeps its precise denies. */ +export const NO_COLLAPSE: CollapseLevel = { + wholeGitDirs: 0, + wholeModulesDirs: 0, +} + +/** + * What of one repository's denies can be degraded: its submodule git + * directories, then its linked worktrees'. A collapse works back from the end, + * so within a repository the worktrees' go first: a command in this working + * directory has less use for another checkout's HEAD and index than for a + * submodule's objects. + * + * Neither is here where nothing is under it. An absent `.git/modules` or + * `.git/worktrees` is never denied: a mount point at one stops + * `git submodule add`, or `git worktree add`, in that repository. + */ +export function degradableGitDirs( + denies: GitDirTreeDenies, +): RepositorySubmodules[] { + const wholeDirDenies = [...denies.unreadableDirs, ...denies.linkedEntryDirs] + return [ + { modulesDir: denies.modulesDir, gitDirs: denies.submodules }, + { + modulesDir: denies.worktreesDir, + gitDirs: denies.worktrees, + linkedWorktrees: true as const, + }, + ] + .map(kept => ({ + ...kept, + // Under it only: a git directory denied whole for an entry of its OWN + // holds `modules` rather than sitting under it. + wholeDirDenies: wholeDirDenies.filter(denyPath => + isAtOrUnder(denyPath, kept.modulesDir), + ), + })) + .filter(kept => kept.gitDirs.length > 0 || kept.wholeDirDenies.length > 0) +} + +/** + * The deny paths of `plan` at `level`. A degraded git directory's deny paths + * are dropped from the list and one read-only bind of it is added; a + * `.git/modules` denied whole takes everything under it the same way. Not + * dropped: a `.git` file's own deny, and what the producer marked as leading + * out of the git directory. + */ +export function collapsedDenyPaths( + plan: SubmoduleDenyPlan, + level: CollapseLevel, +): string[] { + if (level.wholeGitDirs === 0 && level.wholeModulesDirs === 0) { + return plan.denyPaths + } + const order = collapsibleGitDirs(plan) + const wholeGitDirs = new Set( + order.slice(order.length - level.wholeGitDirs).map(entry => entry.gitDir), + ) + const wholeModulesDirs = new Set( + plan.repositories + .slice(plan.repositories.length - level.wholeModulesDirs) + .map(repository => repository.modulesDir), + ) + + // Paths a bind above them now covers, and the binds. The binds go at the end, + // which leaves the rest of the list as the wrap found it. + const covered = new Set() + const wholeBinds: string[] = [] + for (const repository of plan.repositories) { + const wholeModules = wholeModulesDirs.has(repository.modulesDir) + // A bind of `modules` covers the paths that are really under it, and only + // those: an entry the walk reached through a symlink can have its real + // path anywhere, and it keeps the deny it already has. + const coveredByModules = wholeModules + ? underDirectory(repository.modulesDir) + : () => false + if (wholeModules) wholeBinds.push(repository.modulesDir) + for (const submodule of repository.gitDirs) { + const byModules = coveredByModules(submodule.gitDir) + if (!byModules && !wholeGitDirs.has(submodule.gitDir)) continue + const escaping = new Set(submodule.escapingDenyPaths) + for (const denyPath of submodule.denyPaths) { + if (!escaping.has(denyPath)) covered.add(denyPath) + } + if (!byModules) wholeBinds.push(submodule.gitDir) + } + for (const wholeDirDeny of repository.wholeDirDenies) { + if (coveredByModules(wholeDirDeny)) covered.add(wholeDirDeny) + } + } + // A git directory that is a deny path in its own right — one holding a + // symlinked entry — is both dropped as covered and added back as the bind + // that covers it, so the binds are added only where the list has lost them. + const kept = plan.denyPaths.filter(denyPath => !covered.has(denyPath)) + const keptPaths = new Set(kept) + return [...kept, ...wholeBinds.filter(bind => !keptPaths.has(bind))] +} + +/** + * `level` degraded far enough to give back `mountsOver` mounts, or undefined + * where nothing is left to degrade, which is where the profile is refused with + * `too_many_arguments`. What a step really gives back is the caller's to + * measure. The arithmetic decides how many entries one step takes, so that a + * repository with thousands of submodules is not rebuilt once for each. An entry + * none of whose deny paths `takesAMount` (all outside the write allowlist) gives + * nothing back, and the step goes on past it. + */ +export function collapseFurther( + plan: SubmoduleDenyPlan, + level: CollapseLevel, + mountsOver: number, + takesAMount: (denyPath: string) => boolean = () => true, +): CollapseLevel | undefined { + const order = collapsibleGitDirs(plan) + if (level.wholeGitDirs < order.length) { + const taken = stepBack( + order.length - level.wholeGitDirs, + mountsOver, + at => { + const escaping = new Set(order[at]?.escapingDenyPaths) + return (order[at]?.denyPaths ?? []).filter( + denyPath => !escaping.has(denyPath) && takesAMount(denyPath), + ).length + }, + ) + return { ...level, wholeGitDirs: level.wholeGitDirs + taken } + } + if (level.wholeModulesDirs < plan.repositories.length) { + const taken = stepBack( + plan.repositories.length - level.wholeModulesDirs, + mountsOver, + // Each git directory under it is one bind by now, and the one bind of + // `modules` replaces the lot. + at => + Math.max( + (plan.repositories[at]?.gitDirs ?? []).filter(kept => + takesAMount(kept.gitDir), + ).length - 1, + 0, + ), + ) + return { ...level, wholeModulesDirs: level.wholeModulesDirs + taken } + } + return undefined +} + +/** + * The last level, which is where the caller goes once a step has given nothing + * back. + */ +export function collapseEverything(plan: SubmoduleDenyPlan): CollapseLevel { + return { + wholeGitDirs: collapsibleGitDirs(plan).length, + wholeModulesDirs: plan.repositories.length, + } +} + +/** + * Where the caller's passes start: the level counting alone says a profile with + * room for `mounts` cannot do without, since every deny path that `takesAMount` + * (one inside the write allowlist) takes one of its own, and the ancestor pins + * beneath each are more on top. Without it a repository with ten thousand + * submodules is planned out in full before anything is degraded, which takes + * minutes, and a wrapped command can make such a tree. + * + * It is not the answer, and the caller's measurement is: what it leaves out can + * only mean more collapsing, and the one way it can ask for too much — a deny + * path counted here that another deny turns out to cover — is a mount the + * profile saves rather than one it needs. + */ +export function collapseFloor( + plan: SubmoduleDenyPlan, + mounts: number, + takesAMount: (denyPath: string) => boolean, +): CollapseLevel { + let level = NO_COLLAPSE + let deniesBefore = Number.POSITIVE_INFINITY + for (;;) { + const denies = collapsedDenyPaths(plan, level).filter(takesAMount).length + if (denies <= mounts) return level + // A step that gave nothing back took entries that take no mount, and how + // many more of those lie ahead is a command's to choose. + if (denies >= deniesBefore) return collapseEverything(plan) + deniesBefore = denies + const degraded = collapseFurther(plan, level, denies - mounts, takesAMount) + if (degraded === undefined) return level + level = degraded + } +} + +/** How many entries before `from` it takes for `gives` to reach `mounts`, at + * least one: a step that degraded nothing would loop for ever. */ +function stepBack( + from: number, + mounts: number, + gives: (at: number) => number, +): number { + let given = 0 + let taken = 0 + for (let at = from - 1; at >= 0 && given < mounts; at--) { + given += gives(at) + taken += 1 + } + return Math.max(taken, 1) +} + +/** + * One line naming what `level` degraded, for the wrap's warning. Not what a + * whole-directory deny of the plan's own covers already: a collapse of that + * takes nothing away. + */ +export function describeCollapse( + plan: SubmoduleDenyPlan, + level: CollapseLevel, +): string { + const order = collapsibleGitDirs(plan) + const alreadyWhole = coveredByWholeDirDeny(plan) + const parts: string[] = [] + // Submodules and linked worktrees are degraded by the same steps and lose + // different things to them, so each is told in its own words. + for (const linkedWorktrees of [false, true]) { + const ofKind = (entry: { linkedWorktrees?: true }): boolean => + (entry.linkedWorktrees === true) === linkedWorktrees + if (level.wholeGitDirs > 0) { + const collapsed = order + .slice(order.length - level.wholeGitDirs) + .filter(entry => ofKind(entry) && !alreadyWhole(entry.gitDir)) + if (collapsed.length > 0) { + const repositories = [ + ...new Set(collapsed.map(entry => entry.modulesDir)), + ].join(', ') + const total = order.filter(ofKind).length + parts.push( + linkedWorktrees + ? `${collapsed.length} of the ${total} linked worktree git directories under ${repositories} are denied whole rather than by path, so git writes into those worktrees' own HEAD, index and logs fail read-only` + : `${collapsed.length} of the ${total} submodule git directories under ${repositories} are denied whole rather than by path, so git writes inside those submodules fail read-only`, + ) + } + } + if (level.wholeModulesDirs > 0) { + const collapsed = plan.repositories + .slice(plan.repositories.length - level.wholeModulesDirs) + .filter(ofKind) + .map(repository => repository.modulesDir) + .filter(modulesDir => !alreadyWhole(modulesDir)) + if (collapsed.length > 0) { + const taken = linkedWorktrees + ? `every linked worktree's git directory under ${collapsed.length === 1 ? 'it' : 'them'}, and \`git worktree add\` with ${collapsed.length === 1 ? 'it' : 'them'}` + : `every submodule under ${collapsed.length === 1 ? 'it' : 'them'}` + parts.push( + `${collapsed.join(', ')} ${collapsed.length === 1 ? 'is' : 'are'} denied whole, taking ${taken}`, + ) + } + } + } + return parts.join('; ') +} + +/** Whether a path is read-only whole in this plan whatever it degrades: a + * directory the walk could not see through, or one holding an entry that is + * a symlink, covers everything really under it. */ +function coveredByWholeDirDeny( + plan: SubmoduleDenyPlan, +): (candidate: string) => boolean { + const covers = plan.repositories + .flatMap(repository => repository.wholeDirDenies) + .map(realPathOrSelf) + if (covers.length === 0) return () => false + // Resolved once, not once for each of them: both are a command's to multiply. + return candidate => { + const real = realPathOrSelf(candidate) + return covers.some(cover => isAtOrUnder(real, cover)) + } +} + +/** Every submodule git directory the plan may degrade, in the order a + * collapse works back through: by repository as served, each repository's + * own sorted. */ +function collapsibleGitDirs( + plan: SubmoduleDenyPlan, +): Array< + { modulesDir: string; linkedWorktrees?: true; gitDir: string } & GitDirDenies +> { + return plan.repositories.flatMap(repository => + repository.gitDirs.map(submodule => ({ + modulesDir: repository.modulesDir, + linkedWorktrees: repository.linkedWorktrees, + ...submodule, + })), + ) +} + +/** Whether a path really lies under `dir`, both sides resolved: what a + * read-only bind of `dir` actually covers. */ +function underDirectory(dir: string): (candidate: string) => boolean { + const real = realPathOrSelf(dir) + return candidate => isAtOrUnder(realPathOrSelf(candidate), real) +} diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 66f9db747..8033091b3 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1,6 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf, wayTo } from '../utils/real-path.js' +import { realPathOf, realPathOrSelf, wayTo } from '../utils/real-path.js' import { whichSync } from '../utils/which.js' import { randomBytes } from 'node:crypto' import * as fs from 'fs' @@ -8,7 +8,29 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { endianness, tmpdir } from 'node:os' import path, { join } from 'node:path' -import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' +import { + ripGrep, + RipgrepError, + DEFAULT_RIPGREP_TIMEOUT_MS, + type RipgrepConfig, +} from '../utils/ripgrep.js' +import { + collectMountPoints, + discardMountPointManifest, + holdsMountPointManifest, + keepTrackOfMountPoint, + keepTrackOfMountSource, + kindOfMountPoint, + mountPointManifestDirectories, + namedMountPoints, + passesToEnd, + passesUnderWay, + publishMountPointManifest, + removePrivateManifestDirectory, + type MountPointManifest, + type NamedMountPoints, + type OwnManifestRelease, +} from './bwrap-mount-manifests.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { readNamesOf, writeNamesOf } from './path-entries.js' import { @@ -23,11 +45,33 @@ import { isAbsenceErrno, isAtOrUnder, isStrictlyUnder, + coversWriteTree, + meetsWriteRoots, properAncestors, nameLocation, getDangerousDirectories, workingDirectory, } from './sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + gitFileDenies, + gitRedirectPlaceholder, +} from './mandatory-deny-paths.js' +import type { GitDirTreeDenies, GitChainHop } from './mandatory-deny-paths.js' +import type { CollapseLevel, SubmoduleDenyPlan } from './linux-deny-collapse.js' +import { + NO_COLLAPSE, + collapseEverything, + collapseFloor, + collapseFurther, + collapsedDenyPaths, + degradableGitDirs, + describeCollapse, +} from './linux-deny-collapse.js' import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, @@ -277,6 +321,101 @@ function fileOnTheWay(targetPath: string): string | undefined { return undefined } +/** Whether `err` is a directory's refusal to have a file made in it: its mode, + * an immutable or read-only one, or no room left. */ +function refusesAFile(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return ['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT'].includes(code ?? '') +} + +/** + * Whether this process can make a file in `dir`. Asked for the mount point of + * a file git reads back, which is written here rather than left to bubblewrap + * (see {@link writeGitRedirectMountPoint}), and bubblewrap can write where this + * process cannot: in a directory of this user's whose mode refuses it. What + * cannot be asked counts as a yes, and the write says what is wrong. + */ +function takesAFileFromHere(dir: string): boolean { + try { + fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK) + return true + } catch (err) { + return !refusesAFile(err) + } +} + +/** + * See to it that the mount point at `dest`, a file git reads back, is there on + * the HOST and holds what git reads as no redirect (`gitRedirectPlaceholder`). + * One that bubblewrap makes is empty, and git refuses to run at all against + * such a `commondir`, outside the sandbox too for as long as the command runs. + * The profile binds the file read-only onto itself. + * + * INVARIANT: called only once this wrap's manifest, which names `dest`, is + * published and the clean-ups under way then are over. Another wrap that finds + * the file reads the manifests after it has seen it, and takes what none names + * for the caller's own. A process killed from here on leaves a file a manifest + * names. + * + * THREAT: a command already running may be writing the git directory. So the + * directory is held by a descriptor that followed no link, and nothing there is + * written over: the file is made exclusively, or found as another wrap made it. + * + * `changed` where the directory or the name is no longer what the plan saw, + * `refused` where the directory takes no file from this process after all, and + * `empty` where a mount point is there that holds nothing and should. The caller + * plans again on any of them. + */ +function writeGitRedirectMountPoint( + dest: string, +): 'held' | 'changed' | 'refused' | 'empty' { + const placeholder = gitRedirectPlaceholder(dest) ?? '' + let fd: number | undefined + try { + fd = fs.openSync( + path.dirname(dest), + O_PATH | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY, + ) + const gitDir = `/proc/self/fd/${fd}` + // Its link differs from the path when a symlink was followed on the way. + if (fs.readlinkSync(gitDir) !== path.dirname(dest)) return 'changed' + const file = `${gitDir}/${path.basename(dest)}` + try { + fs.writeFileSync(file, placeholder, { flag: 'wx', mode: 0o444 }) + return 'held' + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + if (kindOfMountPoint(file) !== 'file') return 'changed' + return fs.lstatSync(file).size === placeholder.length ? 'held' : 'empty' + } catch (err) { + if (refusesAFile(err)) return 'refused' + if (isAbsenceErrno(err)) return 'changed' + throw placeholderUnavailable(dest, 'its mount point could not be made', err) + } finally { + if (fd !== undefined) fs.closeSync(fd) + } +} + +/** Refuse the wrap, once, naming what could not be prepared and why. */ +function placeholderUnavailable( + dest: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `Cannot deny ${dest} without stopping git from working in that ` + + `repository: ${what} (${errorText(cause)}). git refuses to run at all ` + + `against a redirect file it cannot read, so the command was not run ` + + `rather than sandboxed behind a deny that breaks it` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError( + 'deny_placeholder_unavailable', + message, + cause, + ) +} + /** * Find the first non-existent path component. * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists, @@ -347,6 +486,11 @@ function makeableMountPoint( throw err } } + /** Until a manifest names it, and should the process end before one does. */ + const keepTrackOf = (made: string): void => { + keepTrackOfMountPoint(made) + registerExitCleanupHandler() + } let fd: number | undefined try { // One descriptor answers every question, through /proc: a command still @@ -378,7 +522,8 @@ function makeableMountPoint( // On a Windows drive the Windows ACL decides, and nothing on this side // tells it: every directory there is this user's, with a mode that lets it // write, also one that takes subfolders and no files. So the mount point - // is made here instead of by bubblewrap, which finds it there. + // is made here instead of by bubblewrap, which finds it there. No manifest + // names it yet, and the plan may be given up before one does. if (refusal === undefined) { const entry = `${parent}/${path.basename(dest)}` if ( @@ -386,9 +531,11 @@ function makeableMountPoint( refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === undefined ) { + keepTrackOf(dest) return MountPointKind.File } if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { + keepTrackOf(dest) return MountPointKind.Directory } } @@ -404,35 +551,224 @@ function makeableMountPoint( } } -/** What the `.git` of a directory is, and so what is denied there. */ -enum DotGitKind { - /** A repository: its hooks and config are denied. */ - Directory = 'directory', - /** - * The pointer of a worktree or a submodule ("gitdir: /path/..."). There are - * no .git/hooks for as long as it is a file, so the file is denied, which - * keeps it one. - */ - File = 'file', - /** Nothing is denied: a mount at .git would keep `git init` from making it. */ - Absent = 'absent', +/** The errno ripgrep's walk of an unreadable directory ends on. */ +const EACCES = 13 +/** The errno its walk of something that has gone away ends on. */ +const ENOENT = 2 + +/** + * Every OS error code a failed ripgrep run mentioned, from its `(os error N)` + * tokens. NO PATH IS EVER TAKEN FROM THIS TEXT: a directory's name is chosen by + * whoever made it and lands in stderr unescaped. A name can only ADD tokens, + * and an unrecognised one refuses the wrap. + */ +function ripgrepFailureErrnos(stderr: string): Set { + const errnos = new Set() + for (const match of stderr.matchAll(/\(os error (\d+)\)/g)) { + errnos.add(Number(match[1])) + } + return errnos } -function dotGitKindAt(dotGitPath: string): DotGitKind { +/** At most `limit` characters of `text`, with what was dropped noted. */ +function truncated(text: string, limit = 400): string { + const collapsed = text.trim() + return collapsed.length <= limit + ? collapsed + : `${collapsed.slice(0, limit)}… (${collapsed.length - limit} more characters)` +} + +/** + * Whether `entry`, listed as `child`, is a directory, by the kernel's own entry + * type: a symlink is not followed. Undefined where that cannot be told. + */ +function isDirectoryEntry( + entry: fs.Dirent, + child: string, +): boolean | undefined { + if (entry.isDirectory()) return true + if ( + entry.isFile() || + entry.isSymbolicLink() || + entry.isFIFO() || + entry.isSocket() || + entry.isBlockDevice() || + entry.isCharacterDevice() + ) { + return false + } + // The filesystem returned no type with the entry (DT_UNKNOWN), so ask. try { - return fs.statSync(dotGitPath).isDirectory() - ? DotGitKind.Directory - : DotGitKind.File - } catch { - return DotGitKind.Absent + return fs.lstatSync(child).isDirectory() + } catch (err) { + // Gone since the listing: nothing there to walk or to deny. + return isAbsenceErrno(err) ? false : undefined + } +} + +/** Refuse the wrap where a walk that stands in for the scan is out of time. */ +function walkOutOfTime(cwd: string, dir: string): LinuxSandboxProfileError { + return denyScanFailed( + cwd, + 'left too little of its time budget to walk what it could not report on', + new Error(`the walk stopped at ${dir}`), + ) +} + +/** + * The directories at and under `cwd` this process cannot read, walked the way + * the scan walks: symlinks not followed, `node_modules` skipped, no deeper than + * the scan reaches. It stands in for parsing paths out of rg's stderr, so that + * a directory NAME cannot add to the answer, aim it elsewhere or empty it. + * Exported for testing. + * + * `own` are those whose mode is this user's to change: the caller denies each + * whole, which also keeps the command from giving the mode back. `leftAlone` + * counts the rest, for which there is nothing to deny: another user's, of which + * there can be thousands, and one at the depth ripgrep stops at, which ripgrep + * opens and reports while nothing below it is in the scan's reach. + * + * Throws {@link LinuxSandboxProfileError} when `deadline` passes. + */ +export function unreadableDirectories( + cwd: string, + maxDepth: number, + deadline: number, +): { own: string[]; leftAlone: number } { + const own = new Set() + let leftAlone = 0 + const cannotBeSeenThrough = (dir: string): void => { + let uid: number | undefined + try { + uid = fs.lstatSync(dir).uid + } catch { + // Not to be looked at either: nobody's that can be told. + } + if (uid === process.getuid?.()) own.add(dir) + else leftAlone++ + } + + const walk = (dir: string, depth: number): void => { + if (Date.now() > deadline) throw walkOutOfTime(cwd, dir) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent means it went away mid-walk, which leaves nothing to deny. + if (isAbsenceErrno(err)) return + // Not always this one. A directory that can be listed and not SEARCHED + // (mode 0400) sends the walk here for a CHILD, which cannot be stat'ed + // either: recorded, it would ask bubblewrap for a file mount at a + // directory. One lstat tells the two apart: it needs the parent's search + // bit alone. + let denied = dir + try { + fs.lstatSync(dir) + } catch (lstatErr) { + if (!isAbsenceErrno(lstatErr)) denied = path.dirname(dir) + } + cannotBeSeenThrough(denied) + return + } + for (const entry of entries) { + // Excluded from the scan's globs, so rg never reports one either. + if (entry.name === 'node_modules') continue + const child = path.join(dir, entry.name) + const directory = isDirectoryEntry(entry, child) + if (directory === undefined) { + cannotBeSeenThrough(dir) + return + } + if (!directory) continue + // ripgrep is given one level more than `maxDepth`, and that is a file's. + if (depth + 1 <= maxDepth) { + walk(child, depth + 1) + continue + } + try { + fs.readdirSync(child) + } catch (err) { + if (isAbsenceErrno(err)) continue + try { + // Read as above: an lstat that fails for anything but absence says + // the search bit is missing HERE, so this directory - not the one + // below it, which nothing can reach - is what cannot be seen + // through, and every other entry of it is out of reach too. + fs.lstatSync(child) + leftAlone++ + } catch (lstatErr) { + if (isAbsenceErrno(lstatErr)) continue + cannotBeSeenThrough(dir) + return + } + } + } + } + + walk(cwd, 0) + return { own: [...own], leftAlone } +} + +/** + * The `.git` directories below `cwd` as deep as the scan takes a repository + * from, walked as {@link unreadableDirectories} walks, for a scan that cannot + * go by what a `.git` holds. One that cannot be listed is accounted for where + * ripgrep reports it. + */ +function gitDirectoriesBelow( + cwd: string, + maxDepth: number, + deadline: number, +): string[] { + const gitDirs: string[] = [] + const walk = (dir: string, depth: number): void => { + // How deep the directory holding a repository may lie: see `deepest` in + // linuxGetMandatoryDenyPaths. + if (depth > maxDepth - 2) return + if (Date.now() > deadline) throw walkOutOfTime(cwd, dir) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.name === 'node_modules') continue + const child = path.join(dir, entry.name) + if (isDirectoryEntry(entry, child) !== true) continue + if (entry.name === '.git') gitDirs.push(child) + else walk(child, depth + 1) + } } + walk(cwd, 0) + return gitDirs +} + +/** + * Refuse the wrap, naming what the scan did instead of delivering the deny + * paths below `cwd`. Sandboxing on a listing that stops somewhere unknown is + * how a nested repository keeps writable hooks, so the wrap is refused. + */ +function denyScanFailed( + cwd: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `The ripgrep scan of ${cwd} ${what} (${errorText(cause)}), so what it ` + + `would have denied below there is unknown: the command was not run ` + + `rather than sandboxed behind a deny list that may leave a nested ` + + `repository's hooks writable` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError('deny_scan_failed', message, cause) } /** * The part of the mandatory deny set that follows from the cwd alone: the - * dangerous files and directories resolved against it, plus `.git/hooks` and - * (unless the caller allows git config) `.git/config`, or a `.git` that is a - * file. + * dangerous files and directories resolved against it, and what its own + * `.git` leads to — the repository's hooks, config and redirect files and + * those of its submodule git directories, or, where `.git` is a pointer + * file, the file itself and the git directories it names. * {@link linuxGetMandatoryDenyPaths} adds the nested matches its ripgrep scan * finds on top of these. Split out so a consumer that must not scan — the * violation monitor, which needs the same denies to judge a write bwrap @@ -440,118 +776,471 @@ function dotGitKindAt(dotGitPath: string): DotGitKind { */ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, + allowWritePaths: readonly string[] = [], ): string[] { + const plan = cwdMandatoryDenyPlan(allowGitConfig, undefined, allowWritePaths) + return withoutWorkTreeDenies( + [ + ...collapsedDenyPaths(plan, NO_COLLAPSE), + ...chainHopDenies(plan.chainHops, allowWritePaths).held, + ], + allowWritePaths, + ) +} + +/** {@link linuxGetCwdMandatoryDenyPaths} with the working directory's own + * submodules still separable, which is what a wrap whose profile does not + * fit degrades. */ +function cwdMandatoryDenyPlan( + allowGitConfig: boolean, + deadline: number | undefined, + allowWritePaths: readonly string[], + visited = new Map(), +): SubmoduleDenyPlan { const cwd = process.cwd() - // Note: Settings files are added at the callsite in sandbox-manager.ts - const denyPaths = [ - // Dangerous files in CWD - ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), - // Dangerous directories in CWD - ...getDangerousDirectories().map(d => path.resolve(cwd, d)), - ] + const plan: SubmoduleDenyPlan = { + denyPaths: cwdDangerousDenyPaths(cwd), + repositories: [], + chainHops: [], + } + const { denyPaths, chainHops } = plan - // Only the cwd's own: a .git file below it is git's to rewrite and remove - // (a checkout that recurses into submodules does). const dotGitPath = path.resolve(cwd, '.git') - const dotGit = dotGitKindAt(dotGitPath) - - if (dotGit === DotGitKind.File) { + let dotGitStat: fs.Stats | undefined + try { + dotGitStat = fs.statSync(dotGitPath) + } catch (err) { + // Absent is the ordinary case, and nothing is denied then: a mount at + // .git would block `git init`. Anything else means a .git is there and + // could not be looked at, which is no reason to skip the enumeration — + // deny it whole instead. + if (!isAbsenceErrno(err)) { + denyPaths.push(dotGitPath) + return plan + } + } + const walk = { deadline, visited } + if (dotGitStat?.isDirectory()) { + addGitDirTree(plan, gitDirTreeDenies(dotGitPath, allowGitConfig, walk)) + plan.ownEntries = gitDirDenyPaths(dotGitPath, allowGitConfig) + } else if (dotGitStat?.isFile()) { + // A pointer file (linked worktree, submodule checkout) has no hooks/ + // beneath it, and binding a path under a file makes bwrap fail. + const pointer = gitFileDenies(dotGitPath, allowGitConfig, deadline) + denyPaths.push( + ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), + ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), + ) + chainHops.push(...pointer.chainHops) + // What the pointer names is this checkout's git directory, and the one + // its `commondir` names with it: every entry met so far is one of theirs. + plan.ownEntries = pointer.chainHops + .filter(hop => hop.kind === 'entry') + .map(hop => hop.source) + // A command in a linked worktree is commonly let write the repository's + // git directory, and what that keeps is as much the host's git's to run. + // Where it may write none of it there is nothing to deny, and a walk that + // refuses over a sibling worktree would protect nothing. + for (const gitDir of pointer.gitDirs) { + if (!meetsWriteRoots(gitDir, allowWritePaths)) continue + addGitDirTree(plan, gitDirTreeDenies(gitDir, allowGitConfig, walk)) + } + } else if (dotGitStat !== undefined) { + // Neither: no pointer git reads, and no .git/hooks for as long as it is + // there, so it is denied, which keeps it there. denyPaths.push(dotGitPath) } - if (dotGit === DotGitKind.Directory) { - // Git hooks always blocked for security - denyPaths.push(path.resolve(cwd, '.git/hooks')) - // Git config conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - denyPaths.push(path.resolve(cwd, '.git/config')) + return plan +} + +/** Adds one repository's denies to `plan`. */ +function addGitDirTree( + plan: SubmoduleDenyPlan, + denies: GitDirTreeDenies, +): void { + // One at a time: a call takes only so many arguments, and a command can make + // more git directories than that. + for (const denyPath of linuxGitDirTreeDenyPaths(denies)) { + plan.denyPaths.push(denyPath) + } + for (const hop of denies.chainHops) plan.chainHops.push(hop) + plan.repositories.push(...degradableGitDirs(denies)) +} + +/** + * {@link gitDirTreeDenyPaths} plus the directories denied WHOLE for holding an + * entry that is a symlink, and minus the deny paths that name a symlink itself: + * a bind lands on what a path resolves to, so the directory around a link is + * the only handle on it. + */ +function linuxGitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { + return [ + ...withoutChainHopLinks(gitDirTreeDenyPaths(denies), denies.chainHops), + ...denies.linkedEntryDirs.map(wholeDirDenyLanding), + ] +} + +/** + * Where a whole-directory deny LANDS. The deny loop resolves it either way; + * spelled here, {@link withoutWorkTreeDenies} sees what it would really cover + * (`ln -s ../.. .git/modules/x`). + */ +function wholeDirDenyLanding(dir: string): string { + return realPathOrSelf(dir) +} + +/** + * `denyPaths` without the ones naming a hop of a chain. A bind + * there lands on whatever the link leads to — for a symlinked directory + * component a whole directory nothing asked to deny, and for a `.git/modules` + * entry the git directory it reaches — while holding the link itself, which + * is the point, is what the hop's HOLDER is denied whole for. + */ +function withoutChainHopLinks( + denyPaths: string[], + chainHops: readonly GitChainHop[], +): string[] { + if (chainHops.length === 0) return denyPaths + const links = new Set(chainHops.map(hop => hop.link)) + return denyPaths.filter(denyPath => !links.has(denyPath)) +} + +/** + * Which of the symlinks a git directory is reached THROUGH this backend can + * hold. `held` is the directories holding one, a read-only bind each. + * + * Not in it: a hop a whole deny covers already, which names no holder; a hop no + * write root contains, which is read-only anyway; and a hop whose holder is or + * holds the working directory or a write root, where the bind would take the + * whole tree. That last kind is `unheld`, for the warning or the refusal {@link + * guardedDenyPlan} makes of it. + */ +function chainHopDenies( + chainHops: readonly GitChainHop[], + allowWritePaths: readonly string[], +): { held: string[]; unheld: GitChainHop[] } { + const held = new Set() + const unheld: GitChainHop[] = [] + const cwd = process.cwd() + for (const hop of chainHops) { + const holder = hop.holder + if (holder === undefined) continue + if (!allowWritePaths.some(allowed => isAtOrUnder(holder, allowed))) { + continue } + if ( + isAtOrUnder(cwd, holder) || + allowWritePaths.some(allowed => isAtOrUnder(allowed, holder)) + ) { + unheld.push(hop) + continue + } + held.add(holder) } + return { held: [...held], unheld } +} - return denyPaths +/** + * The deny plan a wrap emits: `plan` with the directories holding a chain hop + * added, one warning for the hops that cannot be held, and without any deny + * that would take the work tree whole ({@link withoutWorkTreeDenies}). + * + * Throws `deny_unresolvable_git_entry` where an unheld hop lies on a chain that + * reaches NOTHING: the warning leans on both ends being denied, and there is no + * far end. + */ +function guardedDenyPlan( + plan: SubmoduleDenyPlan, + allowWritePaths: readonly string[], +): SubmoduleDenyPlan { + const { held, unheld } = chainHopDenies(plan.chainHops, allowWritePaths) + const ownEntries = new Set(plan.ownEntries ?? []) + const unresolvable = unheld.filter( + // Only for an entry of the working directory's own repository: each is + // denied from the first wrap on, so a link there is the user's layout. + // Anything else a command may have made, and refusing for it would let a + // few writes lock every later command out, and protect nothing it could not + // reach by creating a nested repository outright. + hop => + hop.kind === 'entry' && + hop.chain === 'unresolvable' && + ownEntries.has(hop.source), + ) + if (unresolvable.length > 0) throw unresolvableGitEntry(unresolvable) + if (unheld.length > 0) { + const told = unheld + .map(hop => + hop.kind === 'pointer' + ? `the git directory ${hop.source} names is reached through ${hop.link}` + : hop.chain === 'unresolvable' + ? `${hop.source} leads nowhere, through ${hop.link}` + : `${hop.source} reaches what it denies through ${hop.link}`, + ) + .join('; ') + const holders = [...new Set(unheld.map(hop => hop.holder))].join(', ') + logForDebugging( + `[Sandbox Linux] ${told}. A read-only bind of the directory holding such a link is the only thing that holds it, and ${holders} is the working directory or a path this command may write, which binding read-only would take the whole tree with: a command in this sandbox can point the link somewhere else, and a git run on the host afterwards follows it there. Moving the link inside the git directory, spelling the pointer's path without it, or a denyWrite entry naming the directory holding it, closes that.`, + { level: 'warn' }, + ) + } + const denied = new Set(plan.denyPaths) + const added = held.filter(dir => !denied.has(dir)) + if (added.length > 0) { + const isAdded = new Set(added) + // A holder is a directory nothing asked to deny: say which link put it + // there, so a subtree that turns read-only can be traced to its cause. + const why = new Set( + plan.chainHops + .filter(hop => hop.holder !== undefined && isAdded.has(hop.holder)) + .map( + hop => + `${hop.holder} holds ${hop.link}, which ${hop.source} is reached through`, + ), + ) + logForDebugging( + `[Sandbox Linux] Bound read-only to hold a link: ${[...why].join('; ')}. Nothing in such a directory can be written by this command; removing the link, from outside the sandbox, lifts it.`, + ) + } + return { + ...plan, + denyPaths: withoutWorkTreeDenies( + [...plan.denyPaths, ...added], + allowWritePaths, + ), + } } /** - * The directories below `cwd`, down to `maxDepth`, that ripgrep said it could - * not read, that this process cannot read either, and whose mode is this - * user's to change. What is in one is not known, so the caller denies it whole. - * Exported for testing. + * `denyPaths` without any that would deny the work tree WHOLE. + * + * Every producer of a whole-directory deny reads paths a sandboxed command can + * create (`gitdir: ..`, a `.git/modules` entry linked at `../..`, an entry + * linked at the checkout, a directory left unreadable). One that lands on the + * working directory or a write root leaves nothing in the project writable from + * the next wrap on, with nothing inside the sandbox able to undo it. + * + * - A directory this process CAN list and search was denied whole to hold + * something inside it, both ends of which keep their own deny paths: dropped, + * with a warning. + * - One it cannot stands in for a tree nothing else covers: the command is + * REFUSED, naming it. + * + * Each is judged by where it LANDS, not by how it is spelled: `hooks -> ../..` + * is an ordinary deny path for which the deny loop would bind the checkout. + * + * One of the denied NAMES standing where it is spelled (`.vscode`, the `hooks` + * of a git directory) is no stand-in and is kept: only the configuration puts a + * write root at or beneath it, and there the deny wins. */ -export function unreadableDirectories( - stderr: string, - cwd: string, - maxDepth: number, +function withoutWorkTreeDenies( + denyPaths: readonly string[], + allowWritePaths: readonly string[], ): string[] { - const found: string[] = [] - const tried = new Set() - for (const line of stderr.split('\n')) { - // How a message is worded depends on ripgrep's version and on how many - // threads it has, so what stands before each ": " is tried. - const said = line.startsWith('rg: ') ? line.slice(4) : line - for ( - let cut = said.indexOf(': '); - cut !== -1; - cut = said.indexOf(': ', cut + 1) + const cwd = process.cwd() + const kept: string[] = [] + const dropped: string[] = [] + for (const denyPath of denyPaths) { + const landing = wholeDirDenyLanding(denyPath) + if ( + !coversWriteTree(landing, cwd, allowWritePaths) || + (landing === denyPath && + !isAtOrUnder(cwd, landing) && + endsInDeniedName(denyPath)) ) { - for ( - let dir = path.resolve(cwd, said.slice(0, cut)); - dir.startsWith(cwd + path.sep) && !tried.has(dir); - dir = path.dirname(dir) - ) { - tried.add(dir) - try { - // THREAT: the text is only a hint. A name can hold a newline and a - // whole message of its own, and a bind of `link/x` would bring what - // the link leads to INTO the sandbox. So: a directory, with no link - // on the way to it, that cannot be read from here. And this user's: - // nobody else's mode can be given back from inside, and there can - // be thousands of those (`/home`), each of them a mount. - const stat = fs.lstatSync(dir) - if ( - !stat.isDirectory() || - stat.uid !== process.getuid?.() || - realPathOf(dir) !== dir - ) { - break - } - } catch (error) { - // What cannot be looked at, because what holds it cannot be - // searched, is stood in for by the directory above it. - if ((error as NodeJS.ErrnoException).code === 'EACCES') continue - break - } - try { - fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK) - } catch { - if (path.relative(cwd, dir).split(path.sep).length <= maxDepth) { - found.push(dir) - } - } - break + kept.push(denyPath) + continue + } + try { + fs.readdirSync(landing) + fs.accessSync(landing, fs.constants.X_OK) + } catch (err) { + // A file holds no tree. It is a write root itself, /dev/null among the + // default ones, that an entry was linked to: the deny stands. + if ((err as NodeJS.ErrnoException).code === 'ENOTDIR') { + kept.push(denyPath) + continue } + const message = + `[Sandbox Linux] ${denyPath} could not be looked into (${errorText(err)}), ` + + `and it holds the working directory or a path this command may ` + + `write: denying it whole is the only thing that stands in for a tree ` + + `that cannot be read, and that would leave every command after this ` + + `one unable to write the project. Refusing instead; making the ` + + `directory readable, from outside the sandbox, lifts it` + logForDebugging(message, { level: 'warn' }) + throw new LinuxSandboxProfileError('deny_scan_failed', message, err) } + if (!dropped.includes(landing)) dropped.push(landing) + } + if (dropped.length > 0) { + logForDebugging( + `[Sandbox Linux] ${dropped.join(', ')} would have been denied whole, and each holds the working directory or a path this command may write, which a read-only bind would take the whole tree with: dropped. What that deny was holding - a symlinked git directory entry, or a link no bind can be put on - keeps the deny paths of its own two ends, and a command in this sandbox can still point the link somewhere else for a git run on the host to follow. A denyWrite entry naming the directory holding the link closes that.`, + { level: 'warn' }, + ) + } + return kept +} + +/** Whether the last components of `denyPath` are a name that is denied + * wherever it is found: a dangerous file or directory, a `.git`, or an entry + * of a git directory. */ +function endsInDeniedName(denyPath: string): boolean { + const components = denyPath.split(path.sep).map(normalizeCaseForComparison) + return [ + ...DANGEROUS_FILES, + ...getDangerousDirectories(), + '.git', + 'gitdir', + ...gitDirDenyPaths('', false), + ].some(name => { + const last = normalizeCaseForComparison(name).split('/') + return last.every( + (component, k) => + components[components.length - last.length + k] === component, + ) + }) +} + +/** + * The refusal for a git directory entry whose chain reaches nothing and whose + * links this backend has no bind for. Names the entry, the link and the + * directory holding it: every one of the three is somewhere the user can look, + * and each of the three ways out named at the end works on one of them. + */ +function unresolvableGitEntry( + hops: readonly GitChainHop[], +): LinuxSandboxProfileError { + const told = hops + .map( + hop => + `${hop.source} is a symlink whose chain reaches nothing and goes through ${hop.link}, which ${hop.holder} holds`, + ) + .join('; ') + return new LinuxSandboxProfileError( + 'deny_unresolvable_git_entry', + `[Sandbox Linux] ${told}. That directory is the working directory or a path this command may write, so the only thing that holds the link - a read-only bind of the directory around it - would take the whole tree with it, and the chain has no far end to deny instead: a command in this sandbox could put a directory in the link's place and leave the entry pointing at hooks or config this profile never saw, for a git run on the host to use. Refusing the command rather than running it with that open. Repairing the chain so it reaches something, or moving the links inside the git directory, closes it.`, + ) +} + +/** + * The deny paths `cwd` has whatever its `.git` turns out to be, and whatever + * can be read of it. Settings files are added at the callsite in + * src/sandbox/sandbox-manager.ts. + */ +function cwdDangerousDenyPaths(cwd: string): string[] { + return [ + // Dangerous files in CWD + ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), + // Dangerous directories in CWD + ...getDangerousDirectories().map(d => path.resolve(cwd, d)), + ] +} + +/** + * {@link linuxGetCwdMandatoryDenyPaths} for the violation monitor, which is + * started once for the session and must not fail over one repository: where the + * enumeration refuses, this directory's plain deny paths, with a warning. It + * decides which refused write is reported, never what bubblewrap enforces. + * Nothing is collapsed here, so after a wrap that had to, the path in a report + * can be one inside a git directory that is denied whole. + */ +export function linuxGetMonitorCwdDenyPaths( + allowGitConfig: boolean, + allowWritePaths: readonly string[] = [], +): string[] { + try { + return linuxGetCwdMandatoryDenyPaths(allowGitConfig, allowWritePaths) + } catch (err) { + const cwd = process.cwd() + logForDebugging( + `[Sandbox Linux] The git directories ${cwd} leads to could not be enumerated (${errorText(err)}); the violation monitor judges writes against its plain deny paths instead.`, + { level: 'warn' }, + ) + return [ + ...cwdDangerousDenyPaths(cwd), + ...monitorDotGitDenyPaths(cwd, allowGitConfig), + ] + } +} + +/** + * What the monitor treats as denied under `cwd`'s own `.git`, without + * following anything: the answers the wrap gives for the shape `.git` turns out + * to be. Absent is none of them — the wrap denies nothing there either, since a + * mount at `.git` would block `git init`. + */ +function monitorDotGitDenyPaths( + cwd: string, + allowGitConfig: boolean, +): string[] { + const dotGitPath = path.resolve(cwd, '.git') + try { + return fs.statSync(dotGitPath).isDirectory() + ? gitDirDenyPaths(dotGitPath, allowGitConfig) + : [dotGitPath] + } catch (err) { + // There and not inspectable: denied whole, as the wrap denies it. + return isAbsenceErrno(err) ? [] : [dotGitPath] } - return found } /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. - * With --max-depth limiting, this is fast enough to run on each command without memoization. + * + * Runs on each command without memoization. `--max-depth` keeps that to + * milliseconds on ordinary trees, but `--no-ignore` means gitignored data + * within the depth is walked too. A scan that does not deliver what is below + * the working directory aborts the wrap rather than sandboxing with a deny list + * of unknown completeness: one that could not be run at all, one killed before + * it exited on its own — {@link ripGrep}'s timeout, or any other signal that + * reached it — and one that failed for a reason no deny stands in for. Only a + * run that ran to its own exit is told apart by the `(os error N)` codes on + * stderr — see {@link ripgrepFailureErrnos}. */ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, + allowWritePaths: readonly string[] = [], abortSignal?: AbortSignal, -): Promise { +): Promise { const cwd = process.cwd() + const timeoutMs = ripgrepConfig.timeoutMs ?? DEFAULT_RIPGREP_TIMEOUT_MS + // What the scan gets, and what the walk of the working directory's own + // submodules that runs before it gets. Every walk made AFTER the scan takes + // a budget of its own of the same length: the scan has its own timeout, and + // a slow but successful one would otherwise refuse a repository whose + // submodules a walk given the time would have got through. + const deadline = Date.now() + timeoutMs + const walkDeadline = (): number => Date.now() + timeoutMs // Use provided signal or create a fallback controller const fallbackController = new AbortController() const signal = abortSignal ?? fallbackController.signal const dangerousDirectories = getDangerousDirectories() - const denyPaths = linuxGetCwdMandatoryDenyPaths(allowGitConfig) + // The working directory's own repository is the first the collapse works + // back through, so it is the last to lose its precise denies. + const visited = new Map() + const plan = asProfileRefusal(() => + cwdMandatoryDenyPlan(allowGitConfig, deadline, allowWritePaths, visited), + ) + const denyPaths = plan.denyPaths + + // Each nested repository the scan finds, once: the same walk the cwd's own + // git directory already had above, which every file listed under it leads + // back to. Collected rather than denied as they are met, because ripgrep + // lists a tree in whatever order its threads finish it in and a collapse + // works back through them in the order they were served: two wraps of one + // tree would otherwise degrade different repositories. + const seenGitDirs = new Set([path.resolve(cwd, '.git')]) + const nestedGitDirs: string[] = [] + const denyGitDir = (gitDir: string): void => { + if (seenGitDirs.has(gitDir)) return + seenGitDirs.add(gitDir) + nestedGitDirs.push(gitDir) + } // Build iglob args for all patterns in one ripgrep call const iglobArgs: string[] = [] @@ -561,21 +1250,19 @@ async function linuxGetMandatoryDenyPaths( for (const dirName of dangerousDirectories) { iglobArgs.push('--iglob', `**/${dirName}/**`) } - // Git hooks always blocked in nested repos. A repository is known by its - // HEAD, so that its hooks are denied before there are any. - iglobArgs.push('--iglob', '**/.git/hooks/**', '--iglob', '**/.git/HEAD') - - // Git config conditionally blocked in nested repos - if (!allowGitConfig) { - iglobArgs.push('--iglob', '**/.git/config') - } + // A nested repository is recognised by ANY regular file directly inside its + // .git directory, so that its hooks are denied before there are any. + // Detection must not depend on any one file the sandboxed command could move + // aside, nor on allowGitConfig, which governs what is denied and not what is + // found. A FILE named .git is a worktree/submodule pointer (gitFileDenies). + iglobArgs.push('--iglob', '**/.git/*', '--iglob', '**/.git') // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare // and the security benefit doesn't justify the traversal cost // // ripgrep lists files, and its depth is the file's. `sub/.vscode/x` and - // `sub/.git/config` lie at `maxDepth`; `sub/.git/hooks/pre-commit`, of the + // `sub/.git/config` lie at `maxDepth`; `sub/.claude/commands/x.md`, of the // same directory, one level further down. let matches: string[] = [] try { @@ -585,15 +1272,18 @@ async function linuxGetMandatoryDenyPaths( '--hidden', // INVARIANT: no file decides what is listed. An ignore file that names // a directory hides all beneath it, and a configuration file can add - // any flag; both are files in or above the tree. + // any flag (ripGrep passes --no-config); both are files in or above + // the tree, which one command could write to hide a nested repository + // from the next command's scan. '--no-ignore', - '--no-config', - // Into a pipe ripgrep writes by the block, and killed it drops the - // block: it would have listed nothing. - '--line-buffered', '--max-depth', String(maxDepth + 1), ...iglobArgs, + // The directory itself as well as what is under it: excluding only + // the contents leaves rg trying to open an unreadable node_modules, + // which fails the whole run over a directory nothing here wants. + '-g', + '!**/node_modules', '-g', '!**/node_modules/**', ], @@ -603,42 +1293,106 @@ async function linuxGetMandatoryDenyPaths( ) } catch (error) { // Stopped, it found nothing: the caller must not be handed a command - // without the denies it would have found. + // without the denies it would have found. The abort is its own answer and + // travels as itself. signal.throwIfAborted() - // INVARIANT: what ripgrep listed counts, however it ended. It exits 2 if - // there was a directory it could not read, having listed the rest, and is - // killed after ten seconds. What it had not come to by then is not denied. - if (error instanceof RipgrepError) { - matches = error.listed - // A read-only bind also keeps the command from giving the mode back. - denyPaths.push(...unreadableDirectories(error.stderr, cwd, maxDepth)) - } - logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, { - level: 'warn', - }) + if (!(error instanceof RipgrepError)) { + // The run never got far enough to report anything of its own: the + // binary could not be spawned or this process is out of descriptors — + // the missing dependency the start-up check refuses on, met later. + throw denyScanFailed(cwd, 'could not be run', error) + } + if (error.killed) { + // A run that did not exit on its own stops wherever the kill arrived, + // so its listing is a prefix of an unknown whole and not something to + // sandbox on: an unreached nested repository would be one with + // writable hooks. The codes on stderr do not redeem it — a run that + // already reported one unreadable directory and was then killed inside + // its budget has every code it printed stood in for and still never + // reached the rest of the tree. The command that runs next is the one + // that could have made the tree slow to walk, whichever kill it was. + throw denyScanFailed( + cwd, + error.timedOut + ? 'did not finish' + : `was killed by ${error.killedBy ?? 'a signal'} before it finished`, + error, + ) + } + // rg lists the rest of the tree and exits non-zero when an entry gets in + // its way, so the matches still count — but only where every code it + // reported is one a deny stands in for. Anything else, and anything that + // reported no code at all, leaves whatever the run did not reach unknown. + const errnos = ripgrepFailureErrnos(error.stderr) + const stoodInFor = [...errnos].every( + errno => errno === ENOENT || errno === EACCES, + ) + if (errnos.size === 0 || !stoodInFor) { + throw denyScanFailed( + cwd, + `failed for a reason no deny stands in for: ${truncated(error.stderr) || '(nothing on stderr)'}`, + error, + ) + } + matches = error.listed + if (errnos.has(EACCES)) { + // A directory rg could not read holds an unknown tree, so it is denied + // whole. WHICH directories is settled by walking the filesystem, not by + // reading rg's stderr: a name in there is chosen by whoever made the + // directory. A walk finding none means the run failed over something + // this cannot see, so there is nothing to stand in for it. + const { own, leftAlone } = unreadableDirectories( + cwd, + maxDepth, + walkDeadline(), + ) + if (own.length === 0 && leftAlone === 0) { + throw denyScanFailed( + cwd, + `reported a directory it could not read, and none is there now: ${truncated(error.stderr)}`, + error, + ) + } + denyPaths.push(...own) + logForDebugging( + `[Sandbox] ripgrep scan of ${cwd} could not read ${own.length} of this user's directories there, which are denied whole, and ${leftAlone} that are another user's or sit at the depth the scan stops at, for which there is nothing to deny; the ${matches.length} paths it did list still count: ${error}`, + { level: 'warn' }, + ) + } else { + // Every code was ENOENT: entries that went away while rg walked. There + // is nothing at those paths to deny, and mounting one would plant a + // file on the host where the command deleted one. + logForDebugging( + `[Sandbox] ripgrep scan of ${cwd} lost entries while it walked, which leaves nothing to deny; the ${matches.length} paths it did list still count: ${error}`, + { level: 'warn' }, + ) + } } - // The names a match can lie under, by path component. - const directoryNames = [ - ...dangerousDirectories, - '.git/hooks', - '.git/config', - '.git/HEAD', - ].map(name => normalizeCaseForComparison(name).split('/')) + // The directories a match can lie in, by path component. + const directoryNames = [...dangerousDirectories, '.git'].map(name => + normalizeCaseForComparison(name).split('/'), + ) + // The `.git` pointer files the scan listed have ONE budget between them, + // taken when the first is met. Each is a file a command can write, as large + // as git accepts and under as many names as it cares to link it by, so a + // budget apiece would bound nothing of what a tree full of them costs. + let pointersDeadline: number | undefined for (const match of matches) { + // Relative to cwd, so a dangerous name in cwd's own location never counts. const segments = path .relative(cwd, path.resolve(cwd, match)) .split(path.sep) const lower = segments.map(normalizeCaseForComparison) // Where the dangerous name begins and how long it is: the first of - // `directoryNames` on the way down, else the file itself. + // `directoryNames` on the way down to the file, else the file itself. let at = segments.length - 1 let length = 1 // How deep the directory holding the name, `at`, may lie. One level less - // for `directoryNames`, all alike: each costs mounts that keep what holds - // it from being renamed or removed, and bwrap's start grows with them. + // for a directory, all alike: each costs mounts that keep what holds it + // from being renamed or removed, and bwrap's start grows with them. let deepest = maxDepth - 1 - search: for (let i = 0; i < lower.length; i++) { + search: for (let i = 0; i < lower.length - 1; i++) { for (const name of directoryNames) { if (name.every((component, k) => lower[i + k] === component)) { at = i @@ -649,20 +1403,91 @@ async function linuxGetMandatoryDenyPaths( } } if (at > deepest) continue - const found = segments.slice(0, at + length) - if (lower[at] === '.git' && lower[at + 1] === 'head') - found[at + 1] = 'hooks' - denyPaths.push(path.join(cwd, ...found)) + // The directory, not the file, so files created in it later are covered. + const found = path.join(cwd, ...segments.slice(0, at + length)) + if (lower[at] !== '.git') { + denyPaths.push(found) + } else if (at < segments.length - 1) { + denyGitDir(found) + } else if (at > 0) { + // cwd's own pointer file is handled above, before the scan. + const pointer = asProfileRefusal(() => + gitFileDenies( + found, + allowGitConfig, + (pointersDeadline ??= walkDeadline()), + ), + ) + denyPaths.push( + ...withoutChainHopLinks(pointer.denyPaths, pointer.chainHops), + ...pointer.linkedEntryDirs.map(wholeDirDenyLanding), + ) + plan.chainHops.push(...pointer.chainHops) + pointer.gitDirs + .filter(gitDir => meetsWriteRoots(gitDir, allowWritePaths)) + .forEach(denyGitDir) + } } - return [...new Set(denyPaths)] + if (allowGitConfig) { + // The scan recognises a repository by a regular file directly inside its + // .git. With the config deny in place one is always there and cannot be + // removed from inside the sandbox; with config writes allowed a command + // can empty the directory of regular files and hide the repository from + // the next command's scan, so the directories are found here instead. + gitDirectoriesBelow(cwd, maxDepth, walkDeadline()).forEach(denyGitDir) + } + + // One budget between them as well, for the same reason: how many there are + // is a number a command can choose. + const nestedDeadline = walkDeadline() + for (const gitDir of nestedGitDirs.sort()) { + addGitDirTree( + plan, + asProfileRefusal(() => + gitDirTreeDenies(gitDir, allowGitConfig, { + deadline: nestedDeadline, + visited, + }), + ), + ) + } + + // Deduplicated here rather than at emission: a checked-out submodule's + // `.git` pointer names the same git directory the walk of `.git/modules` + // reached, so the two produce the same deny paths, and a collapse of that + // git directory has to leave no copy of them behind. + return { ...plan, denyPaths: [...new Set(denyPaths)] } } -// Track mount points created by bwrap for non-existent deny paths. -// When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty -// file on the host as a mount point. These persist after bwrap exits and must -// be cleaned up explicitly. -const bwrapMountPoints: Set = new Set() +/** + * Run `produce`, turning the refusal a git metadata file can raise into the + * wrap's own typed one. A `.git` pointer or a `commondir` whose target cannot + * be worked out the way git works it out refuses the command, and a + * sandboxed command can write one, so the refusal has to reach the caller as + * something it can branch on — and name the file, since lifting it takes a + * command run outside the sandbox. + */ +function asProfileRefusal(produce: () => T): T { + try { + return produce() + } catch (err) { + if (err instanceof SubmoduleWalkBudgetError) { + // The same answer a ripgrep scan that runs out of its time gets, for + // the same reason: a listing that stops somewhere unknown is not + // something to sandbox on. The walk shares that budget. + logForDebugging(`[Sandbox Linux] ${err.message}`, { level: 'warn' }) + throw new LinuxSandboxProfileError('deny_scan_failed', err.message, err) + } + if (!(err instanceof GitMetadataError)) throw err + logForDebugging(`[Sandbox Linux] ${err.message}`, { level: 'warn' }) + throw new LinuxSandboxProfileError( + 'deny_git_metadata_unreadable', + err.message, + err, + ) + } +} // The source of the empty-directory mount points: at most one at a time, made // on first use, reused while a sandbox is running, and removed with the mount @@ -699,6 +1524,9 @@ function ensureEmptyMountSourceDir(): string { // directory lands on 0500. Set the mode here rather than let the check // above reject the directory this call has just made. fs.chmodSync(emptyMountSourceDir, 0o700) + // The wrap may throw before a manifest names it, and the process end then. + keepTrackOfMountSource(emptyMountSourceDir) + registerExitCleanupHandler() return emptyMountSourceDir } @@ -862,6 +1690,16 @@ const ARG_HEADROOM_BYTES = 4096 /** bwrap's cap on parsed words, the command line and `--args` file together. */ const BWRAP_MAX_ARGS = 9000 +/** Words bubblewrap takes for one mount: the option and its two paths. */ +const BWRAP_WORDS_PER_MOUNT = 3 + +/** + * Words the wrap adds AFTER the mounts, which the mounts must leave room for: + * eighteen today, two for `--args `, and a little over. What comes BEFORE + * the mounts is counted as it is. + */ +const BWRAP_TRAILING_WORDS = 24 + /** * The fd the `--args` file is opened on: a single digit, since dash rejects * multi-digit redirections, and high, since embedders hand the command low @@ -959,7 +1797,14 @@ function errorText(error: unknown): string { /** Why a bubblewrap profile could not be run. */ export type LinuxSandboxProfileErrorCode = - /** More arguments than bubblewrap parses, on the line or through a file. */ + /** + * More arguments than bubblewrap parses, on the line or through a file. + * The submodule denies of a repository are degraded until the profile fits + * (see src/sandbox/linux-deny-collapse.ts), so what reaches this is a + * profile that does not fit with every one of them collapsed: the allow + * binds, the read denies and what they restore, the `denyWrite` entries + * the caller passed and the ancestor pins under them. + */ | 'too_many_arguments' /** A mount path holds a NUL byte, which no carrier of arguments can hold. */ | 'nul_in_path' @@ -972,18 +1817,73 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** + * A git directory's `commondir` or `config.worktree` could not be given a + * mount point git accepts, and the directory cannot be denied whole instead. + * /dev/null there would stop git working in that repository altogether. + */ + | 'deny_placeholder_unavailable' + /** + * The ripgrep scan the mandatory denies below the working directory come + * from did not deliver them: it could not be run, it was killed before it + * finished, or it failed for a reason no deny stands in for. Sandboxing on what it did list would + * leave whatever it never reached — a nested repository's hooks — writable, + * so the command is refused instead. The `.git/modules` walk spends the + * same budget and running out of it is the same answer. + */ + | 'deny_scan_failed' + /** + * A `.git` file, a `commondir` or a symlink names a git directory that cannot + * be worked out the way git works it out, so the hooks and config git runs + * through it are unknown. A sandboxed command can write one, and the message + * names it: the refusal is lifted only from outside the sandbox. + */ + | 'deny_git_metadata_unreadable' + /** + * An entry of the working directory's own git directory is a symlink whose + * chain reaches nothing, through a link that sits in the working directory or + * a write root. Only a read-only bind of that directory would hold the link, + * and left free a command can put a directory in its place, for the chain to + * reach hooks this profile never saw. + */ + | 'deny_unresolvable_git_entry' + /** More mount points than one manifest holds. */ + | 'too_many_mount_points' + /** + * The mount point manifests cannot be read, so a path cannot be told from a + * mount point a running sandbox relies on; or one of their directories cannot + * be looked at, so it cannot be kept out of the command's reach. It ends by + * itself: when this process has descriptors again, when the file system + * answers again, when what sits among them and is no manifest is an hour old, + * when the process on a record with no manifest has ended. `.cause` is the + * reason, with the file where there is one, which the message holds too. + */ + | 'mount_points_unreadable' + /** + * Other sandboxes' mount points kept being made or removed under what the + * profile relies on, or their manifests kept moving while they were read, + * each time the wrap planned it; or another process's clean-up is held up + * half way (stopped, or starved), and `.cause` is its record, named for its + * pid. Passing: wrap again. + */ + | 'mount_points_changed' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the * configuration expands to, with the mandatory denies of the working * directory, is past a limit, or, for `command_too_long` and `nul_in_path`, - * what the caller passed in is. The command was not run and no - * profile file stays open, so do not run the per-command cleanup - * (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that - * threw: it would release a second time, and a sandbox still running would - * lose its mount points. Branch on `.code`, never on `.message`, which - * carries the sizes of the moment. Other wrap-time failures (a shell that is - * not on PATH, a bridge socket that is gone) are plain Errors. + * what the caller passed in is, or, for the two `mount_points_` codes, other + * sandboxes' mount points cannot be told or do not hold still. The command was + * not run and no + * profile file stays open; the wrap has already let go of the mount points it + * had named and is not counted among the commands to clean up after. So do not + * make the per-command cleanup call (`cleanupAfterCommand()`, + * `cleanupBwrapMountPoints()`) for a wrap that threw: it counts for another + * wrap of this process, whose command is then refused its start if it has not + * started yet. It takes nothing from a running sandbox. Branch on `.code`, + * never on `.message`, which carries the sizes of the moment. Other wrap-time + * failures (a shell that is not on PATH, a bridge socket that is gone) are + * plain Errors. */ export class LinuxSandboxProfileError extends Error { readonly code: LinuxSandboxProfileErrorCode @@ -1009,10 +1909,46 @@ export class LinuxSandboxProfileError extends Error { } } +let stepShell: string[] | undefined + +/** + * The shell the returned string runs its own steps in, before bubblewrap: found + * once, bash at one of its two usual places, else /bin/sh, which is bash too on + * some systems (NixOS, Guix). + * + * bash, because where /bin/sh is dash the environment is rebuilt from the + * shell's variables, and every entry whose name is no identifier (`a.b`, + * `INPUT_GITHUB-TOKEN`, an exported function) is lost to the command. + * + * THREAT: this shell runs on the host, unsandboxed, in an environment a + * sandboxed command may have had a hand in. So `-p`: plain `bash -c` imports + * exported functions, and one named `shift`, `local`, `read`, `printf` or `exec` + * would run in place of the step's own word; it also reads the file `$BASH_ENV` + * names. And never a PATH look-up or the caller's `binShell`: a copy planted in + * a writable PATH directory would be run out here. + */ +function shellForSteps(): string[] { + if (stepShell === undefined) { + const isBash = (shell: string): boolean => { + try { + return path.basename(realPathOf(shell)) === 'bash' + } catch { + return false + } + } + const bash = + ['/bin/bash', '/usr/bin/bash'].find(isExecutable) ?? + ['/bin/sh'].find(isBash) + stepShell = bash === undefined ? ['/bin/sh', '-c'] : [bash, '-p', '-c'] + } + return stepShell +} + /** * Thrown by the wrap when the host changed its working directory while the - * mandatory denies were looked for. Nothing has been handed out or left on - * the host by then. `SandboxManager` starts the wrap over. + * mandatory denies were looked for, or while a clean-up was waited for. Nothing + * has been handed out by then, and the wrap has let go of what it had named. + * `SandboxManager` starts the wrap over. */ export class WorkingDirectoryChanged extends Error { constructor() { @@ -1030,13 +1966,50 @@ export class WorkingDirectoryChanged extends Error { * again through /proc. Every other word, the per-command environment and the * command among them, stays on the line. The result stays a simple command, * so a prefix (`exec`, `timeout 30`) or a suffix (`&& next`) still composes. + * + * With `started`, the record of a wrap that named mount points, the string is + * a shell that appends its own /proc/self/stat line to that record and then + * execs bwrap in its place: the pid on record is bwrap's, written before bwrap + * has made a mount. Read by the shell itself, a redirection on a builtin: a + * child's line would be that of a process gone at once. Not /proc/$$: under an + * outer /proc `$$` is nobody there, or an unrelated process. Into a variable + * local to a function: assigned to, one of that name that the caller exports + * would reach the command with the line in it. (`local` is in bash, dash, ash + * and mksh, not in POSIX; the function's name is none a shell has an alias for.) Appended, not written over, so + * that every run of the string is on record while it lasts. + * One shell does both where both are needed. * Throws {@link LinuxSandboxProfileError} when the profile cannot run. */ function renderBwrapInvocation( bwrapBinary: string, bwrapArgs: string[], mounts: { start: number; end: number; mandatoryDenyArgs: number }, + started?: string, ): string { + /** What the string's own shell does, to the file it is given as "$1". */ + type Step = { run: string; file: string } + const record: Step[] = + started === undefined + ? [] + : [ + { + run: 'srt_record() { local s && read -r s >"$1"; } && srt_record "$1"', + file: started, + }, + ] + /** `words`, run by a shell named `name` once it has taken `steps`. */ + const afterSteps = (steps: Step[], name: string, words: string[]): string => + steps.length === 0 + ? quote(words) + : quote([ + ...shellForSteps(), + [...steps.map(step => `${step.run} && shift`), 'exec "$@"'].join( + ' && ', + ), + name, + ...steps.map(step => step.file), + ...words, + ]) // Whose the arguments are and what lowers each share: the mandatory denies // follow from what the working directory holds, not from the configuration. const shares = (): string => @@ -1056,7 +2029,7 @@ function renderBwrapInvocation( 'Sandbox profile contains a path with a NUL byte, which neither a command line nor a file of bwrap arguments can carry', ) } - const inline = quote([bwrapBinary, ...bwrapArgs]) + const inline = afterSteps(record, 'srt', [bwrapBinary, ...bwrapArgs]) const inlineBytes = Buffer.byteLength(inline, 'utf8') const limit = maxArgStrlen() - 1 if (inlineBytes <= limit - ARG_HEADROOM_BYTES) { @@ -1081,20 +2054,25 @@ function renderBwrapInvocation( error, ) } - // /bin/sh opens the profile on the fd and execs bwrap, which reads it to + // The shell opens the profile on the fd and execs bwrap, which reads it to // EOF and closes it before running the command. - const viaArgsFile = quote([ - '/bin/sh', - '-c', - `exec ${BWRAP_ARGS_FD}<"$1" && shift && exec "$@"`, + const viaArgsFile = afterSteps( + [ + ...record, + { + run: `exec ${BWRAP_ARGS_FD}<"$1"`, + file: bwrapArgsProfilePath(argsFd), + }, + ], 'srt-args', - bwrapArgsProfilePath(argsFd), - bwrapBinary, - ...bwrapArgs.slice(0, mounts.start), - '--args', - String(BWRAP_ARGS_FD), - ...bwrapArgs.slice(mounts.end), - ]) + [ + bwrapBinary, + ...bwrapArgs.slice(0, mounts.start), + '--args', + String(BWRAP_ARGS_FD), + ...bwrapArgs.slice(mounts.end), + ], + ) const viaArgsFileBytes = Buffer.byteLength(viaArgsFile, 'utf8') if (viaArgsFileBytes > limit) { closeBwrapArgsProfile(argsFd) @@ -1109,19 +2087,20 @@ function renderBwrapInvocation( return viaArgsFile } -// Number of wrapped commands that have been generated but whose cleanup has -// not yet run. cleanupBwrapMountPoints() defers file deletion while this is -// positive, because deleting a mount point file on the host while another -// bwrap instance is still running detaches that instance's bind mount and -// the deny rule stops applying inside it. +let exitHandlerRegistered = false + +/** + * Wraps handed to the caller and not yet cleaned up after. While above zero, + * some command of this process may not have started, and its manifest and + * --args profile must still be there when it does; so no manifest of a command + * that has not started is released unless the caller names the command. + */ let activeSandboxCount = 0 // Forced cleanups so far. One zeroes the count under a wrap still in flight, // which from then on has nothing in it to give back. let forcedCleanups = 0 -let exitHandlerRegistered = false - /** * Register cleanup handler for bwrap mount points */ @@ -1144,96 +2123,93 @@ function registerExitCleanupHandler(): void { * host filesystem as mount points for --ro-bind. These files persist after * bwrap exits. This function removes them. * - * This should be called after each sandboxed command completes to prevent + * Call it once for each wrapped command, when that command is over, to keep * ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working - * directory. It is also called automatically on process exit as a safety net. + * directory. It is also called on process exit as a safety net. * - * Each call decrements the active-sandbox counter that was incremented by - * wrapCommandWithSandboxLinux(). File deletion is deferred until the counter - * reaches zero. Deleting a mount point file on the host while another bwrap - * instance is still running detaches that instance's bind mount (the dentry - * is unhashed, so path lookup no longer finds the mount) and the deny rule - * stops applying inside that sandbox. + * A wrapped command may be run, and run again, until this process lets go of + * it: no other process collects what it relies on while this one runs. Every + * call lets go of the commands that have run and ended, whatever else is + * outstanding. What only the caller can say is that a command which has NOT + * started is over, and what the call releases of those: * - * Pass `{ force: true }` to delete unconditionally — used by the process-exit - * handler and reset() where deferral is not meaningful. + * - with no `commandId`: none until it has been called once for every wrap + * handed out, since nothing can tell which command the call is for; + * - with the `commandId` its wrap was given: that command's, at once; + * - with `force`: all, because the process or session is ending. * - * Also closes the `--args` profiles the wraps of this batch opened. + * A command line let go of is refused its start from then on. + * + * The --args profiles go when none is outstanding. A mount point goes only when + * no running sandbox relies on it, which each sandbox's own process answers + * (see bwrap-mount-manifests.ts). A call too many takes nothing from a running + * sandbox, but counts for another wrap of this process, whose command is then + * refused its start if it has not started by the time the count reaches zero. + * Does nothing except on Linux. + */ +export function cleanupBwrapMountPoints(opts?: { + force?: boolean + commandId?: string +}): void { + cleanUpAfter(opts ?? {}, 'ended') +} + +/** + * {@link cleanupBwrapMountPoints}. `otherwise` is what is released with wraps + * still outstanding and no command named: the commands that have ended when it + * is the caller who says that one is over, none when it is a wrap that came to + * nothing giving its place in the count back. */ -export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { - if (!opts?.force) { +function cleanUpAfter( + opts: { force?: boolean; commandId?: string }, + otherwise: 'ended' | 'none', +): void { + if (process.platform !== 'linux') { + return + } + let release: OwnManifestRelease + if (opts.force) { + activeSandboxCount = 0 + forcedCleanups++ + release = 'all' + } else { if (activeSandboxCount > 0) { activeSandboxCount-- } - if (activeSandboxCount > 0) { + release = + activeSandboxCount === 0 + ? 'all' + : opts.commandId !== undefined + ? { commandKey: opts.commandId } + : otherwise + if (release !== 'all') { logForDebugging( - `[Sandbox Linux] Deferring mount point cleanup — ${activeSandboxCount} sandbox(es) still active`, + `[Sandbox Linux] ${activeSandboxCount} sandbox(es) of this process still outstanding - ` + + (release === 'none' + ? 'keeping what this process made' + : 'releasing only what the commands that have ended relied on' + + (release === 'ended' ? '' : `, and ${opts.commandId}`)), ) - return } - } else { - activeSandboxCount = 0 - forcedCleanups++ } - // Deepest first: a sandbox wrapped while another's directory mount point is - // there gets its own inside it, and a directory goes only when it is empty. - const deepestFirst = [...bwrapMountPoints].sort((a, b) => b.length - a.length) - for (const mountPoint of deepestFirst) { - try { - // Only remove if it's still the empty file/directory bwrap created. - // If something else has written real content, leave it alone. - const stat = fs.statSync(mountPoint) - if (stat.isFile() && stat.size === 0) { - fs.unlinkSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, - ) - } else if (stat.isDirectory()) { - // Empty directory mount points are created for intermediate - // components (Fix 2). Only remove if still empty. - const entries = fs.readdirSync(mountPoint) - if (entries.length === 0) { - fs.rmdirSync(mountPoint) - logForDebugging( - `[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`, - ) - } - } - } catch { - // Ignore cleanup errors — the file may have already been removed - } + const removed = collectMountPoints(release) + if ( + emptyMountSourceDir !== undefined && + removed.includes(emptyMountSourceDir) + ) { + emptyMountSourceDir = undefined } - bwrapMountPoints.clear() - if (emptyMountSourceDir !== undefined) { - try { - // rmdirSync, not a recursive remove: it neither follows a symlink nor - // descends, so a path that is no longer our empty directory is left - // exactly as found. - fs.rmdirSync(emptyMountSourceDir) - logForDebugging( - `[Sandbox Linux] Cleaned up the empty-directory mount source: ${emptyMountSourceDir}`, - ) - emptyMountSourceDir = undefined - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code === 'ENOTEMPTY') { - logForDebugging( - `[Sandbox Linux] Left the empty-directory mount source behind, something has written into it: ${emptyMountSourceDir}`, - ) - } - // Forget the path only when there is nothing left to remove. ENOTEMPTY, - // EACCES and EBUSY (a forced cleanup while a live sandbox still pins the - // source) leave it ours to try again; the revalidation on next use - // decides whether it can be reused. - if (code === 'ENOENT' || code === 'ENOTDIR') { - emptyMountSourceDir = undefined - } + + // An --args profile belongs to one wrap and nothing maps it back to a + // command, so they go together, when nothing is outstanding. + if (release === 'all') { + for (const argsFd of [...bwrapArgsFds]) { + closeBwrapArgsProfile(argsFd) } } - - for (const argsFd of [...bwrapArgsFds]) { - closeBwrapArgsProfile(argsFd) + if (opts.force) { + removePrivateManifestDirectory() } } @@ -1617,16 +2593,9 @@ export async function initializeLinuxNetworkBridge( throw new Error('Linux bridge process died unexpectedly') } - try { - // fs already imported - if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSockPath)) { - logForDebugging(`Linux bridges ready after ${i + 1} attempts`) - break - } - } catch (err) { - logForDebugging(`Error checking sockets (attempt ${i + 1}): ${err}`, { - level: 'error', - }) + if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSockPath)) { + logForDebugging(`Linux bridges ready after ${i + 1} attempts`) + break } if (i === maxAttempts - 1) { @@ -1891,7 +2860,8 @@ const KERNEL_TOP_LEVEL_DIRS = ['/proc', '/dev', '/sys'] /** * A self-bind ("pin") for every directory between a seed (where a deny bind, - * file mask or read-deny tmpfs lands) and the allowed write root covering it, + * file mask or read-deny tmpfs lands, or a directory of this library's own + * that is bound read-only) and the allowed write root covering it, * shallow-first, which makes each one a mountpoint the kernel refuses to * rename or remove. A directory that is not there is skipped: bwrap cannot * bind a missing source and there is nothing to rename. One that exists but @@ -2090,7 +3060,68 @@ function pushReadDenyDirMounts( } /** - * Generate filesystem bind mount arguments for bwrap + * How often a wrap plans its mounts before it gives up because what a plan + * relied on had changed by the time its manifest was published. + */ +const MOUNT_PLAN_ATTEMPTS = 3 + +/** Thrown in a plan that cannot be finished and may be the next time. */ +class MountPlanFailed extends Error {} + +/** + * `plan`'s deny paths at `level`. What a collapse adds is spelled as the walk + * found it, and an entry reached through a link can land anywhere, the checkout + * included, so it goes past the same guard as the rest. A bind the guard takes + * back stood for precise deny paths, which come back with it. + */ +function collapsedForWrap( + plan: SubmoduleDenyPlan, + level: CollapseLevel, + writeRoots: readonly string[], +): string[] { + const collapsed = collapsedDenyPaths(plan, level) + if (collapsed === plan.denyPaths) return collapsed + const kept = withoutWorkTreeDenies(collapsed, writeRoots) + if (kept.length === collapsed.length) return kept + const keptPaths = new Set(kept) + const takenBack = collapsed.filter(denyPath => !keptPaths.has(denyPath)) + const folded = plan.denyPaths.filter( + denyPath => + !keptPaths.has(denyPath) && + takenBack.some(whole => isStrictlyUnder(denyPath, whole)), + ) + return [...kept, ...folded] +} + +/** The filesystem arguments of a wrap, and what goes with them. */ +type FilesystemArgs = { + args: string[] + /** How many of them, from the first, have to stay on the command line. */ + onTheLine: number + manifest: MountPointManifest | undefined + /** Where the manifest's started record is, as the binds spell its directory. */ + started: string | undefined + /** How many of them are the mandatory denies'. */ + mandatoryDenyArgs: number +} + +/** + * Generate filesystem bind mount arguments for bwrap, inside `wordsAvailable` + * of bubblewrap's cap on parsed words. `undefined`, with its manifest let go + * of, when what the plan relied on on the host had changed by the time that was + * published: the caller plans again, as it does when this throws {@link + * MountPlanFailed}. + * + * The mandatory-deny scan runs once. What it found is planned into mounts, + * counted and, while the count is past what there is room for, degraded a step + * and planned again (src/sandbox/linux-deny-collapse.ts). Each pass measures + * the one before it: the pins a deny needs are known only once the list is + * complete. A pass no shorter than the one before is the last that is stepped + * to. + * + * Only the pass that is settled on is published. `gitDirsThatRefusedAFile` and + * `foundEmpty` are the caller's, for all the plans of one wrap (see + * writeGitRedirectMountPoints). */ async function generateFilesystemArgs( readConfig: FsReadRestrictionConfig | undefined, @@ -2101,19 +3132,219 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, -): Promise<{ args: string[]; mandatoryDenyArgs: number }> { - const args: string[] = [] + commandKey?: string, + wordsAvailable: number = Number.POSITIVE_INFINITY, + gitDirsThatRefusedAFile: Set = new Set(), + foundEmpty: Set = new Set(), +): Promise { const startedIn = workingDirectory() - // fs already imported + const mandatoryDenyScan: MandatoryDenyScanLatch = { done: false } + const build = ( + mandatoryDenyPaths: string[], + isSettledOn: (words: number) => boolean, + ): Promise => + buildFilesystemArgs( + readConfig, + writeConfig, + maskedFileBinds, + maskedFileStoreDir, + mandatoryDenyPaths, + mandatoryDenyScan, + isSettledOn, + gitDirsThatRefusedAFile, + foundEmpty, + startedIn, + abortSignal, + commandKey, + ) + if (writeConfig === undefined) { + // No write config to run a scan for: nothing below is waiting on one. + mandatoryDenyScan.done = true + return build([], () => true) + } + + const writeRootsNow = (): string[] => + (writeConfig.allowOnly ?? []).map(p => + normalizePathForSandbox(p, { literal: true }), + ) + const found = await linuxGetMandatoryDenyPaths( + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + writeRootsNow(), + abortSignal, + ) + mandatoryDenyScan.done = true + // INVARIANT: one plan, one working directory. A relative entry is resolved + // against it on both sides of the await above. + if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + const writeRoots = writeRootsNow() + const plan = guardedDenyPlan(found, writeRoots) + const takesAMount = (denyPath: string): boolean => + writeRoots.some(allowed => isAtOrUnder(denyPath, allowed)) + let level = collapseFloor( + plan, + Math.floor(wordsAvailable / BWRAP_WORDS_PER_MOUNT), + takesAMount, + ) + // What the pass before this one came to, once there has been one. + let wordsBefore: number | undefined + for (;;) { + let degraded: CollapseLevel | undefined + const filesystem = await build( + collapsedForWrap(plan, level, writeRoots), + words => { + degraded = + words <= wordsAvailable + ? undefined + : collapseFurther( + plan, + level, + Math.ceil((words - wordsAvailable) / BWRAP_WORDS_PER_MOUNT), + takesAMount, + ) + // A pass no shorter than the one before took entries that cost this + // profile nothing, such as git directories no write root contains. How + // many more lie ahead is a command's to choose, and each step is the + // whole profile planned again, so the next pass is the last level. + if ( + degraded !== undefined && + wordsBefore !== undefined && + words >= wordsBefore + ) { + degraded = collapseEverything(plan) + } + wordsBefore = words + return degraded === undefined + }, + ) + if (degraded !== undefined) { + // One plan, one working directory, on both sides of that await too. + if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + level = degraded + continue + } + // Settled: either it fits, or there is nothing left to degrade and + // renderBwrapInvocation refuses it with too_many_arguments. One warning + // per wrap, naming what the profile it returns gave up. + const told = filesystem === undefined ? '' : describeCollapse(plan, level) + if (told !== '') { + logForDebugging( + `[Sandbox Linux] The profile for this command did not fit what bubblewrap parses, so ${told}`, + { level: 'warn' }, + ) + } + return filesystem + } +} + +/** + * Set by {@link generateFilesystemArgs} once the mandatory-deny scan is behind + * it, and read by every pass it then builds: no path may be resolved + * (realpath) before that scan, which can run arbitrarily long and would leave + * a resolution taken ahead of it blind to a symlink retargeted meanwhile. A + * pass asked for before the latch is set refuses before it looks at anything, + * so the ordering cannot be lost to a later edit that measures a profile early. + */ +type MandatoryDenyScanLatch = { done: boolean } + +/** One buffered denyWrite bind. */ +type DenyWriteBind = { + /** What the bind reads from. */ + source: string + /** Where the bind lands: the deny path resolved. */ + dest: string + /** The pre-resolution deny path `dest` came from. A bind at the resolved + * dest also re-exposes whatever the symlinked spelling leads to, so the + * re-application passes compare a read deny's landing against both + * spellings. Landings and allowed write paths are canonical, so the extra + * spelling can only match more of them, never fewer. */ + rawDest: string + /** A mandatory deny asked for it, not the configuration. */ + mandatory: boolean + /** No mount point can be made there (see mountPointFor). Planned like the + * others, so that its ancestors are pinned and counted alike, and left out + * at emission. */ + unmakeable?: boolean +} + +/** + * The mounts for one deny list: `mandatoryDenyPaths` are the mandatory denies + * as {@link generateFilesystemArgs} has settled them, and the caller's own + * `denyWithinAllow` entries are added to them here. + * + * `isSettledOn` is asked once the mounts are planned, with how many arguments + * they come to at most once they are published. Until then nothing is + * named, and nothing is written into a repository, and where it says no that is + * all: the answer is `undefined`, and the caller knows why. + */ +async function buildFilesystemArgs( + readConfig: FsReadRestrictionConfig | undefined, + writeConfig: FsWriteRestrictionConfig | undefined, + maskedFileBinds: Array<{ realPath: string; fakePath: string }> | undefined, + maskedFileStoreDir: string | undefined, + mandatoryDenyPaths: string[], + mandatoryDenyScan: MandatoryDenyScanLatch, + isSettledOn: (words: number) => boolean, + gitDirsThatRefusedAFile: Set, + foundEmpty: Set, + startedIn: string | undefined, + abortSignal: AbortSignal | undefined, + commandKey: string | undefined, +): Promise { + if (!mandatoryDenyScan.done) { + throw new Error( + '[Sandbox Linux] The mounts were asked for before the mandatory-deny scan finished: what a realpath taken now says can be stale by the time they are built', + ) + } + const args: string[] = [] + + // The mount points on the host this wrap relies on: placeholders bwrap makes + // for absent deny paths, and another sandbox's that this wrap covers again. + const mountPoints: string[] = [] + // Where the manifests are kept, each made if it can be. + let manifestDirs: string[] = [] + const makeManifestDirs = (recording: boolean): void => { + try { + manifestDirs = mountPointManifestDirectories(recording) + } catch (err) { + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `Where the mount point manifests are kept cannot be told (${errorText(err)}), so they cannot be kept out of the command's reach`, + errorText(err), + ) + } + } + // What the manifests name, as of the last time it was asked. + let named: NamedMountPoints | undefined + const whatIsNamedNow = (ownManifest?: string): ReadonlySet => { + const now = namedMountPoints(named, ownManifest) + if ('inDoubt' in now) { + if (now.keptChanging) { + throw new MountPlanFailed(now.inDoubt) + } + // Not "treat what is there as named": naming what may be the caller's + // own makes it removable, from under a sandbox that bound it as that. + // The reason names the file: this refuses every wrap while it lasts. + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `The mount point manifests cannot be read (${now.inDoubt}), so what is at a denied path cannot be told from a mount point a running sandbox relies on`, + now.inDoubt, + ) + } + named = now + return named.paths + } // Collect normalized allowed write paths. Populated in the writeConfig // block, read again in the denyRead loop to re-bind writes under tmpfs. const allowedWritePaths: string[] = [] // denyWrite binds are buffered and emitted after denyRead processing so that - // a denyRead tmpfs over an ancestor directory doesn't wipe them out. - const denyWriteArgs: string[] = [] - // Where the mandatory denies' binds begin in it: after the configuration's. - let mandatoryDenyArgsStart = Infinity + // a denyRead tmpfs over an ancestor directory doesn't wipe them out. They + // are kept as entries rather than words so that the emission below is the + // one place that knows a deny bind is spelled --ro-bind , + // and so that what is known of one travels with it. + const denyWriteBinds: DenyWriteBind[] = [] // Directories that a deny entry re-binds read-only inside the sandbox // (--ro-bind ), keyed by resolved dest, with every raw // (pre-resolution) spelling each was reached through. A non-existent deny @@ -2131,26 +3362,18 @@ async function generateFilesystemArgs( // Asked of the dest, not of the entry in hand: the deny loop deduplicates // on the dest, so which spelling it sees is the caller's ordering. const symlinkedDenySpellingDests = new Set() - // dest → the pre-resolution deny path it came from. A bind at the resolved - // dest also re-exposes whatever the symlinked spelling leads to, so the - // re-application passes below compare a read deny's landing against both - // spellings. Landings and allowed write paths are canonical, so the extra - // spelling can only match more of them, never fewer. - const denyWriteRawDests = new Map() - // Placeholder dests where no mount point can be made (see mountPointFor). - // Planned like the others, so that their ancestors are pinned and counted - // alike, and left out at emission. - const unmakeableDenyDests = new Set() // The shared empty directory this call's placeholders bind from, resolved at // most once per wrap: resolving again mid-wrap (the cached path having been // tampered with in between) would leave the binds already emitted pointing // at a directory this same call has just judged not ours. let emptySource: string | undefined + // The mount points among the deny destinations that are files git reads + // back. Each is bound onto itself, and is written on the host once the + // manifest that names it is published (see writeGitRedirectMountPoint). + const gitRedirectMountPoints = new Set() // Where a mount given `p` lands: `p` fully resolved, every symlink on the - // way and not one hop. One resolution per path per wrap, so every predicate - // below sees the same answer, and none before the mandatory-deny scan's - // await: that scan can run arbitrarily long, and a realpath taken ahead of - // it would miss a symlink retargeted meanwhile. + // way and not one hop. One resolution per path per pass, so every predicate + // below sees the same answer. const canonicalFormCache = new Map() // Paths whose canonical location could not be LOOKED AT — a settled errno, // or a transient one that outlived the retry — so the recorded spelling @@ -2235,8 +3458,7 @@ async function generateFilesystemArgs( ) const isAllowedWriteRoot = (candidatePath: string): boolean => allowedWritePaths.includes(candidatePath) - // Resolves symlinks, so computed on first use, which is after the - // mandatory-deny scan. + // Resolves symlinks, so computed only where it is used. let readAllowPathsMemo: string[] | undefined const readAllowPaths = (): string[] => (readAllowPathsMemo ??= (readConfig?.allowWithinDeny || []).map(p => @@ -2398,8 +3620,7 @@ async function generateFilesystemArgs( // a deny lands. `mount` is undefined where nothing is mounted at all; // `liftedFile` marks a file deny an allowRead entry naming that very file // cancels — the entry mounts nothing in that case either. Lazy and - // memoised like readDenyEntries(): it resolves symlinks, so it must not run - // before the mandatory-deny scan's await. + // memoised like readDenyEntries(). let readDenyPlanMemo: ReadDenyPlanEntry[] | undefined const readDenyPlan = (): ReadDenyPlanEntry[] => (readDenyPlanMemo ??= readDenyEntries() @@ -2429,12 +3650,15 @@ async function generateFilesystemArgs( // beneath the allow binds, or after them under a '/' write root that would // otherwise bury them (see ancestorPinArgs). let ancestorPinInsertAt: number + // Just past the bind of the root. + let afterRootBind: number // Determine initial root mount based on write restrictions if (writeConfig) { // Write restrictions: Start with read-only root, then allow writes to specific paths args.push('--ro-bind', '/', '/') const beneathAllowBinds = args.length + afterRootBind = beneathAllowBinds // Allow writes to specific paths for (const pathPattern of writeConfig.allowOnly || []) { @@ -2507,11 +3731,9 @@ async function generateFilesystemArgs( // Inputs for the covering-directory vetoes, computed at most once and // only when a deny path (absent or existing) lies strictly beneath a // recorded read-only deny dir — commands with no such covering directory - // skip the extra stat/realpath/readdir syscalls entirely. Lazy - // evaluation also means the derivation runs from inside the deny loop, - // AFTER the (unbounded) mandatory-deny ripgrep await below, keeping the - // snapshot as close as possible to the denyRead loop that later acts on - // the real filesystem. + // skip the extra stat/realpath/readdir syscalls entirely. Running from + // inside the deny loop also keeps the snapshot as close as possible to + // the denyRead loop that later acts on the real filesystem. type StubSkipVetoInputs = | { /** The derivation held; the arrays below describe this wrap. */ @@ -2606,18 +3828,12 @@ async function generateFilesystemArgs( } // Deny writes within allowed paths (user-specified + mandatory denies) const configuredDenyPaths = writeConfig.denyWithinAllow || [] - const denyPaths = [ - ...configuredDenyPaths, - ...(await linuxGetMandatoryDenyPaths( - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - )), - ] - // INVARIANT: one plan, one working directory. A relative entry is resolved - // against it on both sides of the await above, the only one here. - if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + const denyPaths = [...configuredDenyPaths, ...mandatoryDenyPaths] + + // Before any reading of them. What the environment names is believed only + // while none of the others can be written, and what this wrap found named + // there its own manifest would carry to where every process believes it. + makeManifestDirs(false) // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and @@ -2625,52 +3841,135 @@ async function generateFilesystemArgs( const seenDenyWrite = new Set() // Every directory a deny path lies beneath, there or not. const denyPathAncestors = new Set() + // Git directories denied whole because this process can make no mount point + // in them (see the pre-pass). No placeholder is planned inside one. + const gitDirsDeniedWhole = new Set() + /** What a deny entry is, to both passes over denyPaths below. */ + type DenyPathClass = + /** Nothing to deny: --dev /dev has already replaced that tree. */ + | { kind: 'skip' } + /** A symlink cycle, or a chain past the ELOOP bound. */ + | { kind: 'unresolved'; raw: string } + /** A symlink appeared in the resolved path's existing prefix, at + * `at`, inside an allowed write path. */ + | { kind: 'masked-symlink'; raw: string; resolved: string; at: string } + /** A path to deny. What is there is for each pass to ask. */ + | { kind: 'resolved'; raw: string; resolved: string } + /** + * The one filter chain both passes below apply. They walk the same + * denyPaths and must reach the same verdict about each entry in the same + * order: the pre-pass is sound only if it records exactly the directories + * the loop re-binds read-only, and a directory recorded but never + * re-bound suppresses stubs the sandbox still needs. + * + * Run once per entry per pass — twice in all, once before any stub + * decision and once to emit — so each pass judges the filesystem as it + * stands when it acts on it. `alsoDenied` is the pass that gathers (see + * resolveSymlinkedDenyPath). + * + * It stops short of what is AT the path. The pre-pass asks whether that is + * a directory; the loop asks for its shape and then whether it is there, in + * that order, because another process can make or remove a mount point + * between the two. + */ + const classifyDenyPath = ( + pathPattern: string, + alsoDenied?: string[], + ): DenyPathClass => { + const raw = normalizePathForSandbox(pathPattern, { literal: true }) + if (raw.startsWith('/dev/')) return { kind: 'skip' } + // Resolve-before-mask: normalizePathForSandbox keeps the raw symlink + // path whenever resolution crosses isSymlinkOutsideBoundary (the + // common dotfiles case), so canonicalize here. Every mask/bind below + // must be computed against the resolved path — bwrap dest-resolves + // symlinks, so a mask on the raw path either aborts startup (dir + // symlink) or lands on the target inode anyway (file symlink). Writes + // through the original symlinked path resolve to the same denied + // target inside the mount namespace. + const resolved = resolveSymlinkedDenyPath(raw, alsoDenied) + if (resolved === null) return { kind: 'unresolved', raw } + // A deny path can only now land in /dev (e.g. a symlink into it), + // where --dev /dev has already replaced the tree and a bind would + // either miss or fight the new devtmpfs. + if (resolved.startsWith('/dev/')) return { kind: 'skip' } + // Defense-in-depth: the resolved path should be symlink-free for its + // existing prefix, but the tree may change between resolution and this + // check. A hit fails closed — the loop masks the component and bwrap + // refuses to start rather than sandboxing with an unprotected deny + // path, and the pre-pass records no read-only re-bind for it. + const at = findSymlinkInPath(resolved, allowedWritePaths) + return at + ? { kind: 'masked-symlink', raw, resolved, at } + : { kind: 'resolved', raw, resolved } + } // PRE-PASS (order-independent): record the directories the loop below // re-binds read-only, BEFORE any stub decision, so the read-only // conclusion does not depend on where an enclosing directory appears in - // the caller's denyWrite ordering. It applies the loop's own resolution, - // the same symlink re-check, and the same meetsAnyAllowedWritePath - // gate as the --ro-bind emission, and it records every raw spelling each - // directory is reached through. A recorded directory is EVIDENCE for - // skipping a stub only if it also passes the guard's vetoes below (no - // read-deny tmpfs contains it in any spelling; no allowed write path is - // both beneath it and under such a tmpfs), which exclude every way its - // subtree could be writable in the sandbox. Keep the two passes in lockstep: a - // directory recorded here is either re-bound read-only by the loop or - // skipped because a recorded directory above it survived the vetoes and - // is bound in its place, so every record still stands for a bind that - // lands — unless a symlink appears in its path between the two passes, - // where the loop masks that component and emits no bind for the - // directory (the re-check below); an emitted one missing from the record - // only costs a spurious abort. + // the caller's denyWrite ordering. It takes the classification above and + // adds the same meetsAnyAllowedWritePath gate as the --ro-bind + // emission, and it records every raw spelling each directory is reached + // through. A recorded directory is EVIDENCE for skipping a stub only if + // it also passes the guard's vetoes below (no read-deny tmpfs contains + // it in any spelling; no allowed write path is both beneath it and under + // such a tmpfs), which exclude every way its subtree could be writable + // in the sandbox. A directory recorded here is either re-bound read-only + // by the loop or skipped because a recorded directory above it survived + // the vetoes and is bound in its place, so every record still stands for + // a bind that lands — unless a symlink appears in its path between the + // two classifications, where the loop masks that component and emits no + // bind for the directory; an emitted one missing from the record only + // costs a spurious abort. for (const pathPattern of denyPaths) { - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) - if (rawPath.startsWith('/dev/')) { - continue - } - const resolvedPath = resolveSymlinkedDenyPath(rawPath, denyPaths) - if (resolvedPath === null || resolvedPath.startsWith('/dev/')) { + const classified = classifyDenyPath(pathPattern, denyPaths) + if (classified.kind === 'skip' || classified.kind === 'unresolved') { continue } + const { raw: rawPath, resolved: resolvedPath } = classified for (const above of properAncestors(resolvedPath)) { denyPathAncestors.add(above) } - // Same defense-in-depth re-check as the loop: there the --ro-bind is - // replaced by a symlink mask, so such a directory must not be recorded - // as re-bound here. - if (findSymlinkInPath(resolvedPath, allowedWritePaths)) { + if (classified.kind === 'masked-symlink') { continue } if (resolvedPath !== rawPath) { symlinkedDenySpellingDests.add(resolvedPath) } - let isDirectory = false + let isDirectory: boolean | undefined try { isDirectory = fs.statSync(resolvedPath).isDirectory() } catch { - continue // absent (or vanished): not a read-only re-bound directory + // absent (or vanished): not a read-only re-bound directory + } + // The mount point of a file git reads back is written by this process, + // which cannot write every git directory the command can. Refusing over + // that would be a brick a command can plant, and a mount point left to + // bubblewrap is an empty file this process could never remove. So the git + // directory is denied whole, ahead of every stub decision. + const gitDir = path.dirname(resolvedPath) + if ( + gitRedirectPlaceholder(resolvedPath) !== undefined && + isWithinAnyAllowedWritePath(gitDir) && + !gitDirsDeniedWhole.has(gitDir) && + (gitDirsThatRefusedAFile.has(gitDir) || + (isDirectory === undefined && !takesAFileFromHere(gitDir))) + ) { + // No whole-directory deny takes the work tree (see + // withoutWorkTreeDenies), and nothing else stands in for this one. + if (coversWriteTree(gitDir, process.cwd(), allowedWritePaths)) { + throw placeholderUnavailable( + resolvedPath, + `${gitDir} takes no file from this process, and cannot be denied whole instead: it holds the working directory or a path this command may write. Making it writable, from outside the sandbox, lifts this`, + new Error('permission denied'), + ) + } + gitDirsDeniedWhole.add(gitDir) + denyPaths.push(gitDir) + logForDebugging( + `[Sandbox Linux] ${gitDir} takes no mount point for ${path.basename(resolvedPath)} from this process; denying the git directory whole instead, which needs nothing written to it`, + { level: 'warn' }, + ) } - if (!isDirectory) { + if (isDirectory !== true) { continue } if (meetsAnyAllowedWritePath(resolvedPath)) { @@ -2842,6 +4141,16 @@ async function generateFilesystemArgs( const decided = decidedMountPointOver(absentPath) if (decided !== undefined) return none(`decided with ${decided}`) + // Asked apart from the covering directories below, whose vetoes only ever + // keep a placeholder. Here one is a mount point that this process cannot + // write, or that bubblewrap makes and this process cannot remove. The + // bind of the git directory holds the path, or, where a read-deny tmpfs + // hides that bind, hides the path with it (see uncoveredWritePaths). + const whole = [...properAncestors(absentPath)].find(above => + gitDirsDeniedWhole.has(above), + ) + if (whole !== undefined) return none(`${whole} is denied whole`) + // Read-only in the sandbox, where bwrap could not creat() a mount point // either. No allowed write path holds it, which leaves it to the initial // --ro-bind / /, or only covered ones do (see uncoveredWritePaths); or a @@ -2887,26 +4196,45 @@ async function generateFilesystemArgs( // tools that expect a directory there. Whatever the order of denyPaths. const dest = findFirstNonExistentComponent(absentPath) decidedMountPoints.add(dest) - const kind = makeableMountPoint( - dest, - denyPathAncestors.has(dest) - ? MountPointKind.Directory - : MountPointKind.File, - ) - if (kind === MountPointKind.Unmakeable) { - // The directories above it are pinned all the same: a command that - // may write one of THEM could otherwise move the refusing one aside. - unmakeableDenyDests.add(dest) - } else { - // INVARIANT: only a path found absent here is tracked, for - // cleanupBwrapMountPoints() to remove. An existing file that looks - // like a mount point may be the caller's own, or that of another - // process's sandbox still running, whose deny goes with it. - bwrapMountPoints.add(dest) - registerExitCleanupHandler() + const wanted = denyPathAncestors.has(dest) + ? MountPointKind.Directory + : MountPointKind.File + // The manifests are read before anything is made on the host (see + // makeableMountPoint). A plan refused for what they cannot tell would + // leave it there, and a clean-up in the same doubt removes nothing. + if (named === undefined) whatIsNamedNow() + // A file git reads back is made by this process, once a manifest names + // it, and that is where a refusal shows. Made empty any earlier, and left + // by a plan that came to nothing, it would cost the repository its git. + const kind = + wanted === MountPointKind.File && + gitRedirectPlaceholder(dest) !== undefined + ? wanted + : makeableMountPoint(dest, wanted) + // Where none can be made, the directories above it are pinned all the + // same: a command that may write one of THEM could otherwise move the + // refusing one aside. + if (kind !== MountPointKind.Unmakeable) { + // INVARIANT: only a path found absent here, or one a manifest names, + // goes into this wrap's manifest, for a clean-up to remove once no + // running sandbox relies on it. An existing file that looks like a + // mount point and that no manifest names is the caller's own. + mountPoints.push(dest) } return { dest, kind } } + /** + * What goes on a mount point that is a file. /dev/null will not do for one + * git reads back: a bind carries nodev, so git cannot read it. Decided from + * the resolved path, which every spelling of one file shares, rather than + * from the deny entry: a caller's own denyWrite naming the same file must + * not miss it. + */ + const fileMountSourceFor = (dest: string): string => { + if (gitRedirectPlaceholder(dest) === undefined) return '/dev/null' + gitRedirectMountPoints.add(dest) + return dest + } /** What goes on it. The empty directory is made when first asked for. */ const mountSourceFor = ({ dest, kind }: Placeholder): string => { switch (kind) { @@ -2915,34 +4243,19 @@ async function generateFilesystemArgs( case MountPointKind.Directory: return (emptySource ??= ensureEmptyMountSourceDir()) case MountPointKind.File: - return '/dev/null' + return fileMountSourceFor(dest) case MountPointKind.Unmakeable: - // Never mounted: unmakeableDenyDests keeps it out of what is emitted. + // Never mounted: the emission leaves out a bind marked unmakeable. return '/dev/null' } } for (const [index, pathPattern] of denyPaths.entries()) { - if (index === configuredDenyPaths.length) { - mandatoryDenyArgsStart = denyWriteArgs.length - } - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) - - // Skip /dev/* paths since --dev /dev already handles them - if (rawPath.startsWith('/dev/')) { - continue - } - - // Resolve-before-mask: normalizePathForSandbox keeps the raw symlink - // path whenever resolution crosses isSymlinkOutsideBoundary (the - // common dotfiles case), so canonicalize here. Every mask/bind below - // must be computed against the resolved path — bwrap dest-resolves - // symlinks, so a mask on the raw path either aborts startup (dir - // symlink) or lands on the target inode anyway (file symlink). Writes - // through the original symlinked path resolve to the same denied - // target inside the mount namespace. - const normalizedPath = resolveSymlinkedDenyPath(rawPath) - if (normalizedPath === null) { - // Unresolvable: a symlink cycle, or a chain past the ELOOP bound. + // The configuration's come first. + const mandatory = index >= configuredDenyPaths.length + const classified = classifyDenyPath(pathPattern) + if (classified.kind === 'skip') continue + const rawPath = classified.raw + if (classified.kind === 'unresolved') { // Fail closed. Dropping the deny here would sandbox the command with // the path unprotected, so instead mask the symlink component and let // bwrap refuse to start. When no component is a symlink inside an @@ -2954,42 +4267,40 @@ async function generateFilesystemArgs( ) if (unresolvableSymlink && !seenDenyWrite.has(unresolvableSymlink)) { seenDenyWrite.add(unresolvableSymlink) - denyWriteArgs.push('--ro-bind', '/dev/null', unresolvableSymlink) - denyWriteRawDests.set(unresolvableSymlink, rawPath) + denyWriteBinds.push({ + source: '/dev/null', + dest: unresolvableSymlink, + rawDest: rawPath, + mandatory, + }) } logForDebugging( `[Sandbox Linux] Deny path could not be resolved through symlinks, failing closed: ${rawPath}`, ) continue } + const normalizedPath = classified.resolved if (normalizedPath !== rawPath) { logForDebugging( `[Sandbox Linux] Resolved symlinked deny path: ${rawPath} -> ${normalizedPath}`, ) } - // Re-check after resolution: a deny path can only now land in /dev (e.g. - // a symlink into it), where --dev /dev has already replaced the tree and - // a bind would either miss or fight the new devtmpfs. - if (normalizedPath.startsWith('/dev/')) { - continue - } - // Dedup post-resolution: distinct spellings (tilde vs absolute, via // symlink vs direct) converge to the same real path here. if (seenDenyWrite.has(normalizedPath)) continue seenDenyWrite.add(normalizedPath) - // Defense-in-depth: the resolved path should be symlink-free for its - // existing prefix, but the tree may change between resolution and this - // check. A hit still fails closed — bwrap refuses to start rather than - // sandboxing with an unprotected deny path. - const symlinkInPath = findSymlinkInPath(normalizedPath, allowedWritePaths) - if (symlinkInPath) { + if (classified.kind === 'masked-symlink') { + const symlinkInPath = classified.at if (!seenDenyWrite.has(symlinkInPath)) { seenDenyWrite.add(symlinkInPath) - denyWriteArgs.push('--ro-bind', '/dev/null', symlinkInPath) - denyWriteRawDests.set(symlinkInPath, rawPath) + denyWriteBinds.push({ + source: '/dev/null', + dest: symlinkInPath, + rawDest: rawPath, + mandatory, + }) } logForDebugging( `[Sandbox Linux] Mounted /dev/null at symlink ${symlinkInPath} to prevent symlink replacement attack`, @@ -2997,23 +4308,79 @@ async function generateFilesystemArgs( continue } + // A mount point another sandbox made is an absent deny path in all but + // name: bound onto itself it would not be named in this wrap's manifest + // and would go, with this sandbox still bound over it, when its maker is + // cleaned up after. Only a manifest says that a path is one; what no + // manifest names is the caller's own and is never removed. An empty FILE + // that a manifest names is covered with /dev/null and named here, and so + // is a file git reads back that holds its placeholder, with that. A + // DIRECTORY is bound onto itself further down, since /dev/null cannot be + // bound over one, and is named with the other directories this profile + // relies on, where the manifest is published. + // + // Not asked of what lies at or under a placeholder of this plan, which + // covers it whatever is on the host by now. The shape is asked before + // the existence, and once more when the two disagree: another process + // can remove or make a mount point at any moment. + const decided = decidedMountPointOver(normalizedPath) !== undefined + let isEmptyFile = !decided && kindOfMountPoint(normalizedPath) === 'file' + // Asked once for the branches below. + let pathExists = + !decided && (isEmptyFile || fs.existsSync(normalizedPath)) + if (pathExists && !isEmptyFile) { + isEmptyFile = kindOfMountPoint(normalizedPath) === 'file' + } + if ( + isEmptyFile && + (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || + isWithinAnyAllowedWritePath(normalizedPath)) + ) { + // A reading that names the path is believed whenever it was taken. One + // that does not is believed only if taken after the file was seen: a + // sandbox publishes its manifest before bubblewrap makes the file, so + // an older reading would miss a sandbox that started since. + if ( + named?.paths.has(normalizedPath) || + whatIsNamedNow().has(normalizedPath) + ) { + if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { + denyWriteBinds.push({ + source: fileMountSourceFor(normalizedPath), + dest: normalizedPath, + rawDest: rawPath, + mandatory, + }) + } + decidedMountPoints.add(normalizedPath) + mountPoints.push(normalizedPath) + logForDebugging( + `[Sandbox Linux] Re-covering a mount point another sandbox made: ${normalizedPath}`, + ) + continue + } + pathExists = fs.existsSync(normalizedPath) + } + // Handle non-existent paths by mounting /dev/null to block creation. // Without this, a sandboxed process could mkdir+write a denied path that // doesn't exist yet, bypassing the deny rule entirely. - if ( - decidedMountPointOver(normalizedPath) !== undefined || - !fs.existsSync(normalizedPath) - ) { + if (!pathExists) { const placeholder = mountPointFor(normalizedPath) if (placeholder !== undefined) { const { dest, kind } = placeholder const source = mountSourceFor(placeholder) - denyWriteArgs.push('--ro-bind', source, dest) - // The first of the denies that share the destination. The record is - // purely additive: it only gives the tmpfs and mask comparisons - // below a second spelling to test, and `dest` itself is always - // tested. - denyWriteRawDests.set(dest, rawPath) + denyWriteBinds.push({ + source, + dest, + // The first of the denies that share the destination. The record + // is purely additive: it only gives the tmpfs and mask comparisons + // below a second spelling to test, and `dest` itself is always + // tested. + rawDest: rawPath, + mandatory, + unmakeable: kind === MountPointKind.Unmakeable, + }) logForDebugging( kind === MountPointKind.Unmakeable ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` @@ -3053,8 +4420,12 @@ async function generateFilesystemArgs( ) } } - denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) - denyWriteRawDests.set(normalizedPath, rawPath) + denyWriteBinds.push({ + source: normalizedPath, + dest: normalizedPath, + rawDest: rawPath, + mandatory, + }) } else { logForDebugging( `[Sandbox Linux] Skipping deny path not within allowed paths: ${normalizedPath}`, @@ -3069,9 +4440,9 @@ async function generateFilesystemArgs( // specially. Nothing is ever restored for it: no read-deny tmpfs lands // at '/'. allowedWritePaths.push('/') - ancestorPinInsertAt = args.length + ancestorPinInsertAt = afterRootBind = args.length } - // denyWriteArgs is emitted after the denyRead loop below. + // The buffered deny binds are emitted after the denyRead loop below. // Handle read restrictions by mounting tmpfs over denied paths. // Non-glob spellings arrive slash-free from normalizePathForSandbox — the @@ -3299,11 +4670,24 @@ async function generateFilesystemArgs( // each: a symlink spelling would pin the chain of the link, not of where // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. + // + // This library's own read-only directories are seeds as well (the manifest + // directories, the fake-file store, the empty placeholder source): a + // read-only bind protects one only while its name still leads to it, and + // with the temp dir strictly inside a write root a command could otherwise + // rename the temp dir aside and make the name again. + // With a manifest to come, where it goes is settled now, to be pinned too. + if (mountPoints.length > 0 || emptySource !== undefined) { + makeManifestDirs(true) + } const pinArgs = ancestorPinArgs( [ - ...denyWriteRawDests.keys(), + ...denyWriteBinds.map(bind => bind.dest), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), + ...[...manifestDirs, maskedFileStoreDir, emptySource].flatMap(dir => + dir === undefined ? [] : [canonicalForm(dir)], + ), ], { rootIsWriteRoot: isAllowedWriteRoot('/'), @@ -3316,9 +4700,10 @@ async function generateFilesystemArgs( // arguments: the pins above them here, their own binds as they are emitted. const pinned = new Set(pinArgs) const mandatoryDenyPins = new Set() - for (let i = mandatoryDenyArgsStart; i < denyWriteArgs.length; i += 3) { + for (const bind of denyWriteBinds) { + if (!bind.mandatory) continue for ( - let dir = path.dirname(denyWriteArgs[i + 2]!); + let dir = path.dirname(bind.dest); dir !== '/' && !mandatoryDenyPins.has(dir); dir = path.dirname(dir) ) { @@ -3347,10 +4732,9 @@ async function generateFilesystemArgs( // Write paths already restored read-only by a dropped deny bind, so two // denies covering the same path emit one --ro-bind. const restoredReadOnlyWritePaths = new Set() - for (let i = 0; i < denyWriteArgs.length; i += 3) { - const dest = denyWriteArgs[i + 2]! - if (unmakeableDenyDests.has(dest)) continue - const rawDest = denyWriteRawDests.get(dest) ?? dest + for (const bind of denyWriteBinds) { + if (bind.unmakeable) continue + const { dest, rawDest } = bind // A mask's landing, not its dest: the landing is where the mask's bind // actually sits, and this deny's dest is canonical, so the two are // comparable as written. @@ -3402,8 +4786,8 @@ async function generateFilesystemArgs( } continue } - args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) - if (i >= mandatoryDenyArgsStart) mandatoryDenyArgs += 3 + args.push('--ro-bind', bind.source, dest) + if (bind.mandatory) mandatoryDenyArgs += 3 emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit // above a read-denied path?". A bind at the resolved dest also re-exposes @@ -3458,14 +4842,212 @@ async function generateFilesystemArgs( } } + // What on the host the profile takes to be there: what it binds from or + // mounts on, and the directory each of its mount points is made in, which + // bubblewrap would make again, unpinned and named by nothing, if it were gone. + // Not for a wrap that restricts no write: it has no mount point of its own, + // and the manifests are not kept out of its command's reach either. + const madeIn = new Set(mountPoints.map(made => path.dirname(made))) + const boundFrom = new Set() + if (writeConfig !== undefined) { + args.forEach((word, i) => { + if (word === '--bind' || word === '--ro-bind' || word === '--tmpfs') { + boundFrom.add(args[i + 1]!) + } + }) + } + // Those of them, and the directories above them, that a manifest names: + // another sandbox's mount points, which go when no live manifest names them. + // A file among them was made again after the deny loop took it for the + // caller's own. + const mountPointsReliedOn = (ownManifest?: string): string[] => { + const found = new Set() + const namedNow = boundFrom.size > 0 ? whatIsNamedNow(ownManifest) : found + for (const relied of namedNow.size > 0 ? [...madeIn, ...boundFrom] : []) { + for (let dir = relied; dir !== '/'; dir = path.dirname(dir)) { + if (namedNow.has(dir)) found.add(dir) + } + } + return [...found].filter(relied => kindOfMountPoint(relied) !== undefined) + } + + // Name the mount points this wrap relies on before the sandbox can start. + // Those that could be recorded nowhere are this process's to remove. + // + // INVARIANT: the manifest names every path the profile relies on that a + // clean-up could remove, its own mount points and other sandboxes' alike, on + // a reading taken after the last look at the host above. A directory it left + // out (the one a placeholder is nested in, a pin) goes with its maker, and the + // command is then refused its start or, once the pin is mounted, loses it. + // + // Counted with what is still to come: the bind of the manifest, and one of + // each manifest directory and of each directory that mounts are made from. + const stillToCome = + 1 + + manifestDirs.length + + [maskedFileStoreDir, emptySource].filter(dir => dir !== undefined).length + if (!isSettledOn(args.length + BWRAP_WORDS_PER_MOUNT * stillToCome)) { + return undefined + } + const names = [...mountPoints, ...mountPointsReliedOn()] + const manifest = publishMountPointManifest( + names, + emptySource === undefined ? [] : [emptySource], + commandKey, + ) + if (manifest === 'too-large') { + throw new LinuxSandboxProfileError( + 'too_many_mount_points', + `Sandbox profile relies on ${names.length} mount points, more than one manifest holds; reduce the write denies on paths that do not exist`, + ) + } + // From here a clean-up that reads the manifests keeps what this one names. + // Until here any of it could go, or be made again by a sandbox that no + // reading above knew of. So the wrap looks again, this once, and plans afresh + // if anything is amiss. What cannot be looked at counts as there: bubblewrap + // does not start on what it cannot bind. + const isThere = (relied: string, asDirectory: boolean): boolean => { + try { + return fs.statSync(relied).isDirectory() || !asDirectory + } catch (err) { + return !isAbsenceErrno(err) + } + } + // All of them, whatever the first says, so that the next plan of this wrap + // goes by every refusal that asking did not tell (see takesAFileFromHere). + const writeGitRedirectMountPoints = (): boolean => { + const landed = new Set(emittedDenyWriteDests) + let held = true + for (const dest of gitRedirectMountPoints) { + if (!landed.has(dest)) continue + const gitDir = path.dirname(dest) + let outcome = gitDirsThatRefusedAFile.has(gitDir) + ? 'refused' + : writeGitRedirectMountPoint(dest) + if (outcome === 'empty' && foundEmpty.has(dest)) { + // Twice: what bubblewrap made where the file had gone from under a + // command about to start. It is no use to git and is not written over. + outcome = 'refused' + } + // Once: another wrap may be between making the file and writing it, or a + // killed one left it, and letting go of this plan's manifest collects it. + if (outcome === 'empty') foundEmpty.add(dest) + if (outcome === 'refused') gitDirsThatRefusedAFile.add(gitDir) + held &&= outcome === 'held' + } + return held + } + let holds = false + try { + // INVARIANT: no command is handed out while a clean-up is under way that + // may have read the manifests before this one was among them. It is on + // record by now, and what it set out to remove goes whenever its process + // gets to it: under a sandbox that has started, that lifts the deny. So it + // is waited for, and the look at the host comes after. Asked without giving + // the thread back first, which is all there is to it but for a moment now + // and then. + if (manifest !== undefined) { + let heldUp: string | undefined + try { + const underWay = passesUnderWay() + heldUp = + underWay.length > 0 + ? await passesToEnd(underWay, abortSignal) + : undefined + } catch (err) { + if (abortSignal?.aborted) throw err + throw new LinuxSandboxProfileError( + 'mount_points_unreadable', + `The mount point manifests cannot be listed (${errorText(err)}), so a clean-up that is about to remove what the profile relies on cannot be told`, + errorText(err), + ) + } + if (heldUp !== undefined) { + throw new LinuxSandboxProfileError( + 'mount_points_changed', + `A clean-up of mount points by another process is held up (${heldUp}, named for its pid), and what it set out to remove may go from under a sandbox started now`, + heldUp, + ) + } + // One plan, one working directory, on both sides of this await too. + if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + } + holds = + // Else let go of during the wait, by a forced clean-up: no command starts + // under it any more. + (manifest === undefined || holdsMountPointManifest(manifest.file)) && + // Only under a manifest that holds: it writes on the host. And ahead of + // the look at what the profile binds from, which these are among. + writeGitRedirectMountPoints() && + [...boundFrom].every(relied => isThere(relied, false)) && + [...madeIn].every(relied => isThere(relied, true)) && + mountPointsReliedOn(manifest?.file).every(dir => names.includes(dir)) && + // Else settled on since the pins were made: nothing is pinned above it. + (manifest === undefined || manifestDirs.includes(manifest.dir)) + } finally { + if (!holds && manifest !== undefined) { + discardMountPointManifest(manifest.file) + } + } + if (!holds) { + return undefined + } + // INVARIANT: no manifest directory is writable from inside a sandbox, whether + // or not the wrap names a manifest (see mountPointManifestDirectories). Each + // is bound read-only after every allow and deny bind, like the two stores + // below, and where it really is: bubblewrap makes a bind's destination by name + // inside the new root, and a link with an absolute target leads nowhere there. + // + // A read deny that covers one keeps the command out of it already. Bound on + // top of that, it could be read after all: other projects' paths, the pids of + // their sandboxes. + const isInReach = (dir: string): boolean => + !isHiddenByTmpfs(dir, 'writes and reads') + let started: string | undefined + let onTheLine = 0 + if (manifest !== undefined) { + const dir = canonicalForm(manifest.dir) + const file = path.join(dir, path.basename(manifest.file)) + started = path.join(dir, path.basename(manifest.started)) + // INVARIANT: the manifest is the first thing bound after the root, and its + // bind is on the command line, not in an --args file. bubblewrap looks up + // every bind source before it makes anything, so a command started once a + // clean-up has claimed its manifest fails there ("Can't find source path") + // wherever the bind stands. First is for the claim that lands after that + // look: bubblewrap then fails at this bind ("Can't get type of source"), + // and bound any later it would by then have made mount points on the host + // that nothing names or removes. On the line, because the --args file is + // opened by a descriptor number that another wrap's file may have by then. + args.splice(afterRootBind, 0, '--ro-bind', file, file) + onTheLine = afterRootBind + 3 + if (isInReach(dir)) args.push('--ro-bind', dir, dir) + } + if (names.length > 0 || emptySource !== undefined) { + registerExitCleanupHandler() + } + if (writeConfig !== undefined) { + // The others need only be kept out of the command's reach, so one that has + // gone by the time the command starts - a temp dir whose parent was + // replaced - is not an error. + // Each where it really is, once: two of the names may lead to one place. + const others = new Set(manifestDirs.map(canonicalForm)) + if (manifest !== undefined) others.delete(canonicalForm(manifest.dir)) + for (const dir of others) { + if (isInReach(dir)) args.push('--ro-bind-try', dir, dir) + } + } + // INVARIANT: the fake-file store directory must never be writable from // inside the sandbox. If it were, a sandboxed process could plant a // symlink at a fake path and a later host-side write() would follow it, // or replace a fake's content so the bind exposes attacker bytes. Emit // last so it overlays any earlier --bind that covers the store dir - // (e.g. allowWrite: ['/tmp'] when the store is under os.tmpdir()). + // (e.g. allowWrite: ['/tmp'] when the store is under os.tmpdir()). Bound + // where it really is, like the manifest directories above and for the same + // reason. if (maskedFileStoreDir !== undefined) { - args.push('--ro-bind', maskedFileStoreDir, maskedFileStoreDir) + const dir = canonicalForm(maskedFileStoreDir) + args.push('--ro-bind', dir, dir) } // INVARIANT, for the same reason: the empty directory the placeholders above @@ -3473,14 +5055,16 @@ async function generateFilesystemArgs( // the system temp dir, so a caller's allowWrite or a host $TMPDIR under a // default write path makes it writable — and a placeholder binds it onto a // DENIED destination, so a write to the source lands at the deny. Emit last, - // like the store, to overlay any earlier --bind that covers it. A same-uid - // process on the host can still swap the source between wrap and run, which - // is outside what this library defends against. + // like the store, to overlay any earlier --bind that covers it, and where it + // really is, like the store. A same-uid process on the host can still swap + // the source between wrap and run, which is outside what this library + // defends against. if (emptySource !== undefined) { - args.push('--ro-bind', emptySource, emptySource) + const dir = canonicalForm(emptySource) + args.push('--ro-bind', dir, dir) } - return { args, mandatoryDenyArgs } + return { args, onTheLine, manifest, started, mandatoryDenyArgs } } /** @@ -3527,7 +5111,8 @@ async function generateFilesystemArgs( * CALLER OBLIGATION: the euid and capability decisions behind the returned * string are made here, in the process that builds it, so the string must be * run by a process with the same euid and the same capability bounding and - * inheritable sets. + * inheritable sets, and in the same PID namespace: a command that names mount + * points records its own pid, which a clean-up asks after from here. */ export async function wrapCommandWithSandboxLinux( params: LinuxSandboxParams, @@ -3591,15 +5176,6 @@ export async function wrapCommandWithSandboxLinux( return command } - // Mark this sandbox invocation as active. cleanupBwrapMountPoints() will - // defer file deletion until this (and every other concurrent) invocation - // has been cleaned up. The matching decrement happens in - // cleanupBwrapMountPoints(), which the caller must invoke after the - // spawned command exits. If wrapping fails below, the catch block - // decrements so the count does not leak. - activeSandboxCount++ - const countedAfter = forcedCleanups - // One encoded key for both carriers below (SRT_ENCODED_CMD for the seccomp // observer, the proxy username for network denies), so a violation seen // through either resolves to the same registry entry. macOS derives its log @@ -3608,8 +5184,16 @@ export async function wrapCommandWithSandboxLinux( attributionKeyFor(command, commandId), ) + // Counted from here, for the clean-up the caller makes when the command is + // over; the catch below takes it back if no command comes of this wrap. + activeSandboxCount++ + const countedAfter = forcedCleanups + const bwrapArgs: string[] = ['--new-session', '--die-with-parent'] let applySeccompPrefix: string | undefined + // The manifest naming the mount points this wrap relies on, dropped again if + // no command comes of it. + let manifest: MountPointManifest | undefined try { // ========== SECCOMP FILTER (Unix Socket Blocking) ========== @@ -3779,23 +5363,47 @@ export async function wrapCommandWithSandboxLinux( } // ========== FILESYSTEM RESTRICTIONS ========== - const { args: fsArgs, mandatoryDenyArgs } = await generateFilesystemArgs( - readConfig, - writeConfig, - maskedFileBinds, - maskedFileStoreDir, - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - ) - const mountsStart = bwrapArgs.length - bwrapArgs.push(...fsArgs) + let filesystem: Awaited> + const gitDirsThatRefusedAFile = new Set() + const foundEmpty = new Set() + for (let left = MOUNT_PLAN_ATTEMPTS; filesystem === undefined; left--) { + if (left === 0) { + throw new LinuxSandboxProfileError( + 'mount_points_changed', + `What the sandbox profile relies on on the host changed under each of ${MOUNT_PLAN_ATTEMPTS} plans: other sandboxes' mount points, or their manifests, are being made and removed there`, + ) + } + filesystem = await generateFilesystemArgs( + readConfig, + writeConfig, + maskedFileBinds, + maskedFileStoreDir, + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + // Only an id the caller chose: the attribution key falls back to the + // command text, which two wraps in flight may share. + commandId, + // What bubblewrap will parse, less the environment and network words + // already built above and the few that come after the mounts. + BWRAP_MAX_ARGS - bwrapArgs.length - BWRAP_TRAILING_WORDS, + gitDirsThatRefusedAFile, + foundEmpty, + ).catch((error: unknown) => { + if (!(error instanceof MountPlanFailed)) throw error + return undefined + }) + } + // Held for the catch below: a wrap that produces no command has to let go + // of the mount points it named. + manifest = filesystem.manifest const mounts = { - start: mountsStart, - end: bwrapArgs.length, - mandatoryDenyArgs, + start: bwrapArgs.length + filesystem.onTheLine, + end: bwrapArgs.length + filesystem.args.length, + mandatoryDenyArgs: filesystem.mandatoryDenyArgs, } + bwrapArgs.push(...filesystem.args) // Always bind /dev bwrapArgs.push('--dev', '/dev') @@ -3908,6 +5516,7 @@ export async function wrapCommandWithSandboxLinux( bwrapPath ?? 'bwrap', bwrapArgs, mounts, + filesystem.started, ) const restrictions = [] @@ -3926,12 +5535,16 @@ export async function wrapCommandWithSandboxLinux( if (forcedCleanups !== countedAfter) activeSandboxCount++ return wrappedCommand } catch (error) { - // Undo the activeSandboxCount increment — the caller won't call - // cleanupBwrapMountPoints() for a wrap that threw. A command that ended - // meanwhile deferred its removal to this wrap, so it runs here. After a - // forced cleanup the count is of other sandboxes only: one taken from it - // has their mount points removed while they run. - if (forcedCleanups === countedAfter) cleanupBwrapMountPoints() + // No command came of this wrap, so no sandbox starts under its manifest. + if (manifest !== undefined) { + discardMountPointManifest(manifest.file) + } + // Its place in the count goes back through the clean-up: a command that + // ended meanwhile left to this wrap what goes when none is outstanding. + // After a forced cleanup the count is of other wraps only: one taken from + // it has a command that has not started refused its start. It says nothing + // of the other commands: one that has run may be run again. + if (forcedCleanups === countedAfter) cleanUpAfter({}, 'none') throw error } } diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 1eb0db73f..6f34e4bb7 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -1,7 +1,9 @@ import { quote } from '../utils/shell-quote.js' import { spawn } from 'child_process' +import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' +import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' import { whichSync } from '../utils/which.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { @@ -18,7 +20,18 @@ import { pathSpellings, DANGEROUS_FILES, getDangerousDirectories, + isAbsenceErrno, + coversWriteTree, + meetsWriteRoots, } from './sandbox-utils.js' +import { + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + gitFileDenies, + physicalDenyPath, +} from './mandatory-deny-paths.js' +import type { GitChainHop } from './mandatory-deny-paths.js' import { denyGlobRegex, escapeRegexLiteral, @@ -90,18 +103,24 @@ export interface MacOSSandboxParams { } /** - * The mandatory write denies (no filesystem scanning). Each name appears - * twice: once as the path in the cwd, and once as a pattern for the same - * name anywhere beneath it, which macOS matches via a regex. + * The mandatory write denies. Each dangerous name appears twice: once as the + * path in the cwd, and once as a pattern for the same name anywhere beneath + * it, which macOS matches via a regex. The git denies add the working + * directory's own repository, read off disk — the `.git` pointer it may be, + * or the git directories under its `.git/modules` — so those depend on the + * tree at cwd. * - * Both are anchored at the cwd, and the cwd is a name on disk that may - * contain `[`, `*` or `?`. So the first is a literal entry, and the second - * carries the cwd as its anchor — only the `**\/` tail is pattern. - * Compiled as one glob, a cwd like `a[b/c]d` would turn into a character - * class and every one of these denies would match nothing. + * Every pattern here is anchored at the cwd and every path read off disk is a + * literal entry, because both are names a glob would misread: the cwd is a + * name on disk that may contain `[`, `*` or `?`, and a submodule's directory + * is named by whatever the sandboxed command put under `.git/modules`. + * Compiled as one glob, `a[b/c]d` turns into a character class and the deny + * matches nothing at all — including the very directory it was built from. */ export function macGetMandatoryDenyEntries( allowGitConfig = false, + allowWritePaths: readonly string[] = [], + hasWritePatterns = false, ): PathEntry[] { const cwd = normalizePathForSandbox(process.cwd(), { literal: true }) const entries: PathEntry[] = [] @@ -122,19 +141,174 @@ export function macGetMandatoryDenyEntries( entries.push(beneathCwd(`**/${dirName}/**`)) } - // Git hooks are always blocked for security - entries.push(literal('.git/hooks')) - entries.push(beneathCwd('**/.git/hooks/**')) + // Nested repositories and the submodule git directories they keep under + // .git/modules/ are matched by pattern: there is no scan on macOS, and a + // glob covers a git directory that does not exist yet as well. The + // submodule name is a single segment here — a nested repository's + // `vendor/lib` submodule is not covered — because a `**` in the middle + // would also match any component named config or hooks (a branch named + // feature/config, a submodule named config), which fails ordinary git + // operations that worked before. + for (const gitDirPattern of ['**/.git', '**/.git/modules/*']) { + for (const pattern of gitDirDenyPaths(gitDirPattern, allowGitConfig)) { + entries.push(beneathCwd(pattern)) + } + } - // Git config - conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - entries.push(literal('.git/config')) - entries.push(beneathCwd('**/.git/config')) + // The working directory's own repository is enumerated instead: literals + // are exact whatever a submodule is named, and each one pins its + // directories against being renamed out from under the deny. The producers + // in mandatory-deny-paths.ts return the string arrays the Linux backend + // binds, and every string in them was read off the filesystem, so each + // becomes a literal entry here — both of its spellings, since a filter and + // a bind hold a path that goes through a symlink by different ends (see + // gitDiskDenyEntries). + // One budget for all that is read off disk, which a command can make large. + const deadline = Date.now() + DEFAULT_RIPGREP_TIMEOUT_MS + const withOnDisk = ( + denyPaths: string[], + chainHops: readonly GitChainHop[] = [], + ): PathEntry[] => [ + ...entries, + ...gitDiskDenyEntries(denyPaths, chainHops, cwd, allowWritePaths, deadline), + ] + const dotGit = path.resolve(cwd, '.git') + let dotGitStat: fs.Stats | undefined + try { + dotGitStat = fs.statSync(dotGit) + } catch (err) { + // Absent is the ordinary case. Anything else means a .git is there and + // could not be looked at, which is no reason to skip the enumeration: a + // literal entry is a subpath deny, so this denies it whole, as the Linux + // backend's bind of the same path does. + if (!isAbsenceErrno(err)) return withOnDisk([dotGit]) + } + if (dotGitStat?.isDirectory()) { + const tree = gitDirTreeDenies(dotGit, allowGitConfig, { deadline }) + return withOnDisk(gitDirTreeDenyPaths(tree), tree.chainHops) } + // Absent, or a pointer file: the repository's own hooks and config are + // denied either way, so neither can be created under a .git that is not + // there yet. + const own = gitDirDenyPaths(dotGit, allowGitConfig) + if (!dotGitStat?.isFile()) return withOnDisk(own) + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFilter), which cannot follow them. + const pointer = gitFileDenies(dotGit, allowGitConfig, deadline) + // Only where the command may write some of it: else there is nothing to deny, + // and a walk that refuses over a sibling worktree would protect nothing. What + // a write root that is a pattern reaches is not worked out. + const trees = pointer.gitDirs + .filter( + gitDir => hasWritePatterns || meetsWriteRoots(gitDir, allowWritePaths), + ) + .map(gitDir => gitDirTreeDenies(gitDir, allowGitConfig, { deadline })) + return withOnDisk( + [...own, ...pointer.denyPaths, ...trees.flatMap(gitDirTreeDenyPaths)], + [...pointer.chainHops, ...trees.flatMap(tree => tree.chainHops)], + ) +} +/** + * Literal entries for the deny paths a git producer read off disk: each as it + * was written, and the path the kernel lands on walking it, without + * duplicates. + * + * Seatbelt matches a filter against the path an operation RESOLVED to, while + * the path in the filter is compared as a string, so the two spellings of a + * path that goes through a symlink hold different halves of it. Measured with + * `(deny file-write* (subpath P))` and a `dlink` leading out of the tree: + * + * P = dlink/sub P = dlink P = the landing + * write P/f lands lands REFUSED + * write link/f lands lands REFUSED + * rm / mv dlink lands REFUSED lands + * + * `literal` and `regex` filters answer the same, and so does `file-read*`. + * The path as written is therefore the only name an unlink or a rename of a + * link in it uses, and the landing the only one a write to what it leads to + * matches — a chain needs both ends named, and either alone leaves a hole. + * + * Emitted here rather than by the producers both backends share, because + * Linux has no such split: bwrap resolves a deny path before it binds and + * lands on the same directory whichever spelling it was handed. + * + * The two collapse to one entry wherever nothing on the way is a symlink, + * which is every path of an ordinary repository, and wherever the difference + * is only the `/tmp` and `/var` prefixes ({@link normalizePathForSandbox} + * canonicalises those onto `/private`, which is the spelling Seatbelt + * compares against). + * + * A CHAIN HOP is the exception, and gets its own path alone: what a hop leads + * to is a whole directory nothing asked to deny - the `/private/var` a + * `/var/folders` pointer walks through, the shared directory a `links/gd` + * value crosses - and denying it would take everything beside the git + * directory with it. The link's own name is the entire handle on a hop, which + * is why Linux drops it from its binds for the mirror-image reason (see + * `withoutChainHopLinks` in src/sandbox/linux-sandbox-utils.ts). What lies + * past the hop is protected by the git directory's own deny paths, which are + * in this same list and do get both spellings. + */ +function gitDiskDenyEntries( + denyPaths: readonly string[], + chainHops: readonly GitChainHop[], + cwd: string, + allowWritePaths: readonly string[], + deadline: number, +): PathEntry[] { + const hopLinks = new Set(chainHops.map(hop => hop.link)) + const entries: PathEntry[] = [] + const named = new Set() + const dropped: string[] = [] + for (const denyPath of denyPaths) { + const spellings = hopLinks.has(denyPath) + ? [denyPath] + : [denyPath, physicalDenyPath(denyPath, deadline)] + // A literal entry is a subpath deny, so one naming the working directory + // or a write root - by either spelling - takes the whole tree read-only + // for every command after the one that arranged it, and a `.git` pointer + // naming the checkout or an entry linked at `..` is one write. Neither + // end of what such a deny was holding loses its own entry; see + // `withoutWorkTreeDenies` in src/sandbox/linux-sandbox-utils.ts, which + // makes the same call for the same reason. + if ( + spellings.some(spelling => + coversWriteTree(spelling, cwd, allowWritePaths), + ) + ) { + dropped.push(denyPath) + continue + } + for (const spelling of spellings) { + const entry = toLiteralPathEntry(spelling) + if (named.has(entry.path)) continue + named.add(entry.path) + entries.push(entry) + } + } + if (dropped.length > 0) { + logForDebugging( + `[Sandbox macOS] ${dropped.join(', ')} would have been denied whole, and each is the working directory or a path this command may write: dropped, since denying one leaves every later command unable to write the project. What it was holding keeps the entries of its own two ends.`, + { level: 'warn' }, + ) + } return entries } +/** + * SBPL filter matching a regular file named `.git` anywhere under cwd, a + * `gitdir:` pointer. Matched by vnode type so a repository's .git DIRECTORY + * stays writable. Anchored at the cwd like the mandatory patterns, so a cwd + * whose own name holds a bracket is escaped into the regex rather than + * compiled as a character class that matches neither it nor anything under + * it. + */ +function gitPointerFilter(): string { + const cwd = normalizePathForSandbox(process.cwd(), { literal: true }) + return `(require-all (vnode-type REGULAR-FILE) ${pathFilter(anchoredGlobEntry(cwd, '**/.git'))})` +} + export interface SandboxViolationEvent { line: string command?: string @@ -1001,7 +1175,13 @@ function generateWriteRules( const denyEntries = [ ...(config.denyWithinAllow || []).map(toPathEntry), ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), - ...macGetMandatoryDenyEntries(allowGitConfig), + ...macGetMandatoryDenyEntries( + allowGitConfig, + // The write roots that are names, a path marked literal among them. A + // pattern names no directory that a deny could take whole. + writeRoots.flatMap(root => (root.glob ? [] : [root.path])), + writeRoots.some(root => root.glob), + ), ] denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) @@ -1015,7 +1195,25 @@ function generateWriteRules( for (const entry of ungrouped) { denyFilters.add(denyPathFilter(entry)) } + const gitPointer = gitPointerFilter() + denyFilters.add(gitPointer) rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag)) + // An existing pointer cannot be rewritten; `git worktree add` still creates + // new ones, but only inside the write roots, since this allow follows the + // denies. User and mandatory denies are re-applied to creation by the rule + // below, and removing or renaming over an existing pointer by the + // file-write-unlink deny after it. + if (allowFilters.size > 0) { + const createPointer = `(require-all ${gitPointer} (require-any ${[...allowFilters].join(' ')}))` + rules.push( + ...renderRule( + 'allow', + ['file-write-create'], + new Set([createPointer]), + logTag, + ), + ) + } // Block file movement to prevent bypass via mv/rename. A grouped path // contributes its regex, the pin for its parent directory, and the @@ -1038,6 +1236,15 @@ function generateWriteRules( ), ) + // Unlink only, so creating a pointer stays allowed: the rule above and the + // read section's re-allow of file-write-unlink for write roots would + // otherwise leave `rm lib/.git` and `mv evil lib/.git` open, which is the + // same rewrite the file-write* deny blocks. Emitted last; nothing after it + // in the profile re-allows unlink. + rules.push( + ...renderRule('deny', ['file-write-unlink'], new Set([gitPointer]), logTag), + ) + return rules } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts new file mode 100644 index 000000000..2c7a9c5f1 --- /dev/null +++ b/src/sandbox/mandatory-deny-paths.ts @@ -0,0 +1,1665 @@ +import * as fs from 'fs' +import * as path from 'path' +import { logForDebugging } from '../utils/debug.js' +import { linkTextOf, realPathOrSelf } from '../utils/real-path.js' +import { DEFAULT_RIPGREP_TIMEOUT_MS } from '../utils/ripgrep.js' +import { + isAbsenceErrno, + isAtOrUnder, + MAX_SYMLINK_RESOLUTION_DEPTH, +} from './sandbox-utils.js' + +/** + * Absent, so a command could still create it. Narrower than the shared {@link + * isAbsenceErrno}: a symlink loop is walked instead, for its links to be held, + * and ENAMETOOLONG is {@link isUnusablePathError}. + */ +function isAbsenceError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'ELOOP' || code === 'ENAMETOOLONG') return false + return isAbsenceErrno(err) +} + +/** + * Longer than the filesystem allows: no file can be there, for git to read or + * for a command to create. + */ +function isUnusablePathError(err: unknown): boolean { + return (err as NodeJS.ErrnoException | undefined)?.code === 'ENAMETOOLONG' +} + +/** + * How much of a `.git` file or a `commondir` is read: what git accepts for a + * `.git` file. git has no bound for a `commondir`, so a larger one fails + * closed. + */ +const MAX_GIT_METADATA_BYTES = 1024 * 1024 + +/** What a walk gets where the caller sets no deadline. */ +const DEFAULT_SUBMODULE_WALK_TIMEOUT_MS = DEFAULT_RIPGREP_TIMEOUT_MS + +/** + * A walk looks at the clock once per this many entries or path components: one + * directory, or one value, can hold as many as all the rest. + */ +const WALK_DEADLINE_CHECK_INTERVAL = 128 + +/** + * Whether a directory holding `entries` is taken for a git directory. + * Deliberately looser than git's `is_git_directory` (a valid HEAD plus objects/ + * and refs/), which only ever denies more: any one of these will do. git needs + * `HEAD` and `objects`, and neither is a deny path, so a command can rename both + * aside. `config`, `hooks` and `commondir` are what this file protects: from the + * first wrap that saw the directory on, one that is there cannot be moved. + * + * Each counts as the kind of entry git reads there, so that `vendor/config/lib` + * does not make a git directory of `vendor`. An entry of no known type counts. + */ +function looksLikeGitDir(entries: fs.Dirent[]): boolean { + return entries.some(entry => + ['hooks', 'objects'].includes(entry.name) + ? !entry.isFile() + : ['HEAD', 'config', 'commondir'].includes(entry.name) && + !entry.isDirectory(), + ) +} + +/** + * Directories git and its helpers keep in a git directory, which is where a + * repository is large. The `.git/modules` walk goes on past a git directory + * ({@link walkModules}) and stays out of these but for one listing. + */ +const GIT_OWN_DIRECTORIES = new Set([ + 'objects', + 'refs', + 'logs', + 'hooks', + 'info', + 'worktrees', + 'lfs', + 'rr-cache', +]) + +/** + * What tells a submodule's git directory that has one of those names + * (`vendor/lfs`) from git's own directory of that name. `logs/HEAD` and + * `lfs/objects` rule the other markers out. + */ +const GIT_PROTECTED_ENTRIES = new Set(['config', 'hooks']) + +/** The entries of a git directory that are denied and are files. */ +const GIT_ENTRY_FILES = new Set(['config', 'commondir', 'config.worktree']) + +/** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ +type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' + +/** What {@link readGitMetadataFile} found at a `.git` file or a `commondir`. */ +type GitMetadata = + /** The whole file, within the size git accepts. */ + | { kind: 'contents'; bytes: Buffer } + /** Absent, or not the regular file git requires: nothing git reads here. */ + | { kind: 'none' } + /** Longer than {@link MAX_GIT_METADATA_BYTES}. */ + | { kind: 'too-large' } + +/** + * A git metadata file, or a link, whose target cannot be worked out the way git + * works it out. The wrap is refused: a deny list that misses the hooks and + * config git reads is worse than no sandbox. The message names the file, since + * a sandboxed command can write one and only a command run outside lifts it. On + * Linux it becomes a `LinuxSandboxProfileError` with + * `deny_git_metadata_unreadable`; on macOS it reaches the caller as itself. + */ +export class GitMetadataError extends Error { + readonly code = 'git_metadata_unreadable' as const + constructor(message: string) { + super(message) + this.name = 'GitMetadataError' + } +} + +/** + * A walk ran out of the time it was given, so what it never reached would have + * been left writable. On Linux it becomes a `LinuxSandboxProfileError` with + * `deny_scan_failed`; on macOS it reaches the caller as itself. + */ +export class SubmoduleWalkBudgetError extends Error { + readonly code = 'submodule_walk_budget_exhausted' as const + constructor(message: string) { + super(message) + this.name = 'SubmoduleWalkBudgetError' + } +} + +/** One symlink a chain walk went through, and the directory holding it. */ +interface ChainHop { + /** The link's own path, as the walk reached it. */ + link: string + /** The directory the link sits in. */ + holder: string +} + +/** Where a chain walk landed, and what it went through; see + * {@link resolveChain}. */ +interface ResolvedChain { + landing: string + /** + * In the order the walk met them, each once: the first is the path the caller + * asked about. + */ + hops: ChainHop[] + /** + * False where the walk gave up past the kernel's hop limit, on a loop or a + * chain too long: `landing` is then only where it stopped. + */ + resolved: boolean +} + +/** + * A symlink a git directory is reached THROUGH: an entry of a `modules` or a + * `worktrees`, a further hop of a symlinked entry, a symlinked directory on the + * way, or a component of the path a `gitdir:` or a `commondir` names. The denies at the two ends of the chain + * cover neither it nor the directory it sits in, and a command that retargets + * it moves where the chain leads, for the host's git to follow. + */ +export interface GitChainHop { + /** + * Where the chain starts: a git directory ENTRY that is a symlink, or the + * VALUE of a `gitdir:` or a `commondir`. Held alike; the warning differs. + */ + kind: 'entry' | 'pointer' + /** + * `unresolvable` is a loop, or a chain past the kernel's limit. Nothing is + * behind it to deny, and each link is a name that makes it reach a directory + * as soon as a command puts one there. A backend that cannot hold such a link + * refuses the command; for a chain that resolves it warns. + */ + chain: 'resolved' | 'unresolvable' + /** The git directory entry, or the metadata file, whose chain goes + * through it. */ + source: string + /** + * The link's own path: a deny path for the backend that matches the name a + * rename or an unlink uses (macOS). A bind there would land on what the link + * leads to, so Linux drops it (`withoutChainHopLinks`). + */ + link: string + /** + * The directory holding the link, which Linux denies WHOLE to hold it. + * Undefined where a whole deny covers the link already. + */ + holder: string | undefined +} + +/** Directories found under a `.git/modules`, and what could not be read. */ +export interface SubmoduleScan { + /** + * Sorted. One reached through a symlink is here as the path it was reached + * by, and one whose target is not there yet as the path that target folds to. + */ + gitDirs: string[] + /** + * Directories the walk could not see through, denied whole: one it could not + * list, or the deepest one reachable towards a target it could not look at. + * Everything under one is read-only, so git writes there stop working. + * Sorted, no duplicates. + */ + unreadableDirs: string[] + /** + * Directories holding an entry that is a SYMLINK, denied whole for the link's + * sake by the backend whose denies resolve: the target's deny does not stop a + * command from putting a git directory of its own in the link's place. + * Sorted, no duplicates. + */ + linkedEntryDirs: string[] + /** Such an entry, and the hops between it and what it lands on. */ + chainHops: GitChainHop[] +} + +/** + * What a git directory's tree is denied by, in the pieces a backend with a + * ceiling on its mounts degrades it in (src/sandbox/linux-deny-collapse.ts). + * Nothing here is collapsed. + */ +export interface GitDirTreeDenies { + /** The git directory's own hooks, config and redirect files. */ + ownDenyPaths: string[] + /** `/modules`, whether or not the walk found anything under it. */ + modulesDir: string + /** One per submodule git directory under `modulesDir`, sorted by path. */ + submodules: Array<{ gitDir: string } & GitDirDenies> + /** `/worktrees`, whether or not anything is under it. */ + worktreesDir: string + /** One per linked worktree's git directory under `worktreesDir`, sorted by + * path; see {@link keptWorktrees}. */ + worktrees: Array<{ gitDir: string } & GitDirDenies> + /** What neither walk could see through; see {@link SubmoduleScan}. */ + unreadableDirs: string[] + /** Directories holding a symlinked entry; see {@link SubmoduleScan}. */ + linkedEntryDirs: string[] + /** Every hop met anywhere in this tree. */ + chainHops: GitChainHop[] +} + +/** + * The files in a git directory that send git to another git directory or + * config, and what must stand in for one that does not exist. + * + * Denying an absent path means mounting something at it, and git reads these + * two: it refuses to run at all against a `commondir` it cannot read, which + * both an empty file and a bound /dev/null are. Each placeholder is what git + * concludes with the file absent: `.` is the git directory itself, and an empty + * `config.worktree` is no worktree config. + * + * That is not invisible. The file's existence sets git's `different_commondir`, + * so `git rev-parse --git-common-dir` prints an absolute path, and a script + * that compares it with `--git-dir` takes an ordinary repository for a linked + * worktree while the deny stands. + * + * The Linux backend writes them: `writeGitRedirectMountPoint`. + */ +const GIT_REDIRECT_FILES: ReadonlyArray<{ + name: string + placeholder: string + /** Denied even where the caller allows writes to the git config. */ + deniedWithConfigAllowed: boolean +}> = [ + // Moves the hooks and config git reads to another directory entirely. + { name: 'commondir', placeholder: '.\n', deniedWithConfigAllowed: true }, + // Read instead of `config` wherever extensions.worktreeConfig is on. + { name: 'config.worktree', placeholder: '', deniedWithConfigAllowed: false }, +] + +/** + * The paths in a git directory through which a write becomes code the host's + * git runs: hooks/, config (core.fsmonitor, core.hooksPath and the like) unless + * the caller allows config writes, and {@link GIT_REDIRECT_FILES}, each under + * the condition of what it redirects. Path arithmetic alone, so that macOS can + * hand it a glob. {@link gitDirDenies} is for a directory that is there. + */ +export function gitDirDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + const redirectFiles = (deniedWithConfigAllowed: boolean): string[] => + GIT_REDIRECT_FILES.filter( + file => file.deniedWithConfigAllowed === deniedWithConfigAllowed, + ).map(file => path.join(gitDir, file.name)) + + const denyPaths = [path.join(gitDir, 'hooks'), ...redirectFiles(true)] + if (!allowGitConfig) { + denyPaths.push(path.join(gitDir, 'config'), ...redirectFiles(false)) + } + return denyPaths +} + +/** + * The same for a linked worktree's git directory, `/worktrees/`, + * reached from the repository that keeps it: the redirect files, and `gitdir`, + * which names the checkout that `git worktree remove` deletes. Not `hooks` and + * `config`, which git does not read here while a `commondir` stands. + */ +function worktreeGitDirDenyPaths( + worktreeGitDir: string, + allowGitConfig: boolean, +): string[] { + return [ + ...GIT_REDIRECT_FILES.filter( + file => file.deniedWithConfigAllowed || !allowGitConfig, + ).map(file => path.join(worktreeGitDir, file.name)), + path.join(worktreeGitDir, 'gitdir'), + ] +} + +/** What a git directory is denied by once its entries have been looked at. */ +export interface GitDirDenies { + /** The deny paths, in the order the backends emit them. */ + denyPaths: string[] + /** + * The ones that lead OUT of the git directory, which a backend that degrades + * it to one read-only bind keeps. + */ + escapingDenyPaths: string[] + /** + * The git directory itself where an entry of it is a symlink, else empty. See + * {@link SubmoduleScan.linkedEntryDirs}. + */ + linkedEntryDirs: string[] + /** + * The hops between a symlinked entry and what it leads to and, from {@link + * gitFileDenies}, those in the path a pointer's value walks. Each hop's own + * path is in `denyPaths` too. + */ + chainHops: GitChainHop[] +} + +/** + * {@link gitDirDenyPaths} for a git directory that is there, with what an entry + * that is a SYMLINK adds. + * + * Such an entry is two things: the target, where a write through it lands, and + * the link, which a command can unlink to leave its own `hooks/` in its place. + * A Linux bind lands on what the path resolves to and covers the first; a + * Seatbelt filter matches the name an unlink uses and covers the second. So + * both are named, and the git directory goes into `linkedEntryDirs` for Linux + * to deny whole: `git status` and `log` go on working there, `git add` and `git + * commit` fail read-only until the link is gone. Further links on the way are + * held as {@link GitChainHop} says. + * + * An ordinary git directory costs one lstat per entry. `deadline` bounds the + * walk of a chain ({@link resolveChain}). `entryPaths` is what is looked at, + * for {@link worktreeGitDirDenyPaths}. + */ +export function gitDirDenies( + gitDir: string, + allowGitConfig: boolean, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, + entryPaths: string[] = gitDirDenyPaths(gitDir, allowGitConfig), +): GitDirDenies { + const denyPaths: string[] = [] + const escapingDenyPaths: string[] = [] + const chainHops: GitChainHop[] = [] + let denyWhole = false + // Resolved on the first symlinked entry and not before: an ordinary git + // directory must not pay a realpath for a question it never asks. + let realGitDir: string | undefined + const insideGitDir = (candidate: string): boolean => { + realGitDir ??= realPathOrSelf(gitDir) + return isAtOrUnder(candidate, realGitDir) + } + // The first hop IS the entry: its own path is a deny path already, and the + // directory holding it is this git directory, denied whole below. The rest + // lie between the two ends, and neither end holds them. + const holdChain = ( + entryPath: string, + hops: ChainHop[], + chain: GitChainHop['chain'], + ): void => { + for (const hop of hops.slice(1)) { + denyPaths.push(hop.link) + // No bind of the git directory covers it, so a degrade must not drop it. + if (!insideGitDir(hop.link)) escapingDenyPaths.push(hop.link) + chainHops.push({ + kind: 'entry', + chain, + source: entryPath, + link: hop.link, + holder: insideGitDir(hop.holder) ? undefined : hop.holder, + }) + } + } + for (const entryPath of entryPaths) { + const entry = gitDirEntry(entryPath, deadline) + switch (entry.kind) { + case 'plain': + denyPaths.push(entryPath) + break + case 'link': + denyWhole = true + // Both spellings: the Linux bind resolves the entry's own path to the + // same place, while on macOS a filter matches where the write lands + // and the target is the only one of the two that names it. + denyPaths.push(entryPath, entry.target) + if (!insideGitDir(entry.target)) { + escapingDenyPaths.push(entry.target) + } + holdChain(entryPath, entry.hops, 'resolved') + break + case 'unreachable': + // A loop, or a name no file can occupy: nothing is behind it to deny. + // The whole deny holds the entry, and each link the walk went through + // is held like any other hop: a directory put in its place makes the + // chain reach one. + denyWhole = true + holdChain(entryPath, entry.hops, 'unresolvable') + break + case 'unknown': + // There and not classifiable. Fail closed both ways: the entry's own + // deny, kept through a degrade in case it is a link, and the whole deny + // one would need. + denyWhole = true + denyPaths.push(entryPath) + escapingDenyPaths.push(entryPath) + holdChain(entryPath, entry.hops, 'resolved') + break + } + } + // Neither is a deny path: each git directory under one has precise denies of + // its own. But remove one that is a link, put a directory where it was, and + // every git directory this repository keeps there is one nothing denied. So + // it is held as a chain hop is: by its own name, and by this git directory + // denied whole. + for (const name of ['modules', 'worktrees']) { + const keptPath = path.join(gitDir, name) + const kept = gitDirEntry(keptPath, deadline) + switch (kept.kind) { + case 'plain': + break + case 'link': + case 'unknown': + denyWhole = true + denyPaths.push(keptPath) + chainHops.push({ + kind: 'entry', + chain: 'resolved', + source: keptPath, + link: keptPath, + holder: undefined, + }) + holdChain(keptPath, kept.hops, 'resolved') + break + case 'unreachable': + denyWhole = true + holdChain(keptPath, kept.hops, 'unresolvable') + break + } + } + return { + denyPaths, + escapingDenyPaths, + linkedEntryDirs: denyWhole ? [gitDir] : [], + chainHops, + } +} + +/** What an entry of a git directory turns out to be. */ +type GitDirEntry = + /** Not a symlink, or nothing at all: the deny it has always had. */ + | { kind: 'plain' } + /** A symlink, resolved or dangling, leading to `target` through `hops`. */ + | { kind: 'link'; target: string; hops: ChainHop[] } + /** A symlink that reaches nothing: a loop, or a name no file can occupy. + * `hops` is what the walk went through before it gave up. */ + | { kind: 'unreachable'; hops: ChainHop[] } + /** There, and what it is could not be worked out; `hops` is as much of the + * chain as the walk read before it stopped. */ + | { kind: 'unknown'; hops: ChainHop[] } + +function gitDirEntry(entryPath: string, deadline: number): GitDirEntry { + let stats: fs.Stats + try { + stats = fs.lstatSync(entryPath) + } catch (err) { + // The shared classification: a loop or a name too long ABOVE the entry + // reaches no file here, and leaves nothing to protect. + if (isAbsenceErrno(err)) return { kind: 'plain' } + logForDebugging( + `[Sandbox] Could not tell whether ${entryPath} is a symlink (${err}); denying the git directory holding it whole, and the entry as it stands`, + { level: 'warn' }, + ) + return { kind: 'unknown', hops: [] } + } + if (!stats.isSymbolicLink()) return { kind: 'plain' } + // From the directory holding the entry, itself resolved first: a symlink + // ABOVE the git directory is where this repository lives, not a hop. + const chain = resolveChain( + physicalPath(path.parse(entryPath).root, path.dirname(entryPath), deadline), + path.basename(entryPath), + deadline, + ) + try { + fs.statSync(entryPath) + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'ELOOP' || isUnusablePathError(err)) { + return { kind: 'unreachable', hops: chain.hops } + } + if (!isAbsenceErrno(err)) { + logForDebugging( + `[Sandbox] Could not follow ${entryPath} (${err}); denying the git directory holding it whole, the entry as it stands, and the hops of its chain that could be read`, + { level: 'warn' }, + ) + return { kind: 'unknown', hops: chain.hops } + } + // Dangling, which is no reason to pass over it: nothing is there yet and + // a command can put a hooks directory there for the host's git to find, + // so the deny goes where the link lands and blocks creating it. + } + return { kind: 'link', target: chain.landing, hops: chain.hops } +} + +/** + * What must stand in for `denyPath` where it does not exist, or undefined for a + * path git does not read this way. By the basename, so that every spelling of + * one path answers the same. See {@link GIT_REDIRECT_FILES}. + */ +export function gitRedirectPlaceholder(denyPath: string): string | undefined { + const name = path.basename(denyPath) + return GIT_REDIRECT_FILES.find(file => file.name === name)?.placeholder +} + +/** + * What a `.git` file, the `gitdir:` pointer of a linked worktree or submodule + * checkout, is denied by: the file, the entries of the git directory it names + * and, for a linked worktree, of the one its `commondir` names, and every + * symlink the walk of either path went through. A `..` can land the kernel + * elsewhere than where the path folds to, so both directories are denied + * ({@link gitMetadataTargets}). + * + * What such a git directory keeps under its `modules` and `worktrees` is not + * enumerated here: `gitDirs` names each one reached, for the caller to hand to + * {@link gitDirTreeDenies}. + * + * Throws {@link GitMetadataError} where the pointer or the `commondir` names + * something this cannot resolve the way git does, and {@link + * SubmoduleWalkBudgetError} past `deadline`. + */ +export function gitFileDenies( + gitFile: string, + allowGitConfig: boolean, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): GitDirDenies & { gitDirs: string[] } { + const gathered: GatheredDenies = { + denyPaths: [gitFile], + linkedEntryDirs: [], + chainHops: [], + gitDirs: [], + } + const denies = { ...gathered, escapingDenyPaths: [] } + // Before the file is read, whatever it holds: a tree full of `.git` files is + // as many reads of up to a megabyte as a command cared to leave names for. + if (Date.now() > deadline) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The time given for working out what the .git files lead to ran out before ${gitFile} was read; refusing to sandbox on the ones read before it`, + ) + } + try { + const pointer = readGitMetadataFile(gitFile) + let target: string | undefined + switch (pointer.kind) { + case 'contents': + target = parseGitdirPointer(pointer.bytes, gitFile) + break + case 'too-large': + // git refuses a .git file this large outright, so it leads nowhere. + logForDebugging( + `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, + { level: 'warn' }, + ) + return denies + case 'none': + break + } + if (target === undefined) { + return denies + } + const targets = gitMetadataTargets(path.dirname(gitFile), target, deadline) + holdValueChain(gitFile, targets, gathered) + const gitDirs = targets.gitDirs.map(gitDir => ({ + gitDir, + kind: gitDirKind(gitDir), + })) + for (const { gitDir, kind } of gitDirs) { + gatherTargetDenies( + gitDir, + kind, + allowGitConfig, + gitFile, + gathered, + deadline, + ) + } + + // A linked worktree's git directory holds the path of the main one, whose + // hooks and config its commits run. git reads it out of the directory it + // opened, so each candidate above has its own. + for (const { gitDir, kind } of gitDirs) { + if (kind === 'git-dir') { + gatherCommonDirDenies(gitDir, allowGitConfig, gathered, deadline) + } else if (kind === 'unreadable') { + // THREAT: one `chmod` by a command, which leaves the repository that + // keeps this worktree as writable as it was, with nothing read here to + // name it. WHERE the directory is names it as well. + const worktrees = path.dirname(physicalDenyPath(gitDir, deadline)) + if (path.basename(worktrees) !== 'worktrees') continue + const keeper = path.dirname(worktrees) + gatherTargetDenies( + keeper, + gitDirKind(keeper), + allowGitConfig, + gitFile, + gathered, + deadline, + ) + } + } + } catch (err) { + // A dangling pointer names nothing git would read. A pointer this process + // may not read is one the host's git, the same user's, cannot read either. + // For those the file itself is the whole deny; an unreadable TARGET is + // denied whole by gitDirTargetDenies instead. + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'EACCES' || code === 'EPERM' || code === 'ELOOP') { + logForDebugging( + `[Sandbox] Could not read ${gitFile}, denying only the file itself: ${err}`, + { level: 'warn' }, + ) + } else if ( + err instanceof GitMetadataError || + err instanceof SubmoduleWalkBudgetError + ) { + throw err + } else if (!isAbsenceError(err)) { + // No descriptor left, a disk that fails: git may well read it later. + throw new GitMetadataError( + `[Sandbox] ${gitFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, + ) + } + } + return denies +} + +/** What following a `gitdir:` pointer or a `commondir` adds to, one target + * and one hop at a time. */ +type GatheredDenies = Pick< + GitDirDenies, + 'denyPaths' | 'linkedEntryDirs' | 'chainHops' +> & { + /** The targets that are git directories. */ + gitDirs: string[] +} + +/** The denies of one directory a `gitdir:` or a `commondir` names, added to + * `gathered`; see {@link gitDirTargetDenies}. */ +function gatherTargetDenies( + target: string, + kind: GitDirKind, + allowGitConfig: boolean, + source: string, + gathered: GatheredDenies, + deadline: number, +): void { + const denies = gitDirTargetDenies( + target, + kind, + allowGitConfig, + source, + deadline, + ) + gathered.denyPaths.push(...denies.denyPaths) + gathered.linkedEntryDirs.push(...denies.linkedEntryDirs) + gathered.chainHops.push(...denies.chainHops) + if (kind === 'git-dir') gathered.gitDirs.push(target) +} + +/** + * The links the walk of a `gitdir:` or `commondir` value went through, added to + * `gathered`. A value that reaches nothing names no git directory, and those + * links are all a command can move. One warning says so: a `.git` file is + * something a command may create, and refusing or denying the checkout whole + * for it would lock the session out of its own project. + */ +function holdValueChain( + source: string, + targets: GitMetadataTargets, + gathered: GatheredDenies, +): void { + for (const hop of targets.hops) { + gathered.denyPaths.push(hop.link) + gathered.chainHops.push({ + kind: 'pointer', + chain: targets.resolved ? 'resolved' : 'unresolvable', + source, + link: hop.link, + holder: hop.holder, + }) + } + if (targets.resolved) return + logForDebugging( + `[Sandbox] The path ${source} names cannot be walked to an end (a symlink loop, or more than the ${MAX_SYMLINK_RESOLUTION_DEPTH} hops the kernel follows), so git opens nothing through it either; denying ${source} and the ${targets.hops.length} link(s) the walk did go through, and nothing besides`, + { level: 'warn' }, + ) +} + +/** + * What the `commondir` of `gitDir` leads to, added to `gathered`: the links its + * value walks, and the denies of each directory it names that is not in + * `heldAlready`. Throws {@link GitMetadataError} for one that is there and + * whose target cannot be worked out: the directory whose hooks git runs is then + * unknown. + */ +function gatherCommonDirDenies( + gitDir: string, + allowGitConfig: boolean, + gathered: GatheredDenies, + deadline: number, + heldAlready: readonly string[] = [gitDir], +): void { + const commonFile = path.join(gitDir, 'commondir') + let common: GitMetadata + try { + common = readGitMetadataFile(commonFile) + } catch (err) { + if (isAbsenceError(err)) return + // The file is there and could not be read, so the directory whose hooks + // this worktree's commits run is unknown. Returning the denies gathered + // so far would leave that repository's hooks writable. + throw new GitMetadataError( + `[Sandbox] ${commonFile} could not be read (${String(err)}); refusing to sandbox without the git directory it names`, + ) + } + let commonTarget: string | undefined + switch (common.kind) { + case 'contents': + commonTarget = gitMetadataPath(common.bytes, commonFile) + break + case 'too-large': + // git reads commondir whole, with no size limit of its own, so a file + // past this bound still names the directory whose hooks git runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + case 'none': + break + } + if (commonTarget === undefined) return + const commonTargets = gitMetadataTargets(gitDir, commonTarget, deadline) + holdValueChain(commonFile, commonTargets, gathered) + for (const commonDir of commonTargets.gitDirs) { + if (heldAlready.includes(commonDir)) continue + gatherTargetDenies( + commonDir, + gitDirKind(commonDir), + allowGitConfig, + commonFile, + gathered, + deadline, + ) + } +} + +/** + * Every git directory a deny must cover once `gitDir` is one: its own entries, + * those of each submodule git directory under its `modules`, what redirects git + * in each linked worktree's under its `worktrees`, and whatever the walks could + * not see through. A submodule keeps linked worktrees and a linked worktree + * keeps submodules, so the two walks call each other ({@link keptSubmodules}, + * {@link keptWorktrees}). + * + * `deadline` bounds the walks and the chains they meet. `visited` is for a + * caller that enumerates several git directories and puts what comes back + * together, so that what one of them keeps of another, or links to, is not + * walked once for each ({@link isCutShort}). + * + * Kept in pieces so that a backend that cannot carry every mount can degrade + * some; {@link gitDirTreeDenyPaths} is the flat form. + */ +export function gitDirTreeDenies( + gitDir: string, + allowGitConfig: boolean, + options: { deadline?: number; visited?: Map } = {}, +): GitDirTreeDenies { + const walk: TreeWalk = { + allowGitConfig, + deadline: + options.deadline ?? Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, + root: gitDir, + realRoot: realPathOrSelf(gitDir), + visited: options.visited ?? new Map(), + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + chainHops: [], + } + const submodules = keptSubmodules(gitDir, walk) + const own = gitDirDenies(gitDir, allowGitConfig, walk.deadline) + const worktrees = keptWorktrees(gitDir, walk) + const all = [own, ...submodules, ...worktrees] + return { + ownDenyPaths: own.denyPaths, + modulesDir: path.join(gitDir, 'modules'), + submodules, + worktreesDir: path.join(gitDir, 'worktrees'), + worktrees, + unreadableDirs: [...walk.unreadableDirs].sort(), + linkedEntryDirs: [ + ...[...walk.linkedEntryDirs].sort(), + ...all.flatMap(denies => denies.linkedEntryDirs), + ], + chainHops: [...walk.chainHops, ...all.flatMap(denies => denies.chainHops)], + } +} + +/** {@link gitDirTreeDenies} as one list, which is what a backend with no + * ceiling to stay under emits. */ +export function gitDirTreeDenyPaths(denies: GitDirTreeDenies): string[] { + const denyPaths = [ + ...denies.ownDenyPaths, + ...denies.unreadableDirs, + ...denies.submodules.flatMap(submodule => submodule.denyPaths), + ...denies.worktrees.flatMap(worktree => worktree.denyPaths), + ] + // A hop of a `.git/modules` entry has no other list to sit in, and a hop of a + // git directory's own entry is among its deny paths already. + const named = new Set(denyPaths) + for (const hop of denies.chainHops) { + if (named.has(hop.link)) continue + named.add(hop.link) + denyPaths.push(hop.link) + } + return denyPaths +} + +/** A git directory another one keeps, and what it is denied by. */ +type KeptGitDir = { gitDir: string } & GitDirDenies + +/** + * `own`, the denies of `gitDir`, with those of the git directories it `keeps` + * in turn: all under it, so that a bind of it stands in for them where the + * profile is degraded. Except one reached through a link, which is somewhere + * else, and what leads out of any of them. + */ +function withWhatItKeeps( + gitDir: string, + own: GitDirDenies, + keeps: KeptGitDir[], +): KeptGitDir { + const all = [own, ...keeps] + const real = keeps.length > 0 ? realPathOrSelf(gitDir) : gitDir + return { + gitDir, + denyPaths: all.flatMap(denies => denies.denyPaths), + escapingDenyPaths: [ + ...own.escapingDenyPaths, + ...keeps.flatMap(kept => + isAtOrUnder(realPathOrSelf(kept.gitDir), real) + ? kept.escapingDenyPaths + : kept.denyPaths, + ), + ], + linkedEntryDirs: all.flatMap(denies => denies.linkedEntryDirs), + chainHops: all.flatMap(denies => denies.chainHops), + } +} + +/** + * The submodule git directories under the `modules` of `gitDir`, sorted by + * path ({@link walkModules}), each with the linked worktrees it keeps: + * `git worktree add` inside a submodule puts the worktree's git directory under + * the SUBMODULE's. + */ +function keptSubmodules(gitDir: string, walk: TreeWalk): KeptGitDir[] { + return walkModules(path.join(gitDir, 'modules'), walk).map(submodule => + withWhatItKeeps( + submodule, + gitDirDenies(submodule, walk.allowGitConfig, walk.deadline), + keptWorktrees(submodule, walk), + ), + ) +} + +/** + * The git directories of the linked worktrees `gitDir` keeps, one per entry of + * its `worktrees`, sorted, and what each is denied by ({@link + * worktreeGitDirDenyPaths}). With the submodules it keeps: their git + * directories are under the WORKTREE's `modules`, while their checkouts can lie + * outside everything the command may write. + * + * Every directory there is one, whatever it holds: git makes nothing else under + * `worktrees`, and one a command emptied or made is one the host's git opens + * all the same. An entry that is a symlink is followed and held ({@link + * walkEntry}). + * + * Each one's `commondir` is followed as from a `.git` file. No bind of the + * worktree's git directory covers what it names, so that is marked as leading + * out. + */ +function keptWorktrees(gitDir: string, walk: TreeWalk): KeptGitDir[] { + const { allowGitConfig, deadline } = walk + const worktreesDir = path.join(gitDir, 'worktrees') + if (isCutShort('worktrees', worktreesDir, walk)) return [] + const kept: KeptGitDir[] = [] + const entries = listDirectory(worktreesDir, walk) ?? [] + for (const [at, entry] of entries.entries()) { + if (at % WALK_DEADLINE_CHECK_INTERVAL === 0 && Date.now() > deadline) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${worktreesDir} ran out of the time it was given with ${entries.length - at} of its ${entries.length} entries still to look at; refusing to sandbox on the linked worktrees it did reach`, + ) + } + if (isNoText(entry, worktreesDir, walk)) continue + const child = path.join(worktreesDir, entry.name) + const found = walkEntry(entry, child, walk) + if (found.kind === 'skip') continue + // Where a link leads to nothing is denied as a git directory that is not + // there yet, as the `.git/modules` walk denies one. + const worktree = found.kind === 'dangling' ? found.landing : child + if (isCutShort('worktree', worktree, walk)) continue + if (found.kind === 'directory') { + // git opens what is in there by name, which takes leave to search it and + // none to list it. Without that leave git, the same user's, reads no + // `commondir` either: denied whole, and nothing to refuse the command + // over. + try { + fs.accessSync(worktree, fs.constants.X_OK) + } catch (err) { + denyUnreadable(worktree, err, walk) + continue + } + } + const own = gitDirDenies( + worktree, + allowGitConfig, + deadline, + worktreeGitDirDenyPaths(worktree, allowGitConfig), + ) + const common: GatheredDenies = { + denyPaths: [], + linkedEntryDirs: [], + chainHops: [], + gitDirs: [], + } + gatherCommonDirDenies(worktree, allowGitConfig, common, deadline, [ + worktree, + gitDir, + ]) + kept.push( + withWhatItKeeps( + worktree, + { + denyPaths: [...own.denyPaths, ...common.denyPaths], + escapingDenyPaths: [...own.escapingDenyPaths, ...common.denyPaths], + linkedEntryDirs: [...own.linkedEntryDirs, ...common.linkedEntryDirs], + chainHops: [...own.chainHops, ...common.chainHops], + }, + keptSubmodules(worktree, walk), + ), + ) + } + return kept.sort((a, b) => (a.gitDir < b.gitDir ? -1 : 1)) +} + +/** + * {@link walkModules} by itself, with what it could not see through. + */ +export function submoduleGitDirs( + modulesDir: string, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): SubmoduleScan { + const walk: TreeWalk = { + allowGitConfig: false, + deadline, + root: modulesDir, + realRoot: realPathOrSelf(modulesDir), + visited: new Map(), + unreadableDirs: new Set(), + linkedEntryDirs: new Set(), + chainHops: [], + } + return { + gitDirs: walkModules(modulesDir, walk), + unreadableDirs: [...walk.unreadableDirs].sort(), + linkedEntryDirs: [...walk.linkedEntryDirs].sort(), + chainHops: walk.chainHops, + } +} + +/** + * Git directories of the submodules under `modulesDir` (a `.git/modules`), + * nested ones included, sorted. A submodule's name is its path, so one can sit + * several levels down (modules/vendor/lib). + * + * A directory recorded as a git directory is walked on all the same. A segment + * of a name (`vendor`) is a directory a command can write, and a walk that + * stopped at what {@link looksLikeGitDir} would let one `HEAD` left there take + * the denies off every submodule beneath it. That goes for its `modules` too: + * where a git directory keeps its own submodules, and maybe the submodule + * `vendor/modules`. Only {@link GIT_OWN_DIRECTORIES} are left at one listing. + * `modulesDir` itself is never taken for a git directory. + * + * Nothing bounds how deep a tree may be, and the walk keeps its own stack. A + * bind mount gives a directory a second real path, which the visited set cannot + * see, so the deadline is what bounds the walk. + */ +function walkModules(modulesDir: string, walk: TreeWalk): string[] { + if (isCutShort('root', modulesDir, walk)) return [] + const gitDirs = new Set() + // Where the walk still has to look. A directory that was listed carries its + // entries, and `gitDir` says it was recorded as a git directory, so that some + // of them may be git's own. + const pending: Array<{ + dir: string + entries?: fs.Dirent[] + gitDir?: boolean + }> = [{ dir: modulesDir }] + let entriesSeen = 0 + for (let item = pending.pop(); item !== undefined; item = pending.pop()) { + const entries = item.entries ?? listDirectory(item.dir, walk) + if (entries === undefined) continue + for (const entry of entries) { + if ( + entriesSeen++ % WALK_DEADLINE_CHECK_INTERVAL === 0 && + Date.now() > walk.deadline + ) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${modulesDir} ran out of the time it was given while listing ${item.dir}, with ${pending.length} directories still to look at; refusing to sandbox on the submodule git directories it did reach`, + ) + } + if (isNoText(entry, item.dir, walk)) continue + const child = path.join(item.dir, entry.name) + let childEntries: fs.Dirent[] | undefined + if (item.gitDir === true) { + // Read as this git directory's entry by what denies it (gitDirDenies). + // Followed here as well, what it leads to would be taken for a + // submodule. + if (entry.isSymbolicLink() && GIT_ENTRY_FILES.has(entry.name)) continue + // git's own by its name: walked on only where one listing shows what a + // submodule's git directory of that name has and git's own never does. + // Listed before it is followed: that would deny whole a git directory + // whose `objects` or `rr-cache` is a link to a shared store. + if ( + GIT_OWN_DIRECTORIES.has(entry.name) && + (entry.isDirectory() || entry.isSymbolicLink()) + ) { + childEntries = listDirectory(child, walk) + if ( + childEntries?.some(e => GIT_PROTECTED_ENTRIES.has(e.name)) !== true + ) { + continue + } + } + } + // git accepts a symlinked entry under .git/modules, and + // Dirent.isDirectory is false for one, so the link is followed — and + // the real path recorded, since a link back up would otherwise loop. + const found = walkEntry(entry, child, walk) + if (found.kind === 'skip') continue + if (found.kind === 'dangling') { + // Where a link leads to nothing is denied as a git directory that is + // not there yet, so a command cannot fill one in for the host's git + // to find. Nothing is walked: there is nothing there to walk. + gitDirs.add(found.landing) + continue + } + // In the `modules` of a git directory every entry is a submodule's name, + // as in `modulesDir`, whatever a command left beside them. + const holdsNames = item.gitDir === true && entry.name === 'modules' + // Before it is listed: how many links lead to it and how much it holds + // are both a command's to choose. + if (isCutShort(holdsNames ? 'names' : 'entry', child, walk)) continue + childEntries ??= listDirectory(child, walk) + if (childEntries === undefined) continue + // `hooks` and `objects` are names there too, so neither makes a git + // directory of it. + const isGitDir = looksLikeGitDir( + holdsNames + ? childEntries.filter(e => e.name !== 'hooks' && e.name !== 'objects') + : childEntries, + ) + if (isGitDir) gitDirs.add(child) + // Anything else is a segment of a submodule name (`vendor` of + // `vendor/lib`) - and a directory that looks like a git directory can + // be one as well, so what else it holds is looked at either way. + pending.push({ + dir: child, + entries: childEntries, + gitDir: isGitDir && !holdsNames, + }) + } + } + return [...gitDirs].sort() +} + +/** + * What the walks of one enumeration go by, and what they find beside the git + * directories. + */ +interface TreeWalk { + allowGitConfig: boolean + deadline: number + /** The directory that is enumerated, and where it really is. */ + root: string + realRoot: string + /** See {@link isCutShort}. */ + visited: Map + /** + * As in {@link SubmoduleScan}. Sets: one unreadable entry and the next fold to + * the same ancestor, and a repeated path is a mount the profile never pays + * for but would otherwise be counted as one. + * + * INVARIANT: each of `unreadableDirs` is where the directory really is. The + * name of a link is what holds the link, and no deny of what it leads to + * ({@link GitChainHop.link}). + */ + unreadableDirs: Set + linkedEntryDirs: Set + chainHops: GitChainHop[] +} + +/** + * Whether a walk stops short of `dir`, which it would read `as` an `entry` of a + * walked directory (a git directory or a segment of a name, by what it holds), + * as the `root` of a walk, as the `names` in the `modules` of a git directory + * (which may be one itself, and a root is not), as a `worktrees` or as one + * linked `worktree`: a walk has read it so before, by where `dir` really is. + * That is what ends a link that leads back up, and what keeps many links to one + * directory, or many `.git` files into one repository, from costing as many + * walks of it. Such a link is held by its own name all the same ({@link + * walkEntry}). + * + * INVARIANT: for a directory that is where its name says, with no link on the + * way from the root, only a walk that came to it so stands in. A command can + * link any directory into a `modules` or a `worktrees`, to have it visited from + * there first and as something else. What really lies under a git directory is + * walked from it all the same. + */ +function isCutShort( + as: 'entry' | 'root' | 'names' | 'worktrees' | 'worktree', + dir: string, + walk: TreeWalk, +): boolean { + const real = realPathOrSelf(dir) + const key = `${as}:${real}` + const isWhereItsNameSays = + real === path.join(walk.realRoot, path.relative(walk.root, dir)) + const cameSoBefore = walk.visited.get(key) + if (cameSoBefore === true) return true + if (cameSoBefore === false && !isWhereItsNameSays) return true + walk.visited.set(key, isWhereItsNameSays) + return false +} + +/** What one entry of a walked directory is worth looking into. */ +type WalkEntry = + /** A directory to walk, reached directly or through a symlink. */ + | { kind: 'directory' } + /** A symlink whose target is not there: nothing to walk, and `landing` is + * where a command putting a git directory there would put it. */ + | { kind: 'dangling'; landing: string } + /** Nothing to walk and nothing to deny. */ + | { kind: 'skip' } + +/** + * Whether the name of `entry`, of `dir`, may be bytes that are no text. The + * string made of those names nothing, so no deny can be aimed at it, and `dir` + * is denied whole. + */ +function isNoText(entry: fs.Dirent, dir: string, walk: TreeWalk): boolean { + if (!entry.name.includes('\uFFFD')) return false + walk.unreadableDirs.add(realPathOrSelf(dir)) + logForDebugging( + `[Sandbox] An entry of ${dir} has a name that is not valid UTF-8, denying ${dir} whole`, + { level: 'warn' }, + ) + return true +} + +/** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ +function listDirectory(dir: string, walk: TreeWalk): fs.Dirent[] | undefined { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent is the common case: no submodules, or none nested in this one. + if (!isAbsenceError(err)) denyUnreadable(dir, err, walk) + return undefined + } +} + +function denyUnreadable(dir: string, err: unknown, walk: TreeWalk): void { + const denied = + deepestReachableAncestor(physicalDenyPath(dir, walk.deadline)) ?? dir + walk.unreadableDirs.add(denied) + logForDebugging( + `[Sandbox] Could not look into ${dir}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) +} + +/** + * What `entry` is worth doing with, following a symlink to a directory. A link + * that leads anywhere puts the directory HOLDING it on the whole-directory + * denies ({@link SubmoduleScan.linkedEntryDirs}), and it and the further links + * on the way are held hop by hop ({@link GitChainHop}). A command can plant one + * and leave every submodule beside it read-only for the commands after it. + */ +function walkEntry( + entry: fs.Dirent, + entryPath: string, + walk: TreeWalk, +): WalkEntry { + if (entry.isDirectory()) return { kind: 'directory' } + if (!entry.isSymbolicLink()) return { kind: 'skip' } + const { deadline } = walk + const holder = path.dirname(entryPath) + let chain: ResolvedChain + try { + chain = resolveChain( + physicalPath(path.parse(entryPath).root, holder, deadline), + entry.name, + deadline, + ) + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + // Where it leads cannot be named, so a directory there cannot be denied, + // nor what cannot be looked at. + let behind: fs.Stats + try { + behind = fs.statSync(entryPath) + } catch (statErr) { + if (!isAbsenceErrno(statErr)) throw err + // Nothing is there. A command can make such a link here, so it costs what + // any link here does and not every command after it. + walk.unreadableDirs.add(realPathOrSelf(holder)) + logForDebugging( + `[Sandbox] ${entryPath} leads through a symlink whose target is not valid UTF-8, to nothing; denying ${holder} whole`, + { level: 'warn' }, + ) + return { kind: 'skip' } + } + if (behind.isDirectory()) throw err + return { kind: 'skip' } + } + // The first hop is the entry, whose holder joins `linkedEntryDirs` below. The + // rest lie between it and what it lands on, and that deny reaches neither + // them nor the directories they sit in. + const holdChain = (chainState: GitChainHop['chain']): void => { + for (const hop of chain.hops) { + walk.chainHops.push({ + kind: 'entry', + chain: chainState, + source: entryPath, + link: hop.link, + holder: isAtOrUnder(hop.holder, holder) ? undefined : hop.holder, + }) + } + } + try { + if (!fs.statSync(entryPath).isDirectory()) return { kind: 'skip' } + walk.linkedEntryDirs.add(holder) + holdChain('resolved') + return { kind: 'directory' } + } catch (err) { + if (isAbsenceError(err)) { + // Dangling. Nothing is there to walk, and a command can create it: what + // the link lands on is denied as a git directory that is not there yet. + walk.linkedEntryDirs.add(holder) + holdChain('resolved') + return { kind: 'dangling', landing: chain.landing } + } + if (isUnusablePathError(err)) return { kind: 'skip' } + if ((err as NodeJS.ErrnoException).code === 'ELOOP') { + // Nothing is behind it to deny, and no bind can be put at it. Denying the + // directory that HOLDS it would let one link take every submodule beside + // it read-only. The links it goes through are left alone too: putting a + // directory at one is no shorter a way to a planted git directory under + // a writable `.git/modules` than creating one outright. + logForDebugging( + `[Sandbox] ${entryPath} is a symlink loop, which leads to nothing to deny: ${err}`, + { level: 'warn' }, + ) + return { kind: 'skip' } + } + // Something is there and could not be looked at. Failed closed on the + // deepest directory reachable TOWARDS it, not on the one holding the link, + // which is denied for the link's own sake. + const denied = deepestReachableAncestor(chain.landing) ?? entryPath + walk.unreadableDirs.add(denied) + walk.linkedEntryDirs.add(holder) + holdChain('resolved') + logForDebugging( + `[Sandbox] Could not follow ${entryPath}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) + return { kind: 'skip' } + } +} + +/** + * What a directory a `gitdir:` or a `commondir` names is denied by. One that is + * no git directory is left alone. An absent one is denied, so that a command + * cannot create it and fill it with hooks. + */ +function gitDirTargetDenies( + target: string, + kind: GitDirKind, + allowGitConfig: boolean, + source: string, + deadline: number, +): GitDirDenies { + const only = (denyPaths: string[]): GitDirDenies => ({ + denyPaths, + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + }) + switch (kind) { + case 'git-dir': + case 'absent': + return gitDirDenies(target, allowGitConfig, deadline) + case 'unreadable': { + // Towards where it really is. A value can go through a link, whose name + // holds the link and is no deny of what it leads to ({@link + // GitChainHop.link}), and what cannot be looked into may lie behind it. + const denied = + deepestReachableAncestor(physicalDenyPath(target, deadline)) ?? target + logForDebugging( + `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, + { level: 'warn' }, + ) + return only([denied]) + } + case 'unusable': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is longer than the filesystem allows: no file can be there for git to read or for a command to create; denying only ${source}`, + { level: 'warn' }, + ) + return only([]) + case 'other': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is not a git directory; denying only ${source}`, + { level: 'warn' }, + ) + return only([]) + } +} + +/** + * Whether `dir`, which a `gitdir:` or a `commondir` names, is a git directory: + * by what it holds ({@link looksLikeGitDir}), or by where it is. WHERE a + * directory is cannot be arranged by what a file says, so one that sits where + * only git puts a git directory is one whatever it holds just now: see + * {@link isGitOwnedLocation}. + */ +function gitDirKind(dir: string): GitDirKind { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + if (isUnusablePathError(err)) return 'unusable' + return isAbsenceError(err) ? 'absent' : 'unreadable' + } + return looksLikeGitDir(entries) || isGitOwnedLocation(dir) + ? 'git-dir' + : 'other' +} + +/** + * Whether `dir` sits where git keeps a git directory of its own making: + * `.git/worktrees/`, or anywhere under a `.git/modules`, a linked + * worktree's own included. Asked of the path the kernel lands on: a command can + * make `x/.git` a symlink to any directory, and a value written through it has + * this shape. + */ +function isGitOwnedLocation(dir: string): boolean { + const parts = physicalDenyPath(dir).split('/') + return parts.some((name, at) => { + if (name !== '.git') return false + let below = parts.slice(at + 1) + if (below[0] === 'worktrees' && below.length >= 2) { + if (below.length === 2) return true + below = below.slice(2) + } + return below[0] === 'modules' && below.length >= 2 + }) +} + +/** + * The contents of `file`, or what stopped this from reading it the way git + * does. A command may create the path: a FIFO there would block every later + * wrap, hence O_NONBLOCK and the type check. + */ +function readGitMetadataFile(file: string): GitMetadata { + // O_NONBLOCK is POSIX-only; this file's callers are the Linux and macOS + // backends, and 0 leaves the flags as they were. + const nonBlocking = fs.constants.O_NONBLOCK ?? 0 + let fd: number + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | nonBlocking) + } catch (err) { + if (isAbsenceError(err) || isUnusablePathError(err)) return { kind: 'none' } + throw err + } + try { + // From the open file description, so it describes what was actually + // opened rather than what the path named a moment ago. + const stat = fs.fstatSync(fd) + if (!stat.isFile()) return { kind: 'none' } + if (stat.size > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + // Sized from that stat rather than from the bound, and one byte past it + // so a file that grew since is read on rather than taken for whole; + // readSync can also stop short of the length it was given. + let buffer = Buffer.alloc(stat.size + 1) + let read = 0 + for (;;) { + if (read === buffer.length) { + if (buffer.length > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + const grown = Buffer.alloc(MAX_GIT_METADATA_BYTES + 1) + buffer.copy(grown) + buffer = grown + } + const chunk = fs.readSync(fd, buffer, read, buffer.length - read, read) + if (chunk === 0) break + read += chunk + } + return { kind: 'contents', bytes: buffer.subarray(0, read) } + } finally { + fs.closeSync(fd) + } +} + +/** + * The `gitdir:` target of a pointer file, or undefined when git would not + * follow the file at all. git requires the 8-byte prefix at offset 0 — no + * leading whitespace, no other spelling — and everything after it is path. + */ +function parseGitdirPointer( + contents: Buffer, + file: string, +): string | undefined { + const prefix = Buffer.from('gitdir: ') + if (!contents.subarray(0, prefix.length).equals(prefix)) return undefined + return gitMetadataPath(contents.subarray(prefix.length), file) +} + +/** + * The path a git metadata file names, by git's rules (`read_gitfile_gently`, + * `get_common_dir_noenv`): `\n` and `\r` are stripped from the END OF THE FILE, + * nothing else is trimmed, and what is left is a C string that ends at the + * first NUL. + * + * Re-implemented instead of asking git: the library has to work without git, + * and running git in a checkout the command can write is the hazard these + * denies contain. test/sandbox/git-pointer-parity.test.ts keeps the two in + * step. + * + * Throws {@link GitMetadataError} for bytes that are not valid UTF-8: a string + * of them names another file than git opens. + */ +function gitMetadataPath(contents: Buffer, file: string): string | undefined { + let end = contents.length + while ( + end > 0 && + (contents[end - 1] === 0x0a || contents[end - 1] === 0x0d) + ) { + end-- + } + const nul = contents.subarray(0, end).indexOf(0) + // git joins the empty string to the directory it reads the path from. + if (nul === 0) return '.' + const pathBytes = contents.subarray(0, nul === -1 ? end : nul) + if (pathBytes.length === 0) return undefined + + const decoded = pathBytes.toString('utf8') + if (!Buffer.from(decoded, 'utf8').equals(pathBytes)) { + throw new GitMetadataError( + `[Sandbox] ${file} names a path that is not valid UTF-8; refusing to sandbox with a deny list that would name a different directory than git opens`, + ) + } + return decoded +} + +/** Where a `gitdir:` or `commondir` value leads, and what it goes through; + * see {@link gitMetadataTargets}. */ +interface GitMetadataTargets { + /** Empty where the walk reached no end: see {@link resolveChain}. */ + gitDirs: string[] + /** Every symlink the walk of the value went through, in the order it met + * them. A link ABOVE the file holding the value is not one of them: the + * base is resolved before the walk starts, so a checkout reached through a + * symlink is where the repository lives rather than a hop of this chain. */ + hops: ChainHop[] + /** {@link ResolvedChain.resolved} for the walk of the value. */ + resolved: boolean +} + +/** + * The git directories a `gitdir:` or `commondir` value read in `base` leads to: + * the path as it folds on paper and, where a `..` in it could send the kernel + * elsewhere, the directory the kernel reaches. git hands the joined string to + * stat, so a symlink is followed before a later `..` applies, and it opens the + * kernel's landing. Both are denied where they differ. + * + * A value whose walk reaches no end names NO git directory: git opens nothing + * through it, and the deepest directory a fail-closed deny could reach is + * usually the checkout. The links it went through come back as hops all the + * same. + */ +function gitMetadataTargets( + base: string, + target: string, + deadline: number, +): GitMetadataTargets { + const lexical = path.resolve(base, target) + const root = path.parse(lexical).root + const chain = resolveChain( + // An absolute value starts at the root and never looks at the base, so + // resolving the base then is a walk that buys nothing. + path.isAbsolute(target) ? root : physicalPath(root, base, deadline), + target, + deadline, + ) + const hops = chain.hops + const resolved = chain.resolved + if (!resolved) return { gitDirs: [], hops, resolved } + if (!target.split('/').includes('..')) { + return { gitDirs: [lexical], hops, resolved } + } + // Both sides resolved the same way, so a base that merely spells itself + // differently (a /var that is a symlink to /private/var) is no difference. + return chain.landing === physicalPath(root, lexical, deadline) + ? { gitDirs: [lexical], hops, resolved } + : { gitDirs: [lexical, chain.landing], hops, resolved } +} + +/** + * Where the kernel lands walking `target` from `base`, and every symlink it + * went through. `path.resolve` and `fs.realpathSync` fold `..` on paper, and a + * walk also reaches a tail that does not exist yet. A component that cannot be + * walked ends it, and the rest is taken as written. + * + * The hop limit bounds how many links are followed and nothing bounds how long + * each is, so `deadline` does: past it this throws {@link + * SubmoduleWalkBudgetError}. A link whose target is no text throws {@link + * GitMetadataError}, as a value that is none does. + */ +function resolveChain( + base: string, + target: string, + deadline: number, +): ResolvedChain { + let current = path.isAbsolute(target) ? path.parse(target).root : base + // What is still to be walked, last component first: a value can hold half a + // million, and an array taken from at the front is copied each time. + const pending = target.split('/').reverse() + const hops: ChainHop[] = [] + const named = new Set() + let followed = 0 + let componentsSeen = 0 + for (let name = pending.pop(); name !== undefined; name = pending.pop()) { + // On the first component as well as every so many after it: a tree full + // of short chains is as many walks as a command cared to leave, and none + // of them is long enough to come round to a later look. + if ( + componentsSeen++ % WALK_DEADLINE_CHECK_INTERVAL === 0 && + Date.now() > deadline + ) { + throw new SubmoduleWalkBudgetError( + `[Sandbox] The walk of ${abridged(target)} from ${base} ran out of the time it was given at ${current}, ${followed} symlinks in and with ${pending.length} path components still to go; refusing to sandbox on a path that was not walked to where it leads`, + ) + } + if (name === '' || name === '.') continue + if (name === '..') { + current = path.dirname(current) + continue + } + const next = path.join(current, name) + let link: string | undefined + try { + if (fs.lstatSync(next).isSymbolicLink()) link = linkTextOf(next) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EILSEQ') { + throw new GitMetadataError( + `[Sandbox] ${next} is a symlink whose target is not valid UTF-8; refusing to sandbox with a deny list that would name another path than the one it leads to`, + ) + } + return { + landing: path.join(next, pending.reverse().join('/')), + hops, + resolved: true, + } + } + if (link === undefined) { + current = next + continue + } + // Under the name the kernel reached it by, which is the one an unlink or a + // rename uses. A loop arrives at the same link over and over: one hop. + if (!named.has(next)) { + named.add(next) + hops.push({ link: next, holder: current }) + } + // Past the kernel's own limit the chain reaches nothing, for git as for + // this. The links it went THROUGH come back for the caller to hold. + if (++followed > MAX_SYMLINK_RESOLUTION_DEPTH) { + return { landing: next, hops, resolved: false } + } + // A link's own target is walked in its place, from the directory holding + // it unless it is absolute. + if (path.isAbsolute(link)) current = path.parse(link).root + for (const part of link.split('/').reverse()) pending.push(part) + } + return { landing: current, hops, resolved: true } +} + +/** {@link resolveChain} for a caller that wants only where the walk landed. */ +function physicalPath(base: string, target: string, deadline: number): string { + return resolveChain(base, target, deadline).landing +} + +/** `value` cut to what a message can carry: a pointer's value is as long as + * its file, and the rest of it says nothing the start does not. */ +function abridged(value: string, limit = 200): string { + return value.length <= limit + ? value + : `${value.slice(0, limit)}… (${value.length - limit} more characters)` +} + +/** + * Where the kernel lands walking `denyPath` from the root: the spelling for a + * backend whose filters are matched against the path an operation RESOLVED to + * (`gitDiskDenyEntries` on macOS). `fs.realpathSync` throws on a path that is + * not there, and an absent `hooks` is what these denies are for. + */ +export function physicalDenyPath( + denyPath: string, + deadline: number = Date.now() + DEFAULT_SUBMODULE_WALK_TIMEOUT_MS, +): string { + return physicalPath(path.parse(denyPath).root, denyPath, deadline) +} + +/** + * The deepest ancestor of `target`, itself included, that this process can stat + * as a directory. Denying it fails closed where what lies below cannot be + * inspected. Symlinks are followed, so a link to an unlistable directory is + * answered with the link's own path, not with the directory holding it. That + * path may be a {@link GitChainHop.link}, which is no deny of what it leads to: + * a caller denies where it really is. + */ +function deepestReachableAncestor(target: string): string | undefined { + for (let dir = target; ; dir = path.dirname(dir)) { + try { + if (fs.statSync(dir).isDirectory()) return dir + } catch { + // Unreachable at this level; try the parent. + } + if (path.dirname(dir) === dir) return undefined + } +} diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 3c4f96974..003894ca8 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1015,6 +1015,17 @@ export const RipgrepConfigSchema = z.object({ .describe( 'Override argv[0] when spawning (for multicall binaries that dispatch on argv[0])', ), + timeoutMs: z + .number() + .int() + .positive() + // A timer takes no more, and takes one millisecond for anything above. + .max(2 ** 31 - 1) + .optional() + .describe( + 'How long one ripgrep run may take before it is killed, in milliseconds ' + + '(default: DEFAULT_RIPGREP_TIMEOUT_MS, 10000)', + ), }) /** diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 545f5df16..89225b5bc 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -62,7 +62,7 @@ import { checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, - linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js' import { @@ -1210,7 +1210,10 @@ async function initializeSteps( // normalizePathForSandbox resolves `~`, relative spellings and symlinks), // plus the built-in write denies the wrapper always applies. // It does not reproduce the wrapper's existence and boundary-symlink - // filters, nor the ripgrep scan for nested dangerous paths; and it still + // filters, nor the ripgrep scan for nested dangerous paths; a repository + // whose git metadata cannot be resolved leaves it the cwd's plain denies + // rather than failing this whole start-up (every wrap there is still + // refused); and it still // reports writes bwrap permits through `--dev`, `--proc` and the tmpfs // over each read-denied directory. Started once, so both lists are fixed // at the cwd and configuration of this call, and at the disk as it is @@ -1243,7 +1246,15 @@ async function initializeSteps( // them, so the monitor must not judge by them either. ...(config.filesystem.disabled ? [] - : linuxGetCwdMandatoryDenyPaths(getAllowGitConfig())), + : linuxGetMonitorCwdDenyPaths( + getAllowGitConfig(), + // The write roots decide which directory holding a symlink + // between a git entry and its target a wrap can bind: the + // monitor has to judge by the same ones. + writeRootsOf(monitoredWrites).map(p => + normalizePathForSandbox(p, { literal: true }), + ), + )), ], ignoreViolations: config.ignoreViolations, resolveCommandText, @@ -2330,6 +2341,15 @@ export type WrapWithSandboxOptions = { * * An id that a network allow list is registered under must also be * unguessable: see `registerCommandNetworkLists`. + * + * On Linux it is also the name under which the wrap records the mount + * points its command relies on: pass the same value to + * `cleanupAfterCommand({ commandId })` to let go of them at once when the + * command will never be started, whatever other commands are still in + * flight (a command that ran is let go of by any such call once it has + * ended). It has to be unique among the wraps in flight. Only a value passed + * here counts for that; the `command` default does not, since two wraps in + * flight may share their text. */ commandId?: string /** @@ -2753,7 +2773,8 @@ async function wrapWithSandboxOnce( observeSocketPath: linuxMonitor?.observeSocketPath, abortSignal, }).catch((err: unknown) => { - // It awaits once more, for its scan: see the invariant above. + // It awaits again, for its scan and for a clean-up under way: see the + // invariant above. if (err instanceof WorkingDirectoryChanged) return startOver() throw err }) @@ -3066,11 +3087,20 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { * when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig). * These persist after bwrap exits. This function removes them. * - * Safe to call on any platform — it's a no-op on macOS. + * Call it once for each wrapped command, when that command is over. Every + * call removes what commands that ran and have ended relied on: this + * process's, and those of processes that are gone. A command that was wrapped + * and has not started is let go of only once it has been called for every wrap + * handed out, because a call cannot tell which command it is for; pass the + * `commandId` the wrap was given (`WrapWithSandboxOptions.commandId`) to let + * go of that one at once. A wrapped string that has been let go of refuses to + * start; until then it may be run again. + * + * Safe to call on any platform: it does nothing except on Linux. * Also called automatically by reset() and on process exit as safety nets. */ -function cleanupAfterCommand(): void { - cleanupBwrapMountPoints() +function cleanupAfterCommand(options?: { commandId?: string }): void { + cleanupBwrapMountPoints({ commandId: options?.commandId }) } /** @@ -3526,7 +3556,7 @@ export interface ISandboxManager { getAwsPairRegistry(): AwsPairRegistry getMaskedFileStore(): MaskedFileStore updateConfig(newConfig: SandboxRuntimeConfig): void - cleanupAfterCommand(): void + cleanupAfterCommand(options?: { commandId?: string }): void reset(): Promise } diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c85cbbdb9..9a55a340b 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,7 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf } from '../utils/real-path.js' +import { realPathOf, realPathOrSelf } from '../utils/real-path.js' import { denyGlobRegex, globToRegex, @@ -70,6 +70,44 @@ export function isAtOrUnder(p: string, dir: string): boolean { return p === dir || p.startsWith(dir === '/' ? '/' : dir + '/') } +/** + * Whether denying `dir` WHOLE would take the tree a sandbox exists to let the + * command write: `dir` is the working directory, an ancestor of it, a path + * the caller allows writes to, or a directory holding one. + * + * A whole-directory deny is what both backends fall back on for a tree they + * cannot enumerate, or a link they have no other handle on. Every producer of + * one reads paths a sandboxed command can create - a `.git` pointer naming + * the checkout, a `.git/modules` entry linked at `../..`, a directory left + * unreadable - so one that lands here is a command leaving every command + * after it unable to write the project. `dir` must be where a deny would + * LAND, symlinks resolved, since that is what the deny covers. + */ +export function coversWriteTree( + dir: string, + cwd: string, + allowWritePaths: readonly string[], +): boolean { + return ( + isAtOrUnder(cwd, dir) || + allowWritePaths.some(allowed => isAtOrUnder(allowed, dir)) + ) +} + +/** + * Whether a command may write anything at or under `dir`: it lies in a path the + * caller allows writes to, or holds one. Both as they really are. + */ +export function meetsWriteRoots( + dir: string, + allowWritePaths: readonly string[], +): boolean { + const real = realPathOrSelf(dir) + return allowWritePaths + .map(realPathOrSelf) + .some(allowed => isAtOrUnder(real, allowed) || isAtOrUnder(allowed, real)) +} + /** `p` lies strictly beneath `dir` (isAtOrUnder, excluding `dir` itself). */ export function isStrictlyUnder(p: string, dir: string): boolean { return p !== dir && isAtOrUnder(p, dir) @@ -102,6 +140,13 @@ export function pathSpellings(candidatePath: string): string[] { return [candidatePath] } +/** + * Symlink hops allowed while resolving one path: the limit Linux itself + * applies before it gives up with ELOOP, so a chain this library walks by + * hand stops where the kernel's own walk would. + */ +export const MAX_SYMLINK_RESOLUTION_DEPTH = 40 + /** * Where the name `p` lives: its parent directories as `canonicalOf` resolves * them, its last component as written. An allowRead entry is read so: one diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts index 8ffbceebd..745f8d301 100644 --- a/src/utils/real-path.ts +++ b/src/utils/real-path.ts @@ -19,6 +19,11 @@ function textOf(ask: typeof fs.readlinkSync, at: string): string { return text } +/** Where the link at `at` points. EILSEQ where that is no text. */ +export function linkTextOf(at: string): string { + return textOf(fs.readlinkSync, at) +} + /** * `p` with every symbolic link on the way to it resolved. The real path is * the kernel's on every runtime, whatever characters its names hold. @@ -50,6 +55,15 @@ export function realPathOf(p: string): string { return real || '/' } +/** {@link realPathOf}, or `p` itself where nothing there can be resolved. */ +export function realPathOrSelf(p: string): string { + try { + return realPathOf(p) + } catch { + return p + } +} + /** * The kernel's way to `folded`, an absolute path with no `.`, `..` or `//` of * its own: name by name, split at `/` alone, every link followed. `real` is @@ -103,7 +117,7 @@ export function wayTo(folded: string): { { code: 'ELOOP', syscall: 'realpath', path: folded }, ) } - const target = textOf(fs.readlinkSync, next) + const target = linkTextOf(next) if (target.startsWith('/')) real = '' rest.unshift(...target.split('/')) } diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 739037ca7..4971540a6 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -6,15 +6,70 @@ export interface RipgrepConfig { args?: string[] /** Override argv[0] when spawning (for multicall binaries that dispatch on argv[0]) */ argv0?: string + /** + * How long the run may take before it is killed (default: + * {@link DEFAULT_RIPGREP_TIMEOUT_MS}). + */ + timeoutMs?: number +} + +export const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 + +/** + * ripgrep ended otherwise than by finding something or nothing. `listed` is + * what it had listed by then: rg reports an unreadable directory with exit code + * 2 after printing every match it could reach, and names each one in `stderr`. + * The paths in that are the tree's own text. + * + * `killed` says the run did not exit on its own — the timeout, an OOM kill, a + * signal to the process group — so what it listed is a prefix of an unknown + * whole rather than everything it could reach, whatever its stderr already + * held. `timedOut` is the one such kill this can name, the run having lasted + * as long as it was given; `killedBy` is the signal, where the platform + * reported one. A caller deciding whether a listing can be used at all wants + * `killed`: a run cut off inside its budget is as incomplete as one cut off + * at the end of it. + */ +export class RipgrepError extends Error { + readonly listed: string[] + readonly stderr: string + readonly timedOut: boolean + readonly killed: boolean + readonly killedBy: NodeJS.Signals | undefined + + constructor( + message: string, + details: { + listed: string[] + stderr: string + timedOut: boolean + killed: boolean + killedBy: NodeJS.Signals | undefined + }, + ) { + super(message) + this.name = 'RipgrepError' + this.listed = details.listed + this.stderr = details.stderr + this.timedOut = details.timedOut + this.killed = details.killed + this.killedBy = details.killedBy + } } /** - * Execute ripgrep with the given arguments + * Execute ripgrep with the given arguments. + * + * The run is `--null`-delimited: a path may contain a newline, and a run cut + * short by the timeout can end mid-path, so line splitting would turn one + * path into two and hand back a truncated one. Output is split on NUL and an + * unterminated tail is dropped. + * * @param args Command-line arguments to pass to rg * @param target Target directory or file to search * @param abortSignal AbortSignal to cancel the operation * @param config Ripgrep configuration (command and optional args) - * @returns Array of matching lines (one per line of output) + * @returns Array of matching paths * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ export async function ripGrep( @@ -23,49 +78,76 @@ export async function ripGrep( abortSignal: AbortSignal, config: RipgrepConfig = { command: 'rg' }, ): Promise { - const { command, args: commandArgs = [], argv0 } = config - - const child = spawn(command, [...commandArgs, ...args, target], { + const { + command, + args: commandArgs = [], argv0, - signal: abortSignal, - timeout: 10_000, - windowsHide: true, - }) + timeoutMs = DEFAULT_RIPGREP_TIMEOUT_MS, + } = config + + const startedAt = Date.now() + const child = spawn( + // --no-config: RIPGREP_CONFIG_PATH routinely points inside a project, at + // a file a sandboxed command can write, and one line of it + // (--max-filesize=1) is enough to make a scan return nothing at all and + // no error with it. + command, + [...commandArgs, '--no-config', '--null', ...args, target], + { + argv0, + signal: abortSignal, + timeout: timeoutMs, + windowsHide: true, + }, + ) - const [stdout, stderr, code] = await Promise.all([ + const [stdout, stderr, exit] = await Promise.all([ text(child.stdout), text(child.stderr), - new Promise((resolve, reject) => { - child.on('close', resolve) - child.on('error', reject) - }), + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.on('close', (code, signal) => resolve({ code, signal })) + child.on('error', reject) + }, + ), ]) - if (code === 0) { - return stdout.trim().split('\n').filter(Boolean) + const matches = splitNullDelimited(stdout) + if (exit.code === 0) { + return matches } - if (code === 1) { + if (exit.code === 1) { // Exit code 1 means "no matches found" - this is normal return [] } - // Whole lines only: killed, it can leave half of one. - throw new RipgrepError( - `ripgrep failed with exit code ${code}: ${stderr}`, - stdout.split('\n').slice(0, -1).filter(Boolean), + // A null exit code means the child was killed rather than exiting. The + // timeout is one reason for that and not the only one — a Ctrl-C to the + // process group, an OOM kill — so it is the one this claims only where the + // run actually lasted as long as it was given. Either way the listing stops + // wherever the kill arrived, which is what `killed` carries and what a + // caller has to refuse on; the rest are reported as what they were. + const killed = exit.code === null + const elapsedMs = Date.now() - startedAt + const timedOut = killed && elapsedMs >= timeoutMs + const failure = timedOut + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${elapsedMs} ms, the ${timeoutMs} ms it was given` + : killed + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${elapsedMs} ms, inside the ${timeoutMs} ms it was given` + : `ripgrep failed with exit code ${exit.code}` + throw new RipgrepError(`${failure}: ${stderr}`, { + listed: matches, stderr, - ) + timedOut, + killed, + killedBy: exit.signal ?? undefined, + }) } -/** ripgrep ended otherwise than by finding something or nothing. */ -export class RipgrepError extends Error { - constructor( - message: string, - /** What it had listed by then. */ - readonly listed: string[], - /** What it said went wrong. The paths in it are the tree's own text. */ - readonly stderr: string, - ) { - super(message) - this.name = 'RipgrepError' - } +/** NUL-terminated records, dropping an unterminated (truncated) last one. */ +function splitNullDelimited(output: string): string[] { + const records = output.split('\0') + // A complete run ends with a terminator, so the tail is empty; anything + // else is a record the run was cut off in the middle of. + records.pop() + return records.filter(Boolean) } diff --git a/test/cli.test.ts b/test/cli.test.ts index fc8b8c9bf..9b14ee16d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -12,10 +12,11 @@ import { join } from 'node:path' import { isLinux } from './helpers/platform.js' /** - * 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/config-validation.test.ts b/test/config-validation.test.ts index c0f353325..744613ad9 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach, spyOn } from 'bun:test' import { NetworkConfigSchema, + RipgrepConfigSchema, SandboxRuntimeConfigSchema, WindowsConfigSchema, } from '../src/sandbox/sandbox-config.js' @@ -320,6 +321,14 @@ describe('Config Validation', () => { } }) + test('should take no ripgrep timeout that a timer cannot', () => { + const withTimeout = (timeoutMs: number) => + RipgrepConfigSchema.safeParse({ command: 'rg', timeoutMs }).success + + expect(withTimeout(2 ** 31 - 1)).toBe(true) + expect(withTimeout(2 ** 31)).toBe(false) + }) + test('should validate config with custom ripgrep command and args', () => { const config = { network: { 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/bwrap-argv.ts b/test/helpers/bwrap-argv.ts index d01e2b19d..be73f074d 100644 --- a/test/helpers/bwrap-argv.ts +++ b/test/helpers/bwrap-argv.ts @@ -1,3 +1,5 @@ +import { existsSync, realpathSync } from 'node:fs' +import { basename } from 'node:path' import { quote } from '../../src/utils/shell-quote.js' /** The $0 renderBwrapInvocation gives the shell that opens an over-long @@ -5,13 +7,31 @@ import { quote } from '../../src/utils/shell-quote.js' * file and not in the command. */ const ARGS_FILE_ARGV0 = 'srt-args' +/** + * How a wrapped command that takes steps of its own before bubblewrap begins, on + * this host: bash where it is at one of its two usual places, else /bin/sh. + */ +export const STEP_SHELL = ((): string => { + const bash = + ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) ?? + ['/bin/sh'].find( + shell => existsSync(shell) && basename(realpathSync(shell)) === 'bash', + ) + return bash === undefined ? '/bin/sh -c' : `${bash} -p -c` +})() + +/** The step that puts the command on its manifest's started record. */ +export const RECORD_STEP = + 'srt_record() { local s && read -r s >"$1"; } && srt_record "$1" && shift' + /** The mount flags this generator emits with a source and a destination. */ -const MOUNT_FLAGS = ['--bind', '--ro-bind'] +const MOUNT_FLAGS = ['--bind', '--ro-bind', '--ro-bind-try'] /** How many words follow each mount flag this generator emits. */ const MOUNT_ARITY: Record = { '--bind': 2, '--ro-bind': 2, + '--ro-bind-try': 2, '--dev-bind': 2, '--tmpfs': 1, '--dev': 1, @@ -73,6 +93,28 @@ function runIndices(command: string, words: readonly string[]): number[] { return found } +/** + * The manifest the wrap recorded its mount points in, by the started record + * the command's own shell is given, or `undefined` for a wrap that recorded + * none. + */ +export function manifestOf(command: string): string | undefined { + const id = / srt(?:-args)? (\S+)\.started /.exec(command)?.[1] + return id === undefined ? undefined : `${id}.json` +} + +/** + * `command` with the name of its manifest taken out. A wrap that names mount + * points names a manifest of its own for them, so two wraps by one policy + * differ in that name and in nothing else. + */ +export function withoutManifestName(command: string): string { + return command.replace( + /(srt-mount-points(?:-\d+)?\/)\d+-[0-9a-f]{16}/g, + '$1MANIFEST', + ) +} + /** How many times that whole mount appears. */ export function countMounts(command: string, ...words: MountWords): number { return runIndices(command, words).length 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/mount-point-fixtures.ts b/test/helpers/mount-point-fixtures.ts new file mode 100644 index 000000000..3de6e0c80 --- /dev/null +++ b/test/helpers/mount-point-fixtures.ts @@ -0,0 +1,195 @@ +import { expect } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { + LinuxSandboxProfileError, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { manifestOf } from './bwrap-argv.js' + +/** A process as a manifest, a started record or a pass names one. */ +export type Process = { pid: number; start: string } + +/** What bubblewrap leaves on the host for `--ro-bind /dev/null `. */ +export function leftover(p: string): void { + writeFileSync(p, '') + chmodSync(p, 0o444) +} + +/** A pid nothing in this PID namespace has. */ +export function deadPid(): number { + for (let pid = 4194000; pid > 1; pid--) { + if (!existsSync(`/proc/${pid}`)) return pid + } + throw new Error('no free pid') +} + +/** Field 22 of a /proc/PID/stat line: when that process started. */ +export function startOf(stat: string): string | undefined { + return stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ')[19] +} + +export function processAt(pid: number): Process { + return { pid, start: startOf(readFileSync(`/proc/${pid}/stat`, 'utf8'))! } +} + +/** This process, which is running. */ +export const thisProcess = (): Process => processAt(process.pid) + +/** One that is gone. */ +export const aDeadProcess = (): Process => ({ pid: deadPid(), start: '1' }) + +/** + * The manifest of a wrap that named `paths`, in `dir`, which is made: written a + * minute ago, so long past its grace, by a process that is gone. What a killed + * process leaves. `fields` overrides what it says, and one that is undefined is + * left out. + */ +export function writeManifest( + dir: string, + paths: string[], + fields: Record = {}, +): string { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + chmodSync(dir, 0o700) + const body: Record = { + version: 1, + ...aDeadProcess(), + ns: readlinkSync('/proc/self/ns/pid'), + created: Date.now() - 60_000, + paths, + sources: [], + ...fields, + } + for (const [key, value] of Object.entries(body)) { + if (value === undefined) delete body[key] + } + const file = join( + dir, + `${String(body['pid'])}-${Math.random().toString(16).slice(2).padEnd(16, '0')}.json`, + ) + writeFileSync(file, JSON.stringify(body), { mode: 0o600 }) + return file +} + +/** Where the started record of `manifest` is. */ +export const recordOf = (manifest: string): string => + manifest.replace(/\.json$/, '.started') + +/** + * The line the wrapper shell records for a process, with a name that holds what + * a careless reading would trip over. + */ +export function statLine(who: Process): string { + const after = ['S', ...Array(18).fill('0'), who.start, '0', '0'] + return `${who.pid} (a) b (c) ${after.join(' ')}\n` +} + +/** Puts `manifest` on record as started by those processes, in that order. */ +export function started(manifest: string, ...by: Process[]): string { + writeFileSync(recordOf(manifest), by.map(statLine).join(''), { mode: 0o600 }) + return manifest +} + +/** The code the wrap was refused with. */ +export async function refusal(wrapping: Promise): Promise { + const thrown: unknown = await wrapping.then( + () => 'the wrap resolved', + (error: unknown) => error, + ) + expect(thrown).toBeInstanceOf(LinuxSandboxProfileError) + return (thrown as LinuxSandboxProfileError).code +} + +/** The mount points the wrap named in its manifest, none without one. */ +export function namedBy(command: string): string[] { + const file = manifestOf(command) + return file === undefined + ? [] + : (JSON.parse(readFileSync(file, 'utf8')) as { paths: string[] }).paths +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function until( + what: string, + holds: () => boolean, + timeoutMs = 30000, +): Promise { + const deadline = Date.now() + timeoutMs + while (!holds()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await sleep(10) + } +} + +/** Until `file` is there: how a command in a sandbox says how far it has got. */ +export const waitFor = (file: string, timeoutMs?: number): Promise => + until(file, () => existsSync(file), timeoutMs) + +/** `command`, allowed to write `area` but for `deny`. */ +export function wrapIn( + area: string, + command: string, + deny: string[], +): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [area], denyWithinAllow: deny }, + }) +} + +/** + * The line by which a process of its own, which has imported the library, does + * the like: what it is handed is `wrapped`. + */ +export const wrapLine = ( + allowOnly: string[], + command: string, + deny: string[], +): string => + `const wrapped = await wrapCommandWithSandboxLinux(${JSON.stringify({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow: deny }, + })})` + +export function run(command: string): { + status: number | null + said: string + stdout: string +} { + const r = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + return { status: r.status, said: `${r.stdout}${r.stderr}`, stdout: r.stdout } +} + +/** Writes a script of those lines to `dir`, for a process of its own. */ +export function writeScript( + dir: string, + name: string, + lines: string[], +): string { + const file = join(dir, name) + writeFileSync(file, lines.join('\n')) + return file +} diff --git a/test/helpers/private-manifest-directory.ts b/test/helpers/private-manifest-directory.ts new file mode 100644 index 000000000..8ee398342 --- /dev/null +++ b/test/helpers/private-manifest-directory.ts @@ -0,0 +1,168 @@ +import { afterAll, afterEach, beforeAll } from 'bun:test' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { forgetMountPointManifestDirectory } from '../../src/sandbox/bwrap-mount-manifests.js' +import './isolated/places.js' +import { startOf } from './mount-point-fixtures.js' +import { cleanupBwrapMountPoints } from '../../src/sandbox/linux-sandbox-utils.js' + +/** + * Kills every bubblewrap that is still on a started record in `dir`, and its + * sandbox with it: a test that failed half way must not leave one polling. Then + * empties `dir`. Only bubblewrap: a test may put any process on a record, itself + * included. + */ +function endSandboxesOnRecord(dir: string): void { + const attempt = (what: () => T): T | undefined => { + try { + return what() + } catch { + // No directory, no record, or the process is gone: nothing to end. + return undefined + } + } + for (const name of attempt(() => readdirSync(dir)) ?? []) { + const file = join(dir, name) + // A test may plant a FIFO there, and reading one waits for a writer. + if ( + !name.endsWith('.started') || + !attempt(() => lstatSync(file).isFile()) + ) { + continue + } + const record = attempt(() => readFileSync(file, 'utf8')) ?? '' + for (const line of record.split('\n')) { + const pid = /^\d+/.exec(line)?.[0] + const now = attempt(() => readFileSync(`/proc/${pid}/stat`, 'utf8')) + if (now?.includes(' (bwrap) ') && startOf(now) === startOf(line)) { + attempt(() => process.kill(Number(pid), 'SIGKILL')) + } + } + } + for (const name of attempt(() => readdirSync(dir)) ?? []) { + attempt(() => rmSync(join(dir, name), { recursive: true, force: true })) + } +} + +/** + * 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. + * + * 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. 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 + * run, and other suites wrap without cleaning up. After each test it ends + * whatever sandbox is still on record there and empties the directory, so no + * test finds what an earlier one left. + * + * Call it inside the `describe` callback. Returns where the manifests go. + */ +export function usePrivateManifestDirectory(): { manifestDir(): string } { + const replaced = ['XDG_RUNTIME_DIR', 'TMPDIR'] as const + const saved = replaced.map(name => process.env[name]) + let base: string | undefined + + beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-test-'))) + for (const name of replaced) { + process.env[name] = join(base, name) + mkdirSync(process.env[name], { mode: 0o700 }) + } + forgetMountPointManifestDirectory() + cleanupBwrapMountPoints({ force: true }) + }) + + afterEach(() => { + if (base !== undefined) { + endSandboxesOnRecord(join(base, 'XDG_RUNTIME_DIR', 'srt-mount-points')) + } + }) + + afterAll(() => { + cleanupBwrapMountPoints({ force: true }) + replaced.forEach((name, i) => { + if (saved[i] === undefined) delete process.env[name] + else process.env[name] = saved[i] + }) + forgetMountPointManifestDirectory() + if (base !== undefined) { + rmSync(base, { recursive: true, force: true }) + } + }) + + return { + manifestDir: () => { + if (base === undefined) { + throw new Error('asked for before the tests began') + } + return join(base, 'XDG_RUNTIME_DIR', 'srt-mount-points') + }, + } +} diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 000000000..69c0bd2c3 --- /dev/null +++ b/test/preload.ts @@ -0,0 +1,27 @@ +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. 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. + const base = realpathSync(mkdtempSync(join(tmpdir(), 'srt-'))) + for (const [name, dir] of [ + ['XDG_RUNTIME_DIR', 'x'], + ['TMPDIR', 't'], + ] as const) { + process.env[name] = join(base, dir) + mkdirSync(process.env[name], { mode: 0o700 }) + } + // Once, after the last test file. + afterAll(() => rmSync(base, { recursive: true, force: true })) +} diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts index 138f5bea7..c29c80356 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -19,6 +19,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'DEFAULT_WINDOWS_PROXY_PORT_RANGE', 'FilesystemConfigSchema', 'GitConfigSchema', + 'GitMetadataError', 'IgnoreViolationsConfigSchema', 'LinuxSandboxProfileError', 'NetworkConfigSchema', @@ -28,6 +29,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'SandboxRuntimeConfigSchema', 'SandboxViolationStore', 'SrtWinConfigSchema', + 'SubmoduleWalkBudgetError', 'VENDORED_SRT_WIN_EXE', 'WINDOWS_ALLOW_LOOPBACK_EFFECT', 'WINDOWS_LOOPBACK_PROBE_PORT', @@ -59,6 +61,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'installWindowsSandbox', 'installWindowsSandboxAsync', 'isUncPath', + 'liveMountPoints', 'parseWindowsBinShell', 'parseWindowsSandboxError', 'realPathOf', diff --git a/test/sandbox/credential-mask.test.ts b/test/sandbox/credential-mask.test.ts index 7618f4b19..9a77a014f 100644 --- a/test/sandbox/credential-mask.test.ts +++ b/test/sandbox/credential-mask.test.ts @@ -130,6 +130,46 @@ describe('SentinelRegistry', () => { expect(reg.size).toBe(2) }) + test('a caller-minted sentinel that nests with a registered one is refused', () => { + // Body substitution matches earliest-position-then-registration-order, so + // a nested pair would replace the wrong span, chunk-boundary-dependent. + const reg = new SentinelRegistry() + const outer = 'fake.jwt.abcdef0123456789' + reg.registerWithSentinel('A', outer, 'real-a', ['h.example.com']) + expect(() => + reg.registerWithSentinel('B', outer.slice(5), 'real-b', [ + 'h.example.com', + ]), + ).toThrow(/nests with the one already registered for "A"/) + expect(() => + reg.registerWithSentinel('C', `x${outer}x`, 'real-c', ['h.example.com']), + ).toThrow(/nests with the one already registered for "A"/) + // The refused registrations left nothing behind. + expect(reg.size).toBe(1) + expect(reg.lookupReal(outer)).toBe('real-a') + }) + + test('an empty caller-minted sentinel is refused, first or not', () => { + // It nests with every sentinel registered after it, which would then be + // the one refused, and the substitution scan finds it everywhere. + const reg = new SentinelRegistry() + expect(() => + reg.registerWithSentinel('A', '', 'real-a', ['h.example.com']), + ).toThrow(/empty sentinel/) + expect(reg.size).toBe(0) + expect(reg.lookupReal(reg.register('B', 'real-b', []))).toBe('real-b') + }) + + test('a caller-minted sentinel that nests with nothing registers', () => { + const reg = new SentinelRegistry() + reg.registerWithSentinel('A', 'fake.jwt.aaa', 'real-a', ['h.example.com']) + const b = reg.registerWithSentinel('B', 'fake.jwt.bbb', 'real-b', [ + 'h.example.com', + ]) + expect(reg.lookupReal(b)).toBe('real-b') + expect(reg.size).toBe(2) + }) + test('clear drops every mapping', () => { const reg = new SentinelRegistry() const s = reg.register('T', 'x', []) diff --git a/test/sandbox/default-write-paths.test.ts b/test/sandbox/default-write-paths.test.ts index 362c34ff5..b54c4059e 100644 --- a/test/sandbox/default-write-paths.test.ts +++ b/test/sandbox/default-write-paths.test.ts @@ -123,7 +123,10 @@ describe.if(!isWindows)('getDefaultWritePaths', () => { runtime, [ '-e', - `import('./dist/index.js').then(async ({ SandboxManager }) => { + `import('./dist/sandbox/bwrap-mount-manifests.js').then(async places => { + // As test/helpers/isolated does, for the built library. + places.setMountPointManifestPlacesForTesting('/nonexistent') + const { SandboxManager } = await import('./dist/index.js') await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, filesystem: { @@ -231,7 +234,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: [] }, @@ -261,7 +264,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(fakeHome, NPM_LOGS), { recursive: true }) const wrapped = runWithHome( fakeHome, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') console.log(JSON.stringify(await SandboxManager.wrapWithSandbox('true', undefined, { filesystem: { denyRead: [], allowWrite: [], denyWrite: ['~/.npm'] }, })))`, @@ -360,7 +363,7 @@ describe.if(isLinux || isMacOS)( mkdirSync(join(home, NPM_LOGS), { recursive: true }) return runWithHome( home, - `const { SandboxManager } = await import('./src/sandbox/sandbox-manager.ts') + `const { SandboxManager } = await import('./test/helpers/isolated/manager.ts') const fs = await import('node:fs') const home = process.env.HOME const logs = home + '/${NPM_LOGS}' diff --git a/test/sandbox/deny-order-independence.test.ts b/test/sandbox/deny-order-independence.test.ts index cbcaab1e9..8a30dbd70 100644 --- a/test/sandbox/deny-order-independence.test.ts +++ b/test/sandbox/deny-order-independence.test.ts @@ -15,7 +15,7 @@ import { cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' -import { mountsOf } from '../helpers/bwrap-argv.js' +import { mountsOf, withoutManifestName } from '../helpers/bwrap-argv.js' /** The empty directory mounted over an absent deny path's first missing * component is made fresh per wrap, so its name says nothing about the plan. */ @@ -23,7 +23,7 @@ const EMPTY_MOUNT_SOURCE = /\S*claude-empty-[A-Za-z0-9]+/g /** A profile's mounts as a multiset, for the reason mountsOf gives. */ function mountMultiset(command: string): string[] { - return mountsOf(command) + return mountsOf(withoutManifestName(command)) .map(mount => mount.replace(EMPTY_MOUNT_SOURCE, '')) .sort() } diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts new file mode 100644 index 000000000..e8b7a1d35 --- /dev/null +++ b/test/sandbox/git-pointer-parity.test.ts @@ -0,0 +1,878 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' +import { + GitMetadataError, + gitFileDenies, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Differential tests for the `.git` pointer and `commondir` parsing in + * mandatory-deny-paths.ts, which re-implements git's own (see the comment on + * gitMetadataPath there for why it is not a call to git). Hand-written cases + * only check the shapes their author thought of; what matters here is that + * there is no shape this resolves confidently to one directory while git + * opens another. So every case is resolved twice — once by gitFileDenyPaths, + * once by `git rev-parse` run in the pointer's own directory — and the rule + * is: wherever git follows the pointer, that directory's hooks and config + * are in the deny list, or the wrap was refused outright. Denying more than + * git follows is fine; denying less is the bug. + * + * Skipped where git is not installed. Windows is out of scope for this file + * (mandatory-deny-paths serves the Linux and macOS backends) and several + * shapes here — a newline in a directory name, bytes that are not valid + * UTF-8 — cannot exist there. + */ +const HAS_GIT = + spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0 + +describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { + let root: string + let caseCount = 0 + + beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'git-pointer-parity-'))) + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + /** What precedes the path. Only `gitdir: ` at byte 0 is one git follows. */ + const PREFIXES = { + plain: Buffer.from('gitdir: '), + noSpace: Buffer.from('gitdir:'), + twoSpaces: Buffer.from('gitdir: '), + tab: Buffer.from('gitdir:\t'), + leadingSpace: Buffer.from(' gitdir: '), + byteOrderMark: Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('gitdir: '), + ]), + upperCase: Buffer.from('GITDIR: '), + } + + /** + * What follows it. git strips `\n` and `\r` from the end of the file and + * nothing else, and the path ends at the first NUL. + */ + const TRAILERS = { + none: Buffer.alloc(0), + newline: Buffer.from('\n'), + crlf: Buffer.from('\r\n'), + mixedEndings: Buffer.from('\n\r\n\r'), + nineThousandNewlines: Buffer.from('\n'.repeat(9000)), + sixtyFourKiBOfNewlines: Buffer.from('\n'.repeat(64 * 1024)), + trailingSpaces: Buffer.from(' \n'), + secondLine: Buffer.from('\nnot-a-git-dir\n'), + embeddedNul: Buffer.from('\0/elsewhere\n'), + } + + /** How the target is spelled. Every component of each exists on disk. */ + const SPELLINGS = { + absolute: (_checkout: string, target: string): string => target, + relative: (checkout: string, target: string): string => + relative(checkout, target), + dotSlash: (checkout: string, target: string): string => + `./${relative(checkout, target)}`, + doubledSlash: (checkout: string, target: string): string => + relative(checkout, target).replace('/', '//'), + trailingSlash: (checkout: string, target: string): string => + `${relative(checkout, target)}/`, + backThroughDotDot: (checkout: string, target: string): string => + join('..', basename(checkout), '..', relative(dirname(checkout), target)), + // These two build the symlink they then walk through, and return a + // string rather than a join(), since a `..` after a symlink is exactly + // what a lexical fold would take out. + viaSymlinkThenDotDot: (checkout: string, target: string): string => { + const side = join(dirname(target), 'side') + mkdirSync(side, { recursive: true }) + symlinkSync(side, join(checkout, 'hop')) + return `hop/../${basename(target)}` + }, + viaSymlinkThenTwoDotDots: (checkout: string, target: string): string => { + const deeper = join(dirname(target), 'side', 'deeper') + mkdirSync(deeper, { recursive: true }) + mkdirSync(join(checkout, 'sub'), { recursive: true }) + symlinkSync(deeper, join(checkout, 'sub', 'hop')) + return `sub/hop/../../${basename(target)}` + }, + } + + /** + * The target's shape. git follows a pointer only to a directory its + * `is_git_directory` accepts: a valid HEAD, plus objects/ and refs/ found + * through the target's own `commondir`. + */ + const TARGETS = { + gitDir: (caseDir: string): string => makeGitDir(join(caseDir, 'target')), + realGitInit: (caseDir: string): string => { + const repo = join(caseDir, 'initialized') + expect(runGit(caseDir, ['init', '-q', repo])).not.toBeUndefined() + return join(repo, '.git') + }, + headIsASymlink: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'target')) + rmSync(join(gitDir, 'HEAD')) + symlinkSync('refs/heads/main', join(gitDir, 'HEAD')) + return gitDir + }, + symlinkToAGitDir: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real-target')) + const link = join(caseDir, 'target') + symlinkSync(gitDir, link) + return link + }, + throughASymlinkedParent: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real', 'target')) + symlinkSync(dirname(gitDir), join(caseDir, 'link')) + return join(caseDir, 'link', basename(gitDir)) + }, + /** HEAD and a commondir, no objects/ or refs/: a linked worktree's. */ + worktreeGitDir: (caseDir: string): string => { + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(worktree, 'commondir'), '../..\n') + return worktree + }, + ordinaryDirectory: (caseDir: string): string => { + const target = join(caseDir, 'target') + mkdirSync(join(target, 'config'), { recursive: true }) + return target + }, + absent: (caseDir: string): string => join(caseDir, 'target'), + nameHoldsANewline: (caseDir: string): string => + makeGitDir(join(caseDir, 'two\nlines')), + nameIsNotAscii: (caseDir: string): string => + makeGitDir(join(caseDir, 'ziel-日本-🌱')), + } + + interface PointerCase { + prefix: keyof typeof PREFIXES + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + target: keyof typeof TARGETS + /** Pad the file with newline bytes to exactly this size. */ + padTo?: number + /** The `.git` file is a symlink to the regular file holding the bytes. */ + pointerIsASymlink?: boolean + } + + const MAX_GITFILE_SIZE = 1024 * 1024 + + /** A directory git's `is_git_directory` accepts. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + return gitDir + } + + /** + * git, with the host's configuration and environment kept out of it, so + * what it resolves is the pointer file and nothing else. Returns its + * output with the single newline it adds removed — a directory name may + * end in a space or hold a newline of its own — or undefined when it + * failed, which for `rev-parse` here means it did not follow the pointer. + */ + function runGit( + cwd: string, + args: string[], + options: { runHooks?: boolean } = {}, + ): string | undefined { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: root, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + } + for (const name of [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_INDEX_FILE', + 'GIT_CEILING_DIRECTORIES', + 'GIT_DISCOVERY_ACROSS_FILESYSTEM', + 'GIT_NAMESPACE', + 'GIT_CONFIG', + 'GIT_CONFIG_COUNT', + ]) { + delete env[name] + } + const result = spawnSync( + 'git', + [ + '-c', + 'core.fsmonitor=', + // Nothing here is about running hooks, and a target these cases + // write is not a directory to let git execute out of — except the + // one case that IS about a hooks link, which asks for them. + ...(options.runHooks === true + ? [] + : ['-c', 'core.hooksPath=/dev/null']), + ...args, + ], + { cwd, env, encoding: 'utf8' }, + ) + if (result.status !== 0) return undefined + return result.stdout.endsWith('\n') + ? result.stdout.slice(0, -1) + : result.stdout + } + + /** The directory `git rev-parse ` names in `checkout`, if any. */ + function gitResolves(checkout: string, arg: string): string | undefined { + const printed = runGit(checkout, ['rev-parse', arg]) + if (printed === undefined || printed === '') return undefined + const absolute = isAbsolute(printed) ? printed : resolve(checkout, printed) + const real = realPathOrSelf(absolute) + // Discovery walks up from cwd when a pointer is merely absent, so a + // repository above the scratch tree is not this pointer's target. + return real.startsWith(root + sep) ? real : undefined + } + + function realPathOrSelf(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } + } + + /** Each deny path, keyed by the real directory it sits in. */ + function coverage(denyPaths: string[]): Set { + return new Set( + denyPaths.map(p => join(realPathOrSelf(dirname(p)), basename(p))), + ) + } + + const GUARDED_NAMES = ['hooks', 'config', 'config.worktree', 'commondir'] + + type Verdict = 'checked' | 'refused' | 'not-followed' + + function assertParity( + label: string, + pointer: string, + checkout: string, + ): Verdict { + let denyPaths: string[] + try { + denyPaths = gitFileDenies(pointer, false).denyPaths + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + // Refusing to sandbox covers everything, including what git follows. + return 'refused' + } + const gitDir = gitResolves(checkout, '--absolute-git-dir') + if (gitDir === undefined) return 'not-followed' + const commonDir = gitResolves(checkout, '--git-common-dir') + const covered = coverage(denyPaths) + const missing = [gitDir, commonDir] + .filter((d): d is string => d !== undefined) + .flatMap(d => GUARDED_NAMES.map(name => join(d, name))) + .filter(p => !covered.has(p)) + expect({ label, missing }).toEqual({ label, missing: [] }) + return 'checked' + } + + /** Build one case on disk and resolve it both ways. */ + function runPointerCase(spec: PointerCase, labelPrefix = ''): Verdict { + const label = labelPrefix + JSON.stringify(spec) + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + + const target = TARGETS[spec.target](caseDir) + const spelled = SPELLINGS[spec.spelling](checkout, target) + let contents = Buffer.concat([ + PREFIXES[spec.prefix], + Buffer.from(spelled), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + + const pointer = join(checkout, '.git') + if (spec.pointerIsASymlink === true) { + const regular = join(checkout, 'pointer-file') + writeFileSync(regular, contents) + symlinkSync(regular, pointer) + } else { + writeFileSync(pointer, contents) + } + return assertParity(label, pointer, checkout) + } + + const FIXED_CASES: PointerCase[] = [ + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'dotSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'doubledSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'trailingSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'backThroughDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'crlf', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'mixedEndings', + target: 'gitDir', + }, + // The reported bypass: padding past the bound this used to read to. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'sixtyFourKiBOfNewlines', + target: 'gitDir', + }, + // The size git accepts for a gitfile, exactly and one byte past it. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE + 1, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'trailingSpaces', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'secondLine', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'embeddedNul', + target: 'gitDir', + }, + { + prefix: 'noSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'twoSpaces', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'tab', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'leadingSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'byteOrderMark', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'upperCase', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'headIsASymlink', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'throughASymlinkedParent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'nineThousandNewlines', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'ordinaryDirectory', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'nameHoldsANewline', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'nameIsNotAscii', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + pointerIsASymlink: true, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + pointerIsASymlink: true, + }, + // A `..` after a symlink: the kernel follows the link first, so these + // land somewhere a lexical fold of the path never visits. + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'embeddedNul', + target: 'gitDir', + }, + ] + + it('resolves a corpus of pointer shapes the way git does', () => { + const verdicts = FIXED_CASES.map(spec => runPointerCase(spec)) + // A corpus git followed nothing in would assert nothing at all. + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 120_000) + + it('resolves randomly mixed pointer shapes the way git does', () => { + // Deterministic, so a failing mix is reproducible from the seed below. + const seed = 0x515 + const random = mulberry32(seed) + const specs: PointerCase[] = Array.from({ length: 150 }, () => ({ + // Weighted to the one prefix git follows: the others are worth some + // cases, but they all stop at the same place. + prefix: + random() < 0.5 + ? 'plain' + : pick(random, Object.keys(PREFIXES) as (keyof typeof PREFIXES)[]), + spelling: pick( + random, + Object.keys(SPELLINGS) as (keyof typeof SPELLINGS)[], + ), + trailer: pick(random, Object.keys(TRAILERS) as (keyof typeof TRAILERS)[]), + target: pick(random, Object.keys(TARGETS) as (keyof typeof TARGETS)[]), + ...(random() < 0.1 + ? { padTo: MAX_GITFILE_SIZE + (random() < 0.5 ? 0 : 1) } + : {}), + pointerIsASymlink: random() < 0.1, + })) + const verdicts = specs.map((spec, index) => + runPointerCase(spec, `seed ${seed}, case ${index}: `), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(20) + }, 300_000) + + it('resolves a .. against the directory the pointer file is really in', () => { + // The base counts as much as the target: with the checkout reached + // through a symlink, `..` pops the directory the kernel is in, not the + // one the path was spelled from, and git follows it there. + const caseDir = join(root, `pointer-${caseCount++}`) + mkdirSync(join(caseDir, 'nested', 'checkout'), { recursive: true }) + const checkout = join(caseDir, 'checkout-link') + symlinkSync(join(caseDir, 'nested', 'checkout'), checkout) + makeGitDir(join(caseDir, 'nested', 'target')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + expect(assertParity('symlinked pointer directory', pointer, checkout)).toBe( + 'checked', + ) + }) + + it('follows a .. after a symlink where the kernel lands, not where the path folds', () => { + // Both candidates are git directories git would accept, so which one it + // opens says what git's rule IS rather than which one happened to exist. + // It opens the kernel's landing: the link is followed and the `..` + // applied afterwards, which is the walk mandatory-deny-paths.ts makes. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(join(caseDir, 'real', 'side'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + const lexical = makeGitDir(join(checkout, 'evil')) + const physical = makeGitDir(join(caseDir, 'real', 'evil')) + const hop = join(checkout, 'hop') + symlinkSync(join(caseDir, 'real', 'side'), hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: hop/../evil\n') + + const opened = gitResolves(checkout, '--absolute-git-dir') + expect(opened).toBe(physical) + expect(opened).not.toBe(lexical) + // So the deny list has to carry that directory's hooks — and the link + // that decides which directory it is, since a command that retargets it + // moves where the next git run lands. + const denyPaths = gitFileDenies(pointer, false).denyPaths + expect(denyPaths).toContain(join(physical, 'hooks')) + expect(denyPaths).toContain(hop) + expect(assertParity('a .. after a symlink', pointer, checkout)).toBe( + 'checked', + ) + }) + + it('refuses a pointer that is a git directory only where the path folds', () => { + // The other half of the same rule: with the kernel's landing absent, git + // does not fall back to the lexical path, it refuses the checkout. The + // deny list names that path all the same — denying more than git follows + // is the safe direction, and the directory can still be created. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(join(caseDir, 'real', 'side'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + const lexical = makeGitDir(join(checkout, 'evil')) + symlinkSync(join(caseDir, 'real', 'side'), join(checkout, 'hop')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: hop/../evil\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(lexical, 'hooks'), + ) + }) + + it('opens nothing through a pointer value that runs into a loop', () => { + // What git does with one, recorded: it follows the value the way the + // kernel does, gives up on the loop, and does NOT fall back to anything - + // not the lexical fold, and not the checkout. So there is no directory + // whose hooks a deny has to cover, and the deny list says so: the pointer + // file, and the links the walk went through, which are what a command + // replaces to make the value reach a directory after all. The checkout + // itself is not denied, or a command that wrote this file would leave + // every command after it unable to write the project. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + makeGitDir(join(checkout, 'evil')) + const first = join(checkout, 'loopA') + const second = join(checkout, 'loopB') + symlinkSync('loopB', first) + symlinkSync('loopA', second) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: loopA/../evil\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBeUndefined() + + const denyPaths = gitFileDenies(pointer, false).denyPaths + expect(denyPaths).toEqual([pointer, first, second]) + expect(denyPaths).not.toContain(checkout) + }) + + it('opens the directory holding a pointer whose path ends at once', () => { + // The file is long enough for git to take it for a pointer, and the path in + // it is a C string of no length, which git joins to where the file is. + const checkout = makeGitDir(join(root, `pointer-${caseCount++}`)) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: \0elsewhere\n') + + expect(gitResolves(checkout, '--absolute-git-dir')).toBe(checkout) + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(checkout, 'hooks'), + ) + }) + + // Linux only: the target has to exist for git to follow it, and macOS + // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The + // refusal itself is platform-independent and covered everywhere by the + // unit case in mandatory-deny-paths.test.ts, which needs no such target. + it.if(isLinux)( + 'refuses to sandbox on a pointer whose path is not valid UTF-8', + () => { + // The one shape with no honest answer: the bytes name a directory git + // opens and a JavaScript string of them names a different one, so there + // is nothing to put in the deny list. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const target = Buffer.concat([ + Buffer.from(join(caseDir, 'target-')), + Buffer.from([0xff]), + ]) + mkdirSync(Buffer.concat([target, Buffer.from('/objects')]), { + recursive: true, + }) + mkdirSync(Buffer.concat([target, Buffer.from('/refs')]), { + recursive: true, + }) + writeFileSync( + Buffer.concat([target, Buffer.from('/HEAD')]), + 'ref: refs/heads/main\n', + ) + const pointer = join(checkout, '.git') + writeFileSync( + pointer, + Buffer.concat([Buffer.from('gitdir: '), target, Buffer.from('\n')]), + ) + + // git follows it, so anything short of refusing would be a deny list + // for a directory other than the one whose hooks run. + expect( + runGit(checkout, ['rev-parse', '--absolute-git-dir']), + ).not.toBeUndefined() + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) + }, + ) + + /** The same shapes in a linked worktree's `commondir`, which has no prefix. */ + interface CommonDirCase { + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + /** Bytes before the path. git trims none of them. */ + leading: string + padTo?: number + } + + function runCommonDirCase(spec: CommonDirCase): Verdict { + const label = JSON.stringify(spec) + const caseDir = join(root, `commondir-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + + let contents = Buffer.concat([ + Buffer.from(spec.leading), + Buffer.from(SPELLINGS[spec.spelling](worktree, main)), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + writeFileSync(join(worktree, 'commondir'), contents) + + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktree}\n`) + return assertParity(label, pointer, checkout) + } + + it('resolves a corpus of commondir shapes the way git does', () => { + const verdicts: Verdict[] = [] + // Every trailer against each way of leading the path — git trims none of + // it — and then every spelling of the path itself. + for (const leading of ['', ' ', '\t']) { + for (const trailer of Object.keys( + TRAILERS, + ) as (keyof typeof TRAILERS)[]) { + verdicts.push( + runCommonDirCase({ leading, spelling: 'relative', trailer }), + ) + } + } + for (const spelling of Object.keys( + SPELLINGS, + ) as (keyof typeof SPELLINGS)[]) { + verdicts.push( + runCommonDirCase({ leading: '', spelling, trailer: 'newline' }), + ) + } + verdicts.push( + runCommonDirCase({ + leading: '', + spelling: 'relative', + trailer: 'none', + padTo: MAX_GITFILE_SIZE, + }), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 300_000) +}) + +function mulberry32(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function pick(random: () => number, values: readonly T[]): T { + const value = values[Math.floor(random() * values.length)] + if (value === undefined) throw new Error('nothing to pick from') + return value +} diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 38e8f5827..bb528a97b 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -58,6 +58,7 @@ import type { MappedDriveCwdError, SeclogonDisabledError, } from '../../src/sandbox/windows-sandbox-utils.js' +import { withoutManifestName } from '../helpers/bwrap-argv.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' import { isLinux, isWindows } from '../helpers/platform.js' import { spawnSync } from 'node:child_process' @@ -2787,7 +2788,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { readdirSpy.mockRestore() expect(listed).toBeGreaterThan(2) - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'second')) expect(disturbed).not.toContain(join(root, 'first')) } finally { @@ -2851,7 +2854,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { expect(listed[0]).toBe(root) expect(disturbed).toContain(join(root, 'key.pem')) - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) // The first attempt is left at once; the second lists all seven. expect(listed.length).toBe(at + 7) } finally { @@ -2907,7 +2912,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { const disturbed = await SandboxManager.wrapWithSandbox('true') readdirSpy.mockRestore() - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'to')) expect(disturbed).not.toContain(join(root, 'from')) expect(listed.length).toBe(at + 7) @@ -2948,7 +2955,9 @@ describe.if(isLinux)('getFsReadConfig with glob patterns on Linux', () => { const disturbed = await SandboxManager.wrapWithSandbox('true') expect(fs.readFileSync(scans, 'utf8')).toBe('ran\nran\n') - expect(disturbed).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(disturbed)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) expect(disturbed).toContain(join(root, 'to')) expect(disturbed).not.toContain(join(root, 'from')) } finally { @@ -3308,7 +3317,9 @@ describe.if(isLinux)('wrapWithSandbox while another wrap walks', () => { expect(listed).toBe('aabbbaaa') expect(waited).toContain(join(root, holds)) expect(waited).not.toContain(join(root, lacks)) - expect(waited).toBe(await SandboxManager.wrapWithSandbox('true')) + expect(withoutManifestName(waited)).toBe( + withoutManifestName(await SandboxManager.wrapWithSandbox('true')), + ) }) it('keeps the order through a reset()', async () => { diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index cb3b66b48..d01d97f62 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, readlinkSync, realpathSync, rmSync, @@ -19,7 +20,9 @@ import { import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import { LinuxSandboxProfileError } from '../../src/index.js' import { isLinux } from '../helpers/platform.js' +import { manifestOf, RECORD_STEP, STEP_SHELL } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' describe('the bwrap profile error at the package root', () => { it('carries a name, a code, and a cause only when one is given', () => { @@ -51,19 +54,22 @@ describe('the bwrap profile error at the package root', () => { * command line. */ describe.if(isLinux)('bwrap --args for over-long profiles', () => { + usePrivateManifestDirectory() const MAX_ARG_STRLEN = 32 * Number(spawnSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).stdout) // The largest rendering kept on the command line: the kernel's limit less // the NUL, less the 4 KiB left for a prefix of the caller's own. const INLINE_MAX = MAX_ARG_STRLEN - 1 - 4096 // The one rendered shape: the profile's path, then the options left before - // and the words left after `--args 9`. - const VIA_ARGS_FILE = - /^\/bin\/sh -c 'exec 9<"\$1" && shift && exec "\$@"' srt-args (\S+) bwrap (.*?) ?--args 9 (.*)$/s - const MODULE = join( - import.meta.dir, - '../../src/sandbox/linux-sandbox-utils.ts', + // and the words left after `--args 9`. A wrap that named mount points has + // the same shell put itself on the manifest's record first. + const literally = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + const VIA_ARGS_FILE = new RegExp( + `^${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, '../helpers/isolated/library.ts') let BASE: string const savedCwd = process.cwd() @@ -219,19 +225,37 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { message: error.message, })) const argsPathOf = wrapped => wrapped.match(/' srt-args (\\S+) /)?.[1] + // But for the sockets: the runtime closes those of a child that has ended + // (ripgrep) in its own time, on another thread. + const openFds = () => fs.readdirSync('/proc/self/fd').filter(fd => { + try { + return !fs.readlinkSync('/proc/self/fd/' + fd).startsWith('socket:') + } catch { + // Closed since the listing, as the listing's own is. + return false + } + }).length ${body} ` // A file, not `-e`: the script names every fixture path, and would not // fit one argument itself. const scriptFile = join(BASE, 'isolated.ts') writeFileSync(scriptFile, script) - // A tmpdir of its own, so what a scenario leaves there goes with BASE. + // A tmpdir of its own, so what a scenario leaves there goes with BASE, + // and no runtime directory: the mount point manifests are then kept under + // that tmpdir, which is the arrangement several scenarios are about. mkdirSync(join(BASE, 'tmp'), { recursive: true }) + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TMPDIR: join(BASE, 'tmp'), + ...env, + } + delete childEnv.XDG_RUNTIME_DIR const argv = [...launcher, process.execPath, 'run', scriptFile] const run = spawnSync(argv[0]!, argv.slice(1), { cwd: BASE, encoding: 'utf8', - env: { ...process.env, TMPDIR: join(BASE, 'tmp'), ...env }, + env: childEnv, timeout: 60000, }) expect(run.stderr).toBe('') @@ -268,7 +292,8 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // environment is in it. expect( mounts.filter( - w => w.startsWith('--') && !/^--(ro-bind|bind|tmpfs)$/.test(w), + w => + w.startsWith('--') && !/^--(ro-bind|ro-bind-try|bind|tmpfs)$/.test(w), ), ).toEqual([]) expect(before).toContain( @@ -389,11 +414,12 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { const none = await shares() const [first, second] = repositories(10) - // Hooks, config, and a pin on each of the two directories above them. + // hooks, config, commondir and config.worktree, and a pin on each of the + // two directories above them. const ten = await shares() expect(ten).toEqual({ - total: none.total + 10 * 12, - mandatory: none.mandatory + 10 * 12, + total: none.total + 10 * 18, + mandatory: none.mandatory + 10 * 18, }) // What the configuration adds is not theirs: two words a variable, and a @@ -411,7 +437,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // Each thing the refusal advises lowers their share. expect((await shares({ allowOnly: [first!, second!] })).mandatory).toBe( // A write root is no pin. - 2 * 9, + 2 * 15, ) expect((await shares({ mandatoryDenySearchDepth: 2 })).mandatory).toBe( none.mandatory, @@ -438,7 +464,6 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { it('holds one fd per pending profile and gives them all back at cleanup, a refused wrap included', () => { const seen = isolated(` - const openFds = () => fs.readdirSync('/proc/self/fd').length // The runtime opens event-loop fds of its own on the first wrap. await wrap(small) const baseline = openFds() @@ -459,6 +484,32 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { }) }) + it.each([ + ['that names no command', ''], + ['that names one', `{ commandId: 'another' }`], + ])( + 'keeps a pending profile through a clean-up %s, and gives it back with the last', + (_how, first) => { + // A profile belongs to one wrap and nothing maps it back to a command, so + // none is closed while any wrap of this process is outstanding. + const seen = isolated(` + // The runtime opens event-loop fds of its own on the first wrap. + await wrap(small) + cleanupBwrapMountPoints() + const baseline = openFds() + await wrap(overLong) + await wrap(overLong) + const held = openFds() - baseline + cleanupBwrapMountPoints(${first}) + const afterFirst = openFds() - baseline + cleanupBwrapMountPoints() + console.log(JSON.stringify({ held, afterFirst, afterSecond: openFds() - baseline })) + `) + expect(seen).toEqual({ held: 2, afterFirst: 2, afterSecond: 0 }) + }, + 30000, + ) + it('refuses at wrap time, with the reason, when no directory takes an unnamed file', () => { // Both candidates read-only: tmpdir and /dev/shm. A profile that fits // needs neither and is unaffected. @@ -530,7 +581,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { const { spawnSync } = await import('node:child_process') const wrapped = await wrap(overLong, 'touch ${marker} 2>/dev/null && echo WROTE || echo DENIED') const argsPath = argsPathOf(wrapped) - // The runtime leaves a cache of its own there; nothing of ours. + // The runtime leaves a cache of its own there. Of ours there is one + // thing, and it is not the profile: the directory mount point + // manifests are kept in, which this wrap has none of and binds + // read-only only to keep other sandboxes' out of the command's reach. const namedUnderTmpdir = fs.readdirSync(process.env.TMPDIR, { recursive: true }) .filter(entry => /srt|args|bwrap/.test(entry)) // What a sandbox with the parent writable, or another process of @@ -558,7 +612,10 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { ) expect(seen).toEqual({ argsPath: expect.stringMatching(/^\/proc\/\d+\/fd\/\d+$/), - namedUnderTmpdir: [], + // Empty, and bound with --ro-bind-try: it went aside with its parent + // before the command started, and status 0 below is that not + // mattering. + namedUnderTmpdir: [`srt-mount-points-${process.getuid?.() ?? 0}`], planted: null, status: 0, // The command ran under the profile that was wrapped, not one @@ -594,6 +651,12 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { expect(argsPathOf(wrapped)).toMatch( new RegExp(`^/proc/${process.pid}/fd/\\d+$`), ) + // This wrap names mount points, under the temp dir: one shell puts the + // command on record and opens the profile. + expect(wrapped).toMatch( + / srt-args \S+\/srt-mount-points\/\d+-[0-9a-f]{16}\.started \/proc\/\d+\/fd\/\d+ bwrap /, + ) + expect(wrapped.split(`${STEP_SHELL} `)).toHaveLength(2) const run = spawnSync(`timeout 60 ${wrapped} && echo AFTER`, { shell: true, encoding: 'utf8', @@ -611,4 +674,44 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { }, 60_000, ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'refuses a string run after its clean-up, though its descriptor number now holds the profile of a later wrap', + async () => { + // The manifest's bind is what refuses such a start. In the file, it would + // be the later wrap's manifest that is bound, and the command would run + // under that wrap's profile. + const files = overLongProfile() + const [A, B] = [join(BASE, 'a'), join(BASE, 'b')] + const inArea = (area: string): Promise => { + mkdirSync(area) + return wrap(files, { + allowOnly: [area], + denyWithinAllow: [join(area, 'absent.lock')], + command: `echo ran; touch ${join(B, 'written')}`, + }) + } + const first = await inArea(A) + const manifest = manifestOf(first)! + expect(first.match(VIA_ARGS_FILE)![2]).toEndWith( + ` --ro-bind / / --ro-bind ${manifest} ${manifest}`, + ) + cleanupBwrapMountPoints() + const second = await inArea(B) + expect(argsPathOf(second)).toBe(argsPathOf(first)) + + const run = spawnSync(first, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + expect(run.status).not.toBe(0) + expect(run.stderr).toContain(`Can't find source path ${manifest}`) + expect(run.stdout).toBe('') + // Nothing was made for it, in either place. + expect(readdirSync(A)).toEqual([]) + expect(readdirSync(B)).toEqual([]) + }, + 60_000, + ) }) diff --git a/test/sandbox/linux-file-above-deny.test.ts b/test/sandbox/linux-file-above-deny.test.ts index 2589674ed..630478f9f 100644 --- a/test/sandbox/linux-file-above-deny.test.ts +++ b/test/sandbox/linux-file-above-deny.test.ts @@ -68,11 +68,12 @@ describe.if(isLinux)('A file above a write deny', () => { expect(wrapped).not.toContain(join(area, '.git', 'config')) }) - // git rewrites and removes it: a checkout that recurses into submodules. - it('one below the working directory is left to git', async () => { + // Rewritten, it sends the host's git to a git directory of the command's + // own. A checkout that recurses into submodules is refused with it. + it('one below the working directory is bound onto itself as well', async () => { mkdirSync(join(area, 'sub')) writeFileSync(join(area, 'sub', '.git'), POINTER) - expect(await wrap()).not.toContain(join(area, 'sub')) + expect(held(await wrap(), join(area, 'sub', '.git'))).toBe(1) }) it('a .git that is a directory is denied as before', async () => { @@ -92,7 +93,10 @@ describe.if(isLinux)('A file above a write deny', () => { // Read without the scan too, which would find the file as well. it.each([ ['a file', ['.git']], - ['a directory', ['.git/hooks', '.git/config']], + [ + 'a directory', + ['.git/hooks', '.git/commondir', '.git/config', '.git/config.worktree'], + ], ['not there', []], ])('what follows from the cwd alone, .git being %s', (what, denied) => { if (what === 'a file') writeFileSync(join(area, '.git'), POINTER) diff --git a/test/sandbox/linux-git-dir-whole.test.ts b/test/sandbox/linux-git-dir-whole.test.ts new file mode 100644 index 000000000..f31d4a822 --- /dev/null +++ b/test/sandbox/linux-git-dir-whole.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, + LinuxSandboxProfileError, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { indexOfMount, lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { writeManifest } from '../helpers/mount-point-fixtures.js' +import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' + +/** + * A git directory this process cannot write takes no mount point for the + * files git reads back, so the wrap denies it with one read-only bind of the + * directory. That bind has to be the only one the deny binds put there. One + * that needs a mount point made inside the directory either follows the bind, + * where bubblewrap cannot create it on a read-only mount and the command + * never starts, or precedes it, where bubblewrap creates it in a directory + * this process can never take it out of again. + * + * Root writes a 0555 directory like any other, so nothing here applies to it. + */ +describe.if(isLinux && process.getuid?.() !== 0)( + 'Linux sandbox — a git directory denied whole takes no other deny bind', + () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const LIVE = bwrapCanNamespace() + const runtime = usePrivateManifestDirectory() + const savedCwd = process.cwd() + let dir: string + let project: string + let gitDir: string + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-dir-whole-'))) + project = join(dir, 'project') + gitDir = join(project, 'x', '.git') + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + // Or the temporary directory cannot be removed. + if (existsSync(gitDir)) chmodSync(gitDir, 0o755) + if (existsSync(project)) chmodSync(project, 0o755) + rmSync(dir, { recursive: true, force: true }) + }) + + /** + * A repository nested in the project whose git directory holds `entries` + * beside its HEAD (a directory for null, a file of those bytes otherwise) + * and is then made unwritable. Whichever of `hooks`, `commondir`, `config` + * and `config.worktree` is not named is an absent deny path, which needs + * a mount point. + */ + function makeUnwritableGitDir( + entries: Record, + ): void { + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + for (const [name, contents] of Object.entries(entries)) { + if (contents === null) mkdirSync(join(gitDir, name)) + else writeFileSync(join(gitDir, name), contents) + } + chmodSync(gitDir, 0o555) + } + + function wrapInProject( + command = 'true', + readConfig?: { denyOnly: string[]; allowWithinDeny: string[] }, + ): Promise { + process.chdir(project) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + }) + } + + /** + * Every mount emitted after the write root's own bind that lands on the + * git directory or inside it, as the words it was emitted with. An + * ancestor pin spells the directory's own bind too, but before the write + * root's. indexOfMount refuses a command whose mount words went to the + * argument file, where nothing here could see them. + */ + function denyBindsOnGitDir(command: string): string[] { + const writeRootBind = indexOfMount(command, '--bind', project, project) + expect(writeRootBind).toBeGreaterThan(-1) + const argv = command.split(/\s+/) + const found: string[] = [] + for (let i = writeRootBind + 3; i < argv.length; i++) { + const words = + argv[i] === '--tmpfs' ? 2 : /^--(ro-)?bind$/.test(argv[i]!) ? 3 : 0 + const dest = argv[i + words - 1] + if ( + words > 0 && + dest !== undefined && + (dest === gitDir || dest.startsWith(`${gitDir}/`)) + ) { + found.push(argv.slice(i, i + words).join(' ')) + } + } + return found + } + + function run(command: string): { status: number | null; output: string } { + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: project, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + } + } + + it('emits no placeholder inside the git directory after the bind of it', async () => { + // `hooks` is there, and comes before `commondir`, which is what finds + // that the directory takes no mount point; `config` is not there, and + // its turn comes after. A placeholder for it then is a file bubblewrap + // has to create on the read-only mount it has just made. + // `config.worktree` adds no second bind of the directory. + makeUnwritableGitDir({ hooks: null }) + + const command = await wrapInProject() + + expect(denyBindsOnGitDir(command)).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }) + + it('emits none where no redirect file comes after it either', async () => { + // A `config.worktree` of the caller's own takes a bind from itself and + // never asks the directory for a mount point, so nothing after the + // absent `config` finds the directory unwritable a second time. + makeUnwritableGitDir({ + hooks: null, + 'config.worktree': '[core]\n', + }) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + }) + + it('binds each from itself where nothing has to be made', async () => { + const entries = { + hooks: null, + config: '[core]\n', + commondir: '.\n', + 'config.worktree': '', + } + makeUnwritableGitDir(entries) + + expect(denyBindsOnGitDir(await wrapInProject()).sort()).toEqual( + Object.keys(entries) + .map(name => `--ro-bind ${join(gitDir, name)} ${join(gitDir, name)}`) + .sort(), + ) + }) + + it('emits no placeholder inside the git directory before the bind of it', async () => { + // No `hooks` either: its placeholder is buffered ahead of `commondir`, + // and bubblewrap, which can write the directory where this process + // cannot, would create a mount point no cleanup here could remove. + makeUnwritableGitDir({}) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + }) + + it('still takes away a redirect file a killed wrap left in the directory', async () => { + // A `commondir` a killed wrap left holding the placeholder is named + // again without writing to the directory; `config.worktree`, which is + // absent, is what finds the directory unwritable. The file takes no bind + // of its own, like every other inside the directory, and is named all + // the same: once the directory can be written again it goes with the + // rest of this wrap's mount points. + makeUnwritableGitDir({ hooks: null, commondir: '.\n' }) + writeManifest(runtime.manifestDir(), [join(gitDir, 'commondir')]) + + expect(denyBindsOnGitDir(await wrapInProject())).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + chmodSync(gitDir, 0o755) + cleanupBwrapMountPoints({ force: true }) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }) + + it('emits none under a directory that is denied for reading either', async () => { + // The tmpfs over the directory above hides the bind of the git + // directory until the project is bound back, which is a reason to keep + // any other placeholder. Not this one: it is a file this process cannot + // write, and every plan of the wrap would find that out anew. + makeUnwritableGitDir({ hooks: null }) + + const binds = denyBindsOnGitDir( + await wrapInProject('true', { + denyOnly: [dir], + allowWithinDeny: [project], + }), + ) + + // `hooks` is there: a bind of it needs nothing made. + expect(new Set(binds)).toEqual( + new Set( + [gitDir, join(gitDir, 'hooks')].map(d => `--ro-bind ${d} ${d}`), + ), + ) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }) + + it.each(['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT'])( + 'goes by a refusal that asking did not foretell (%s)', + async code => { + // access(2) answers for the mode. An immutable directory, a file system + // that maps owners or a full one says yes there and no to the write, + // after the manifest is published. The next plan of the same wrap has + // to know, and of every such directory at once: a wrap has few plans. + const gitDirs = ['x', 'y', 'z', 'w'].map(name => + join(project, name, '.git'), + ) + for (const each of gitDirs) { + mkdirSync(join(each, 'hooks'), { recursive: true }) + writeFileSync(join(each, 'HEAD'), 'ref: refs/heads/main') + } + const real = fs.writeFileSync + let refused = 0 + const write = spyOn(fs, 'writeFileSync').mockImplementation((( + ...args: Parameters + ) => { + if (/\/(commondir|config\.worktree)$/.test(String(args[0]))) { + refused++ + throw Object.assign(new Error(code), { code }) + } + return real(...args) + }) as typeof fs.writeFileSync) + + const command = await wrapInProject().finally(() => write.mockRestore()) + + expect(denyBindsOnGitDir(command)).toEqual([ + `--ro-bind ${gitDir} ${gitDir}`, + ]) + for (const each of gitDirs) { + expect(lastMountAt(command, each)).toBe(`--ro-bind ${each} ${each}`) + expect(readdirSync(each).sort()).toEqual(['HEAD', 'hooks']) + } + // Once a directory has refused one file it is not asked for the other. + expect(refused).toBe(gitDirs.length) + }, + ) + + it('refuses the command where the git directory holds the work tree', async () => { + // `x/.git` names the project itself as its git directory. Denied whole, + // that would take everything the command may write. + mkdirSync(join(project, 'x'), { recursive: true }) + mkdirSync(join(project, 'objects')) + mkdirSync(join(project, 'refs')) + writeFileSync(join(project, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(project, 'x', '.git'), 'gitdir: ..\n') + chmodSync(project, 0o555) + + const refusal: unknown = await wrapInProject().catch( + (err: unknown) => err, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'deny_placeholder_unavailable', + ) + expect(readdirSync(project).sort()).toEqual([ + 'HEAD', + 'objects', + 'refs', + 'x', + ]) + }) + + it.if(LIVE)( + 'runs the command, with the git directory read-only and the work tree writable', + async () => { + makeUnwritableGitDir({ hooks: null }) + + const first = run( + await wrapInProject( + `echo ${BOOTED}; echo x > x/.git/probe; echo ok > x/work.txt && echo WORK_OK`, + ), + ) + + expect(first.output).toContain(BOOTED) + expect(first.status).toBe(0) + expect(first.output).toContain('Read-only file system') + expect(first.output).toContain('WORK_OK') + expect(existsSync(join(gitDir, 'probe'))).toBe(false) + expect(existsSync(join(project, 'x', 'work.txt'))).toBe(true) + + // Not something one command can do to the next: the directory is as + // unwritable to the second as it was to the first. + cleanupBwrapMountPoints() + const second = run(await wrapInProject(`echo ${BOOTED}`)) + expect(second.output).toContain(BOOTED) + expect(second.status).toBe(0) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }, + ) + + it.if(LIVE)( + 'runs the command under a directory that is denied for reading', + async () => { + makeUnwritableGitDir({ hooks: null }) + + const result = run( + await wrapInProject( + `echo ${BOOTED}; echo x > x/.git/commondir; echo ok > x/work.txt && echo WORK_OK`, + { denyOnly: [dir], allowWithinDeny: [project] }, + ), + ) + + expect(result.output).toContain(BOOTED) + expect(result.status).toBe(0) + expect(result.output).toContain('Read-only file system') + expect(result.output).toContain('WORK_OK') + cleanupBwrapMountPoints() + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'hooks']) + }, + ) + + it.if(LIVE)( + 'leaves nothing behind in a git directory with no hooks either', + async () => { + makeUnwritableGitDir({}) + + const result = run( + await wrapInProject( + `echo ${BOOTED}; mkdir x/.git/hooks || echo HOOKS_DENIED`, + ), + ) + + expect(result.output).toContain(BOOTED) + expect(result.status).toBe(0) + expect(result.output).toContain('HOOKS_DENIED') + cleanupBwrapMountPoints() + expect(readdirSync(gitDir)).toEqual(['HEAD']) + }, + ) + }, +) diff --git a/test/sandbox/linux-git-redirect-placeholder.test.ts b/test/sandbox/linux-git-redirect-placeholder.test.ts new file mode 100644 index 000000000..febd721fb --- /dev/null +++ b/test/sandbox/linux-git-redirect-placeholder.test.ts @@ -0,0 +1,719 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + indexOfMount, + lastIndexOfMount, + lastMountAt, +} from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + type Process, + processAt, + refusal, + sleep, + waitFor, + writeManifest, + writeScript, +} from '../helpers/mount-point-fixtures.js' +import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' + +/** + * Denying a path that is not there means mounting something at it, and two of + * these the host's git reads back: it refuses to run at all against a + * `commondir` it cannot read, and neither a bound /dev/null nor an empty file + * is one. So the wrap makes these mount points itself, holding what git + * concludes with no file there, and binds each read-only onto itself. + * + * It makes them on the host, in a git directory a command already running may + * be writing: one that made the directory after its own wrap has nothing of it + * mounted. So nothing that is there is ever written over, and nothing is made + * anywhere but in the directory the plan looked at. + */ +describe.if(isLinux)('The mount point of a file git reads back', () => { + const VICTIM = 'a file outside every write root\n' + /** Echoed by a command that runs for real, so nothing concludes anything + * from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const LIBRARY = join(import.meta.dir, '../helpers/isolated/library.ts') + /** One that outlives every test here. */ + const initProcess = (): Process => processAt(1) + const runtime = usePrivateManifestDirectory() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept, wrapped with it as the cwd. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + function wrapIn( + checkout: string, + command = 'true', + denyWithinAllow: string[] = [], + denyRead: string[] = [], + ): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: { denyOnly: denyRead }, + writeConfig: { allowOnly: [checkout], denyWithinAllow }, + }) + } + + /** Whether `gitDir` is bound read-only above the bind that makes `checkout` + * writable. A pin spells the same words beneath that one. */ + function isDeniedWhole( + command: string, + checkout: string, + gitDir: string, + ): boolean { + const madeWritable = indexOfMount(command, '--bind', checkout, checkout) + expect(madeWritable).toBeGreaterThan(-1) + return lastIndexOfMount(command, '--ro-bind', gitDir, gitDir) > madeWritable + } + + /** + * Wraps in `checkout`, and runs `swap` once: as the wrapper is about to open + * `gitDir` to make a `commondir` in it, or about to make the file. Those are + * the last moments a command writing the directory has to change what the + * plan saw. Returns the command, and where each `commondir` made really was: + * one made in the wrong place may be gone again by the end of the wrap. + */ + async function wrapSwappingBefore( + call: 'openSync' | 'writeFileSync', + checkout: string, + gitDir: string, + swap: () => void, + ): Promise<{ command: string; made: string[] }> { + type Fn = (...args: unknown[]) => unknown + const [open, write] = [fs.openSync as Fn, fs.writeFileSync as Fn] + const made: string[] = [] + let swapped = false + const swapOnce = (): void => { + if (!swapped) swap() + swapped = true + } + const spies = [ + spyOn(fs, 'openSync').mockImplementation(((...args: unknown[]) => { + // Nothing is made before a manifest names it, so an open of the + // directory before one is published is for something else. + const isPublished = readdirSync(runtime.manifestDir()).some(name => + name.endsWith('.json'), + ) + if (call === 'openSync' && args[0] === gitDir && isPublished) swapOnce() + return open(...args) + }) as never), + spyOn(fs, 'writeFileSync').mockImplementation(((...args: unknown[]) => { + const makesCommondir = + String(args[0]).endsWith('/commondir') && + (args[2] as { flag?: string } | undefined)?.flag === 'wx' + if (call === 'writeFileSync' && makesCommondir) swapOnce() + write(...args) + if (makesCommondir) made.push(realpathSync(String(args[0]))) + }) as never), + ] + try { + return { command: await wrapIn(checkout), made } + } finally { + for (const spy of spies) spy.mockRestore() + expect(swapped).toBe(true) + } + } + + /** + * What each file of the git directory holds before the wrap, what the host + * finds there while the command runs, and whether the clean-up takes it away. + * `named` by the manifest a killed wrap left, which alone makes it a mount + * point: anything else that is there is somebody's file. + */ + it.each<{ + name: string + file: 'commondir' | 'config.worktree' + before?: { contents: string; mode: number; named?: true } + during?: { contents: string; mode: number } + }>([ + { + name: 'an absent commondir', + file: 'commondir', + during: { contents: '.\n', mode: 0o444 }, + }, + { + name: 'an absent config.worktree', + file: 'config.worktree', + during: { contents: '', mode: 0o444 }, + }, + { + name: 'the commondir a killed wrap left', + file: 'commondir', + before: { contents: '.\n', mode: 0o444, named: true }, + }, + { + name: 'the config.worktree a killed wrap left', + file: 'config.worktree', + before: { contents: '', mode: 0o444, named: true }, + }, + { + name: 'a commondir naming a directory', + file: 'commondir', + before: { contents: '../..\n', mode: 0o644 }, + }, + { + name: 'a commondir as long as what is written, holding something else', + file: 'commondir', + before: { contents: '..', mode: 0o444 }, + }, + { + name: 'a commondir holding what is written, that no manifest names', + file: 'commondir', + before: { contents: '.\n', mode: 0o444 }, + }, + { + name: 'an empty commondir that no manifest names, a host git mid-write', + file: 'commondir', + before: { contents: '', mode: 0o644 }, + }, + { + name: 'a config.worktree with settings in it', + file: 'config.worktree', + before: { contents: '[core]\n', mode: 0o644 }, + }, + { + name: 'an empty read-only config.worktree that no manifest names', + file: 'config.worktree', + before: { contents: '', mode: 0o444 }, + }, + ])('binds $name from itself', async ({ file, before, during = before }) => { + const checkout = makeCheckout('repo') + const dest = join(checkout, '.git', file) + if (before !== undefined) { + writeFileSync(dest, before.contents) + chmodSync(dest, before.mode) + } + if (before?.named) writeManifest(runtime.manifestDir(), [dest]) + + const command = await wrapIn(checkout) + + expect(lastMountAt(command, dest)).toBe(`--ro-bind ${dest} ${dest}`) + expect(readFileSync(dest, 'utf8')).toBe(during!.contents) + expect(statSync(dest).mode & 0o777).toBe(during!.mode) + + cleanupBwrapMountPoints({ force: true }) + const isSomebodys = before !== undefined && !before.named + expect(existsSync(dest)).toBe(isSomebodys) + if (isSomebodys) expect(readFileSync(dest, 'utf8')).toBe(before.contents) + }) + + // What bubblewrap's own ensure_file() makes where the file has gone from under + // a command that then starts. It is no use to git, and is never written over. + it('makes anew the empty commondir a killed wrap left', async () => { + // It goes as any mount point does once no running process's manifest names + // it, which is when the plan that found it lets go of its own. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '', { mode: 0o444 }) + writeManifest(runtime.manifestDir(), [commondir]) + + const command = await wrapIn(checkout) + + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('denies the git directory whole where the empty commondir stays', async () => { + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + writeFileSync(commondir, '', { mode: 0o444 }) + writeManifest(runtime.manifestDir(), [commondir], initProcess()) + + const command = await wrapIn(checkout) + + expect(isDeniedWhole(command, checkout, gitDir)).toBe(true) + expect(lastMountAt(command, commondir)).toBeUndefined() + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('') + expect(existsSync(join(gitDir, 'config.worktree'))).toBe(false) + }) + + it('takes a commondir another wrap is about to write for no leftover', async () => { + // Made and written are two steps. Found between them, it is empty. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + let manifest: string | undefined + + const { command } = await wrapSwappingBefore( + 'writeFileSync', + checkout, + gitDir, + () => { + manifest = writeManifest( + runtime.manifestDir(), + [commondir], + initProcess(), + ) + writeFileSync(commondir, '', { mode: 0o444 }) + // By the time this wrap has planned again the other has written it. + setImmediate(() => { + chmodSync(commondir, 0o644) + writeFileSync(commondir, '.\n') + chmodSync(commondir, 0o444) + }) + }, + ) + + expect(manifest).toBeDefined() + expect(isDeniedWhole(command, checkout, gitDir)).toBe(false) + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + }) + + it('goes by the file, not by the spelling of the deny entry', async () => { + // A caller's own deny for the same file comes first and wins the dedup. Its + // spelling does not name the file, and /dev/null there would cost the + // repository every git command for as long as the command runs. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const command = await wrapIn(checkout, 'true', ['./.git/commondir/']) + + expect(lastMountAt(command, commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + }) + + it('leaves every other absent deny on /dev/null', async () => { + const checkout = makeCheckout('repo') + const bashrc = join(checkout, '.bashrc') + + expect(lastMountAt(await wrapIn(checkout), bashrc)).toBe( + `--ro-bind /dev/null ${bashrc}`, + ) + }) + + it('makes none where the deny takes no bind', async () => { + // A git directory denied for reading is hidden whole, and what is written + // in there never reaches the host. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + + const command = await wrapIn(checkout, 'true', [], [gitDir]) + + expect(lastMountAt(command, gitDir)).toBe(`--tmpfs ${gitDir}`) + expect(readdirSync(gitDir).sort()).toEqual(['HEAD', 'config', 'hooks']) + }) + + it('leaves no mount point behind when the wrap itself throws', async () => { + // The caller does not clean up after a wrap that threw. + const checkout = makeCheckout('repo') + + expect( + await refusal( + wrapIn( + checkout, + 'true', + Array.from({ length: 4000 }, (_unused, n) => + join(checkout, `deny-${n}`), + ), + ), + ), + ).toBe('too_many_arguments') + + expect(existsSync(join(checkout, '.git', 'commondir'))).toBe(false) + }) + + it('does not remove a mount point that became something else', async () => { + // One was written to, as many bytes as it held. The others were swapped for + // links to files that hold what the mount points did: a check that looks + // through a link would pass them, and the unlink does not look through it. + const written = join(makeCheckout('written'), '.git', 'commondir') + const linked = makeCheckout('linked') + const links = { commondir: '.\n', 'config.worktree': '' } + await wrapIn(join(dir, 'written')) + await wrapIn(linked) + + chmodSync(written, 0o644) + writeFileSync(written, '..') + for (const [name, contents] of Object.entries(links)) { + expect(readFileSync(join(linked, '.git', name), 'utf8')).toBe(contents) + writeFileSync(join(dir, name), contents) + rmSync(join(linked, '.git', name)) + symlinkSync(join(dir, name), join(linked, '.git', name)) + } + cleanupBwrapMountPoints({ force: true }) + + expect(readFileSync(written, 'utf8')).toBe('..') + for (const name of Object.keys(links)) { + expect(lstatSync(join(linked, '.git', name)).isSymbolicLink()).toBe(true) + expect(existsSync(join(dir, name))).toBe(true) + } + }) + + it.each<{ + what: string + plant: (victim: string, name: string) => void + isStillThere: (name: string) => boolean + deniedWhole: boolean + }>([ + { + what: 'a link to a file outside every write root', + plant: symlinkSync, + isStillThere: name => lstatSync(name).isSymbolicLink(), + deniedWhole: true, + }, + { + what: 'a hard link to one', + plant: linkSync, + isStillThere: name => lstatSync(name).nlink === 2, + deniedWhole: false, + }, + { + what: 'a FIFO', + plant: (_victim, name) => + expect(spawnSync('mkfifo', [name]).status).toBe(0), + isStillThere: name => lstatSync(name).isFIFO(), + deniedWhole: false, + }, + ])( + 'writes to nothing where the name is taken by $what as it is made', + async ({ plant, isStillThere, deniedWhole }) => { + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + const commondir = join(gitDir, 'commondir') + const victim = join(dir, 'victim') + writeFileSync(victim, VICTIM) + + const { command, made } = await wrapSwappingBefore( + 'writeFileSync', + checkout, + gitDir, + () => plant(victim, commondir), + ) + + expect(made).toEqual([]) + // It plans again, and denies what it finds: somebody's file from itself, + // and the whole directory where the entry is a link. + expect(isDeniedWhole(command, checkout, gitDir)).toBe(deniedWhole) + expect(lastMountAt(command, commondir)).toBe( + deniedWhole ? undefined : `--ro-bind ${commondir} ${commondir}`, + ) + cleanupBwrapMountPoints({ force: true }) + expect(isStillThere(commondir)).toBe(true) + expect(readFileSync(victim, 'utf8')).toBe(VICTIM) + expect(statSync(victim).mode & 0o777).toBe(0o644) + }, + ) + + describe.each(['x/.git', 'x'])('where %s becomes a link', swapped => { + // The plan resolved the path, and the name of the file alone would be + // followed wherever the path leads by now. + it.each(['openSync', 'writeFileSync'] as const)( + 'makes nothing elsewhere (ahead of its %s)', + async call => { + const project = join(dir, 'project') + mkdirSync(project) + const gitDir = join(makeCheckout('project/x'), '.git') + const elsewhere = join(dir, 'elsewhere') + mkdirSync(join(elsewhere, 'x', '.git'), { recursive: true }) + + const { made } = await wrapSwappingBefore(call, project, gitDir, () => { + renameSync(join(project, swapped), join(dir, 'aside')) + symlinkSync(join(elsewhere, swapped), join(project, swapped)) + }) + + // In the directory it opened, wherever that has been moved to. + expect(made).toEqual( + call === 'openSync' + ? [] + : [join(dir, 'aside', swapped === 'x' ? '.git' : '', 'commondir')], + ) + expect(readdirSync(join(elsewhere, 'x', '.git'))).toEqual([]) + cleanupBwrapMountPoints({ force: true }) + expect(lstatSync(join(project, swapped)).isSymbolicLink()).toBe(true) + }, + ) + }) + + it('does not wait on a FIFO that is there', () => { + // Opening one to read waits for a writer, and none comes. In a process of + // its own, which can be given up on. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + expect(spawnSync('mkfifo', [commondir]).status).toBe(0) + + const wrapped = spawnSync( + process.execPath, + [ + writeScript(dir, 'wrap.ts', [ + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY)}`, + `process.chdir(${JSON.stringify(checkout)})`, + `console.log(await wrapCommandWithSandboxLinux(${JSON.stringify({ + command: 'true', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + })}))`, + ]), + ], + { encoding: 'utf8', timeout: 30000 }, + ) + + expect(`${wrapped.signal} ${wrapped.stderr}`).toBe('null ') + expect(lastMountAt(wrapped.stdout.trim(), commondir)).toBe( + `--ro-bind ${commondir} ${commondir}`, + ) + }, 60000) + + describe.if(bwrapCanNamespace() && Bun.which('git') !== null)( + 'with git', + () => { + /** Enough to commit without a hook, an identity or a signature. */ + const IDENT = [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + ] + + function git( + cwd: string, + args: string[], + ): { status: number | null; said: string } { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { + status: result.status, + said: `${result.stdout}${result.stderr}`, + } + } + + /** A repository with one commit, made by git itself. */ + function gitRepo(name: string): string { + const repo = join(dir, name) + mkdirSync(repo, { recursive: true }) + writeFileSync(join(repo, 'index.js'), 'console.log(1)\n') + for (const args of [ + ['-c', 'init.defaultBranch=main', 'init', '-q', '.'], + ['add', 'index.js'], + [...IDENT, 'commit', '-q', '-m', 'one'], + ]) { + expect(git(repo, args)).toMatchObject({ status: 0 }) + } + return repo + } + + /** + * Wraps a command with `checkout` as the cwd in a process of its own, + * which is then killed: nothing of that wrap's clean-up runs. What it + * leaves is its mount points and the manifest that names them. + */ + async function wrapAndBeKilled(checkout: string): Promise { + const killed = spawnSync( + process.execPath, + [ + writeScript(dir, 'killed-wrap.ts', [ + `import { wrapCommandWithSandboxLinux } from ${JSON.stringify(LIBRARY)}`, + `process.chdir(${JSON.stringify(checkout)})`, + `await wrapCommandWithSandboxLinux(${JSON.stringify({ + command: 'true', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + })})`, + "process.kill(process.pid, 'SIGKILL')", + ]), + ], + { encoding: 'utf8', timeout: 60000 }, + ) + expect(`${killed.signal} ${killed.stderr}`).toBe('SIGKILL ') + // A manifest this young counts as live with no command on its record. + await sleep(600) + } + + it("keeps the host's git working while a wrapped command runs", async () => { + const sub = gitRepo('sub') + const repo = gitRepo('repo') + for (const args of [ + [ + '-c', + 'protocol.file.allow=always', + ...IDENT, + 'submodule', + 'add', + '-q', + sub, + 'lib', + ], + [...IDENT, 'commit', '-q', '-m', 'lib'], + ['worktree', 'add', '-q', join(dir, 'wt')], + ]) { + expect(git(repo, args)).toMatchObject({ status: 0 }) + } + const commondirs = [ + join(repo, '.git', 'commondir'), + join(repo, '.git', 'modules', 'lib', 'commondir'), + ] + + // Runs until the host releases it, so the host's git is exercised with + // the sandbox up and the mount points in place. + const child = spawn( + await wrapIn( + repo, + 'echo > up; i=0; while [ ! -f release ] && [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done', + ), + { shell: true, cwd: repo, stdio: 'ignore' }, + ) + try { + await waitFor(join(repo, 'up')) + + for (const commondir of commondirs) { + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + } + for (const args of [ + ['status', '--porcelain'], + ['log', '--oneline'], + ['worktree', 'list'], + ['rev-parse', '--git-common-dir'], + [...IDENT, 'commit', '--allow-empty', '-q', '-m', 'during'], + ]) { + expect({ args, ...git(repo, args) }).toMatchObject({ status: 0 }) + } + expect( + git(join(repo, 'lib'), ['status', '--porcelain']), + ).toMatchObject({ status: 0 }) + } finally { + writeFileSync(join(repo, 'release'), '') + await new Promise(resolve => child.on('close', resolve)) + } + expect(child.exitCode).toBe(0) + + cleanupBwrapMountPoints() + expect(commondirs.filter(existsSync)).toEqual([]) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ + status: 0, + }) + }) + + it('leaves behind a killed wrap a commondir git reads, and takes an empty one away', async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const status = (): number | null => + git(repo, ['status', '--porcelain']).status + + await wrapAndBeKilled(repo) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(status()).toBe(0) + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + + // What bubblewrap makes where the file has gone from under it: git + // refuses every command in the repository until the next clean-up. + await wrapAndBeKilled(repo) + chmodSync(commondir, 0o644) + writeFileSync(commondir, '') + chmodSync(commondir, 0o444) + expect(git(repo, ['status', '--porcelain']).said).toContain('commondir') + + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(status()).toBe(0) + }) + + it('leaves git working across wraps, and the commondir unwritable', async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const run = (command: string): string => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: repo, + env: { ...process.env, LC_ALL: 'C' }, + }).stdout + + // No clean-up in between: the first wrap's mount point is still in the + // git directory, and the second wrap's scan finds it there. By name: + // the mount points of the dotfile denies are not files to stage. + for (const file of ['one.js', 'two.js']) { + writeFileSync(join(repo, file), 'console.log(1)\n') + expect( + run( + await wrapIn( + repo, + `echo ${BOOTED} && git add ${file} && git ${IDENT.join(' ')} commit -q -m ${file} && echo COMMIT_OK`, + ), + ), + ).toBe(`${BOOTED}\nCOMMIT_OK\n`) + } + + expect( + run( + await wrapIn( + repo, + `echo ${BOOTED}; echo ../decoy > .git/commondir || chmod 666 .git/commondir || echo DENIED`, + ), + ), + ).toBe(`${BOOTED}\nDENIED\n`) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + }, + ) +}) diff --git a/test/sandbox/linux-nested-userns.test.ts b/test/sandbox/linux-nested-userns.test.ts index ee8cd578a..25c9d40d7 100644 --- a/test/sandbox/linux-nested-userns.test.ts +++ b/test/sandbox/linux-nested-userns.test.ts @@ -323,7 +323,7 @@ describe.if(isLinux)( it('says once per process that nothing limits namespaces, and only where that is so', () => { // A process of its own: the warning is given once in each. const script = ` - import { wrapCommandWithSandboxLinux } from ${JSON.stringify(join(import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts'))} + import { wrapCommandWithSandboxLinux } from ${JSON.stringify(join(import.meta.dir, '../helpers/isolated/library.ts'))} const wrap = bwrapPath => wrapCommandWithSandboxLinux({ command: 'true', needsNetworkRestriction: false, diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index e6a6c40ba..e44ea80aa 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -10,6 +10,8 @@ import { import { isLinux } from '../helpers/platform.js' import { countMounts, mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { refusal } from '../helpers/mount-point-fixtures.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' const REPO_ROOT = join(import.meta.dir, '../..') const V9FS_MAGIC = 0x01021997 @@ -55,6 +57,9 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { let DIR: string // a directory in it, holding the absent deny paths const savedCwd = process.cwd() const spies: Array<{ mockRestore(): void }> = [] + // Empty at the start of each case: a plan reads what is in it before it looks + // at the parent, with the calls that are stood in for here. + const runtime = usePrivateManifestDirectory() const CAN_RUN = bwrapCanNamespace() // Directories of another's that this user cannot write: not root's view. @@ -182,8 +187,13 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { expect(mountsAt(command, join(DIR, 'absent'))).toEqual( planned ? [emptyDirectoryAt(join(DIR, 'absent'))] : [], ) - // The parent is pinned either way, beneath the allow bind. - expect(mountsOf(command).indexOf(`--ro-bind ${DIR} ${DIR}`)).toBe(1) + // The parent is pinned either way, beneath the allow bind. The + // manifest of a wrap that names mount points is bound before both. + expect( + mountsOf(command) + .filter(mount => !mount.includes('srt-mount-points')) + .indexOf(`--ro-bind ${DIR} ${DIR}`), + ).toBe(1) // And the question is asked without a trace. expect(fs.readdirSync(DIR)).toEqual([]) }, @@ -349,7 +359,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('plans it when the parent is swapped once it is open', async () => { swapOnce( 'fstatSync', - () => true, + fd => real.readlinkSync(`/proc/self/fd/${String(fd)}`) === DIR, () => { fs.renameSync(DIR, join(ROOT, 'aside')) fs.symlinkSync('/usr', DIR) @@ -397,7 +407,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { } if (drive) onWindowsDrive() stub('closeSync', () => { - opened.push([]) + if (opened.length === 1) opened.push([]) return undefined }) @@ -421,6 +431,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { beforeEach(onWindowsDrive) const listing = () => fs.readdirSync(DIR).sort() + /** What is no manifest: every reading is in doubt while it is there. */ + const junk = () => join(runtime.manifestDir(), '1-junk.json') it('makes the mount point itself, and the clean-up removes it', async () => { const leaf = join(DIR, 'policy.json') @@ -440,6 +452,94 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { expect(listing()).toEqual([]) }) + it('has what it made removed as the process ends, when all it did was a wrap that came to nothing', () => { + // No plan held, which is what used to have the clean-up at exit set up. + const absent = join(DIR, 'policy.json') + const own = join(DIR, 'own.empty') + fs.writeFileSync(own, '') + const manifests = join(BASE, 'run', 'srt-mount-points') + fs.mkdirSync(manifests, { recursive: true, mode: 0o700 }) + fs.chmodSync(join(BASE, 'run'), 0o700) + const junk = join(manifests, '1-junk.json') + const child = spawnSync( + process.execPath, + [ + '-e', + `const fs = require('node:fs') + const statfs = fs.statfsSync + fs.statfsSync = (...args) => ({ ...statfs(...args), type: ${V9FS_MAGIC} }) + const open = fs.openSync + fs.openSync = (p, ...rest) => { + if (String(p).endsWith('/policy.json')) fs.writeFileSync(${JSON.stringify(junk)}, '{}') + return open(p, ...rest) + } + const { wrapCommandWithSandboxLinux } = await import('./test/helpers/isolated/library.ts') + const refused = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [${JSON.stringify(ROOT)}], denyWithinAllow: ${JSON.stringify([absent, own])} }, + }).then(() => undefined, error => error) + console.log(refused?.code, fs.existsSync(${JSON.stringify(absent)})) + fs.rmSync(${JSON.stringify(junk)})`, + ], + { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, XDG_RUNTIME_DIR: join(BASE, 'run') }, + }, + ) + expect(child.stdout).toBe('mount_points_unreadable true\n') + expect(listing()).toEqual(['own.empty']) + }) + + describe.each([ + ['file', 'policy.json', 'policy.json', 'openSync'], + ['directory', 'absent/policy.json', 'absent', 'mkdirSync'], + ] as const)( + 'when the plan comes to nothing, the %s it made', + (_kind, denied, made, makes) => { + it.each(['a forced clean-up', 'the next wrap and its clean-up'])( + 'is removed by %s', + async by => { + const absent = join(DIR, denied) + const own = join(DIR, 'own.empty') + fs.writeFileSync(own, '') + // In doubt from the moment the mount point is made: the plan has + // read the manifests by then. + stub(makes, p => { + if (names(p, join(DIR, made))) fs.writeFileSync(junk(), '{}') + return undefined + }) + expect(await refusal(wrap([absent, own]))).toBe( + 'mount_points_unreadable', + ) + fs.rmSync(junk()) + expect(listing()).toEqual([made, 'own.empty'].sort()) + if (by === 'a forced clean-up') { + cleanupBwrapMountPoints({ force: true }) + } else { + expect(mountsAt(await wrap([absent, own]), absent)).toEqual([ + `--ro-bind /dev/null ${absent}`, + ]) + cleanupBwrapMountPoints() + } + expect(listing()).toEqual(['own.empty']) + }, + ) + }, + ) + + it('makes nothing while the manifests cannot be read, when no clean-up would remove it either', async () => { + fs.mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + fs.writeFileSync(junk(), '{}') + expect( + await refusal( + wrap([join(DIR, 'policy.json'), join(DIR, 'absent', 'policy.json')]), + ), + ).toBe('mount_points_unreadable') + expect(listing()).toEqual([]) + }) + it('covers a file with a directory in a folder that takes subfolders and no files', async () => { const denied = join(DIR, 'policy.json') refuse('openSync', denied) @@ -596,7 +696,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('asks nothing of the host for a path that needs no placeholder', async () => { const opened: unknown[] = [] stub('openSync', p => { - opened.push(p) + // Every wrap looks after the manifest directories, whatever it denies. + if (!String(p).includes('srt-mount-points')) opened.push(p) return undefined }) fs.writeFileSync(join(ROOT, 'file'), '') @@ -617,7 +718,8 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { it('asks once for deny paths that share a missing directory', async () => { const opened: unknown[] = [] stub('openSync', p => { - opened.push(p) + // Every wrap looks after the manifest directories, whatever it denies. + if (!String(p).includes('srt-mount-points')) opened.push(p) return undefined }) @@ -699,7 +801,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { () => { const denied = join(DIR, 'policy.json') const script = ` - const { wrapCommandWithSandboxLinux } = await import('./src/sandbox/linux-sandbox-utils.ts') + const { wrapCommandWithSandboxLinux } = await import('./test/helpers/isolated/library.ts') const { spawnSync } = await import('node:child_process') const command = await wrapCommandWithSandboxLinux({ command: 'echo BOOTED; echo x > ${denied}; echo x > ${join(ROOT, 'new')}', diff --git a/test/sandbox/linux-work-tree-guard.test.ts b/test/sandbox/linux-work-tree-guard.test.ts new file mode 100644 index 000000000..e543ad401 --- /dev/null +++ b/test/sandbox/linux-work-tree-guard.test.ts @@ -0,0 +1,465 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + cleanupBwrapMountPoints, + LinuxSandboxProfileError, + linuxGetCwdMandatoryDenyPaths, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux } from '../helpers/platform.js' + +/** + * Every `--ro-bind X X` that denies, by what it binds: those above the bind that + * makes the working directory writable. An ancestor pin spells the same words + * beneath it. + */ +function readOnlyBinds(command: string): string[] { + const cwd = process.cwd() + const madeWritable = command.indexOf(` --bind ${cwd} ${cwd} `) + expect(madeWritable).toBeGreaterThan(-1) + return [...command.slice(madeWritable).matchAll(/--ro-bind (\S+) (\S+)/g)] + .filter(([, source, dest]) => source === dest) + .map(([, source]) => source!) +} + +/** + * A deny path is bound where it LANDS: bubblewrap resolves a destination, so + * a git directory entry that is itself a link to the checkout + * (`hooks -> ../..`) binds the checkout, however the entry is spelled. The + * guard that keeps a whole-directory deny off the work tree has to judge every + * deny path that way, and not only the ones a producer spelled by landing: + * otherwise the landing is dropped and the entry's own path puts the same bind + * back. + */ +describe.if(isLinux)('a git entry that is a link to the work tree', () => { + const LIVE = bwrapCanNamespace() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'work-tree-guard-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + mkdirSync(join(checkout, 'src')) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + writeFileSync(join(checkout, 'src', 'file.c'), 'int x;\n') + return checkout + } + + /** What a few ordinary writes leave: a nested git directory, found by the + * scan, whose `hooks` is a link to `target`. */ + function plantNestedRepository(checkout: string, target: string): string { + const nestedGitDir = join(checkout, 'nested', '.git') + mkdirSync(nestedGitDir, { recursive: true }) + writeFileSync(join(nestedGitDir, 'HEAD'), 'ref: refs/heads/main\n') + symlinkSync(target, join(nestedGitDir, 'hooks')) + return nestedGitDir + } + + function wrapIn( + checkout: string, + command = 'true', + allowOnly: string[] = [checkout], + ): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly, denyWithinAllow: [] }, + }) + } + + function landing(denyPath: string): string { + try { + return realpathSync(denyPath) + } catch { + return denyPath + } + } + + it('does not deny the checkout for its own hooks linked at it', () => { + const checkout = makeCheckout('repo') + rmSync(join(checkout, '.git', 'hooks'), { recursive: true }) + symlinkSync('..', join(checkout, '.git', 'hooks')) + + process.chdir(checkout) + const denyPaths = linuxGetCwdMandatoryDenyPaths(false, [checkout]) + + expect(denyPaths.map(landing)).not.toContain(checkout) + // The link is held by the directory around it, which is what holds any + // symlinked entry. + expect(denyPaths).toContain(join(checkout, '.git')) + }) + + it('does not bind the checkout for a nested entry linked at it', async () => { + const checkout = makeCheckout('repo') + const nestedGitDir = plantNestedRepository(checkout, '../..') + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + const binds = readOnlyBinds(command) + expect(binds).not.toContain(checkout) + expect(binds).toContain(nestedGitDir) + expect(binds).toContain(join(checkout, '.git', 'hooks')) + // Said once, since no bind is there to say it. + expect( + warnings.filter(w => w.includes('would have been denied whole')), + ).toHaveLength(1) + }) + + it('does not bind a second write root an entry is linked at', async () => { + const checkout = makeCheckout('repo') + const other = join(dir, 'other-root') + mkdirSync(other) + writeFileSync(join(other, 'existing.txt'), 'kept\n') + const nestedGitDir = plantNestedRepository(checkout, other) + + const binds = readOnlyBinds( + await wrapIn(checkout, 'true', [checkout, other]), + ) + + expect(binds).not.toContain(other) + expect(binds).toContain(nestedGitDir) + }) + + it('does not refuse for an entry linked at a write root that is a file', async () => { + // /dev/null is among the default ones. It holds no tree that listing it + // could show, and one link would refuse every command from then on. + const checkout = makeCheckout('repo') + const file = join(dir, 'file') + writeFileSync(file, '') + plantNestedRepository(checkout, '/dev/null') + symlinkSync(file, join(checkout, 'nested', '.git', 'config')) + + const binds = readOnlyBinds( + await wrapIn(checkout, 'true', [checkout, '/dev/null', file]), + ) + + // The deny wins: what git reads as its config is not the command's to write. + expect(binds).toContain(file) + }) + + it('does not bind a directory that holds the checkout', async () => { + const checkout = makeCheckout('repo') + // `dir` holds the checkout and is not itself writable here. + plantNestedRepository(checkout, dir) + + const binds = readOnlyBinds(await wrapIn(checkout)) + + expect(binds).not.toContain(dir) + expect(binds).not.toContain(checkout) + }) + + it.if(process.getuid?.() !== 0).each([0o311, 0o600, 0o000])( + 'refuses where a write root that cannot be looked into is a git directory (%o)', + async mode => { + // THREAT: a bare repository that a session in its linked worktree may + // write, and one `chmod` by a command. Nothing in it can be named any + // more, so it would be denied whole, which is dropped for a write root + // that holds something: the next command puts the mode back and writes + // the hooks. + const main = join(dir, 'main.git') + const worktreeGitDir = join(main, 'worktrees', 'wt') + mkdirSync(join(main, 'hooks'), { recursive: true }) + mkdirSync(worktreeGitDir, { recursive: true }) + writeFileSync(join(main, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const checkout = join(dir, 'wt') + mkdirSync(checkout) + writeFileSync(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) + chmodSync(main, mode) + try { + expect( + await wrapIn(checkout, 'true', [checkout, main]).catch(String), + ).toContain(`${main} could not be looked into`) + } finally { + chmodSync(main, 0o755) + } + }, + ) + + it('still binds an ordinary directory an entry is linked at', async () => { + const checkout = makeCheckout('repo') + mkdirSync(join(checkout, 'shared-hooks')) + plantNestedRepository(checkout, '../../shared-hooks') + + const binds = readOnlyBinds(await wrapIn(checkout)) + + expect(binds).toContain(join(checkout, 'shared-hooks')) + expect(binds).not.toContain(checkout) + }) + + it('does not bind the checkout where a collapse folds a linked modules entry', async () => { + // Past the argument cap a submodule's git directory is bound whole, as + // the walk spelled it. One the walk reached through `modules/zz -> ../..` + // is the checkout, which has an entry named `hooks` and so passes for a + // git directory. The collapse adds its bind after the plan was guarded, + // so what it adds is guarded again. + const checkout = makeCheckout('repo') + const gitDir = join(checkout, '.git') + for (let i = 0; i < 3000; i++) { + const submodule = join( + gitDir, + 'modules', + `s${String(i).padStart(5, '0')}`, + ) + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main') + } + symlinkSync('nowhere', join(checkout, 'hooks')) + symlinkSync('../..', join(gitDir, 'modules', 'zz-linked')) + + const binds = readOnlyBinds(await wrapIn(checkout)) + + // The submodules are behind one bind, of `modules` or of what holds it. + expect( + binds.includes(join(gitDir, 'modules')) || binds.includes(gitDir), + ).toBe(true) + expect(binds).not.toContain(checkout) + }, 120000) + + it('says which link a directory is bound read-only to hold', async () => { + // A link chain through `src` makes `src` the only handle on the link in + // it, so `src` is bound read-only: a directory nothing asked to deny. The + // debug log is where that can be traced back to the link. + const checkout = makeCheckout('repo') + mkdirSync(join(checkout, 'tgt')) + symlinkSync('../tgt', join(checkout, 'src', 'a')) + plantNestedRepository(checkout, '../../src/a') + + const lines: string[] = [] + const savedDebug = process.env.SRT_DEBUG + process.env.SRT_DEBUG = '1' + const spies = [ + spyOn(console, 'error').mockImplementation((...parts: unknown[]) => { + lines.push(parts.map(String).join(' ')) + }), + spyOn(console, 'warn').mockImplementation(() => {}), + ] + let command: string + try { + command = await wrapIn(checkout) + } finally { + for (const spy of spies) spy.mockRestore() + if (savedDebug === undefined) delete process.env.SRT_DEBUG + else process.env.SRT_DEBUG = savedDebug + } + + expect(readOnlyBinds(command)).toContain(join(checkout, 'src')) + const told = lines.filter(l => l.includes('Bound read-only to hold a link')) + expect(told).toHaveLength(1) + expect(told[0]).toContain( + `${join(checkout, 'src')} holds ${join(checkout, 'src', 'a')}`, + ) + expect(told[0]).toContain(join(checkout, 'nested', '.git', 'hooks')) + }) + + it.if(LIVE)( + 'leaves the project writable for the command after the one that made the link', + async () => { + const checkout = makeCheckout('repo') + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 20000 }) + + const planted = run( + await wrapIn( + checkout, + "mkdir -p nested/.git && printf 'ref: refs/heads/main\\n' > nested/.git/HEAD && ln -s ../.. nested/.git/hooks && echo PLANTED", + ), + ) + expect(planted.stdout).toContain('PLANTED') + expect(planted.status).toBe(0) + cleanupBwrapMountPoints({ force: true }) + + const next = run( + await wrapIn( + checkout, + 'echo BOOTED; echo w > work.txt && echo edit >> src/file.c && echo WROTE; rm -f nested/.git/hooks; echo "rm=$?"', + ), + ) + + expect(next.stdout).toContain('BOOTED') + expect(next.stdout).toContain('WROTE') + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe('w\n') + expect(readFileSync(join(checkout, 'src', 'file.c'), 'utf8')).toBe( + 'int x;\nedit\n', + ) + // The link itself is still held, by the git directory around it. + expect(next.stdout).toContain('rm=1') + expect( + lstatSync(join(checkout, 'nested', '.git', 'hooks')).isSymbolicLink(), + ).toBe(true) + expect(existsSync(join(checkout, 'nested', '.git', 'HEAD'))).toBe(true) + }, + 60000, + ) +}) + +/** + * A git directory entry whose link chain reaches nothing (a loop) and goes + * through a link the working directory holds has no bind that holds it and no + * far end to deny. For an entry of the working directory's own repository that + * is the user's layout, and the command is refused. For anything a command may + * have made - a nested repository, a `modules` that was not there - refusing + * would let a few ordinary writes lock every later command out, so the wrap + * warns and runs. + */ +describe.if(isLinux)('a git entry whose link chain is a loop', () => { + const LIVE = bwrapCanNamespace() + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'loop-guard-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + /** `a -> b -> a` in the checkout's root. */ + function makeLoop(checkout: string): void { + symlinkSync('b', join(checkout, 'a')) + symlinkSync('a', join(checkout, 'b')) + } + + function wrapIn(checkout: string, command = 'true'): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + it('refuses for an entry of the working directory own repository', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + rmSync(join(checkout, '.git', 'hooks'), { recursive: true }) + symlinkSync('../a', join(checkout, '.git', 'hooks')) + + const refusal = await wrapIn(checkout).then( + () => undefined, + (error: unknown) => error, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'deny_unresolvable_git_entry', + ) + }) + + it('warns and wraps for a nested repository', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + const nestedGitDir = join(checkout, 'nested', '.git') + mkdirSync(nestedGitDir, { recursive: true }) + writeFileSync(join(nestedGitDir, 'HEAD'), 'ref: refs/heads/main\n') + symlinkSync('../../a', join(nestedGitDir, 'hooks')) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + // The link itself is held by the git directory around it. + expect(readOnlyBinds(command)).toContain(nestedGitDir) + expect(readOnlyBinds(command)).not.toContain(checkout) + const told = warnings.filter( + w => + w.includes(join(nestedGitDir, 'hooks')) && + w.includes(join(checkout, 'a')), + ) + expect(told.length).toBeGreaterThan(0) + // Said as what it is: this chain reaches nothing, so there is nothing it + // "reaches what it denies through". + expect(told[0]).toContain('leads nowhere') + expect(told[0]).not.toContain('reaches what it denies') + }) + + it('warns and wraps for a modules that was not there', async () => { + const checkout = makeCheckout('repo') + makeLoop(checkout) + symlinkSync('../a', join(checkout, '.git', 'modules')) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapIn(checkout), + ) + + expect(readOnlyBinds(command)).toContain(join(checkout, '.git')) + expect(readOnlyBinds(command)).not.toContain(checkout) + expect( + warnings.filter(w => w.includes(join(checkout, '.git', 'modules'))) + .length, + ).toBeGreaterThan(0) + }) + + it.if(LIVE)( + 'runs the command after one that left a loop behind a nested repository', + async () => { + const checkout = makeCheckout('repo') + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 20000 }) + + const planted = run( + await wrapIn( + checkout, + "ln -s b a && ln -s a b && mkdir -p nested/.git && printf 'ref: refs/heads/main\\n' > nested/.git/HEAD && ln -s ../../a nested/.git/hooks && echo PLANTED", + ), + ) + expect(planted.stdout).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const next = run( + await wrapIn(checkout, 'echo BOOTED; echo w > work.txt && echo WROTE'), + ) + + expect(next.stdout).toContain('BOOTED') + expect(next.stdout).toContain('WROTE') + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe('w\n') + }, + 60000, + ) +}) diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index 5dc79e9a4..09b8777f4 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -1831,10 +1831,22 @@ const EXPECTED_MACOS_FILE_RULES = String.raw`; File read (regex "^/(.*/)?\\.claude/commands/.*(/.*)?$") (subpath "/.claude/agents") (regex "^/(.*/)?\\.claude/agents/.*(/.*)?$") + (regex "^/(.*/)?\\.git/hooks(/.*)?$") + (regex "^/(.*/)?\\.git/commondir(/.*)?$") + (regex "^/(.*/)?\\.git/config(/.*)?$") + (regex "^/(.*/)?\\.git/config\\.worktree(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/hooks(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/commondir(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/config(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/config\\.worktree(/.*)?$") (subpath "/.git/hooks") - (regex "^/(.*/)?\\.git/hooks/.*(/.*)?$") + (subpath "/.git/commondir") (subpath "/.git/config") - (regex "^/(.*/)?\\.git/config(/.*)?$") + (subpath "/.git/config.worktree") + (require-all (vnode-type REGULAR-FILE) (regex "^/(.*/)?\\.git$")) + (with message "")) +(allow file-write-create + (require-all (require-all (vnode-type REGULAR-FILE) (regex "^/(.*/)?\\.git$")) (require-any (subpath "/srt-literal-path-entries/proj") (regex "^/srt-literal-path-entries/out/.*$") (regex "^/srt-literal-path-entries/build/[^/]*$") (regex "^/srt-literal-path-entries/cache/[0-9]$"))) (with message "")) (deny file-write-unlink file-write-create (subpath "/srt-literal-path-entries/proj/.git") @@ -1871,11 +1883,22 @@ const EXPECTED_MACOS_FILE_RULES = String.raw`; File read (regex "^/(.*/)?\\.claude/commands/.*(/.*)?$") (subpath "/.claude/agents") (regex "^/(.*/)?\\.claude/agents/.*(/.*)?$") + (regex "^/(.*/)?\\.git/hooks(/.*)?$") + (regex "^/(.*/)?\\.git/commondir(/.*)?$") + (regex "^/(.*/)?\\.git/config(/.*)?$") + (regex "^/(.*/)?\\.git/config\\.worktree(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/hooks(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/commondir(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/config(/.*)?$") + (regex "^/(.*/)?\\.git/modules/[^/]*/config\\.worktree(/.*)?$") (subpath "/.git/hooks") (literal "/.git") - (regex "^/(.*/)?\\.git/hooks/.*(/.*)?$") + (subpath "/.git/commondir") (subpath "/.git/config") - (regex "^/(.*/)?\\.git/config(/.*)?$") + (subpath "/.git/config.worktree") + (with message "")) +(deny file-write-unlink + (require-all (vnode-type REGULAR-FILE) (regex "^/(.*/)?\\.git$")) (with message "")) ; File read: keep read-denied paths inside write roots in place @@ -2354,7 +2377,7 @@ describe.if(!isWindows)( join(import.meta.dir, '../../src/sandbox/sandbox-utils.ts'), )}) const { SandboxManager } = await import(${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/sandbox-manager.ts'), + join(import.meta.dir, '../helpers/isolated/manager.ts'), )}) const kept = paths => paths.filter(p => p.endsWith('/debug') || p.endsWith('/_logs')).map(p => p.split('/').slice(-2).join('/')) ${body}` diff --git a/test/sandbox/macos-git-chain-landing.test.ts b/test/sandbox/macos-git-chain-landing.test.ts new file mode 100644 index 000000000..e83883d41 --- /dev/null +++ b/test/sandbox/macos-git-chain-landing.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterAll, beforeAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { isMacOS } from '../helpers/platform.js' + +/** + * A `gitdir:` pointer whose value goes through a symlink, run under + * sandbox-exec. + * + * Seatbelt compares its filters against the path an operation RESOLVED to, + * while the path in the filter is compared as a string, so the pointer's own + * spelling of the git directory — `/linkdir/gd/hooks` — matches + * nothing at all, and the deny that blocks a write to the hooks git will + * actually run has to name where the link lands. The link's own name still + * needs its own spelling, for the unlink that would point the chain + * somewhere else. The generator emits both; these run them. + * + * The profile-level counterparts are in mandatory-deny-paths.test.ts and run + * on every POSIX host. + */ + +interface ChainTree { + /** Write root, so only the denies are under test. */ + root: string + /** The checkout, and the working directory. */ + checkout: string + /** `/linkdir` -> `/real`. */ + link: string + /** `/real/gd`, where the pointer really lands. */ + gitDir: string +} + +function chainTree(): ChainTree { + const root = join(realpathSync(tmpdir()), `git-chain-landing-${Date.now()}`) + const checkout = join(root, 'checkout') + const gitDir = join(root, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + const link = join(checkout, 'linkdir') + symlinkSync(join(root, 'real'), link) + return { root, checkout, link, gitDir } +} + +describe.if(isMacOS)( + 'macOS sandbox: a pointer whose value walks through a symlink', + () => { + let tree: ChainTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = chainTree() + process.chdir(tree.checkout) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function run(command: string): { status: number | null; stderr: string } { + const result = spawnSync( + wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }), + { + shell: true, + encoding: 'utf8', + timeout: 10000, + // Assert on the message, so pin the language it is written in. + env: { ...process.env, LC_ALL: 'C' }, + }, + ) + return { status: result.status, stderr: result.stderr || '' } + } + + function expectRefused(command: string): void { + const result = run(command) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + } + + it('allows an ordinary write in the checkout (sanity check)', () => { + const result = run( + `echo ok > ${JSON.stringify(join(tree.checkout, 'file.txt'))}`, + ) + expect(result.status).toBe(0) + }) + + it('refuses a hook written through the spelling the pointer uses', () => { + expectRefused( + `echo x > ${JSON.stringify(join(tree.link, 'gd', 'hooks', 'pre-commit'))}`, + ) + }) + + it('refuses the same hook written at the landing', () => { + expectRefused( + `echo x > ${JSON.stringify(join(tree.gitDir, 'hooks', 'pre-commit'))}`, + ) + }) + + it('refuses a commondir created at the landing', () => { + // Not there at all, which is the point: the file git reads to move the + // hooks and config it runs elsewhere is denied so it cannot be made. + expectRefused( + `echo . > ${JSON.stringify(join(tree.gitDir, 'commondir'))}`, + ) + }) + + it('refuses to unlink the link the pointer walks through', () => { + expectRefused(`rm ${JSON.stringify(tree.link)}`) + }) + + it('refuses to rename the link aside', () => { + expectRefused( + `mv ${JSON.stringify(tree.link)} ${JSON.stringify(join(tree.checkout, 'moved'))}`, + ) + }) + }, +) diff --git a/test/sandbox/macos-glob-deny-reemit.test.ts b/test/sandbox/macos-glob-deny-reemit.test.ts index 89e778ee7..5db96f61f 100644 --- a/test/sandbox/macos-glob-deny-reemit.test.ts +++ b/test/sandbox/macos-glob-deny-reemit.test.ts @@ -453,10 +453,10 @@ describe.if(isMacOS)('macOS write enforcement for glob denies', () => { expect(readFileSync(join(PROJECT, 'plain.txt'), 'utf8')).toBe('X\n') }) - it("mandatory **/.git/hooks/** still blocks a nested repo's hooks (regression guard)", () => { - // The mandatory patterns are anchored at process.cwd(); this pattern - // already carried its own /** tail before the subtree change, so this - // guards that the change keeps it working rather than fixing it. + it("mandatory **/.git/hooks still blocks a nested repo's hooks (regression guard)", () => { + // The mandatory patterns are anchored at process.cwd(). The pattern + // names the hooks directory itself, and the deny covers everything + // beneath it the way a literal subpath deny does. process.chdir(PROJECT) const hook = join(PROJECT, 'vendor', 'dep', '.git', 'hooks', 'pre-commit') const newHook = join( diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index 11b77c57d..0c36ccebc 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, afterAll, beforeAll } from 'bun:test' import { spawnSync } from 'node:child_process' -import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' @@ -19,9 +25,8 @@ import { isMacOS, isWindows } from '../helpers/platform.js' * spelling is resolved against it, and a cwd may contain `[`, `*` or `?`. * Compiled as a glob, `a[b/c]d` turns into a one-character class and the * filter stops matching the directory it was built from, so the deny - * covers nothing. The `**\/.git/hooks/**` pattern does not make up for - * it: it covers what is inside the directory, never the directory vnode, - * which is what `mv` and `ln -s` operate on. + * covers nothing. The `**\/.git/hooks` pattern does not make up for it: + * it hangs off the same cwd, so the same bracket pair takes it out too. * * The profile tests only inspect generated SBPL and run on every POSIX * host; the enforcement tests run the profile under sandbox-exec. @@ -100,10 +105,10 @@ describe.if(!isWindows)( it('keeps the cwd literal in the subtree patterns', () => { const profile = wrap(tree, 'true') - // `**\/.git/hooks/**` is anchored at the cwd, so only the tail is a + // `**\/.git/hooks` is anchored at the cwd, so only the tail is a // pattern; the cwd itself is escaped into the regex. const anchor = tree.work.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const expected = `^${anchor}/(.*/)?\\.git/hooks/.*(/.*)?$` + const expected = `^${anchor}/(.*/)?\\.git/hooks(/.*)?$` expect(profile).toContain(`(regex ${JSON.stringify(expected)})`) }) @@ -397,6 +402,291 @@ describe.if(isMacOS)( }, ) +/** + * `/.git` as a directory, with a submodule git directory and a + * directory the `.git/modules` walk stops at, each named across a bracket + * pair the way a submodule name can be: a submodule's name is its path, so + * `a[b/c]d` is an ordinary one, and it is the sandboxed command that chooses + * it. + */ +interface ModulesTree { + /** Bracket-free write root, so only the denies are under test. */ + root: string + /** The bracketed working directory. */ + work: string + /** `/.git/modules/s[m/o]d`, a submodule git directory. */ + submodule: string + /** `/.git/modules/u[n/r]d`, where the walk stops. */ + unreadable: string +} + +function modulesTree(prefix: string): ModulesTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const modules = join(work, '.git', 'modules') + const submodule = join(modules, 's[m', 'o]d') + const unreadable = join(modules, 'u[n', 'r]d') + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + mkdirSync(join(submodule, 'objects'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(work, '.git', 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(unreadable, { recursive: true }) + // A directory the walk cannot list, which is denied whole rather than left + // writable with a git directory possibly inside. Restored by + // `openModulesTree` before the tree is removed - and before a sandboxed + // write into it is what has to be refused. + chmodSync(unreadable, 0o000) + return { root, work, submodule, unreadable } +} + +/** A walk cannot be kept out of a directory it owns as root, so the record + * the two cases below are about is not there for one. */ +const CAN_LOCK_A_DIRECTORY = process.getuid?.() !== 0 + +/** Gives `tree.unreadable` its permissions back, so that what refuses a write + * into it is the sandbox and not the mode, and so that it can be removed. */ +function openModulesTree(tree: ModulesTree): void { + chmodSync(tree.unreadable, 0o755) +} + +/** + * `/.git` as a pointer file, naming a git directory whose `commondir` + * names another. Every directory here is one the file's own contents chose. + */ +interface PointerTree { + root: string + work: string + /** The `.git` pointer file in the cwd. */ + pointer: string + /** `/g[h/i]j`, the git directory the pointer names. */ + gitDir: string + /** `/c[o/m]n`, the git directory its `commondir` names. */ + commonDir: string +} + +function pointerTree(prefix: string): PointerTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const gitDir = join(root, 'g[h', 'i]j') + const commonDir = join(root, 'c[o', 'm]n') + mkdirSync(work, { recursive: true }) + mkdirSync(gitDir, { recursive: true }) + mkdirSync(commonDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'commondir'), `${commonDir}\n`) + const pointer = join(work, '.git') + writeFileSync(pointer, `gitdir: ${gitDir}\n`) + return { root, work, pointer, gitDir, commonDir } +} + +/** The four paths inside a git directory the mandatory denies name. */ +const GIT_DIR_LEAVES = ['hooks', 'commondir', 'config', 'config.worktree'] + +/** + * The git directories the macOS denies cover are read off the filesystem, + * and what they are called is up to the sandboxed command. Compiled as a + * glob, `[m/o]` is a one-character class, so a deny built from + * `.git/modules/s[m/o]d` matches neither that directory nor anything else — + * and a `.gitmodules` naming that submodule makes the host's next + * `git submodule update --init` run whatever hook was planted in it. + */ +describe.if(!isWindows)( + 'macOS profile: git directories read off disk under a bracketed cwd', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + openModulesTree(tree) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function profile(): string { + return wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + } + + it('denies a submodule git directory by subpath', () => { + const text = profile() + for (const leaf of GIT_DIR_LEAVES) { + const denied = join(tree.submodule, leaf) + expect(text).toContain(`(subpath ${JSON.stringify(denied)})`) + expect(text).not.toContain(sniffedFilter(denied, '(/.*)?$')) + } + }) + + it.if(CAN_LOCK_A_DIRECTORY)( + 'denies the directory the walk could not list by subpath', + () => { + const text = profile() + expect(text).toContain(`(subpath ${JSON.stringify(tree.unreadable)})`) + expect(text).not.toContain(sniffedFilter(tree.unreadable, '(/.*)?$')) + }, + ) + + it('leaves no regex carrying a bracket read off the filesystem', () => { + // A spelling sniffed as a pattern keeps its brackets verbatim, since + // they are the glob syntax it is taken to be asking for. An anchored + // pattern escapes the part of it the library built. + const regexes = emittedRegexes(profile()) + expect(regexes.length).toBeGreaterThan(0) + for (const regex of regexes) { + for (const opening of ['s[m/', 'u[n/', 'a[b/']) { + expect(regex).not.toContain(opening) + } + } + }) + + it('anchors the nested-repository patterns at the escaped cwd', () => { + const anchor = tree.work.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const text = profile() + // A nested repository, and a nested repository's submodule, have no + // path on disk to enumerate: they are matched by pattern, hung off the + // cwd, which is escaped into the regex like any other literal. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git/modules/[^/]*/hooks(/.*)?$`)})`, + ) + // Same for the `.git` pointer filter, matched by vnode type. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git$`)})`, + ) + }) + }, +) + +/** + * A pointer file's target, and the target its `commondir` names, are paths + * the sandboxed command wrote into a file. Both are followed the way git + * follows them, and both are names on disk once followed. + */ +describe.if(!isWindows)( + 'macOS profile: a bracketed git directory named by a pointer file', + () => { + let tree: PointerTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = pointerTree('bracket-pointer-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + it('denies the pointer and both git directories by subpath', () => { + const text = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + const denied = [ + tree.pointer, + ...GIT_DIR_LEAVES.map(leaf => join(tree.gitDir, leaf)), + ...GIT_DIR_LEAVES.map(leaf => join(tree.commonDir, leaf)), + ] + for (const path of denied) { + expect(text).toContain(`(subpath ${JSON.stringify(path)})`) + expect(text).not.toContain(sniffedFilter(path, '(/.*)?$')) + } + }) + }, +) + +describe.if(isMacOS)( + 'macOS sandbox: a bracketed submodule git directory keeps its hooks', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-exec') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + openModulesTree(tree) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function run(command: string): { status: number | null; stderr: string } { + const result = spawnSync( + wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }), + { + shell: true, + encoding: 'utf8', + timeout: 10000, + // Assert on the message, so pin the language it is written in. + env: { ...process.env, LC_ALL: 'C' }, + }, + ) + return { status: result.status, stderr: result.stderr || '' } + } + + it('writes elsewhere in that git directory (sanity check)', () => { + const target = join(tree.submodule, 'objects', 'written') + const result = run(`echo ok > ${JSON.stringify(target)}`) + expect(result.status).toBe(0) + }) + + it('refuses a hook planted in that git directory', () => { + const target = join(tree.submodule, 'hooks', 'pre-commit') + const result = run(`echo planted > ${JSON.stringify(target)}`) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + }) + + it.if(CAN_LOCK_A_DIRECTORY)( + 'refuses a write in the directory the walk could not list', + () => { + const target = join(tree.unreadable, 'planted') + // The profile is built while the directory is locked, which is what + // puts the deny in it; the mode is then given back, so that what + // refuses the write is the sandbox. + const command = wrapCommandWithSandboxMacOS({ + command: `echo planted > ${JSON.stringify(target)}`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + openModulesTree(tree) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, LC_ALL: 'C' }, + }) + expect(result.status).not.toBe(0) + expect((result.stderr || '').toLowerCase()).toContain( + 'operation not permitted', + ) + }, + ) + }, +) + /** * The deny-glob compiler exists twice on purpose: a string-taking one in * `sandbox-utils.ts`, where a caller's configured spelling is all pattern, @@ -440,7 +730,7 @@ describe.if(!isWindows)( const regexes = emittedRegexes(wrap(tree, 'true')) const anchored = regexes.filter(regex => regex.startsWith(`^${anchor}/`)) expect(anchored.length).toBeGreaterThan(0) - // `**\/.git/hooks/**` covers a nested repository's hooks under the real + // `**/.git/hooks` covers a nested repository's hooks under the real // working directory, and nothing under the two directories the bracket // class would have matched instead. const nested = join(tree.work, 'vendor/lib/.git/hooks/pre-commit') diff --git a/test/sandbox/mandatory-deny-modules-walk.test.ts b/test/sandbox/mandatory-deny-modules-walk.test.ts new file mode 100644 index 000000000..bfb43bc96 --- /dev/null +++ b/test/sandbox/mandatory-deny-modules-walk.test.ts @@ -0,0 +1,768 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' +import { macGetMandatoryDenyEntries } from '../../src/sandbox/macos-sandbox-utils.js' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + submoduleGitDirs, +} from '../../src/sandbox/mandatory-deny-paths.js' + +/** + * The walk of a `.git/modules` where one directory is two things at once: a + * git directory by what it holds, and a segment of a submodule's name by what + * lies under it. + * + * A submodule named `vendor/lib` keeps its git directory at + * `.git/modules/vendor/lib`, so `.git/modules/vendor` is an ordinary directory + * a sandboxed command can write, and what makes a directory a git directory + * to the walk is the NAME of one entry in it. A command that leaves a `HEAD` + * in `vendor` must not end the walk there: the submodule beneath it would go + * without its denies for every command after that one. + */ + +/** The names the walk takes for what git itself keeps in a git directory. */ +const GIT_OWN_NAMES = [ + 'objects', + 'refs', + 'logs', + 'hooks', + 'info', + 'worktrees', + 'lfs', + 'rr-cache', +] + +/** A submodule git directory as `git clone` leaves one. */ +function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir +} + +describe('The .git/modules walk beneath what looks like a git directory', () => { + let dir: string + let modules: string + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-modules-walk-'))) + modules = join(dir, 'modules') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('finds the submodule beneath a name segment a command left a marker in', () => { + // Each of the names, as the emptiest thing of its kind a command can leave. + const markers = ['HEAD', 'config', 'commondir', 'hooks/', 'objects/'] + const expected: string[] = [] + for (const marker of markers) { + const vendor = join(modules, `vendor-${basename(marker)}`) + const lib = makeGitDir(join(vendor, 'lib')) + if (marker.endsWith('/')) mkdirSync(join(vendor, marker)) + else writeFileSync(join(vendor, marker), '') + // The segment is recorded too. The markers only ever deny more, and a + // HEAD alone is also what a git directory looks like with the rest of + // it moved aside, so what the segment was denied by before it still is. + expected.push(vendor, lib) + } + + const scan = submoduleGitDirs(modules) + + expect(scan).toEqual({ + gitDirs: expected.sort(), + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const gitDir of expected) { + expect(denies).toEqual( + expect.arrayContaining(gitDirDenyPaths(gitDir, false)), + ) + } + }) + + it('finds it several segments further down as well', () => { + const deep = makeGitDir(join(modules, 'a', 'b', 'c', 'lib')) + writeFileSync(join(modules, 'a', 'HEAD'), '') + writeFileSync(join(modules, 'a', 'b', 'config'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + join(modules, 'a'), + join(modules, 'a', 'b'), + deep, + ]) + }) + + it('takes a marker for one only as the kind of entry git reads there', () => { + // `x/config` and `x/HEAD`: each git directory is a DIRECTORY of that name. + // Taken for a git directory, `x` would be denied its `config` whole, which + // costs that submodule its git writes with no help from any command. + const subs = ['HEAD', 'commondir', 'config'].map(name => + makeGitDir(join(modules, 'x', name)), + ) + writeFileSync(join(modules, 'x', 'objects'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual(subs) + }) + + it('finds a submodule named hooks, and denies it whole', () => { + // A directory is what git reads there, so `x` is recorded as well. + const x = join(modules, 'x') + const sub = makeGitDir(join(x, 'hooks')) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([x, sub]) + }) + + it('finds a submodule named like one of the directories git keeps, beneath such a segment', () => { + // `vendor/lfs`, `vendor/refs` and the rest: not walked into where they + // are git's own, which a `HEAD` left in `vendor` would otherwise make + // them. What this file protects is what tells the two apart: git keeps + // no `config` and no `hooks` in any of its own directories. + const expected: string[] = [] + for (const name of GIT_OWN_NAMES) { + const vendor = join(modules, `vendor-${name}`) + const sub = makeGitDir(join(vendor, name)) + writeFileSync(join(vendor, 'HEAD'), '') + expected.push(vendor, sub) + } + + expect(submoduleGitDirs(modules).gitDirs).toEqual(expected.sort()) + }) + + it('never takes the directory it was asked to walk for a git directory', () => { + // A `.git/modules` is as writable as any segment under it, and its + // entries are submodule names whatever else is left beside them. + const expected = GIT_OWN_NAMES.map(name => makeGitDir(join(modules, name))) + writeFileSync(join(modules, 'HEAD'), '') + writeFileSync(join(modules, 'config'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual(expected.sort()) + }) + + it('takes the modules of a git directory for what it holds', () => { + // Where `lib` keeps its own submodules, and the submodule `vendor/modules` + // once a command has left a `HEAD` in `vendor`: nothing tells the two apart. + const lib = makeGitDir(join(modules, 'lib')) + const nested = makeGitDir(join(lib, 'modules', 'nested')) + const vendor = join(modules, 'vendor') + const named = makeGitDir(join(vendor, 'modules')) + writeFileSync(join(vendor, 'HEAD'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + nested, + vendor, + named, + ]) + }) + + it('takes every entry of the modules of a git directory for a name', () => { + // As in the directory it was asked to walk, whatever a command left beside + // them: taken for git's own, `lfs` would hide the submodule `lfs/client`. + const lib = makeGitDir(join(modules, 'lib')) + const nested = makeGitDir(join(lib, 'modules', 'lfs', 'client')) + writeFileSync(join(lib, 'modules', 'HEAD'), '') + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + join(lib, 'modules'), + nested, + ]) + }) + + describe.if(!isWindows)('a directory that links lead to as well', () => { + /** A link to `target` on either side of every other name here, whichever + * way the walk goes through them. */ + function linkFromBothSides(target: string): void { + for (const name of ['a', 'z']) { + mkdirSync(join(modules, name), { recursive: true }) + symlinkSync(target, join(modules, name, 'link')) + } + } + + it('is listed once more at most, and each link held by its own name', () => { + // How many links and how much they lead to are a command's to choose. + const lib = makeGitDir(join(modules, 'lib')) + linkFromBothSides('../lib') + const list = spyOn(fs, 'readdirSync') + try { + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toContain(lib) + expect(scan.gitDirs.length).toBeLessThanOrEqual(2) + expect(scan.chainHops.map(hop => hop.link).sort()).toEqual([ + join(modules, 'a', 'link'), + join(modules, 'z', 'link'), + ]) + expect( + list.mock.calls.filter( + call => realpathSync(String(call[0])) === realpathSync(lib), + ).length, + ).toBeLessThanOrEqual(2) + } finally { + list.mockRestore() + } + }) + + it('has each link to it held that has a name git keeps for itself', () => { + // `refs` is listed before it is followed, and followed for what it holds. + const store = makeGitDir(join(dir, 'store')) + const gitDirs = ['a', 'b'].map(name => makeGitDir(join(modules, name))) + for (const gitDir of gitDirs) symlinkSync(store, join(gitDir, 'refs')) + + expect(submoduleGitDirs(modules).linkedEntryDirs).toEqual(gitDirs) + }) + + it.if(process.getuid?.() !== 0)( + 'is denied whole where it is, where it cannot be listed', + () => { + // The name of the link holds the link, and on Linux is no deny at all. + const locked = join(dir, 'locked') + mkdirSync(locked) + mkdirSync(modules) + symlinkSync(locked, join(modules, 'link')) + chmodSync(locked, 0o311) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it.if(process.getuid?.() !== 0)( + 'has what shuts it off denied whole, behind the link and not around it', + () => { + const locked = join(dir, 'locked') + mkdirSync(join(locked, 'deep'), { recursive: true }) + symlinkSync(join(locked, 'deep'), modules) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('is walked from where it is', () => { + const lib = makeGitDir(join(modules, 'vendor', 'deep', 'lib')) + linkFromBothSides('../vendor/deep') + + expect(submoduleGitDirs(modules).gitDirs).toContain(lib) + }) + + it('is so where the walk was asked for by another name itself', () => { + makeGitDir(join(modules, 'vendor', 'deep', 'lib')) + linkFromBothSides('../vendor/deep') + symlinkSync(dir, `${dir}-alias`) + try { + expect( + submoduleGitDirs(join(`${dir}-alias`, 'modules')).gitDirs, + ).toContain(join(`${dir}-alias`, 'modules', 'vendor', 'deep', 'lib')) + } finally { + rmSync(`${dir}-alias`) + } + }) + + it('is walked as each thing that it is reached as', () => { + // All of it lies elsewhere, so nothing is walked from where it is. + const lib = makeGitDir(join(dir, 'elsewhere', 'lib')) + makeGitDir(join(lib, 'modules', 'lfs', 'client')) + writeFileSync(join(lib, 'modules', 'HEAD'), '') + linkFromBothSides(join(lib, 'modules')) + symlinkSync(lib, join(modules, 'lib')) + + expect(submoduleGitDirs(modules).gitDirs).toContain( + join(modules, 'lib', 'modules', 'lfs', 'client'), + ) + }) + }) + + it('takes no nested submodule named hooks for the hooks of a git directory', () => { + // It would be denied whole, with no help from any command. + const lib = makeGitDir(join(modules, 'lib')) + const nested = ['hooks', 'objects'].map(name => + makeGitDir(join(lib, 'modules', name)), + ) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([lib, ...nested]) + }) + + it.if(!isWindows)('nor one that is kept behind a link', () => { + const lib = makeGitDir(join(modules, 'lib')) + mkdirSync(join(lib, 'modules')) + symlinkSync( + makeGitDir(join(dir, 'store', 'hooks.git')), + join(lib, 'modules', 'hooks'), + ) + + expect(submoduleGitDirs(modules).gitDirs).toEqual([ + lib, + join(lib, 'modules', 'hooks'), + ]) + }) + + describe.if(!isWindows)('what cannot be named', () => { + const NO_TEXT = Buffer.from([0x63, 0x61, 0x66, 0xe9]) + + it('has the directory holding a link to it denied whole, where nothing is there', () => { + // A command can make one here, so it costs what any link here does, and + // not every command after it. + const linked = join(modules, 'linked') + mkdirSync(linked, { recursive: true }) + symlinkSync(NO_TEXT, join(linked, 'x')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [], + unreadableDirs: [linked], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it('has that directory denied where it really is', () => { + // The name of a link holds the link, and on Linux is no deny at all. + const store = join(dir, 'store') + mkdirSync(store) + mkdirSync(modules) + symlinkSync(NO_TEXT, join(store, 'x')) + symlinkSync(store, join(modules, 'link')) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([store]) + }) + + // The macOS file systems take no such name. + it.if(isLinux)('has the directory holding it denied whole', () => { + // Where it really is as well. + const named = [join(modules, 'named'), join(dir, 'store')] + for (const holder of named) { + mkdirSync(holder, { recursive: true }) + mkdirSync(Buffer.concat([Buffer.from(`${holder}/`), NO_TEXT])) + } + symlinkSync(join(dir, 'store'), join(modules, 'link')) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual(named) + }) + + it.if(isLinux)( + 'passes over a link that leads to a file of such a name', + () => { + // As over any link to a file: git opens no git directory there. + mkdirSync(modules) + writeFileSync(Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT]), '') + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + + it.each([ + { what: 'what lies below a file', before: '../file/', after: '' }, + { what: 'a name no file can have', before: '', after: 'x'.repeat(300) }, + ])('takes $what for nothing', ({ before, after }) => { + mkdirSync(modules) + writeFileSync(join(dir, 'file'), '') + symlinkSync( + Buffer.concat([Buffer.from(before), NO_TEXT, Buffer.from(after)]), + join(modules, 'x'), + ) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([modules]) + }) + + it.if(isLinux)('takes a loop for nothing', () => { + mkdirSync(modules) + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + symlinkSync('modules/x', Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT])) + + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([modules]) + }) + + it.if(process.getuid?.() !== 0)( + 'refuses where what is behind the link cannot be looked at', + () => { + const locked = join(dir, 'locked') + mkdirSync(locked) + mkdirSync(modules) + symlinkSync( + Buffer.concat([Buffer.from('../locked/'), NO_TEXT]), + join(modules, 'x'), + ) + chmodSync(locked, 0o000) + try { + expect(() => submoduleGitDirs(modules)).toThrow(GitMetadataError) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it.if(isLinux)('refuses where a link leads to it', () => { + // What is there cannot be denied, and the host's git follows the link. + mkdirSync(modules) + mkdirSync(Buffer.concat([Buffer.from(`${dir}/`), NO_TEXT])) + symlinkSync( + Buffer.concat([Buffer.from('../'), NO_TEXT]), + join(modules, 'x'), + ) + + expect(() => submoduleGitDirs(modules)).toThrow(GitMetadataError) + }) + }) + + it('does not walk into what git itself keeps in a git directory', () => { + // A real submodule git directory with everything in it that is large: + // loose objects fanned out over 256 directories, refs and their logs, an + // LFS store, rerere's cache and a linked worktree. Each holds something + // shaped like a git directory, which a walk into it would record. + const sub = makeGitDir(join(modules, 'sub')) + for (let i = 0; i < 256; i++) { + mkdirSync(join(sub, 'objects', i.toString(16).padStart(2, '0'))) + } + mkdirSync(join(sub, 'objects', 'pack')) + mkdirSync(join(sub, 'objects', 'info')) + mkdirSync(join(sub, 'refs', 'heads'), { recursive: true }) + mkdirSync(join(sub, 'logs', 'refs', 'heads'), { recursive: true }) + // Every repository with a reflog has this one, and an LFS store keeps + // its own `objects`: neither makes a git directory of what holds it. + writeFileSync(join(sub, 'logs', 'HEAD'), '') + mkdirSync(join(sub, 'lfs', 'objects'), { recursive: true }) + mkdirSync(join(sub, 'info')) + for (const inside of [ + 'objects/ab', + 'objects/pack', + 'refs/heads', + 'logs/refs', + 'lfs/objects', + 'hooks', + 'info', + 'rr-cache', + 'worktrees', + ]) { + makeGitDir(join(sub, inside, 'planted')) + } + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'fails closed where a link it cannot look behind really leads', + () => { + // `..` after a link is the parent of where the link leads, not of the + // directory that holds it. + const elsewhere = join(dir, 'elsewhere') + const locked = join(elsewhere, 'locked') + mkdirSync(join(elsewhere, 'deep'), { recursive: true }) + mkdirSync(join(locked, 'inner'), { recursive: true }) + mkdirSync(modules) + symlinkSync(join(elsewhere, 'deep'), join(modules, 'via')) + symlinkSync('via/../locked/inner', join(modules, 'x')) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(modules).unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it.if(!isWindows)( + 'does not follow a link that is one of those directories', + () => { + // A rerere cache shared between clones. Following an entry that is a + // symlink denies the directory holding it whole, which here would be an + // ordinary submodule's git directory. + const sub = makeGitDir(join(modules, 'sub')) + const shared = join(dir, 'shared-rr-cache') + mkdirSync(shared) + symlinkSync(shared, join(sub, 'rr-cache')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + + it.if(!isWindows)( + 'leaves a link at one of a git directory own entries to that directory', + () => { + // `config`, `commondir` and `config.worktree` are read as the git + // directory's entries, a link at one included, by what denies them, and + // both ends of such a link are held there. Followed here as well, the + // same chain would be walked a second time and what it leads to taken + // for a submodule of its own. + const sub = makeGitDir(join(modules, 'sub')) + const elsewhere = makeGitDir(join(dir, 'elsewhere')) + rmSync(join(sub, 'config')) + symlinkSync(elsewhere, join(sub, 'config')) + symlinkSync(elsewhere, join(sub, 'commondir')) + symlinkSync(elsewhere, join(sub, 'config.worktree')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, + ) + + it('walks what else a git directory holds, and finds nothing in an ordinary one', () => { + // Directories that are neither git's own large ones nor `modules`: the + // empty `branches` of an older template, a rebase in progress. + const sub = makeGitDir(join(modules, 'sub')) + mkdirSync(join(sub, 'branches')) + mkdirSync(join(sub, 'rebase-merge')) + writeFileSync(join(sub, 'rebase-merge', 'head-name'), 'refs/heads/main\n') + const inner = makeGitDir(join(sub, 'modules', 'dep')) + + expect(submoduleGitDirs(modules)).toEqual({ + gitDirs: [sub, inner], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it.if(!isWindows)( + 'follows a symlinked entry beneath such a segment like any other', + () => { + const elsewhere = makeGitDir(join(dir, 'elsewhere')) + const vendor = join(modules, 'vendor') + mkdirSync(vendor, { recursive: true }) + symlinkSync(elsewhere, join(vendor, 'lib')) + writeFileSync(join(vendor, 'HEAD'), '') + + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toEqual([vendor, join(vendor, 'lib')]) + expect(scan.linkedEntryDirs).toEqual([vendor]) + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'denies a directory beneath such a segment that it could not list', + () => { + const vendor = join(modules, 'vendor') + const locked = join(vendor, 'locked') + makeGitDir(join(locked, 'deep')) + writeFileSync(join(vendor, 'HEAD'), '') + chmodSync(locked, 0o000) + try { + const scan = submoduleGitDirs(modules) + + expect(scan.gitDirs).toEqual([vendor]) + expect(scan.unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('keeps looking at the clock beneath such a segment', () => { + // A thousand entries under a segment that looks like a git directory, + // and a clock that moves once each time the walk looks at it: the walk + // looks once per so many entries, so it gets past the root on the first + // look and has to run out somewhere among the thousand. + const vendor = join(modules, 'vendor') + mkdirSync(vendor, { recursive: true }) + writeFileSync(join(vendor, 'HEAD'), '') + for (let i = 0; i < 1000; i++) { + writeFileSync(join(vendor, `f${i}`), '') + } + + let now = 0 + const clock = spyOn(Date, 'now').mockImplementation(() => now++) + try { + expect(() => submoduleGitDirs(modules, 1)).toThrow( + SubmoduleWalkBudgetError, + ) + } finally { + clock.mockRestore() + } + }) + + it.if(!isWindows)( + 'names the literals of such a submodule for Seatbelt as well', + () => { + // Seatbelt's glob for `.git/modules/*` stops at one segment, so the + // literals read off this walk are all a `vendor/lib` has there. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const vendor = join(checkout, '.git', 'modules', 'vendor') + const lib = makeGitDir(join(vendor, 'lib')) + const saved = process.cwd() + process.chdir(checkout) + try { + const literals = (): string[] => + macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + const before = literals() + writeFileSync(join(vendor, 'HEAD'), '') + const after = literals() + + for (const deny of gitDirDenyPaths(lib, false)) { + expect(before).toContain(deny) + expect(after).toContain(deny) + } + } finally { + process.chdir(saved) + } + }, + ) +}) + +describe.if(isLinux)( + 'A marker left in a segment of a submodule name, under bubblewrap', + () => { + const LIVE = bwrapCanNamespace() + let dir: string + let checkout: string + let vendor: string + let lib: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-modules-walk-live-'))) + // A superproject with one submodule named `vendor/lib`, and no checkout + // of it: the scan of the work tree has no `.git` pointer file to reach + // the git directory by, so the walk of `.git/modules` is what covers it. + checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + vendor = join(checkout, '.git', 'modules', 'vendor') + lib = makeGitDir(join(vendor, 'lib')) + writeFileSync(join(checkout, 'work.txt'), 'one\n') + process.chdir(checkout) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + function wrap(command: string): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + } + + it('still binds the hooks and config of the submodule beneath it read-only', async () => { + writeFileSync(join(vendor, 'HEAD'), '') + + const command = await wrap('true') + + for (const name of ['hooks', 'config']) { + const deny = join(lib, name) + expect(lastMountAt(command, deny)).toBe(`--ro-bind ${deny} ${deny}`) + } + // The two that are not there take a mount all the same: a placeholder + // git reads as it reads the file's absence. + for (const name of ['commondir', 'config.worktree']) { + expect(lastMountAt(command, join(lib, name))).toBeDefined() + } + }) + + it.if(LIVE)( + 'holds them against the command after the one that left the marker', + async () => { + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: checkout, + env: { ...process.env, LC_ALL: 'C' }, + }) + + // `vendor` is no git directory and nothing denies a write to it. + const first = run( + await wrap( + 'echo BOOTED; : > .git/modules/vendor/HEAD && echo PLANTED', + ), + ) + expect(first.stdout).toContain('BOOTED') + expect(first.stdout).toContain('PLANTED') + expect(existsSync(join(vendor, 'HEAD'))).toBe(true) + + // No cleanup in between: the second wrap walks the tree exactly as + // the first command left it. + const second = run( + await wrap( + 'echo BOOTED; ' + + "{ echo '[core]' >> .git/modules/vendor/lib/config; } 2>&1 || echo CONFIG_DENIED; " + + '{ echo x > .git/modules/vendor/lib/hooks/pre-commit; } 2>&1 || echo HOOK_DENIED; ' + + 'echo two >> work.txt && echo WORK_WRITTEN; ' + + 'echo DONE', + ), + ) + expect(second.stdout).toContain('BOOTED') + expect(second.stdout).toContain('DONE') + expect(second.stdout).toContain('CONFIG_DENIED') + expect(second.stdout).toContain('HOOK_DENIED') + expect(second.stdout).toContain('Read-only file system') + expect(second.stdout).toContain('WORK_WRITTEN') + expect(readFileSync(join(lib, 'config'), 'utf8')).toBe('[core]\n') + expect(existsSync(join(lib, 'hooks', 'pre-commit'))).toBe(false) + expect(readFileSync(join(checkout, 'work.txt'), 'utf8')).toBe( + 'one\ntwo\n', + ) + }, + 60000, + ) + }, +) diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 3090265b2..66654dec2 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -13,17 +13,30 @@ import { chmodSync, mkdirSync, readdirSync, + mkdtempSync, + renameSync, rmdirSync, rmSync, writeFileSync, readFileSync, symlinkSync, + readlinkSync, existsSync, + lstatSync, statSync, + realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' +import { + indexOfMount, + lastIndexOfMount, + lastMountAt, + manifestOf, +} from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' import { wrapCommandWithSandboxMacOS, macGetMandatoryDenyEntries, @@ -31,14 +44,38 @@ import { import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, + LinuxSandboxProfileError, unreadableDirectories, type LinuxSandboxParams, } from '../../src/sandbox/linux-sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenies, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + gitFileDenies, + gitRedirectPlaceholder, + submoduleGitDirs, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { + NO_COLLAPSE, + collapseFurther, + collapsedDenyPaths, + describeCollapse, + degradableGitDirs, +} from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' import { DANGEROUS_FILES, getDangerousDirectories, } from '../../src/sandbox/sandbox-utils.js' -import { isLinux, isSupportedPlatform } from '../helpers/platform.js' +import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' +import { quote } from '../../src/utils/shell-quote.js' +import type { RipgrepConfig } from '../../src/utils/ripgrep.js' /** * Integration tests for mandatory deny paths. @@ -55,6 +92,12 @@ describe.if(isSupportedPlatform)( 'Mandatory Deny Paths - Integration Tests', () => { const TEST_DIR = join(tmpdir(), `mandatory-deny-integration-${Date.now()}`) + // A read-denied region outside cwd, so the read section emits its + // operation-specific unlink/create rules (which a deny has to survive). + const READ_DENY_DIR = join( + tmpdir(), + `mandatory-deny-readdeny-${Date.now()}`, + ) const ORIGINAL_CONTENT = 'ORIGINAL' const MODIFIED_CONTENT = 'MODIFIED' /** Written into every directory getDangerousDirectories() names. */ @@ -64,6 +107,8 @@ describe.if(isSupportedPlatform)( beforeAll(() => { originalCwd = process.cwd() mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(READ_DENY_DIR, { recursive: true }) + writeFileSync(join(READ_DENY_DIR, 'secret.txt'), ORIGINAL_CONTENT) for (const name of DANGEROUS_FILES) { writeFileSync(join(TEST_DIR, name), ORIGINAL_CONTENT) @@ -102,6 +147,98 @@ describe.if(isSupportedPlatform)( ) writeFileSync(join(TEST_DIR, '.git', 'index'), ORIGINAL_CONTENT) + // A nested repository directly under cwd, with `nested/` gitignored: + // its config sits at the default scan depth, its hook files one past + // it, and an ignore file must not hide either from the scan. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'hooks'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'nested', 'src'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + writeFileSync(join(TEST_DIR, 'nested', 'src', 'ok.txt'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.gitignore'), 'nested/\nlib/\n') + // The nested repository has a submodule of its own. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'hooks'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'HEAD'), + 'ref: x', + ) + // A submodule: its git directory lives under cwd's .git/modules and its + // checkout has a .git FILE pointing there. + mkdirSync(join(TEST_DIR, '.git', 'modules', 'lib', 'hooks'), { + recursive: true, + }) + writeFileSync(join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + mkdirSync(join(TEST_DIR, 'lib'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'lib', '.git'), + 'gitdir: ../.git/modules/lib', + ) + // A linked worktree of this repository checked out inside it: its + // .git file points at .git/worktrees/wt, whose commondir is the main + // .git, so the hooks a commit in the worktree runs are the main ones. + mkdirSync(join(TEST_DIR, '.git', 'worktrees', 'wt'), { recursive: true }) + writeFileSync(join(TEST_DIR, '.git', 'worktrees', 'wt', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'worktrees', 'wt', 'commondir'), + '../..\n', + ) + mkdirSync(join(TEST_DIR, 'wt-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'wt-checkout', '.git'), + `gitdir: ${join(TEST_DIR, '.git', 'worktrees', 'wt')}`, + ) + // A checkout whose .git pointer carries 9,000 newline bytes after the + // path. git strips them and follows the pointer, so its target needs + // the same denies an unpadded pointer's does. + mkdirSync(join(TEST_DIR, 'padded-target', 'hooks'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-target', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync(join(TEST_DIR, 'padded-target', 'config'), ORIGINAL_CONTENT) + mkdirSync(join(TEST_DIR, 'padded-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-checkout', '.git'), + `gitdir: ${join(TEST_DIR, 'padded-target')}${'\n'.repeat(9000)}`, + ) + // A nested .claude/commands one level down (a name spanning two + // segments), within reach only of a deeper scan. + mkdirSync(join(TEST_DIR, 'pkg', '.claude', 'commands'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'pkg', '.claude', 'commands', 'x.md'), + ORIGINAL_CONTENT, + ) + // A working directory whose own location has a dangerous name in it. + mkdirSync(join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub', '.gitconfig'), + ORIGINAL_CONTENT, + ) + // Create safe file within .claude that SHOULD be writable (not commands/agents) writeFileSync( join(TEST_DIR, '.claude', 'some-other-file.txt'), @@ -112,6 +249,7 @@ describe.if(isSupportedPlatform)( afterAll(() => { process.chdir(originalCwd) rmSync(TEST_DIR, { recursive: true, force: true }) + rmSync(READ_DENY_DIR, { recursive: true, force: true }) }) beforeEach(() => { @@ -126,22 +264,28 @@ describe.if(isSupportedPlatform)( cleanupBwrapMountPoints({ force: true }) }) - function runSandboxedWrite( - filePath: string, - content: string, - ): Promise<{ success: boolean; stderr: string }> { - return runSandboxed(`echo '${content}' > '${filePath}'`) + interface SandboxRunOptions { + mandatoryDenySearchDepth?: number + allowGitConfig?: boolean + allowOnly?: string[] + /** + * A read config makes the read section emit its own + * operation-specific unlink/create rules, which the write section's + * denies have to survive. + */ + readConfig?: { denyOnly: string[]; allowWithinDeny?: string[] } + ripgrepArgs?: string[] } async function runSandboxed( command: string, - ripgrepArgs: string[] = [], - ): Promise<{ success: boolean; stderr: string }> { + opts: SandboxRunOptions = {}, + ): Promise<{ success: boolean; stderr: string; stdout: string }> { const platform = getPlatform() // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { - allowOnly: ['.'], + allowOnly: opts.allowOnly ?? ['.'], denyWithinAllow: [], // Empty - relying on mandatory denies } @@ -150,16 +294,19 @@ describe.if(isSupportedPlatform)( wrappedCommand = wrapCommandWithSandboxMacOS({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, + allowGitConfig: opts.allowGitConfig, }) } else { wrappedCommand = await wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, - ripgrepConfig: { command: 'rg', args: ripgrepArgs }, + mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, + allowGitConfig: opts.allowGitConfig, + ripgrepConfig: { command: 'rg', args: opts.ripgrepArgs ?? [] }, }) } @@ -172,9 +319,31 @@ describe.if(isSupportedPlatform)( return { success: result.status === 0, stderr: result.stderr || '', + stdout: result.stdout || '', } } + /** + * The write did not land. On Linux the mount point for an absent deny + * path stays until the cleanup: bwrap's own empty file, or, for a file + * git reads back, the placeholder the wrap wrote there (`expected`). On + * macOS nothing is created. + */ + function expectNotWritten(absolutePath: string, expected = ''): void { + const content = existsSync(absolutePath) + ? readFileSync(absolutePath, 'utf8') + : '' + expect(content).toBe(isLinux ? expected : '') + } + + async function runSandboxedWrite( + filePath: string, + content: string, + opts: SandboxRunOptions = {}, + ): Promise<{ success: boolean; stderr: string; stdout: string }> { + return runSandboxed(`echo '${content}' > '${filePath}'`, opts) + } + describe('Dangerous files should be blocked', () => { for (const name of DANGEROUS_FILES) { it(`blocks writes to ${name}`, async () => { @@ -207,1425 +376,6363 @@ describe.if(isSupportedPlatform)( }) }) - describe('Dangerous directories should be blocked', () => { - for (const dir of getDangerousDirectories()) { - it(`blocks writes to ${dir}/`, async () => { - const target = `${dir}/${DIRECTORY_PROBE_FILE}` - const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + describe('Nested repositories, submodules and worktree pointers', () => { + /** + * One write each, refused: `left` is what is at the path afterwards. On + * Linux the mount point of an absent deny path stays until the clean-up, + * bubblewrap's own empty file or what the wrap wrote for git to read; on + * macOS nothing is created. + */ + it.each<{ + what: string + file: string + left: string | { absent: true } | { mountPoint: string } + opts?: SandboxRunOptions + }>([ + { + what: "a nested repository's config, gitignored or not", + file: 'nested/.git/config', + left: ORIGINAL_CONTENT, + }, + { + what: "a nested repository's existing hook, at the default depth", + file: 'nested/.git/hooks/pre-commit', + left: ORIGINAL_CONTENT, + }, + { + // The scan does not go by .git/config then, and the hook files lie + // one level past the default depth. + what: "a nested repository's hook with config writes allowed", + file: 'nested/.git/hooks/pre-commit', + left: ORIGINAL_CONTENT, + opts: { allowGitConfig: true }, + }, + { + what: 'a new hook in a nested repository', + file: 'nested/.git/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a submodule's config under .git/modules", + file: '.git/modules/lib/config', + left: ORIGINAL_CONTENT, + }, + { + what: "a new hook in a submodule's git directory", + file: '.git/modules/lib/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a new hook in a nested repository's submodule", + file: 'nested/.git/modules/dep/hooks/post-checkout', + left: { absent: true }, + }, + { + what: "a submodule checkout's .git file", + file: 'lib/.git', + left: 'gitdir: ../.git/modules/lib', + }, + { + // git reads hooks and config through it, so a write here moves every + // other deny to a directory of the command's choosing. + what: "a commondir in the repository's git directory", + file: '.git/commondir', + left: { mountPoint: '.\n' }, + }, + { + what: "a commondir in a submodule's git directory", + file: '.git/modules/lib/commondir', + left: { mountPoint: '.\n' }, + }, + { + what: "a commondir in a nested repository's", + file: 'nested/.git/commondir', + left: { mountPoint: '.\n' }, + }, + { + // Read on top of .git/config wherever extensions.worktreeConfig is on. + what: '.git/config.worktree', + file: '.git/config.worktree', + left: { mountPoint: '' }, + }, + ])('blocks writing $what', async ({ file, left, opts }) => { + const result = await runSandboxedWrite(file, MODIFIED_CONTENT, opts) - expect(result.success).toBe(false) - expect(readFileSync(target, 'utf8')).toBe(ORIGINAL_CONTENT) - }) - } - }) + expect(result.success).toBe(false) + const isMade = + typeof left === 'string' || ('mountPoint' in left && isLinux) + expect(existsSync(file)).toBe(isMade) + if (isMade) { + expect(readFileSync(file, 'utf8')).toBe( + typeof left === 'string' + ? left + : (left as { mountPoint: string }).mountPoint, + ) + } + }) - describe('The same names below the working directory', () => { - /** What holds files the host runs or trusts, and a file in each. */ - const heldIn = new Map([ - ['.git/hooks', 'pre-commit'], - ...getDangerousDirectories().map( - dir => [dir, DIRECTORY_PROBE_FILE] as const, - ), - ]) - const existing = [ - ...DANGEROUS_FILES, - '.git/config', - ...[...heldIn].map(([dir, file]) => `${dir}/${file}`), - ] - const populate = (container: string): void => { - for (const dir of heldIn.keys()) { - mkdirSync(join(TEST_DIR, container, dir), { recursive: true }) + it('finds a nested repository with no file directly inside its .git', async () => { + // The scan recognises a repository by a regular file directly inside + // its .git. With config writes allowed a command can delete every + // one of them — HEAD, config, index, description, packed-refs — and + // hide the repository from the next command's scan, so the walk + // looks for the directory itself instead. + for (const sub of ['hooks', 'objects', 'refs']) { + mkdirSync(join('emptied', '.git', sub), { recursive: true }) } - for (const name of [...existing, 'safe-file.txt']) { - writeFileSync(join(TEST_DIR, container, name), ORIGINAL_CONTENT) + try { + const result = await runSandboxedWrite( + 'emptied/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(existsSync('emptied/.git/hooks/pre-commit')).toBe(false) + } finally { + rmSync('emptied', { recursive: true, force: true }) } - } - // How far the default depth reaches on Linux: every name in `sub`, and - // the files alone in `a/b`. - const covered = [ - ['sub', existing], - ['a/b', DANGEROUS_FILES], - ] as const - const beyond = 'a/b/c' - beforeAll(() => ['sub', 'a/b', beyond].forEach(populate)) + }) - for (const [container, names] of covered) { - for (const name of names) { - it(`blocks writes to ${container}/${name}`, async () => { - const target = `${container}/${name}` - const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + it('keeps the rest of a nested repository writable', async () => { + const result = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('nested/src/ok.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it.if(isLinux && process.getuid?.() !== 0)( + 'keeps what the scan found when a directory under cwd is unreadable', + async () => { + mkdirSync('unreadable', { mode: 0o000 }) + try { + const result = await runSandboxedWrite( + 'nested/.git/config', + MODIFIED_CONTENT, + ) expect(result.success).toBe(false) - expect(readFileSync(target, 'utf8')).toBe(ORIGINAL_CONTENT) - }) - } - it(`allows writes to ${container}/safe-file.txt`, async () => { - const target = `${container}/safe-file.txt` - const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + expect(readFileSync('nested/.git/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + chmodSync('unreadable', 0o755) + rmSync('unreadable', { recursive: true, force: true }) + } + }, + ) - expect(result.success).toBe(true) - expect(readFileSync(target, 'utf8').trim()).toBe(MODIFIED_CONTENT) + it('blocks removing an existing .git pointer file', async () => { + const result = await runSandboxed('rm -f lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, }) - } - for (const dir of heldIn.keys()) { - it(`blocks a new file in sub/${dir}/`, async () => { - const target = `sub/${dir}/new-file` - const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + }) + + it('blocks renaming a file over an existing .git pointer', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'decoy'), 'gitdir: /tmp/elsewhere') + try { + const result = await runSandboxed('mv -f lib/decoy lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) expect(result.success).toBe(false) - expect(existsSync(target)).toBe(false) - }) - } + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + } finally { + rmSync(join(TEST_DIR, 'lib', 'decoy'), { force: true }) + } + }) - for (const state of ['empty', 'missing']) { - it(`blocks a new hook in a repository whose hooks directory is ${state}`, async () => { - const repository = `hooks-${state}` - const hooks = `${repository}/.git/hooks` - mkdirSync(join(TEST_DIR, repository, '.git'), { recursive: true }) - writeFileSync( - join(TEST_DIR, repository, '.git', 'HEAD'), - 'ref: refs/heads/main', + it('still removes an ordinary file with the same read config', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'plain.txt'), ORIGINAL_CONTENT) + try { + const result = await runSandboxed('rm -f lib/plain.txt', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(true) + expect(existsSync(join(TEST_DIR, 'lib', 'plain.txt'))).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'lib', 'plain.txt'), { force: true }) + } + }) + + it('allows .git/config.worktree when allowGitConfig is true', async () => { + try { + const result = await runSandboxedWrite( + '.git/config.worktree', + 'bare = false', + { allowGitConfig: true }, ) - if (state === 'empty') mkdirSync(join(TEST_DIR, hooks)) - const result = await runSandboxed( - `mkdir -p '${hooks}' && echo x > '${hooks}/pre-commit'`, + expect(result.success).toBe(true) + } finally { + rmSync(join(TEST_DIR, '.git', 'config.worktree'), { force: true }) + } + }) + + it('finds a submodule git directory whose HEAD was moved aside', async () => { + const head = join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD') + renameSync(head, `${head}.bak`) + try { + const result = await runSandboxedWrite( + '.git/modules/lib/hooks/pre-commit', + MODIFIED_CONTENT, ) expect(result.success).toBe(false) - expect(existsSync(`${hooks}/pre-commit`)).toBe(false) - // The rest of the repository is still the command's to write. expect( - (await runSandboxedWrite(`${repository}/.git/HEAD`, 'x')).success, - ).toBe(true) - }) - } + readFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + 'utf8', + ), + ).toBe(ORIGINAL_CONTENT) + } finally { + renameSync(`${head}.bak`, head) + } + }) - for (const [file, line] of [ - ['.gitignore', 'sub/'], - ['.ignore', '*'], - ] as const) { - it(`blocks them all the same when ${file} holds ${line}`, async () => { - writeFileSync(join(TEST_DIR, file), `${line}\n`) + it.if(isLinux)( + 'finds a nested repository whose HEAD was moved aside', + async () => { + // With allowGitConfig the scan does not look for config either, and + // the hook files are one level past the default depth: the + // repository has to be recognised by whatever else its .git holds. + const head = join(TEST_DIR, 'nested', '.git', 'HEAD') + renameSync(head, `${head}.bak`) try { - for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { - const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) - expect([name, result.success]).toEqual([name, false]) - expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) - } + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) } finally { - rmSync(join(TEST_DIR, file)) + renameSync(`${head}.bak`, head) } - }) - } + }, + ) it.if(isLinux)( - "blocks them all the same whatever ripgrep's configuration file says", + 'does not follow a .git file that names an ordinary directory', async () => { - const configuration = join(TEST_DIR, 'ripgrep-configuration') - writeFileSync(configuration, '--glob=!sub\n') - process.env.RIPGREP_CONFIG_PATH = configuration + // `gitdir: ..` from app/tools would otherwise make app/config and + // app/hooks — an ordinary Rails-shaped tree — read-only. + mkdirSync(join(TEST_DIR, 'app', 'config'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'app', 'tools'), { recursive: true }) + writeFileSync(join(TEST_DIR, 'app', 'tools', '.git'), 'gitdir: ..') try { const result = await runSandboxedWrite( - 'sub/.bashrc', + 'app/config/settings.yml', MODIFIED_CONTENT, ) - expect(result.success).toBe(false) - expect(readFileSync('sub/.bashrc', 'utf8')).toBe(ORIGINAL_CONTENT) + expect(result.success).toBe(true) } finally { - delete process.env.RIPGREP_CONFIG_PATH - rmSync(configuration) + rmSync(join(TEST_DIR, 'app'), { recursive: true, force: true }) } }, ) - it('blocks them all the same beside a directory that cannot be read', async () => { - const unreadable = join(TEST_DIR, 'unreadable') - mkdirSync(unreadable, { mode: 0o000 }) - try { - for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { - const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + it.if(isLinux)( + 'blocks filling in the git directory a dangling .git file names', + async () => { + mkdirSync(join(TEST_DIR, 'dangling'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'dangling', '.git'), + 'gitdir: ../dangling-gitdir', + ) + try { + const result = await runSandboxed( + 'mkdir -p dangling-gitdir/hooks && echo X > dangling-gitdir/hooks/pre-commit', + ) - expect([name, result.success]).toEqual([name, false]) - expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) + expect(result.success).toBe(false) + expect( + existsSync( + join(TEST_DIR, 'dangling-gitdir', 'hooks', 'pre-commit'), + ), + ).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'dangling'), { recursive: true, force: true }) + rmSync(join(TEST_DIR, 'dangling-gitdir'), { + recursive: true, + force: true, + }) } - } finally { - rmdirSync(unreadable) - } - }) + }, + ) - for (const [directory, mode] of [ - ['locked', 0o000], - ['locked/.git', 0o000], - // As deep as one of the names can itself lie. - ['locked/.claude/commands', 0o000], - // Can be listed, and nothing in it looked at. - ['locked', 0o444], - // The other way about. - ['locked', 0o111], - ] as const) { - // With one thread, as on one CPU, ripgrep words what it says otherwise. - for (const threads of [[], ['-j1']]) { - it(`blocks them all the same in locked/ when ${directory} has mode ${mode.toString(8)} (rg ${threads.join()})`, async () => { - const names = [ - '.bashrc', - '.git/config', - '.git/hooks/pre-commit', - `.claude/commands/${DIRECTORY_PROBE_FILE}`, - ] - populate('locked') - try { - for (const name of names) { - // Each time: a command that is not held gives the mode back. - chmodSync(join(TEST_DIR, directory), mode) - const result = await runSandboxed( - `chmod 755 locked '${directory}'; echo x > 'locked/${name}'`, - threads, - ) - - expect([name, result.success]).toEqual([name, false]) - } - } finally { - chmodSync(join(TEST_DIR, directory), 0o755) - } - for (const name of names) { - expect(readFileSync(`locked/${name}`, 'utf8')).toBe( - ORIGINAL_CONTENT, - ) - } - }) - } - } + it.if(isLinux)( + 'refuses to sandbox at all when the scan does not finish', + async () => { + // One complete path, one the run was cut off in the middle of, and + // then a run that outlives its timeout: what it did not reach is + // unknown, so there is nothing safe to wrap the next command with. + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig: { + command: '/bin/sh', + args: [ + '-c', + 'printf "%s\\0%s" "$PWD/a/.git/HEAD" "$PWD/b/.gi"; exec sleep 30', + ], + timeoutMs: 200, + }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/did not finish/) + }, + ) - // root reads a directory of any mode. it.if(isLinux && process.getuid?.() !== 0)( - 'takes what ripgrep says it could not read for a hint only', + 'denies a directory the scan could not read', async () => { - const project = join(TEST_DIR, 'hints') - const outside = join(TEST_DIR, 'hints-outside') - for (const directory of [ - join(project, 'is-readable'), - join(project, 'really-locked'), - join(project, 'locked: too'), - join(project, 'locked\\so'), - join(project, 'one-thread'), - join(project, 'no-prefix'), - join(project, 'listed-only/child'), - join(project, 'entered-only'), - join(project, 'a/b/c/too-deep'), - join(outside, 'behind-a-link'), - ]) { - mkdirSync(directory, { recursive: true }) - } - symlinkSync(outside, join(project, 'link')) - const locked = [ - join(project, 'really-locked'), - join(project, 'locked: too'), - join(project, 'locked\\so'), - join(project, 'one-thread'), - join(project, 'no-prefix'), - join(project, 'a/b/c/too-deep'), - join(outside, 'behind-a-link'), - ] - // What a name in the tree could make ripgrep say. - const said = [ - 'is-readable', - 'really-locked', - 'a/b/c/too-deep', - 'link/behind-a-link', - '../hints-outside/behind-a-link', - 'not-there/at-all', - 'entered-only/not-there', - 'locked: too', - 'locked\\so', - 'listed-only/child', - ].map( - name => `rg: ${project}/${name}: Permission denied (os error 13)`, - ) - said.push( - `rg: ${project}/one-thread: IO error for operation on ${project}/one-thread: Permission denied (os error 13)`, - `${project}/no-prefix: Permission denied (os error 13)`, - ) - const standIn = join(TEST_DIR, 'says-it-could-not-read') - const asked = join(TEST_DIR, 'was-asked') + mkdirSync(join(TEST_DIR, 'locked', '.git', 'hooks'), { + recursive: true, + }) writeFileSync( - standIn, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" > '${asked}'`, - ...said.map(line => `echo '${line}' >&2`), - 'exit 2', - ].join('\n'), - { mode: 0o755 }, + join(TEST_DIR, 'locked', '.git', 'HEAD'), + 'ref: refs/heads/main', ) - process.chdir(project) - locked.forEach(directory => chmodSync(directory, 0o000)) - chmodSync(join(project, 'listed-only'), 0o444) - chmodSync(join(project, 'entered-only'), 0o111) + chmodSync(join(TEST_DIR, 'locked'), 0o000) try { - const wrapped = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, - ripgrepConfig: { command: standIn }, - }) - - expect(unreadableDirectories(said.join('\n'), project, 3)).toEqual([ - join(project, 'really-locked'), - join(project, 'locked: too'), - join(project, 'locked\\so'), - join(project, 'listed-only'), - join(project, 'one-thread'), - join(project, 'no-prefix'), - ]) - // Nobody else's mode can be given back from inside. - const uid = process.getuid!() - const getuid = spyOn(process, 'getuid').mockReturnValue(uid + 1) - try { - expect( - unreadableDirectories(said.join('\n'), project, 3), - ).toEqual([]) - } finally { - getuid.mockRestore() - } - expect(wrapped).toContain('really-locked') - expect(readFileSync(asked, 'utf8').split('\n')).toEqual( - expect.arrayContaining([ - '--no-ignore', - '--no-config', - '--line-buffered', - ]), + const result = await runSandboxed( + 'echo BOOTED; chmod 755 locked && echo X > locked/.git/hooks/pre-commit', ) + + // The sandbox ran, and the directory it could not read is + // read-only inside it, so the chmod that would open it fails. + expect(result.stdout).toContain('BOOTED') + expect(result.success).toBe(false) + expect(result.stderr).not.toBe('') } finally { - for (const directory of [ - ...locked, - join(project, 'listed-only'), - join(project, 'entered-only'), - ]) { - chmodSync(directory, 0o755) - } + chmodSync(join(TEST_DIR, 'locked'), 0o755) + rmSync(join(TEST_DIR, 'locked'), { recursive: true, force: true }) } }, ) - it.if(isLinux)( - 'takes no directory above the working directory for one of the names', - async () => { - const project = join(TEST_DIR, 'above', '.idea', 'project') - mkdirSync(join(project, 'sub'), { recursive: true }) - writeFileSync(join(project, 'sub', '.bashrc'), ORIGINAL_CONTENT) - process.chdir(project) + /** + * What the scan does when it fails, driven by a fake rg: the run that + * fails for real needs a directory this process cannot read, and as + * root — which CI is — there is none, so none of this would be + * exercised there. The last arm is the real thing, where the uid allows. + */ + describe.if(isLinux)('when the scan fails', () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + + const wrapWith = ( + ripgrepConfig: RipgrepConfig, + abortSignal?: AbortSignal, + ): Promise => + wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig, + abortSignal, + }) - expect((await runSandboxedWrite('sub/.bashrc', 'x')).success).toBe( - false, + /** A fake rg: `matches` NUL-terminated on stdout, `stderr`, exit 2. */ + const failingRipgrep = ( + matches: string[], + stderr: string, + ): RipgrepConfig => ({ + command: '/bin/sh', + args: [ + '-c', + `printf '%s\\0' ${quote(matches)}; ` + + // %s, not the format string: a backslash in a directory's name + // reaches rg's stderr as itself and must reach this one too. + `printf '%s' ${quote([stderr])} >&2; exit 2`, + ], + }) + + /** + * The deny bind of `denyPath`, as the wrapper spells it in the + * command. Matched as text because a directory's name is chosen by + * whoever made it: a space, a colon or a newline in one is + * shell-quoted, and the argv helpers refuse a token of that shape. + */ + const denyBindOf = (denyPath: string): string => + `--ro-bind ${quote([denyPath])} ${quote([denyPath])}` + + /** + * The part of the command that carries the denies: everything after + * the writable bind of the working directory. An ancestor pin is + * spelled with the same words as a deny bind and sits before that + * bind, buried under it, so only position tells the two apart. + */ + function denySectionOf(command: string): string { + const cwd = quote([process.cwd()]) + const writeRoot = `--bind ${cwd} ${cwd}` + const at = command.indexOf(writeRoot) + expect(at).toBeGreaterThan(-1) + return command.slice(at + writeRoot.length) + } + + const nestedConfig = (): string => + join(process.cwd(), 'nested', '.git', 'config') + + /** An unreadable directory, and the line real rg prints about it. */ + function lockDirectory(name: string): { + path: string + stderr: string + release: () => void + } { + const locked = join(process.cwd(), name) + mkdirSync(locked, { recursive: true }) + chmodSync(locked, 0o000) + return { + path: locked, + stderr: `rg: ${locked}: Permission denied (os error 13)\n`, + release: () => { + chmodSync(locked, 0o755) + rmSync(locked, { recursive: true, force: true }) + }, + } + } + + it('refuses the wrap when the failure names no error code', async () => { + // Nothing here stands in for a failure of this shape, so what the + // run never reached would stay writable — and the raw line goes in + // the message, since only a command run outside the sandbox can + // act on it. + const error = await wrapWith( + failingRipgrep([nestedConfig()], 'rg: unrecognized option --frob'), + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', ) - expect((await runSandboxedWrite('safe-file.txt', 'x')).success).toBe( - true, + expect((error as Error).message).toMatch( + /failed for a reason no deny stands in for/, ) - }, - ) - - for (const [container, names] of [ - ['a/b', existing.filter(name => name.includes('/'))], - [beyond, existing], - ] as const) { - it.if(isLinux)( - `looks no deeper than the depth says: ${names.length} names in ${container}/`, - async () => { - for (const name of names) { - const target = `${container}/${name}` - const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + expect((error as Error).message).toContain( + 'rg: unrecognized option --frob', + ) + expect((error as Error).message).toMatch(/was not run/) + }) - expect([target, result.success]).toEqual([target, true]) - } - }, - 60_000, - ) - } - }) + it('refuses the wrap on an error code no deny stands in for', async () => { + const error = await wrapWith( + failingRipgrep( + [nestedConfig()], + 'rg: /proc/self/fd/9: Input/output error (os error 5)\n', + ), + ).catch((e: unknown) => e) - describe('Safe files should still be writable', () => { - it('allows writes to regular files', async () => { - const result = await runSandboxedWrite( - 'safe-file.txt', - MODIFIED_CONTENT, - ) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain('(os error 5)') + }) - expect(result.success).toBe(true) - expect(readFileSync('safe-file.txt', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) - }) + it('refuses the wrap when the run said nothing at all', async () => { + const error = await wrapWith( + failingRipgrep([nestedConfig()], ''), + ).catch((e: unknown) => e) - it('allows writes to .git/objects (not hooks/config)', async () => { - const result = await runSandboxedWrite( - '.git/objects/test-obj', - MODIFIED_CONTENT, - ) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain('(nothing on stderr)') + }) - expect(result.success).toBe(true) - expect(readFileSync('.git/objects/test-obj', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) - }) + it('carries on when the run only lost entries it had listed', async () => { + // A file the command deleted while rg walked. There is nothing at + // that path to deny, and mounting one would plant a file on the + // host exactly where the command removed one. + const vanished = join(process.cwd(), 'nested', 'src', 'gone.txt') + const config = nestedConfig() + const command = await wrapWith( + failingRipgrep( + [config], + `rg: ${vanished}: No such file or directory (os error 2)\n`, + ), + ) - it('allows writes to .git/refs/heads (not hooks/config)', async () => { - const result = await runSandboxedWrite( - '.git/refs/heads/main', - MODIFIED_CONTENT, - ) + expect(lastMountAt(command, config)).toBe( + `--ro-bind ${config} ${config}`, + ) + expect(lastMountAt(command, vanished)).toBeUndefined() + expect(existsSync(vanished)).toBe(false) + }) - expect(result.success).toBe(true) - expect(readFileSync('.git/refs/heads/main', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) - }) + it('refuses the wrap when nothing unreadable is there to deny', async () => { + // rg said it could not read something and the walk finds every + // directory readable: the run failed over something this cannot + // see, and nothing stands in for that. + const error = await wrapWith( + failingRipgrep( + [nestedConfig()], + `rg: ${join(process.cwd(), 'nested')}: Permission denied (os error 13)\n`, + ), + ).catch((e: unknown) => e) + + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/none is there now/) + }) - it('allows writes to .git/index (not hooks/config)', async () => { - const result = await runSandboxedWrite('.git/index', MODIFIED_CONTENT) + describe.if(process.getuid?.() !== 0)( + 'with a directory this process really cannot read', + () => { + it('denies the unreadable directory and nothing beside it', async () => { + // The name holds rg's own separator. Cut at it, the path reads + // as the directory above — which is readable, was scanned, and + // must not be denied — and the unreadable one never is. + const parent = join(process.cwd(), 'src: x') + mkdirSync(parent, { recursive: true }) + const locked = lockDirectory(join('src: x', 'locked')) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) - expect(result.success).toBe(true) - expect(readFileSync('.git/index', 'utf8').trim()).toBe(MODIFIED_CONTENT) - }) + const denies = denySectionOf(command) + expect(denies).toContain(denyBindOf(locked.path)) + expect(denies).not.toContain(denyBindOf(parent)) + } finally { + locked.release() + rmSync(parent, { recursive: true, force: true }) + } + }) - it('allows writes to .claude/ files outside commands/agents', async () => { - const result = await runSandboxedWrite( - '.claude/some-other-file.txt', - MODIFIED_CONTENT, - ) + it('never denies the working directory over a name', async () => { + const parent = join(process.cwd(), '.: x') + mkdirSync(parent, { recursive: true }) + const locked = lockDirectory(join('.: x', 'locked')) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) - expect(result.success).toBe(true) - expect(readFileSync('.claude/some-other-file.txt', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) - }) - }) + const denies = denySectionOf(command) + expect(denies).toContain(denyBindOf(locked.path)) + expect(denies).not.toContain(denyBindOf(process.cwd())) + } finally { + locked.release() + rmSync(parent, { recursive: true, force: true }) + } + }) - describe('allowGitConfig option', () => { - async function runSandboxedWriteWithGitConfig( - filePath: string, - content: string, - allowGitConfig: boolean, - ): Promise<{ success: boolean; stderr: string }> { - const platform = getPlatform() - const command = `echo '${content}' > '${filePath}'` + it('denies a name ending in a space, which no trim survives', async () => { + const locked = lockDirectory('locked ') + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [], - } + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + } finally { + locked.release() + } + }) - let wrappedCommand: string - if (platform === 'macos') { - wrappedCommand = wrapCommandWithSandboxMacOS({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - allowGitConfig, - }) - } else { - wrappedCommand = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - allowGitConfig, - }) - } + it('denies a name holding a newline whole, rather than refusing', async () => { + const locked = lockDirectory('bad\nname') + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + } finally { + locked.release() + } + }) - return { - success: result.status === 0, - stderr: result.stderr || '', - } - } + it('denies what it can read of a run that both lost and was refused', async () => { + const vanished = join(process.cwd(), 'nested', 'gone.txt') + const locked = lockDirectory('mixed-locked') + try { + const command = await wrapWith( + failingRipgrep( + [nestedConfig()], + `rg: ${vanished}: No such file or directory (os error 2)\n${locked.stderr}`, + ), + ) - it('blocks writes to .git/config when allowGitConfig is false (default)', async () => { - // Reset .git/config to original content - writeFileSync('.git/config', ORIGINAL_CONTENT) + expect(denySectionOf(command)).toContain( + denyBindOf(locked.path), + ) + expect(lastMountAt(command, vanished)).toBeUndefined() + } finally { + locked.release() + } + }) - const result = await runSandboxedWriteWithGitConfig( - '.git/config', - MODIFIED_CONTENT, - false, + it('accounts for an unreadable directory at the depth the scan stops at', async () => { + // ripgrep opens a directory that sits AT its --max-depth to + // list it, and reports one it cannot read. This walk stops a + // level above, so it found nothing for that EACCES and the + // wrap was refused - over a layout as ordinary as a + // `docker/data/postgres/base` another user owns. Nothing below + // such a directory is in the scan's reach on either side, so it + // is accounted for with nothing to deny. + const locked = lockDirectory( + join('docker', 'data', 'postgres', 'base'), + ) + try { + const command = await wrapWith( + failingRipgrep([nestedConfig()], locked.stderr), + ) + + const denies = denySectionOf(command) + expect(denies).not.toContain(denyBindOf(locked.path)) + // And nothing widened to stand in for it. + expect(denies).not.toContain( + denyBindOf(join(process.cwd(), 'docker', 'data', 'postgres')), + ) + expect(denies).toContain(denyBindOf(nestedConfig())) + } finally { + locked.release() + } + }) + + it('refuses a run killed inside its budget, whatever it reported', async () => { + // Every code this run printed is one a deny stands in for, and + // the directory it named is really there - so the listing + // would be used, if the run had finished. It did not: a kill + // that is not the timeout (an OOM kill, a signal to the + // process group) stops the listing wherever it arrived, and + // what lies past that is unknown however complete the stderr + // looks. An unreadable directory is one `chmod 000` a command + // can leave behind, so taking the two together would be a + // fail-open with a plantable half. + const locked = lockDirectory('killed-mid-listing') + try { + const error = await wrapWith({ + command: '/bin/sh', + args: [ + '-c', + `printf '%s\\0' ${quote([nestedConfig()])}; ` + + `printf '%s' ${quote([locked.stderr])} >&2; ` + + 'kill -KILL $$', + ], + timeoutMs: 30000, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch( + /was killed by SIGKILL before it finished/, + ) + // Named as what it was: nothing here ran out of time. + expect((error as Error).message).not.toMatch(/did not finish/) + } finally { + locked.release() + } + }) + }, ) - expect(result.success).toBe(false) - expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) - }) + it('refuses the wrap when the scan could not be run at all', async () => { + const error = await wrapWith({ + command: join(TEST_DIR, 'no-such-ripgrep'), + }).catch((e: unknown) => e) - it('allows writes to .git/config when allowGitConfig is true', async () => { - // Reset .git/config to original content - writeFileSync('.git/config', ORIGINAL_CONTENT) + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/could not be run/) + }) - const result = await runSandboxedWriteWithGitConfig( - '.git/config', - MODIFIED_CONTENT, - true, - ) + it("lets the caller's own abort through as itself", async () => { + const controller = new AbortController() + controller.abort() - expect(result.success).toBe(true) - expect(readFileSync('.git/config', 'utf8').trim()).toBe( - MODIFIED_CONTENT, - ) - }) + const error = await wrapWith( + { command: '/bin/sh', args: ['-c', 'true'] }, + controller.signal, + ).catch((e: unknown) => e) - it('still blocks writes to .git/hooks even when allowGitConfig is true', async () => { - // Reset pre-commit to original content - writeFileSync('.git/hooks/pre-commit', ORIGINAL_CONTENT) + expect((error as Error).name).toBe('AbortError') + expect(error).not.toBeInstanceOf(LinuxSandboxProfileError) + }) - const result = await runSandboxedWriteWithGitConfig( - '.git/hooks/pre-commit', - MODIFIED_CONTENT, - true, - ) + it.if(process.getuid?.() !== 0 && bwrapCanNamespace())( + 'denies a nested repository the real rg could not walk past', + async () => { + const cwd = process.cwd() + // Named with rg's own separator between a path and its message, + // so the whole round trip runs against a name that defeats + // reading paths out of stderr. + const blind = join(cwd, 'a: b') + const hook = join(cwd, 'nested', '.git', 'hooks', 'pre-commit') + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + }) + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd, + }) - expect(result.success).toBe(false) - expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( - ORIGINAL_CONTENT, + mkdirSync(blind, { recursive: true }) + try { + // The first command leaves a directory the next command's scan + // cannot read: that scan fails, and the hooks of the nested + // repository beside it must be denied all the same. + const first = run( + await wrap(`echo ${BOOTED} && chmod 000 ${quote([blind])}`), + ) + expect(first.stdout).toContain(BOOTED) + expect(first.status).toBe(0) + cleanupBwrapMountPoints({ force: true }) + + const second = run( + await wrap( + `echo ${BOOTED}; echo X > ${hook} || echo DENIED; ` + + `chmod 755 ${quote([blind])} || echo BLIND_DENIED`, + ), + ) + expect(second.stdout).toContain(BOOTED) + expect(second.stdout).toContain('DENIED') + // The directory the scan could not read is read-only inside the + // sandbox, so the command cannot open it up for the next scan. + expect(second.stdout).toContain('BLIND_DENIED') + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + } finally { + chmodSync(blind, 0o755) + rmSync(blind, { recursive: true, force: true }) + } + }, + 60000, ) }) - }) - - describe.if(isLinux)( - 'Non-existent deny path protection and cleanup (Linux only)', - () => { - // This tests that: - // 1. Non-existent deny paths within writable areas are blocked by mounting - // /dev/null at the first non-existent component - // 2. The mount point artifacts bwrap creates on the host are cleaned up - // by cleanupBwrapMountPoints() - // - // Background: When bwrap does --ro-bind /dev/null /nonexistent/path, it - // creates an empty file on the host as a mount point. Without cleanup, - // these "ghost dotfiles" persist and pollute the working directory. - async function runSandboxedWriteWithDenyPaths( - command: string, - denyPaths: string[], - ): Promise<{ success: boolean; stdout: string; stderr: string }> { - const platform = getPlatform() - if (platform !== 'linux') { - return { success: true, stdout: '', stderr: '' } - } + it.if(isLinux)( + 'scans with the ripgrep config file left out of it', + async () => { + // RIPGREP_CONFIG_PATH routinely points inside a project, at a file + // a sandboxed command can write, and one line of it is enough to + // make the scan list nothing and report no error for it. + const rc = join(TEST_DIR, 'scan.rgrc') + writeFileSync(rc, '--max-filesize=1\n') + const saved = process.env.RIPGREP_CONFIG_PATH + process.env.RIPGREP_CONFIG_PATH = rc + try { + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + ) - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: denyPaths, + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + if (saved === undefined) delete process.env.RIPGREP_CONFIG_PATH + else process.env.RIPGREP_CONFIG_PATH = saved + rmSync(rc, { force: true }) } + }, + ) - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - }) - - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + it('still lets a command create a .git file where none exists', async () => { + mkdirSync('fresh-checkout', { recursive: true }) + try { + const result = await runSandboxedWrite( + 'fresh-checkout/.git', + 'gitdir: ../.git/modules/fresh', + ) - return { - success: result.status === 0, - stdout: result.stdout || '', - stderr: result.stderr || '', - } + expect(result.success).toBe(true) + expect(readFileSync('fresh-checkout/.git', 'utf8').trim()).toBe( + 'gitdir: ../.git/modules/fresh', + ) + } finally { + rmSync('fresh-checkout', { recursive: true, force: true }) } + }) - // --- Security: deny path blocking --- - - it('blocks creation of non-existent file when parent dir exists', async () => { - // .claude directory exists from beforeAll setup - // .claude/settings.json does NOT exist - const nonExistentFile = '.claude/settings.json' - - const result = await runSandboxedWriteWithDenyPaths( - `echo '{"hooks":{}}' > '${nonExistentFile}'`, - [join(TEST_DIR, nonExistentFile)], + it('does not let a .git file be created outside the allowed write paths', async () => { + const opts = { allowOnly: [join(TEST_DIR, 'nested', 'src')] } + mkdirSync('unlisted', { recursive: true }) + try { + const pointer = await runSandboxedWrite( + 'unlisted/.git', + 'gitdir: /tmp/elsewhere', + opts, ) + expect(pointer.success).toBe(false) + expect(existsSync('unlisted/.git')).toBe(false) - expect(result.success).toBe(false) - // Verify file content was NOT written (bwrap creates empty mount point) - const content = readFileSync(nonExistentFile, 'utf8') - expect(content).toBe('') + const control = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + } finally { + rmSync('unlisted', { recursive: true, force: true }) + } + }) - cleanupBwrapMountPoints() + describe('from a linked worktree checkout', () => { + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'wt-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'wt-checkout', 'notes.txt'), { force: true }) }) - it('blocks creation of non-existent file when parent dir also does not exist', async () => { - const nonExistentPath = 'nonexistent-dir/settings.json' + it("blocks the main repository's hooks, which the worktree's commits run", async () => { + const hook = join(TEST_DIR, '.git', 'hooks', 'pre-commit') + const denied = await runSandboxedWrite(hook, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) - const result = await runSandboxedWriteWithDenyPaths( - `mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, ) + expect(control.success).toBe(true) + }) - expect(result.success).toBe(false) - // bwrap mounts an empty read-only directory at first non-existent - // intermediate component, blocking mkdir inside it - const stat = statSync('nonexistent-dir') - expect(stat.isDirectory()).toBe(true) + it("blocks rewriting the worktree's commondir", async () => { + // It names the git directory whose hooks and config a commit here + // runs, so it chooses what the denies below apply to. + const commondir = join( + TEST_DIR, + '.git', + 'worktrees', + 'wt', + 'commondir', + ) + const denied = await runSandboxedWrite(commondir, '../../decoy', opts) - cleanupBwrapMountPoints() + expect(denied.success).toBe(false) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') }) - it('blocks creation of deeply nested non-existent path', async () => { - const nonExistentPath = 'a/b/c/file.txt' - - const result = await runSandboxedWriteWithDenyPaths( - `mkdir -p a/b/c && echo 'test' > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], + it("blocks repointing the checkout's own .git file", async () => { + const original = readFileSync('.git', 'utf8') + const result = await runSandboxedWrite( + '.git', + 'gitdir: /tmp/elsewhere', + opts, ) expect(result.success).toBe(false) - // bwrap mounts an empty read-only directory at 'a', blocking the - // entire subtree - const stat = statSync('a') - expect(stat.isDirectory()).toBe(true) - - cleanupBwrapMountPoints() + expect(readFileSync('.git', 'utf8')).toBe(original) }) + }) - // --- Cleanup: mount point artifact removal --- + describe('from a checkout whose pointer is padded with newlines', () => { + // git strips them from the end of the file and follows the pointer, + // so reading less of the file than git does would leave the target's + // hooks and config writable. + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'padded-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'padded-checkout', 'notes.txt'), { + force: true, + }) + }) - it('cleanupBwrapMountPoints removes mount point artifacts', async () => { - const nonExistentPath = 'cleanup-test-dir/file.txt' + it("blocks the padded pointer's target, as an unpadded one's", async () => { + const config = join(TEST_DIR, 'padded-target', 'config') + const denied = await runSandboxedWrite(config, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(config, 'utf8')).toBe(ORIGINAL_CONTENT) + + const hook = join(TEST_DIR, 'padded-target', 'hooks', 'pre-commit') + const hookWrite = await runSandboxedWrite( + hook, + MODIFIED_CONTENT, + opts, + ) + expect(hookWrite.success).toBe(false) + expectNotWritten(hook) - await runSandboxedWriteWithDenyPaths( - `echo test > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, ) + expect(control.success).toBe(true) + }) + }) - // Mount point artifact should exist on host after bwrap exits - expect(existsSync('cleanup-test-dir')).toBe(true) + it("matches dangerous names below cwd only, not in cwd's own location", async () => { + process.chdir(join(TEST_DIR, '.vscode', 'ext', 'foo')) - // Clean up - cleanupBwrapMountPoints() + const denied = await runSandboxedWrite( + 'sub/.gitconfig', + MODIFIED_CONTENT, + ) + expect(denied.success).toBe(false) + expect(readFileSync('sub/.gitconfig', 'utf8')).toBe(ORIGINAL_CONTENT) - // Artifact should be gone - expect(existsSync('cleanup-test-dir')).toBe(false) - }) + const control = await runSandboxedWrite('sub/ok.txt', MODIFIED_CONTENT) + expect(control.success).toBe(true) + }) - it('cleanupBwrapMountPoints removes multiple mount points from a single command', async () => { - // Two non-existent deny paths in different subtrees - const path1 = 'ghost-dir-a/secret.txt' - const path2 = 'ghost-dir-b/secret.txt' + it('denies a nested .claude/commands as a directory once the scan reaches it', async () => { + // pkg/.claude/commands/x.md is four segments deep: found with a + // depth of 4 (macOS matches by pattern at any depth), and then the + // whole directory is read-only, new files included. + const existing = await runSandboxedWrite( + 'pkg/.claude/commands/x.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(existing.success).toBe(false) + expect(readFileSync('pkg/.claude/commands/x.md', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) - await runSandboxedWriteWithDenyPaths( - `mkdir -p ghost-dir-a ghost-dir-b`, - [join(TEST_DIR, path1), join(TEST_DIR, path2)], - ) + const created = await runSandboxedWrite( + 'pkg/.claude/commands/new.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(created.success).toBe(false) + expect(existsSync('pkg/.claude/commands/new.md')).toBe(false) + }) + }) - // Both mount point artifacts should exist - expect(existsSync('ghost-dir-a')).toBe(true) - expect(existsSync('ghost-dir-b')).toBe(true) + describe('Dangerous directories should be blocked', () => { + for (const dir of getDangerousDirectories()) { + it(`blocks writes to ${dir}/`, async () => { + const target = `${dir}/${DIRECTORY_PROBE_FILE}` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + expect(readFileSync(target, 'utf8')).toBe(ORIGINAL_CONTENT) + }) + } + }) - // Both should be cleaned up - expect(existsSync('ghost-dir-a')).toBe(false) - expect(existsSync('ghost-dir-b')).toBe(false) + describe('The same names below the working directory', () => { + /** What holds files the host runs or trusts, and a file in each. */ + const heldIn = new Map([ + ['.git/hooks', 'pre-commit'], + ...getDangerousDirectories().map( + dir => [dir, DIRECTORY_PROBE_FILE] as const, + ), + ]) + const existing = [ + ...DANGEROUS_FILES, + '.git/config', + ...[...heldIn].map(([dir, file]) => `${dir}/${file}`), + ] + const populate = (container: string): void => { + for (const dir of heldIn.keys()) { + mkdirSync(join(TEST_DIR, container, dir), { recursive: true }) + } + for (const name of [...existing, 'safe-file.txt']) { + writeFileSync(join(TEST_DIR, container, name), ORIGINAL_CONTENT) + } + } + // How far the default depth reaches on Linux: every name in `sub`, and + // the files alone in `a/b`. + const covered = [ + ['sub', existing], + ['a/b', DANGEROUS_FILES], + ] as const + const beyond = 'a/b/c' + beforeAll(() => ['sub', 'a/b', beyond].forEach(populate)) + + for (const [container, names] of covered) { + for (const name of names) { + it(`blocks writes to ${container}/${name}`, async () => { + const target = `${container}/${name}` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect(result.success).toBe(false) + expect(readFileSync(target, 'utf8')).toBe(ORIGINAL_CONTENT) + }) + } + it(`allows writes to ${container}/safe-file.txt`, async () => { + const target = `${container}/safe-file.txt` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect(result.success).toBe(true) + expect(readFileSync(target, 'utf8').trim()).toBe(MODIFIED_CONTENT) }) + } - it('cleanupBwrapMountPoints preserves non-empty directories', async () => { - const nonExistentPath = 'preserve-test-dir/file.txt' + for (const dir of heldIn.keys()) { + it(`blocks a new file in sub/${dir}/`, async () => { + const target = `sub/${dir}/new-file` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) - await runSandboxedWriteWithDenyPaths( - `echo test > '${nonExistentPath}'`, - [join(TEST_DIR, nonExistentPath)], + expect(result.success).toBe(false) + expect(existsSync(target)).toBe(false) + }) + } + + for (const state of ['empty', 'missing']) { + it(`blocks a new hook in a repository whose hooks directory is ${state}`, async () => { + const repository = `hooks-${state}` + const hooks = `${repository}/.git/hooks` + mkdirSync(join(TEST_DIR, repository, '.git'), { recursive: true }) + writeFileSync( + join(TEST_DIR, repository, '.git', 'HEAD'), + 'ref: refs/heads/main', ) + if (state === 'empty') mkdirSync(join(TEST_DIR, hooks)) - // Simulate something else creating content in the mount point directory - // (e.g., another process created files here legitimately) - const mountPoint = join(TEST_DIR, 'preserve-test-dir') - if (existsSync(mountPoint)) { - // Create a file inside — cleanup should NOT delete non-empty directories - writeFileSync(join(mountPoint, 'real-file.txt'), 'real content') - } + const result = await runSandboxed( + `mkdir -p '${hooks}' && echo x > '${hooks}/pre-commit'`, + ) - cleanupBwrapMountPoints() + expect(result.success).toBe(false) + expect(existsSync(`${hooks}/pre-commit`)).toBe(false) + // The rest of the repository is still the command's to write. + expect( + (await runSandboxedWrite(`${repository}/.git/HEAD`, 'x')).success, + ).toBe(true) + }) + } - // Directory with real content should be preserved - if (existsSync(mountPoint)) { - expect(statSync(mountPoint).isDirectory()).toBe(true) - const content = readFileSync( - join(mountPoint, 'real-file.txt'), - 'utf8', - ) - expect(content).toBe('real content') - // Manual cleanup for this test - rmSync(mountPoint, { recursive: true, force: true }) + for (const [file, line] of [ + ['.gitignore', 'sub/'], + ['.ignore', '*'], + ] as const) { + it(`blocks them all the same when ${file} holds ${line}`, async () => { + writeFileSync(join(TEST_DIR, file), `${line}\n`) + try { + for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { + const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + + expect([name, result.success]).toEqual([name, false]) + expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) + } + } finally { + rmSync(join(TEST_DIR, file)) } }) + } - it('cleanupBwrapMountPoints is safe to call when there are no mount points', () => { - // Should not throw - cleanupBwrapMountPoints() - cleanupBwrapMountPoints() + it.if(isLinux)( + "blocks them all the same whatever ripgrep's configuration file says", + async () => { + const configuration = join(TEST_DIR, 'ripgrep-configuration') + writeFileSync(configuration, '--glob=!sub\n') + process.env.RIPGREP_CONFIG_PATH = configuration + try { + const result = await runSandboxedWrite( + 'sub/.bashrc', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('sub/.bashrc', 'utf8')).toBe(ORIGINAL_CONTENT) + } finally { + delete process.env.RIPGREP_CONFIG_PATH + rmSync(configuration) + } + }, + ) + + it('blocks them all the same beside a directory that cannot be read', async () => { + const unreadable = join(TEST_DIR, 'unreadable') + mkdirSync(unreadable, { mode: 0o000 }) + try { + for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { + const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + + expect([name, result.success]).toEqual([name, false]) + expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) + } + } finally { + rmdirSync(unreadable) + } + }) + + for (const [directory, mode] of [ + ['locked', 0o000], + ['locked/.git', 0o000], + // As deep as one of the names can itself lie. + ['locked/.claude/commands', 0o000], + // Can be listed, and nothing in it looked at. + ['locked', 0o444], + // The other way about. + ['locked', 0o111], + ] as const) { + // With one thread, as on one CPU, ripgrep words what it says otherwise. + for (const threads of [[], ['-j1']]) { + it(`blocks them all the same in locked/ when ${directory} has mode ${mode.toString(8)} (rg ${threads.join()})`, async () => { + const names = [ + '.bashrc', + '.git/config', + '.git/hooks/pre-commit', + `.claude/commands/${DIRECTORY_PROBE_FILE}`, + ] + populate('locked') + try { + for (const name of names) { + // Each time: a command that is not held gives the mode back. + chmodSync(join(TEST_DIR, directory), mode) + const result = await runSandboxed( + `chmod 755 locked '${directory}'; echo x > 'locked/${name}'`, + { ripgrepArgs: threads }, + ) + + expect([name, result.success]).toEqual([name, false]) + } + } finally { + chmodSync(join(TEST_DIR, directory), 0o755) + } + for (const name of names) { + expect(readFileSync(`locked/${name}`, 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } + }) + } + } + + // root reads a directory of any mode. + it.if(isLinux && process.getuid?.() !== 0)( + 'finds what cannot be read on the file system, whatever ripgrep says', + async () => { + const project = join(TEST_DIR, 'hints') + const outside = join(TEST_DIR, 'hints-outside') + for (const directory of [ + join(project, 'is-readable'), + join(project, 'really-locked'), + join(project, 'locked: too'), + join(project, 'locked\\so'), + join(project, 'one-thread'), + join(project, 'no-prefix'), + join(project, 'listed-only/child'), + join(project, 'entered-only'), + join(project, 'a/b/c/too-deep'), + join(outside, 'behind-a-link'), + ]) { + mkdirSync(directory, { recursive: true }) + } + symlinkSync(outside, join(project, 'link')) + const locked = [ + join(project, 'really-locked'), + join(project, 'locked: too'), + join(project, 'locked\\so'), + join(project, 'one-thread'), + join(project, 'no-prefix'), + join(project, 'a/b/c/too-deep'), + join(outside, 'behind-a-link'), + ] + // What a name in the tree could make ripgrep say. + const said = [ + 'is-readable', + 'really-locked', + 'a/b/c/too-deep', + 'link/behind-a-link', + '../hints-outside/behind-a-link', + 'not-there/at-all', + 'entered-only/not-there', + 'locked: too', + 'locked\\so', + 'listed-only/child', + ].map( + name => `rg: ${project}/${name}: Permission denied (os error 13)`, + ) + said.push( + `rg: ${project}/one-thread: IO error for operation on ${project}/one-thread: Permission denied (os error 13)`, + `${project}/no-prefix: Permission denied (os error 13)`, + ) + const standIn = join(TEST_DIR, 'says-it-could-not-read') + const asked = join(TEST_DIR, 'was-asked') + writeFileSync( + standIn, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > '${asked}'`, + ...said.map(line => `echo '${line}' >&2`), + 'exit 2', + ].join('\n'), + { mode: 0o755 }, + ) + process.chdir(project) + locked.forEach(directory => chmodSync(directory, 0o000)) + chmodSync(join(project, 'listed-only'), 0o444) + chmodSync(join(project, 'entered-only'), 0o111) + try { + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig: { command: standIn }, + }) + + const found = unreadableDirectories(project, 3, Date.now() + 10_000) + expect(found.own.sort()).toEqual( + [ + 'really-locked', + 'locked: too', + 'locked\\so', + 'listed-only', + 'entered-only', + 'one-thread', + 'no-prefix', + ] + .map(name => join(project, name)) + .sort(), + ) + // As deep as ripgrep opens a directory, and looks at nothing in. + expect(found.leftAlone).toBe(1) + // Nobody else's mode can be given back from inside. + const uid = process.getuid!() + const getuid = spyOn(process, 'getuid').mockReturnValue(uid + 1) + try { + expect( + unreadableDirectories(project, 3, Date.now() + 10_000), + ).toEqual({ own: [], leftAlone: 8 }) + } finally { + getuid.mockRestore() + } + expect(wrapped).toContain('really-locked') + for (const name of ['is-readable', 'behind-a-link', 'too-deep']) { + expect(wrapped).not.toContain(name) + } + expect(readFileSync(asked, 'utf8').split('\n')).toEqual( + expect.arrayContaining(['--no-ignore', '--no-config']), + ) + } finally { + for (const directory of [ + ...locked, + join(project, 'listed-only'), + join(project, 'entered-only'), + ]) { + chmodSync(directory, 0o755) + } + } + }, + ) + + it.if(isLinux)( + 'takes no directory above the working directory for one of the names', + async () => { + const project = join(TEST_DIR, 'above', '.idea', 'project') + mkdirSync(join(project, 'sub'), { recursive: true }) + writeFileSync(join(project, 'sub', '.bashrc'), ORIGINAL_CONTENT) + process.chdir(project) + + expect((await runSandboxedWrite('sub/.bashrc', 'x')).success).toBe( + false, + ) + expect((await runSandboxedWrite('safe-file.txt', 'x')).success).toBe( + true, + ) + }, + ) + + for (const [container, names] of [ + ['a/b', existing.filter(name => name.includes('/'))], + [beyond, existing], + ] as const) { + it.if(isLinux)( + `looks no deeper than the depth says: ${names.length} names in ${container}/`, + async () => { + for (const name of names) { + const target = `${container}/${name}` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect([target, result.success]).toEqual([target, true]) + } + }, + 60_000, + ) + } + }) + + describe('Safe files should still be writable', () => { + it('allows writes to regular files', async () => { + const result = await runSandboxedWrite( + 'safe-file.txt', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('safe-file.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it('allows writes to .git/objects (not hooks/config)', async () => { + const result = await runSandboxedWrite( + '.git/objects/test-obj', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('.git/objects/test-obj', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it('allows writes to .git/refs/heads (not hooks/config)', async () => { + const result = await runSandboxedWrite( + '.git/refs/heads/main', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('.git/refs/heads/main', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it('allows writes to .git/index (not hooks/config)', async () => { + const result = await runSandboxedWrite('.git/index', MODIFIED_CONTENT) + + expect(result.success).toBe(true) + expect(readFileSync('.git/index', 'utf8').trim()).toBe(MODIFIED_CONTENT) + }) + + it('allows writes to .claude/ files outside commands/agents', async () => { + const result = await runSandboxedWrite( + '.claude/some-other-file.txt', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('.claude/some-other-file.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + }) + + describe('allowGitConfig option', () => { + async function runSandboxedWriteWithGitConfig( + filePath: string, + content: string, + allowGitConfig: boolean, + ): Promise<{ success: boolean; stderr: string }> { + const platform = getPlatform() + const command = `echo '${content}' > '${filePath}'` + + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [], + } + + let wrappedCommand: string + if (platform === 'macos') { + wrappedCommand = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + allowGitConfig, + }) + } else { + wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + allowGitConfig, + }) + } + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stderr: result.stderr || '', + } + } + + it('blocks writes to .git/config when allowGitConfig is false (default)', async () => { + // Reset .git/config to original content + writeFileSync('.git/config', ORIGINAL_CONTENT) + + const result = await runSandboxedWriteWithGitConfig( + '.git/config', + MODIFIED_CONTENT, + false, + ) + + expect(result.success).toBe(false) + expect(readFileSync('.git/config', 'utf8')).toBe(ORIGINAL_CONTENT) + }) + + it('allows writes to .git/config when allowGitConfig is true', async () => { + // Reset .git/config to original content + writeFileSync('.git/config', ORIGINAL_CONTENT) + + const result = await runSandboxedWriteWithGitConfig( + '.git/config', + MODIFIED_CONTENT, + true, + ) + + expect(result.success).toBe(true) + expect(readFileSync('.git/config', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it('still blocks writes to .git/hooks even when allowGitConfig is true', async () => { + // Reset pre-commit to original content + writeFileSync('.git/hooks/pre-commit', ORIGINAL_CONTENT) + + const result = await runSandboxedWriteWithGitConfig( + '.git/hooks/pre-commit', + MODIFIED_CONTENT, + true, + ) + + expect(result.success).toBe(false) + expect(readFileSync('.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + }) + + describe.if(isLinux)( + 'Non-existent deny path protection and cleanup (Linux only)', + () => { + // This tests that: + // 1. Non-existent deny paths within writable areas are blocked by mounting + // /dev/null at the first non-existent component + // 2. The mount point artifacts bwrap creates on the host are cleaned up + // by cleanupBwrapMountPoints() + // + // Background: When bwrap does --ro-bind /dev/null /nonexistent/path, it + // creates an empty file on the host as a mount point. Without cleanup, + // these "ghost dotfiles" persist and pollute the working directory. + + async function runSandboxedWriteWithDenyPaths( + command: string, + denyPaths: string[], + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const platform = getPlatform() + if (platform !== 'linux') { + return { success: true, stdout: '', stderr: '' } + } + + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: denyPaths, + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + } + } + + // --- Security: deny path blocking --- + + it('blocks creation of non-existent file when parent dir exists', async () => { + // .claude directory exists from beforeAll setup + // .claude/settings.json does NOT exist + const nonExistentFile = '.claude/settings.json' + + const result = await runSandboxedWriteWithDenyPaths( + `echo '{"hooks":{}}' > '${nonExistentFile}'`, + [join(TEST_DIR, nonExistentFile)], + ) + + expect(result.success).toBe(false) + // Verify file content was NOT written (bwrap creates empty mount point) + const content = readFileSync(nonExistentFile, 'utf8') + expect(content).toBe('') + + cleanupBwrapMountPoints() + }) + + it('blocks creation of non-existent file when parent dir also does not exist', async () => { + const nonExistentPath = 'nonexistent-dir/settings.json' + + const result = await runSandboxedWriteWithDenyPaths( + `mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + expect(result.success).toBe(false) + // bwrap mounts an empty read-only directory at first non-existent + // intermediate component, blocking mkdir inside it + const stat = statSync('nonexistent-dir') + expect(stat.isDirectory()).toBe(true) + + cleanupBwrapMountPoints() + }) + + it('blocks creation of deeply nested non-existent path', async () => { + const nonExistentPath = 'a/b/c/file.txt' + + const result = await runSandboxedWriteWithDenyPaths( + `mkdir -p a/b/c && echo 'test' > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + expect(result.success).toBe(false) + // bwrap mounts an empty read-only directory at 'a', blocking the + // entire subtree + const stat = statSync('a') + expect(stat.isDirectory()).toBe(true) + + cleanupBwrapMountPoints() + }) + + // --- Cleanup: mount point artifact removal --- + + it('cleanupBwrapMountPoints removes mount point artifacts', async () => { + const nonExistentPath = 'cleanup-test-dir/file.txt' + + await runSandboxedWriteWithDenyPaths( + `echo test > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + // Mount point artifact should exist on host after bwrap exits + expect(existsSync('cleanup-test-dir')).toBe(true) + + // Clean up + cleanupBwrapMountPoints() + + // Artifact should be gone + expect(existsSync('cleanup-test-dir')).toBe(false) + }) + + it('cleanupBwrapMountPoints removes multiple mount points from a single command', async () => { + // Two non-existent deny paths in different subtrees + const path1 = 'ghost-dir-a/secret.txt' + const path2 = 'ghost-dir-b/secret.txt' + + await runSandboxedWriteWithDenyPaths( + `mkdir -p ghost-dir-a ghost-dir-b`, + [join(TEST_DIR, path1), join(TEST_DIR, path2)], + ) + + // Both mount point artifacts should exist + expect(existsSync('ghost-dir-a')).toBe(true) + expect(existsSync('ghost-dir-b')).toBe(true) + + cleanupBwrapMountPoints() + + // Both should be cleaned up + expect(existsSync('ghost-dir-a')).toBe(false) + expect(existsSync('ghost-dir-b')).toBe(false) + }) + + it('cleanupBwrapMountPoints preserves non-empty directories', async () => { + const nonExistentPath = 'preserve-test-dir/file.txt' + + await runSandboxedWriteWithDenyPaths( + `echo test > '${nonExistentPath}'`, + [join(TEST_DIR, nonExistentPath)], + ) + + // Simulate something else creating content in the mount point directory + // (e.g., another process created files here legitimately) + const mountPoint = join(TEST_DIR, 'preserve-test-dir') + if (existsSync(mountPoint)) { + // Create a file inside — cleanup should NOT delete non-empty directories + writeFileSync(join(mountPoint, 'real-file.txt'), 'real content') + } + + cleanupBwrapMountPoints() + + // Directory with real content should be preserved + if (existsSync(mountPoint)) { + expect(statSync(mountPoint).isDirectory()).toBe(true) + const content = readFileSync( + join(mountPoint, 'real-file.txt'), + 'utf8', + ) + expect(content).toBe('real content') + // Manual cleanup for this test + rmSync(mountPoint, { recursive: true, force: true }) + } + }) + + it('cleanupBwrapMountPoints is safe to call when there are no mount points', () => { + // Should not throw + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + }) + + // --- Concurrent sandbox mount point cleanup --- + // + // When two sandboxed commands run concurrently and one finishes first, + // cleanupBwrapMountPoints() must NOT delete mount point files that the + // still-running sandbox depends on. Deleting a mountpoint's dentry on the + // host detaches the bind mount in the child namespace, so the deny rule + // stops applying inside the still-running sandbox. + + it('defers mount point cleanup while another sandbox is still running', async () => { + const raceDir = join(TEST_DIR, 'race-test') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + // Sandbox A: long-running command that sleeps then tries to write + // to the denied path. The write should be blocked. + // allowAllUnixSockets skips seccomp (environment-dependent) while + // keeping the filesystem isolation we're testing. + const wrappedA = await wrapCommandWithSandboxLinux({ + command: `sleep 2; echo '{"hooks":{}}' > .claude/settings.json`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', code => resolve(code)) + }) + + // Wait for bwrap A to start and create the mount point on the host + await new Promise(r => setTimeout(r, 500)) + expect(existsSync(protectedFile)).toBe(true) + + // Sandbox B: short command. When it finishes, the caller invokes + // cleanupBwrapMountPoints() — simulating the real-world race. + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // This is what the caller does after every command completes. + // Without deferral, this would delete sandbox A's mount point too. + cleanupBwrapMountPoints() + + // Wait for sandbox A to attempt its write + await exitA + + // The deny rule must have held — the file should not contain the + // write from sandbox A. If cleanup had deleted the mount point + // early, A's bind mount would have detached and the write would + // have landed on the host. + const content = existsSync(protectedFile) + ? readFileSync(protectedFile, 'utf8') + : '' + expect(content).not.toContain('hooks') + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('defers cleanup when two sandboxes share the same non-existent deny path', async () => { + const raceDir = join(TEST_DIR, 'race-test-2') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + // Generate both wrapped commands BEFORE spawning, so both see the + // deny path as non-existent and both add it to bwrapMountPoints. + const wrappedA = await wrapCommandWithSandboxLinux({ + command: `sleep 2; echo WRITTEN > .claude/settings.json`, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'sleep 0.5', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', code => resolve(code)) + }) + + // Sandbox B runs and finishes first + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + cleanupBwrapMountPoints() + + await exitA + + const content = existsSync(protectedFile) + ? readFileSync(protectedFile, 'utf8') + : '' + expect(content).not.toContain('WRITTEN') + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('deferred cleanup runs once all concurrent sandboxes finish', async () => { + const raceDir = join(TEST_DIR, 'race-test-3') + mkdirSync(raceDir, { recursive: true }) + mkdirSync(join(raceDir, '.claude'), { recursive: true }) + + const originalDir = process.cwd() + process.chdir(raceDir) + + try { + const protectedFile = join(raceDir, '.claude', 'settings.json') + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [protectedFile], + } + + const wrappedA = await wrapCommandWithSandboxLinux({ + command: 'sleep 1', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + const wrappedB = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + }) + + const childA = spawn(wrappedA, { shell: true }) + const exitA = new Promise(resolve => { + childA.on('exit', () => resolve()) + }) + + await new Promise(r => setTimeout(r, 300)) + expect(existsSync(protectedFile)).toBe(true) + + spawnSync(wrappedB, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + cleanupBwrapMountPoints() + + // Cleanup deferred — mount point still present while A runs + expect(existsSync(protectedFile)).toBe(true) + + await exitA + cleanupBwrapMountPoints() + + // Both sandboxes done — mount point now cleaned up + expect(existsSync(protectedFile)).toBe(false) + } finally { + process.chdir(originalDir) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + // The caller runs no cleanup for a wrap that threw, so what was + // deferred to that wrap has nobody else to let go of it. A command that + // ran and has ended is let go of by any clean-up call; what waits for + // the count is the manifest of one that was wrapped and never started. + + const wrapInCwd = ( + command: string, + failing?: Partial, + ): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + enableWeakerNestedSandbox: true, + allowAllUnixSockets: true, + ...failing, + }) + + it.each(['aborted', 'refused'])( + 'runs the cleanup deferred to a wrap that is then %s', + async how => { + const raceDir = join(TEST_DIR, 'race-test-4') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + + try { + const manifestA = manifestOf(await wrapInCwd('true')) ?? '' + expect(existsSync(manifestA)).toBe(true) + + // B is still looking for the mandatory denies when A's caller + // cleans up, and nothing tells that call from one for B. + const controller = new AbortController() + const wrappedB = wrapInCwd('true', { + abortSignal: controller.signal, + binShell: how === 'refused' ? 'no-such-shell' : undefined, + }) + cleanupBwrapMountPoints() + expect(existsSync(manifestA)).toBe(true) + + if (how === 'aborted') controller.abort(new Error('stopped')) + expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( + Error, + ) + expect(existsSync(manifestA)).toBe(false) + expect(readdirSync(raceDir)).toEqual([]) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }, + ) + + it('forgets the mount points planned by a wrap that is then refused', async () => { + const raceDir = join(TEST_DIR, 'race-test-6') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + + try { + expect( + await wrapInCwd('true', { binShell: 'no-such-shell' }).catch( + (e: unknown) => e, + ), + ).toBeInstanceOf(Error) + + // bwrap never ran, so this is no mount point: the host made it. + writeFileSync('.mcp.json', '') + spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + expect(readdirSync(raceDir)).toEqual(['.mcp.json']) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }) + + it.each([ + ['aborted', 'counted along with it'], + ['aborted', 'started since a forced cleanup'], + ['handed out', 'started since a forced cleanup'], + ])( + 'a wrap that is %s leaves the mount points of a running sandbox %s', + async (how, which) => { + const raceDir = join(TEST_DIR, 'race-test-5') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + // Stands in for ripgrep, and goes on until told to end. + const heldScan = join(raceDir, 'held-scan') + writeFileSync( + heldScan, + `#!/bin/sh\nuntil [ -e '${raceDir}/scanned' ]; do sleep 0.1; done\n`, + { mode: 0o755 }, + ) + + try { + const controller = new AbortController() + const wrappedB = wrapInCwd('true', { + abortSignal: controller.signal, + ripgrepConfig: { command: heldScan }, + }) + if (which !== 'counted along with it') { + cleanupBwrapMountPoints({ force: true }) + } + + // C runs throughout, and tries a denied name once told to. + const childC = spawn( + await wrapInCwd( + 'until [ -e go ]; do sleep 0.1; done; echo WRITTEN > .bashrc', + ), + { shell: true }, + ) + const exitC = new Promise(resolve => { + childC.on('exit', () => resolve()) + }) + while (!existsSync('.bashrc')) { + await new Promise(r => setTimeout(r, 50)) + } + + if (how === 'aborted') { + controller.abort(new Error('stopped')) + expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( + Error, + ) + } else { + writeFileSync('scanned', '') + spawnSync(await wrappedB, { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + } + expect(existsSync('.bashrc')).toBe(true) + + // C is still counted, so the next command to end defers to it. + spawnSync(await wrapInCwd('true'), { + shell: true, + timeout: 10000, + }) + cleanupBwrapMountPoints() + expect(existsSync('.bashrc')).toBe(true) + + writeFileSync('go', '') + await exitC + expect(readFileSync('.bashrc', 'utf8')).toBe('') + + cleanupBwrapMountPoints() + expect(existsSync('.bashrc')).toBe(false) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }, + 15000, + ) + + // A running sandbox answers for its own mount points, so what a count + // that is one short costs is the start of a command that is wrapped and + // has not started: bubblewrap refuses to start without its manifest. + it.each(['aborted', 'handed out'])( + 'a wrap from before a forced cleanup that is %s leaves the manifest of a command wrapped since', + async how => { + const raceDir = join(TEST_DIR, 'race-test-9') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + const heldScan = join(raceDir, 'held-scan') + writeFileSync( + heldScan, + `#!/bin/sh\nuntil [ -e '${raceDir}/scanned' ]; do sleep 0.1; done\n`, + { mode: 0o755 }, + ) + + try { + const controller = new AbortController() + const wrappedB = wrapInCwd('true', { + abortSignal: controller.signal, + ripgrepConfig: { command: heldScan }, + }) + cleanupBwrapMountPoints({ force: true }) + const commandC = await wrapInCwd('echo STARTED') + + if (how === 'aborted') { + controller.abort(new Error('stopped')) + expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( + Error, + ) + } else { + writeFileSync('scanned', '') + spawnSync(await wrappedB, { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + } + + const ranC = spawnSync(commandC, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + expect(ranC.stdout).toContain('STARTED') + cleanupBwrapMountPoints() + expect(existsSync('.bashrc')).toBe(false) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }, + 15000, + ) + + it('leaves nothing of a directory mount point that another sandbox was wrapped under', async () => { + const raceDir = join(TEST_DIR, 'race-test-7') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + + try { + const childT = spawn( + await wrapInCwd('until [ -e go ]; do sleep 0.1; done'), + { shell: true }, + ) + const exitT = new Promise(resolve => { + childT.on('exit', () => resolve()) + }) + while (!existsSync('.claude')) { + await new Promise(r => setTimeout(r, 50)) + } + + // T's .claude is there by now, so X's mount points go inside it. + // They are collected once X has ended; the directory is T's still. + spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + expect(readdirSync('.claude')).toEqual([]) + + writeFileSync('go', '') + await exitT + cleanupBwrapMountPoints() + expect(readdirSync(raceDir)).toEqual(['go']) + + spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) + cleanupBwrapMountPoints() + expect(readdirSync(raceDir)).toEqual(['go']) + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }, 15000) + + it('takes only its own where the host has written at a mount point or inside one', async () => { + const raceDir = join(TEST_DIR, 'race-test-8') + mkdirSync(raceDir, { recursive: true }) + process.chdir(raceDir) + + try { + // The second is wrapped with the first's .claude there. + spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) + spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) + expect(readdirSync('.claude')).not.toEqual([]) + writeFileSync('.claude/kept', '') + rmSync('.bashrc') + writeFileSync('.bashrc', 'kept') + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + expect(readdirSync(raceDir).sort()).toEqual(['.bashrc', '.claude']) + expect(readdirSync('.claude')).toEqual(['kept']) + expect(readFileSync('.bashrc', 'utf8')).toBe('kept') + } finally { + process.chdir(TEST_DIR) + rmSync(raceDir, { recursive: true, force: true }) + } + }) + + it('non-existent .git/hooks deny does not turn .git into a file, breaking git', async () => { + // When .git doesn't exist yet, denying .git/hooks causes + // findFirstNonExistentComponent to return .git itself. bwrap then does + // --ro-bind /dev/null .git, creating .git as a FILE (not a directory). + // Inside the sandbox, every git command fails because .git is a file. + + // Use a clean directory with NO .git + const noGitDir = join(TEST_DIR, 'no-git-dir') + mkdirSync(noGitDir, { recursive: true }) + + const originalDir = process.cwd() + process.chdir(noGitDir) + + try { + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + // This calls linuxGetMandatoryDenyPaths which unconditionally adds + // .git/hooks to the deny list. When .git doesn't exist, + // findFirstNonExistentComponent returns .git and bwrap mounts + // /dev/null there — making .git a file. + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'git init && git status', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // git init + git status should succeed — .git must be creatable as + // a directory, not blocked by a /dev/null file mount. + expect(result.status).toBe(0) + + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(noGitDir, { recursive: true, force: true }) + } + }) + + it('git worktree with .git as a file does not break sandboxed commands', async () => { + // Reproduces the bug reported by nvidia/netflix with git worktrees: + // In a worktree, .git is a FILE (e.g., "gitdir: /path/to/.git/worktrees/foo"), + // not a directory. The mandatory deny list includes .git/hooks, but since + // .git is a file, .git/hooks doesn't exist. The non-existent path handling + // tries to mount /dev/null at .git/hooks, but bwrap can't create a mount + // point under .git because it's a file — causing every command to fail. + + const worktreeDir = join(TEST_DIR, 'fake-worktree') + mkdirSync(worktreeDir, { recursive: true }) + + // Simulate a git worktree: .git is a file, not a directory + writeFileSync( + join(worktreeDir, '.git'), + 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', + ) + + const originalDir = process.cwd() + process.chdir(worktreeDir) + + try { + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + // .git is a pointer file here, so it goes through + // gitFileDenyPaths: the file itself and the hooks and config it + // leads to are denied, and nothing is mounted under the file + // (bwrap could not create a mount point there). + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // A simple echo should succeed — the .git-as-file worktree layout + // should not cause the sandbox to fail. + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('hello') + cleanupBwrapMountPoints() + } finally { + process.chdir(originalDir) + rmSync(worktreeDir, { recursive: true, force: true }) + } + }) + + it('does not leave ghost dotfiles after command + cleanup cycle', async () => { + // This is the exact scenario from issue #85: running a sandboxed command + // should NOT leave .bashrc, .gitconfig, etc. in the working directory. + // + // The mandatory deny list includes paths like ~/.bashrc, ~/.gitconfig. + // When CWD is within an allowed write path and these dotfiles don't exist + // in CWD, the old code left empty mount point files behind. + + // Use a clean subdirectory with no dotfiles + const cleanDir = join(TEST_DIR, 'clean-subdir') + mkdirSync(cleanDir, { recursive: true }) + + const originalDir = process.cwd() + process.chdir(cleanDir) + + try { + // Run a simple command through the sandbox + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: [] as string[], + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo hello', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + enableWeakerNestedSandbox: true, + }) + + spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // Run cleanup (as the CLI / Claude Code would) + cleanupBwrapMountPoints() + + // Verify no ghost dotfiles were left behind + const { readdirSync } = await import('node:fs') + const files = readdirSync(cleanDir) + const ghostDotfiles = files.filter(f => f.startsWith('.')) + expect(ghostDotfiles).toEqual([]) + } finally { + process.chdir(originalDir) + rmSync(cleanDir, { recursive: true, force: true }) + } + }) + }, + ) + + describe.if(isLinux)( + 'Symlink replacement attack protection (Linux only)', + () => { + // This tests the fix for symlink replacement attacks where an attacker + // could delete a symlink and create a real directory with malicious content + + async function runSandboxedCommandWithDenyPaths( + command: string, + denyPaths: string[], + ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const platform = getPlatform() + if (platform !== 'linux') { + return { success: true, stdout: '', stderr: '' } + } + + const writeConfig = { + allowOnly: ['.'], + denyWithinAllow: denyPaths, + } + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + return { + success: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + } + } + + // bwrap mount destinations resolve symlinks, so a deny path crossing + // a directory symlink is canonicalized and the deny applied to the + // resolved target (resolve-before-mask). Masking the raw symlink + // with /dev/null instead made bwrap abort at startup, failing every + // sandboxed command whenever .claude was a symlink. The guarantees + // around a deny path whose parent stays writable are unchanged from + // the non-symlink case. + it('denies writes through a symlinked .claude directory', async () => { + // Setup: Create a symlink .claude -> decoy (simulating malicious git repo) + const decoyDir = 'symlink-decoy' + const claudeSymlink = 'symlink-claude' + mkdirSync(decoyDir, { recursive: true }) + writeFileSync(join(decoyDir, 'settings.json'), '{}') + symlinkSync(decoyDir, claudeSymlink) + + try { + // The deny path is the settings.json through the symlink + const denyPath = join(TEST_DIR, claudeSymlink, 'settings.json') + + // The sandbox must start despite the directory symlink in the + // deny path (bwrap used to abort on the /dev/null symlink mask). + const benign = await runSandboxedCommandWithDenyPaths('true', [ + denyPath, + ]) + expect(benign.success).toBe(true) + + // Writing through the symlink must fail: the deny lands on the + // resolved target, the inode the write actually reaches. + const result = await runSandboxedCommandWithDenyPaths( + `echo '{"hooks":{}}' > ${claudeSymlink}/settings.json`, + [denyPath], + ) + expect(result.success).toBe(false) + expect(readFileSync(join(decoyDir, 'settings.json'), 'utf8')).toBe( + '{}', + ) + } finally { + // Cleanup + rmSync(claudeSymlink, { force: true }) + rmSync(decoyDir, { recursive: true, force: true }) + } + }) + + it('denies writes to a file reached through a directory symlink', async () => { + // Setup: Create a symlink + const targetDir = 'symlink-target-dir' + const symlinkPath = 'protected-symlink' + mkdirSync(targetDir, { recursive: true }) + writeFileSync(join(targetDir, 'file.txt'), 'content') + symlinkSync(targetDir, symlinkPath) + + try { + const denyPath = join(TEST_DIR, symlinkPath, 'file.txt') + + // Assert the sandbox starts first: a write-denied assertion alone + // also passes when bwrap aborts, which is the failure this deny + // path used to cause. + const benign = await runSandboxedCommandWithDenyPaths('true', [ + denyPath, + ]) + expect(benign.success).toBe(true) + + // The file is write-protected both through the symlink and via + // its resolved path. + for (const writePath of [ + `${symlinkPath}/file.txt`, + `${targetDir}/file.txt`, + ]) { + const result = await runSandboxedCommandWithDenyPaths( + `echo tampered > ${writePath}`, + [denyPath], + ) + expect(result.success).toBe(false) + } + expect(readFileSync(join(targetDir, 'file.txt'), 'utf8')).toBe( + 'content', + ) + } finally { + rmSync(symlinkPath, { force: true }) + rmSync(targetDir, { recursive: true, force: true }) + } + }) + }, + ) + }, +) + +describe('macGetMandatoryDenyEntries - Unit Tests', () => { + it('includes .git/config in deny patterns when allowGitConfig is false', () => { + const patterns = macGetMandatoryDenyEntries(false).map(e => e.path) + + // Should include .git/config pattern + const hasGitConfigPattern = patterns.some( + p => p.includes('.git/config') || p.endsWith('.git/config'), + ) + expect(hasGitConfigPattern).toBe(true) + }) + + it('excludes .git/config from deny patterns when allowGitConfig is true', () => { + const patterns = macGetMandatoryDenyEntries(true).map(e => e.path) + + // Should NOT include .git/config pattern + const hasGitConfigPattern = patterns.some( + p => p.includes('.git/config') || p.endsWith('.git/config'), + ) + expect(hasGitConfigPattern).toBe(false) + }) + + it('always includes .git/hooks in deny patterns regardless of allowGitConfig', () => { + const patternsWithoutGitConfig = macGetMandatoryDenyEntries(false).map( + e => e.path, + ) + const patternsWithGitConfig = macGetMandatoryDenyEntries(true).map( + e => e.path, + ) + + // Both should include .git/hooks pattern + const hasHooksPatternFalse = patternsWithoutGitConfig.some(p => + p.includes('.git/hooks'), + ) + const hasHooksPatternTrue = patternsWithGitConfig.some(p => + p.includes('.git/hooks'), + ) + + expect(hasHooksPatternFalse).toBe(true) + expect(hasHooksPatternTrue).toBe(true) + }) + + it.if(!isWindows)( + 'carries a symlinked entry as the link, the target and the git directory', + () => { + // A Seatbelt filter matches where the write lands, so the entry's own + // path covers the link and the target needs an entry of its own; the + // git directory whole is what keeps the link from being replaced, + // which on macOS the unlink re-deny of a literal entry does as well. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-deny-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const shared = join(dir, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(shared) + // Not the git directory whole: a Seatbelt filter matches the link's + // own path for a rename or an unlink, so the entry covers it, and + // git writes in the repository go on working. + expect(paths).not.toContain(gitDir) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)('names every hop of a chain, and denies none whole', () => { + // A Seatbelt filter matches the name a rename or an unlink uses, so each + // hop's own path is the whole handle on it there: no directory is denied + // whole for one, and git goes on working in the repository. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-chain-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const landing = join(dir, '.githooks') + mkdirSync(landing, { recursive: true }) + const hop = join(dir, 'hooks-link') + symlinkSync('.githooks', hop) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(hop) + expect(paths).toContain(landing) + // Neither the git directory nor the directory holding the hop: holding + // a link by the directory around it is the other backend's answer. + expect(paths).not.toContain(gitDir) + expect(paths).not.toContain(dir) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.if(!isWindows)("names every hop of a pointer's own path", () => { + // The same answer where the chain is the VALUE of a `.git` pointer file + // rather than an entry of a git directory: each link the path goes + // through is a name of its own, and nothing is denied whole for it. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-pointer-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const gitDir = join(dir, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(join(dir, 'real'), hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(checkout, '.git')) + expect(paths).toContain(hop) + expect(paths).toContain(join(hop, 'gd', 'hooks')) + expect(paths).not.toContain(checkout) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.if(!isWindows)( + "names a pointer's git directory by the landing as well as through the link", + () => { + // The link spelling holds the link's own name and nothing under it, + // because Seatbelt compares its filters against the path an operation + // resolved to; the landing holds every write that reaches the git + // directory, by either spelling. Both, or half the chain is open. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-landing-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const gitDir = join(dir, 'real', 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(join(dir, 'real'), hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + // Through the link: the hop's own name, and the git directory's + // files as the pointer spells them. + expect(paths).toContain(hop) + expect(paths).toContain(join(hop, 'gd', 'hooks')) + expect(paths).toContain(join(hop, 'gd', 'config')) + // And the same files where the kernel puts them, absent ones + // included: `commondir` and `config.worktree` are not there, and + // creating one is what the deny blocks. + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(join(gitDir, 'config')) + expect(paths).toContain(join(gitDir, 'commondir')) + expect(paths).toContain(join(gitDir, 'config.worktree')) + // One entry per spelling and no more. + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)('never denies what a chain HOP leads to', () => { + // A hop's own name is the whole handle on it: what it leads to is a + // directory nothing asked to deny - the `/private/var` a `/var/folders` + // pointer walks through - and denying that would take the checkout and + // everything beside it read-only. What lies past the hop is held by the + // git directory's own deny paths, which do carry both spellings. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-hop-'))) + const saved = process.cwd() + try { + const checkout = join(dir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const shared = join(dir, 'shared') + const gitDir = join(shared, 'gd') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const hop = join(checkout, 'linkdir') + symlinkSync(shared, hop) + writeFileSync(join(checkout, '.git'), 'gitdir: linkdir/gd\n') + process.chdir(checkout) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(hop) + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).not.toContain(shared) + expect(paths).not.toContain(dir) + expect(paths).not.toContain(checkout) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.if(!isWindows)('names every link a looping entry went through', () => { + // A filter matches the name an unlink or a rename uses, so each link of + // the loop is held by its own name here and nothing else is needed: the + // command that would make `.git/hooks` reach a directory has to remove + // one of them first. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-loop-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const links = join(dir, 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + symlinkSync('../links/a', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(links, 'a')) + expect(paths).toContain(join(links, 'b')) + // What a hop leads to is never denied, and a loop leads nowhere to + // deny in the first place: the directory holding the links stays out. + expect(paths).not.toContain(links) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.if(!isWindows)( + 'names a submodule reached through a symlinked modules entry both ways', + () => { + // A symlinked ENTRY of `.git/modules` puts the whole submodule git + // directory behind a link, so every file denied inside it is spelled + // through that link. The entry's own name is what an unlink of it + // uses; where it leads is what a hook written into that submodule + // matches. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-module-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const submodule = join(dir, 'elsewhere') + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main') + const entry = join(gitDir, 'modules', 'lib') + symlinkSync(submodule, entry) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(entry, 'hooks')) + expect(paths).toContain(join(entry, 'config')) + expect(paths).toContain(join(submodule, 'hooks')) + expect(paths).toContain(join(submodule, 'config')) + expect(paths).toContain(join(submodule, 'config.worktree')) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)( + 'names the hops of a symlinked entry once, landing and all', + () => { + // The chain of a symlinked entry is walked from a resolved directory, + // so each hop and the landing are already the paths the kernel reaches: + // nothing here has a second spelling, and nothing is named twice. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-entry-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + const landing = join(dir, '.githooks') + mkdirSync(landing, { recursive: true }) + const hop = join(dir, 'hooks-link') + symlinkSync('.githooks', hop) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(pathEntry => !pathEntry.glob) + .map(pathEntry => pathEntry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths).toContain(hop) + expect(paths).toContain(landing) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it.if(!isWindows)( + 'leaves an ordinary repository named exactly once per path', + () => { + // Nothing on the way is a symlink, so the walk lands where the path was + // written and the second spelling is the first: no repository without + // one pays a single extra rule for this. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'mac-git-plain-'))) + const saved = process.cwd() + try { + const gitDir = join(dir, '.git') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + process.chdir(dir) + + const paths = macGetMandatoryDenyEntries(false) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(paths).toContain(join(gitDir, 'hooks')) + expect(paths.length).toBe(new Set(paths).size) + } finally { + process.chdir(saved) + rmSync(dir, { recursive: true, force: true }) + } + }, + ) + + it('defaults to blocking .git/config when no argument provided', () => { + const patterns = macGetMandatoryDenyEntries().map(e => e.path) + + const hasGitConfigPattern = patterns.some( + p => p.includes('.git/config') || p.endsWith('.git/config'), + ) + expect(hasGitConfigPattern).toBe(true) + }) +}) +// Read by the Linux and macOS backends alone. +describe.if(!isWindows)('Git metadata deny paths - Unit Tests', () => { + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + // Real, so a temporary directory that is itself reached through a + // symlink (macOS /var) does not make every path here two paths. + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-deny-paths-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A directory git would accept as a git directory. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + return gitDir + } + + /** A checkout whose `.git` file holds exactly `contents`. */ + function writePointer(checkout: string, contents: string | Buffer): string { + mkdirSync(join(dir, checkout), { recursive: true }) + const pointer = join(dir, checkout, '.git') + writeFileSync(pointer, contents) + return pointer + } + + function makePointer(checkout: string, target: string): string { + return writePointer(checkout, `gitdir: ${target}\n`) + } + + it('denies commondir in every git directory, and config.worktree with config', () => { + expect(gitDirDenyPaths('/repo/.git', false)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + '/repo/.git/config', + '/repo/.git/config.worktree', + ]) + expect(gitDirDenyPaths('/repo/.git', true)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + ]) + }) + + it('denies a git directory whole where an entry is a symlink, and what the link leads to', () => { + // The deny of the entry lands where the link points - the Linux bind + // resolves the path, a Seatbelt filter matches where the write goes - + // so the link's own path carries nothing, and it sits in a directory + // the sandbox can write. The git directory whole is the handle on it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const shared = join(dir, 'repo', 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'hooks'), + shared, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + // What a read-only bind of the git directory would not cover, and so + // what a degrade of it has to keep. + expect(denies.escapingDenyPaths).toEqual([shared]) + // What holds the link, for the backend whose denies resolve. + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }) + + it('leaves an ordinary git directory exactly as it was', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + writeFileSync(join(gitDir, 'config'), '[core]\n') + + expect(gitDirDenies(gitDir, false)).toEqual({ + denyPaths: gitDirDenyPaths(gitDir, false), + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it('keeps a link inside the git directory out of the escaping set', () => { + // hooks -> hooks.real, both under the git directory: the bind that + // denies the directory whole covers the target as well, so a degrade + // has nothing to keep. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'hooks.real'), { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks.real'), join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + }) + + it('denies where a DANGLING entry lands, so the target cannot be created', () => { + // The link is there and its target is not: a command can create the + // target and the host's git runs what it finds there. Denying the + // landing is what blocks creating it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'not-there-yet') + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(landing, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(landing) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([landing]) + }) + + it('follows a chain of dangling links to where a write through it would land, and holds the hop', () => { + // The landing's deny blocks creating it and the entry's deny holds the + // entry; the link in between is held by neither, and a command that + // points it elsewhere moves where the chain lands without touching + // either end. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', 'end') + const middle = join(dir, 'repo', 'middle') + symlinkSync(landing, middle) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(middle, join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(landing) + expect(denies.denyPaths).toContain(middle) + expect(denies.escapingDenyPaths).toContain(middle) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: middle, + holder: join(dir, 'repo'), + }, + ]) + }) + + it('carries every hop of a two-link chain', () => { + // hooks -> ../hooks-link -> .githooks: the entry and the landing are + // denied, and the link between them is a name of its own. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const hop = join(dir, 'repo', 'hooks-link') + mkdirSync(landing, { recursive: true }) + symlinkSync('.githooks', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../hooks-link', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'hooks'), + landing, + hop, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + // No bind of the git directory covers either, so a degrade keeps both. + expect(denies.escapingDenyPaths).toEqual([landing, hop]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }) + + it('names a symlinked directory component on the way as a hop', () => { + // hooks -> ../shared/hooks with shared -> shared-real: the hop is a + // directory in the middle of the path, not a second link at the end, + // and retargeting it moves the chain just the same. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const real = join(dir, 'repo', 'shared-real') + mkdirSync(join(real, 'hooks'), { recursive: true }) + const hop = join(dir, 'repo', 'shared') + symlinkSync('shared-real', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../shared/hooks', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(real, 'hooks')) + expect(denies.denyPaths).toContain(hop) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }) + + it('leaves a hop inside the git directory to the whole-directory deny', () => { + // hooks -> hooks-link -> ../.githooks, with the hop under the git + // directory: the bind that denies the directory whole already holds + // it, so it needs no directory of its own and a degrade may drop it. + // Its own path is still named, for the backend that has no such bind. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const hop = join(gitDir, 'hooks-link') + mkdirSync(landing, { recursive: true }) + symlinkSync('../.githooks', hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('hooks-link', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(hop) + expect(denies.escapingDenyPaths).toEqual([landing]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: undefined, + }, + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + }) + + it.if(process.getuid?.() !== 0)( + 'holds the hops of a chain it could read where a later one cannot be', + () => { + // The chain runs into a directory this process cannot search, so what + // is behind it is unknown and the git directory is denied whole for + // it. The hop it DID read is held as a resolved chain's is. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const locked = join(dir, 'repo', 'locked') + const hop = join(dir, 'repo', 'mid') + mkdirSync(locked, { recursive: true }) + symlinkSync(join(locked, 'target'), hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../mid', join(gitDir, 'hooks')) + chmodSync(locked, 0o000) + try { + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(gitDir, 'hooks')) + expect(denies.denyPaths).toContain(hop) + expect(denies.escapingDenyPaths).toContain(hop) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('denies the git directory whole for an entry that is a symlink loop, and nothing behind it', () => { + // A loop reaches nothing: there is nothing to deny at the other end, + // and nothing a command can create through it. Replacing the link is + // what the whole-directory deny is for. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(gitDir, 'hooks'), join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + // A link onto itself is the one link of its own chain, and the whole + // deny of the directory holding it covers that: no hop is left over. + expect(denies.chainHops).toEqual([]) + }) + + it('holds the links a looping entry goes through, which are what makes it loop', () => { + // hooks -> ../links/a, a -> b, b -> a. The entry reaches nothing while + // that stands, and the whole-directory deny holds the entry, but the + // two links are ordinary work-tree names: a command that puts a + // directory at either one makes the chain reach it, and the host's git + // runs what is inside. Every link the walk visited before it gave up is + // therefore a hop, named once however many times the loop met it. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const links = join(dir, 'repo', 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('b', join(links, 'a')) + symlinkSync('a', join(links, 'b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../links/a', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(links, 'a')) + expect(denies.denyPaths).toContain(join(links, 'b')) + // Neither is under the git directory, so a degrade to one bind of it + // must not drop them. + expect(denies.escapingDenyPaths).toEqual([ + join(links, 'a'), + join(links, 'b'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'a'), + holder: links, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(links, 'b'), + holder: links, + }, + ]) + }) + + it('holds the hops before a loop it runs into', () => { + // Two ordinary hops and then the loop: the chain gives up in the middle, + // and the links it walked to get there are held like any others. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const links = join(dir, 'repo', 'links') + mkdirSync(links, { recursive: true }) + symlinkSync('second', join(links, 'first')) + symlinkSync('loopA', join(links, 'second')) + symlinkSync('loopB', join(links, 'loopA')) + symlinkSync('loopA', join(links, 'loopB')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../links/first', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.chainHops.map(hop => hop.link)).toEqual([ + join(links, 'first'), + join(links, 'second'), + join(links, 'loopA'), + join(links, 'loopB'), + ]) + expect(denies.chainHops.every(hop => hop.chain === 'unresolvable')).toBe( + true, + ) + }) + + it('leaves a loop confined to the git directory to the whole-directory deny', () => { + // Both links live under the git directory, which is denied whole for + // the entry anyway: each hop names no holder of its own, and nothing a + // command can write is left over. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync('inner-b', join(gitDir, 'inner-a')) + symlinkSync('inner-a', join(gitDir, 'inner-b')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('inner-a', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual([]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-a'), + holder: undefined, + }, + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: join(gitDir, 'inner-b'), + holder: undefined, + }, + ]) + }) + + it('holds a modules directory that is a symlink', () => { + // `modules` is no deny path of its own - denying it whole would take + // every submodule git directory under it read-only - but removing the + // link and putting a directory in its place aims every submodule this + // repository has at hooks nothing denied. So it is held the way a hop is: + // its own name for the backend that matches a rename, and this git + // directory denied whole for the one whose denies resolve. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const elsewhere = join(dir, 'repo', 'nm') + mkdirSync(elsewhere, { recursive: true }) + symlinkSync('../nm', join(gitDir, 'modules')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toContain(join(gitDir, 'modules')) + // Never what it leads to: that is a tree of submodule git directories, + // each with precise denies of its own. + expect(denies.denyPaths).not.toContain(elsewhere) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'modules'), + link: join(gitDir, 'modules'), + holder: undefined, + }, + ]) + }) + + it('leaves an ordinary modules directory out of the denies entirely', () => { + // The other half: a real `modules` is where every submodule git + // directory lives, and each of those is denied precisely. Naming it + // would take them all read-only. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + mkdirSync(join(gitDir, 'modules', 'lib'), { recursive: true }) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + expect(denies.linkedEntryDirs).toEqual([]) + expect(denies.chainHops).toEqual([]) + }) + + it("holds a submodule's own modules directory, and the chain past it", () => { + // The shape the whole of this exists for: a submodule git directory + // whose own `modules` leads out of the repository through a link. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) + const links = join(dir, 'repo', 'links') + mkdirSync(join(dir, 'repo', 'nm', 'bar'), { recursive: true }) + mkdirSync(links, { recursive: true }) + symlinkSync('../nm', join(links, 'hop')) + symlinkSync('../../../links/hop', join(submodule, 'modules')) + + const denies = gitDirDenies(submodule, false) + + expect(denies.denyPaths).toContain(join(submodule, 'modules')) + expect(denies.linkedEntryDirs).toEqual([submodule]) + // The link in the middle is a hop like any other, held by the + // directory it sits in. + expect(denies.chainHops.map(hop => hop.link)).toEqual([ + join(submodule, 'modules'), + join(links, 'hop'), + ]) + expect(denies.chainHops.map(hop => hop.holder)).toEqual([undefined, links]) + }) + + it('holds the hops of an entry whose chain names something too long to exist', () => { + // ENAMETOOLONG, not a loop: the chain reaches nothing all the same, and + // the link it went through is a name a command can point elsewhere. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const hop = join(dir, 'repo', 'hop') + symlinkSync(`../${'n'.repeat(300)}`, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../hop', join(gitDir, 'hooks')) + + const denies = gitDirDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.chainHops).toEqual([ + { + kind: 'entry', + chain: 'unresolvable', + source: join(gitDir, 'hooks'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + }) + + it.if(process.getuid?.() !== 0)( + 'takes an entry it cannot look at for one that needs both denies', + () => { + // lstat refused: whether it is a link is unknown, so the entry keeps + // the deny it has (and keeps it through a degrade, in case it is one) + // and the git directory is denied whole as a link would need. `modules` + // and `worktrees` are in the same position, and named for the backend + // that holds a link by its own name. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + chmodSync(gitDir, 0o000) + try { + const denies = gitDirDenies(gitDir, false) + + expect(denies.denyPaths).toEqual([ + ...gitDirDenyPaths(gitDir, false), + join(gitDir, 'modules'), + join(gitDir, 'worktrees'), + ]) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(denies.escapingDenyPaths).toEqual(gitDirDenyPaths(gitDir, false)) + } finally { + chmodSync(gitDir, 0o755) + } + }, + ) + + it('denies whole a git directory a pointer leads to, where an entry of it is a symlink', () => { + // A pointer's target is a git directory like any other: the link + // inside it is replaceable where nothing holds the directory around + // it, and the backend whose denies resolve needs that held. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const shared = join(dir, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const pointer = makePointer('checkout', '../gitdir') + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toContain(shared) + expect(denies.linkedEntryDirs).toEqual([gitDir]) + // The list a backend that needs none of that emits is what it was. + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) + }) + + it('denies the directory holding a symlinked modules entry, and where a dangling one lands', () => { + // The walk follows the link and denies the git directory it reaches; + // the link itself stays replaceable in a writable `modules`, and a + // pointer that names it would then find whatever was put in its place. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, join(modules, 'lib')) + const landing = join(dir, 'repo', 'not-there-yet') + symlinkSync(landing, join(modules, 'gone')) + + const scan = submoduleGitDirs(modules) + + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.gitDirs).toEqual([join(modules, 'lib'), landing].sort()) + const tree = gitDirTreeDenies(gitDir, false) + expect(tree.linkedEntryDirs).toEqual([modules]) + expect(gitDirTreeDenyPaths(tree)).toContain(join(landing, 'hooks')) + }) + + it('holds the hops between a symlinked modules entry and the git directory it reaches', () => { + // modules/lib -> ../../mid -> elsewhere: the deny of `modules` holds + // the entry and the git directory it reaches keeps its own denies, but + // the link in between sits in the work tree and moves the whole chain. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const modules = join(gitDir, 'modules') + const outside = makeGitDir(join(dir, 'elsewhere')) + const hop = join(dir, 'repo', 'mid') + mkdirSync(modules, { recursive: true }) + symlinkSync(outside, hop) + symlinkSync(hop, join(modules, 'lib')) + + const scan = submoduleGitDirs(modules) + + expect(scan.linkedEntryDirs).toEqual([modules]) + expect(scan.chainHops).toEqual([ + // The entry itself, for the backend that holds a link by its name. + { + kind: 'entry', + chain: 'resolved', + source: join(modules, 'lib'), + link: join(modules, 'lib'), + holder: undefined, + }, + { + kind: 'entry', + chain: 'resolved', + source: join(modules, 'lib'), + link: hop, + holder: join(dir, 'repo'), + }, + ]) + // The hop's own path has no other list to sit in, so the flat form is + // where a backend reads it. + const tree = gitDirTreeDenies(gitDir, false) + expect(gitDirTreeDenyPaths(tree)).toContain(hop) + expect(tree.chainHops).toEqual(scan.chainHops) + }) + + it('gives the files git reads a placeholder that is no redirect', () => { + // `.` is the git directory itself, which is where git looks when there + // is no commondir, and no config.worktree reads as an empty one does. + expect(gitRedirectPlaceholder('/repo/.git/commondir')).toBe('.\n') + expect(gitRedirectPlaceholder('/repo/.git/config.worktree')).toBe('') + expect(gitRedirectPlaceholder('/repo/.git/config')).toBeUndefined() + expect(gitRedirectPlaceholder('/repo/.git/hooks')).toBeUndefined() + }) + + it('follows a pointer to the git directory it names', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it("follows a linked worktree's commondir as well", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('leaves a pointer that names an ordinary directory alone', () => { + // `gitdir: ..` from app/tools would otherwise deny app/config and + // app/hooks, which are an ordinary tree and not a git directory. + mkdirSync(join(dir, 'app', 'config'), { recursive: true }) + const pointer = makePointer(join('app', 'tools'), '..') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + }) + + it('blocks the git directory a dangling pointer names from being filled in', () => { + const pointer = makePointer('checkout', '../not-created-yet') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'not-created-yet'), false), + ]) + }) + + it('follows a pointer padded with the newline bytes git strips', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${'\n'.repeat(9000)}`, + ) + + // git strips them from the end of the whole file, so this is a pointer + // it follows; reading less of the file than git does would leave the + // target's hooks and config out of the deny list. + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('counts trailing spaces as part of the path, as git does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const padded = `${gitDir}${' '.repeat(200)}` + const pointer = writePointer('checkout', `gitdir: ${padded}\n`) + + // Nothing is trimmed but the newline, so the pointer names a directory + // that does not exist — denied against being created, not confused for + // the real git directory next to it. + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(padded, false), + ]) + }) + + it('follows a pointer to a path no file can occupy nowhere', () => { + // Past the filesystem's name limit: git's own stat of it fails, and a + // sandboxed command cannot create a git directory there either, so + // there is nothing below the pointer to deny. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${' '.repeat(9000)}\n`, + ) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + }) + + it('follows a pointer of the largest size git accepts, and no larger', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const maxSize = 1024 * 1024 + const head = `gitdir: ${gitDir}` + + const atBound = writePointer( + 'checkout', + head + '\n'.repeat(maxSize - head.length), + ) + expect(statSync(atBound).size).toBe(maxSize) + expect(gitFileDenies(atBound, false).denyPaths).toEqual([ + atBound, + ...gitDirDenyPaths(gitDir, false), + ]) + + // One byte more is a .git file git refuses outright, so it leads nowhere. + const pastBound = writePointer( + 'past-bound', + head + '\n'.repeat(maxSize + 1 - head.length), + ) + expect(statSync(pastBound).size).toBe(maxSize + 1) + expect(gitFileDenies(pastBound, false).denyPaths).toEqual([pastBound]) + }) + + it('takes a path that spans lines whole, as git does', () => { + // Only the newline bytes at the END of the file are stripped, so one in + // the middle is part of the directory name. + const gitDir = makeGitDir(join(dir, 'two\nlines')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\n`) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('stops the path at the first NUL byte, as a C string does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}\0/../elsewhere\n`, + ) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('strips a CRLF line ending', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\r\n`) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('refuses to sandbox at all on a path that is not valid UTF-8', () => { + // Decoded, those bytes name a different directory than git opens, so + // there is no deny list to be had — and sandboxing without one would + // leave the hooks git runs writable. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + Buffer.concat([ + Buffer.from(`gitdir: ${gitDir}`), + Buffer.from([0xff]), + Buffer.from('\n'), + ]), + ) + + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) + }) + + it("follows a commondir padded past the pointer's own bound", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + `../..${'\n'.repeat(9000)}`, + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('does not trim a commondir, which git reads byte for byte', () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + // A leading space makes this a relative path beginning with a space, + // which is where git looks too — not the main git directory. + writeFileSync(join(worktreeGitDir, 'commondir'), ` ${main}\n`) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + const denyPaths = gitFileDenies(pointer, false).denyPaths + expect(denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(join(worktreeGitDir, ` ${main}`), false), + ]) + expect(denyPaths).not.toContain(join(main, 'hooks')) + }) + + it('denies where a .. after a symlink lands, the lexical path and the link between', () => { + // checkout/hop is real/side, so the kernel reads hop/../evil as + // real/evil while folding it on paper gives checkout/evil. git opens + // the first; denying only the second leaves its hooks writable. The + // link itself is a third thing: retargeting it moves where git lands + // without touching the pointer file or either directory. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + const hop = join(dir, 'checkout', 'hop') + symlinkSync(join(dir, 'real', 'side'), hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + expect(gitFileDenies(pointer, false).denyPaths).toEqual(denies.denyPaths) + }) + + it('denies both when both are git directories', () => { + const lexical = makeGitDir(join(dir, 'checkout', 'evil')) + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + join(dir, 'checkout', 'hop'), + ...gitDirDenyPaths(lexical, false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it('walks a chain of symlinks as the kernel does', () => { + // first is second, second is an absolute path to real/side/deeper. Both + // links are on the way to what git opens, and each is held. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side', 'deeper'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: first/../../evil\n') + const first = join(dir, 'checkout', 'first') + const second = join(dir, 'checkout', 'second') + symlinkSync('second', first) + symlinkSync(join(dir, 'real', 'side', 'deeper'), second) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + first, + second, + ...gitDirDenyPaths(join(dir, 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + // In the order the walk met them, each named where the kernel reached it. + expect(denies.chainHops.map(hop => hop.link)).toEqual([first, second]) + expect(denies.chainHops.map(hop => hop.holder)).toEqual([ + join(dir, 'checkout'), + join(dir, 'checkout'), + ]) + }) + + it('takes the rest of a path as written past what is not there', () => { + // The kernel cannot traverse a missing directory, so nothing after it + // redirects the path: gone/x is denied against being created, and so + // is the lexical checkout/x. The link it did read is held all the same. + const pointer = writePointer('checkout', 'gitdir: hop/../x\n') + const hop = join(dir, 'checkout', 'hop') + symlinkSync('gone/deeper', hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), + ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) + + it('denies what it can reach when symlinks loop', () => { + const pointer = writePointer('checkout', 'gitdir: loopA/../evil\n') + symlinkSync(join(dir, 'checkout', 'loopB'), join(dir, 'checkout', 'loopA')) + symlinkSync(join(dir, 'checkout', 'loopA'), join(dir, 'checkout', 'loopB')) + + // A value the kernel cannot walk to an end names no git directory: git + // opens nothing through it, and the lexical fold is not a fallback git + // takes. The links it walked are the whole of what a command can move, + // and the checkout - which a fail-closed deny of the deepest directory + // still reachable would take whole, over a file a sandboxed command may + // create - stays writable. + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + join(dir, 'checkout', 'loopA'), + join(dir, 'checkout', 'loopB'), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'unresolvable', + source: pointer, + link: join(dir, 'checkout', 'loopA'), + holder: join(dir, 'checkout'), + }, + { + kind: 'pointer', + chain: 'unresolvable', + source: pointer, + link: join(dir, 'checkout', 'loopB'), + holder: join(dir, 'checkout'), + }, + ]) + }) + + it('never widens a looping pointer value to a write root', () => { + // The shape a sandboxed command can plant in one write: a .git file + // whose value runs into a loop in the checkout itself. Nothing here + // names the checkout or any directory above it, so the commands after + // it still write the project; the pointer file and the loop's links + // are what they cannot touch. + const pointer = writePointer('checkout', 'gitdir: loop/gd\n') + symlinkSync('loop2', join(dir, 'checkout', 'loop')) + symlinkSync('loop', join(dir, 'checkout', 'loop2')) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + join(dir, 'checkout', 'loop'), + join(dir, 'checkout', 'loop2'), + ]) + expect(denies.denyPaths).not.toContain(join(dir, 'checkout')) + expect(denies.denyPaths).not.toContain(dir) + expect(denies.linkedEntryDirs).toEqual([]) + }) + + it('resolves a commondir the same way', () => { + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + const physical = makeGitDir(join(dir, 'real', 'common')) + const hop = join(worktreeGitDir, 'hop') + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + symlinkSync(join(dir, 'real', 'side'), hop) + writeFileSync(join(worktreeGitDir, 'commondir'), 'hop/../common\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + hop, + ...gitDirDenyPaths(join(worktreeGitDir, 'common'), false), + ...gitDirDenyPaths(physical, false), + ]) + // The file that named the path is the commondir, not the pointer: it is + // what a reader has to look at to see where the chain starts. + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: join(worktreeGitDir, 'commondir'), + link: hop, + holder: worktreeGitDir, + }, + ]) + }) + + it('resolves a .. against the directory the pointer file is really in', () => { + // The checkout is reached through a symlink, so `..` pops the + // directory the kernel is in and not the one the path was spelled + // from — nested/target, not dir/target. + const physical = makeGitDir(join(dir, 'nested', 'target')) + mkdirSync(join(dir, 'nested', 'checkout'), { recursive: true }) + symlinkSync(join(dir, 'nested', 'checkout'), join(dir, 'link')) + const pointer = join(dir, 'link', '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'target'), false), + ...gitDirDenyPaths(physical, false), + ]) + // The link ABOVE the pointer file is where this checkout lives, not a + // hop of the chain its value walks: the walk starts where the kernel + // already is, so nothing here is held for it. + expect(denies.chainHops).toEqual([]) + }) + + it('holds a symlinked directory component of a value with no .. in it', () => { + // `gitdir: linkdir/gd` reaches the git directory through `linkdir`, + // and git opens what the link leads to. Nothing else in the deny list + // names the link, so without this a command retargets it and every + // later git run in this checkout reads a git directory of its choosing. + makeGitDir(join(dir, 'realdir', 'gd')) + const pointer = writePointer('checkout', 'gitdir: linkdir/gd\n') + const hop = join(dir, 'checkout', 'linkdir') + symlinkSync(join(dir, 'realdir'), hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(hop, 'gd'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) + + it('holds a link of a value that leads nowhere yet', () => { + // `mid` is there and what it names is not: the deny goes where the path + // lands, so a command cannot fill that git directory in, and the link is + // held so it cannot be aimed at one that already exists either. + const pointer = writePointer('checkout', 'gitdir: mid/gitdir\n') + const hop = join(dir, 'checkout', 'mid') + symlinkSync('gone', hop) + + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([ + pointer, + hop, + ...gitDirDenyPaths(join(hop, 'gitdir'), false), + ]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + }) + + it.if(process.getuid?.() !== 0)( + 'holds the link it read where what lies past it cannot be', + () => { + // The chain runs into a directory this process cannot search, so the + // git directory is failed closed on the deepest directory it can reach + // TOWARDS it, behind the link and not around it, and the link it DID read + // is held as any other is. + const locked = join(dir, 'checkout', 'locked') + mkdirSync(join(locked, 'inner'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: mid/gitdir\n') + const hop = join(dir, 'checkout', 'mid') + symlinkSync(join(locked, 'inner'), hop) + chmodSync(locked, 0o000) + try { + const denies = gitFileDenies(pointer, false) + + expect(denies.denyPaths).toEqual([pointer, hop, locked]) + expect(denies.chainHops).toEqual([ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: hop, + holder: join(dir, 'checkout'), + }, + ]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('refuses to sandbox at all on a commondir past the size it reads', () => { + // git reads commondir whole and with no bound of its own, so one this + // large still names the git directory whose hooks a commit here runs. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) + }) + + /** + * A whole-directory deny is what a backend falls back on for a tree it + * cannot enumerate or a link it has no other handle on, and every producer + * of one reads paths a sandboxed command can write. One that lands on the + * working directory or a write root takes the project read-only for every + * command after the one that arranged it, and nothing inside the sandbox + * can undo it: a denial of service planted by the code the sandbox + * contains. None is emitted, whichever producer asked. + */ + describe.if(isLinux)('a deny that would take the work tree whole', () => { + /** A directory git accepts and whose `hooks` is a link, which is what + * makes whatever holds it a git directory denied WHOLE. */ + function makeLinkedGitDir(gitDir: string): string { + mkdirSync(gitDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + symlinkSync('nowhere', join(gitDir, 'hooks')) + return gitDir + } + + /** `linuxGetCwdMandatoryDenyPaths` from `checkout`. */ + function denyPathsIn(checkout: string, allowWritePaths: string[]) { + process.chdir(checkout) + return linuxGetCwdMandatoryDenyPaths(false, allowWritePaths) + } + + it('never denies the working directory a pointer of its own names', () => { + // One write plants it: `.git` naming the checkout, whose `hooks` is a + // link. The checkout is then a git directory with a symlinked entry, + // which is denied whole - and that is the project. + const checkout = makeLinkedGitDir(join(dir, 'repo')) + writeFileSync(join(checkout, '.git'), 'gitdir: .\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).not.toContain(checkout) + // What the deny was holding keeps its own two ends. + expect(denyPaths).toContain(join(checkout, 'hooks')) + }) + + it('never denies an ancestor of the working directory', () => { + const parent = makeLinkedGitDir(join(dir, 'outer')) + const checkout = join(parent, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, '.git'), 'gitdir: ..\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).not.toContain(parent) + }) + + it('never denies a second write root', () => { + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const other = makeLinkedGitDir(join(dir, 'other')) + writeFileSync(join(checkout, '.git'), `gitdir: ${other}\n`) + + const denyPaths = denyPathsIn(checkout, [checkout, other]) + + expect(denyPaths).not.toContain(other) + }) + + it('never denies a directory that holds a write root', () => { + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const container = makeLinkedGitDir(join(dir, 'container')) + const secondRoot = join(container, 'inner') + mkdirSync(secondRoot, { recursive: true }) + writeFileSync(join(checkout, '.git'), `gitdir: ${container}\n`) + + const denyPaths = denyPathsIn(checkout, [checkout, secondRoot]) + + expect(denyPaths).not.toContain(container) + }) + + it('still denies an ordinary directory whole', () => { + // The guard is about the work tree and nothing else: a git directory + // beside it keeps the deny that holds its symlinked entry. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const gitDir = makeLinkedGitDir(join(checkout, 'gitdir-real')) + writeFileSync(join(checkout, '.git'), 'gitdir: gitdir-real\n') + + const denyPaths = denyPathsIn(checkout, [checkout]) + + expect(denyPaths).toContain(gitDir) + }) + + it('never denies the working directory a modules entry is linked to', () => { + // The same end state through the `.git/modules` walk, and with no + // pointer file: the entry is followed, the checkout has an entry named + // `hooks` so the walk takes it for a git directory, and its `hooks` is + // a link - so the entry's own git directory, which resolves to the + // checkout, is denied whole. + const checkout = join(dir, 'repo') + const gitDir = join(checkout, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + symlinkSync('nowhere', join(checkout, 'hooks')) + symlinkSync('../..', join(gitDir, 'modules', 'x')) + + const denyPaths = denyPathsIn(checkout, [checkout]) + + // By where each lands, not by how it is spelled: a bind resolves its + // destination, so `.git/modules/x` IS the checkout here. + expect( + denyPaths.map(denyPath => { + try { + return realpathSync(denyPath) + } catch { + return denyPath + } + }), + ).not.toContain(checkout) + }) + }) + + it.if(isLinux)( + 'hands the monitor plain deny paths for a repository it cannot read', + () => { + // The violation monitor starts once for the session, so one + // repository whose metadata cannot be resolved must not take the whole + // start-up with it. Every wrap in it is still refused. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + const checkout = join(dir, 'wt-checkout') + process.chdir(checkout) + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) + + const monitored = linuxGetMonitorCwdDenyPaths(false) + + expect(monitored).toContain(pointer) + expect(monitored).toContain(join(checkout, '.bashrc')) + // A pointer file is itself the deny; the hooks and config of a git + // directory that is not there are not denies the wrapper makes. + expect(monitored).not.toContain(join(pointer, 'hooks')) + }, + ) + + it.if(isLinux)('gives the monitor no git denies without a .git', () => { + // A mount at an absent .git would block `git init`, so the wrapper makes + // none there and the monitor must not judge writes against four it will + // never see refused. + const checkout = join(dir, 'no-repo') + mkdirSync(checkout, { recursive: true }) + process.chdir(checkout) + const monitored = linuxGetMonitorCwdDenyPaths(false) + + expect(monitored).toContain(join(checkout, '.bashrc')) + expect(monitored).not.toContain(join(checkout, '.git', 'hooks')) + expect(monitored).not.toContain(join(checkout, '.git', 'config')) + }) + + it.if(process.getuid?.() !== 0)( + 'refuses to sandbox at all on a commondir it cannot read', + () => { + // Returning the denies gathered so far would leave the main + // repository's hooks — the ones this worktree's commits run — writable + // for the next command, which is what made this worth planting. + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) + const commondir = join(worktreeGitDir, 'commondir') + writeFileSync(commondir, '../..\n') + chmodSync(commondir, 0o000) + const pointer = makePointer('wt-checkout', worktreeGitDir) + try { + expect(() => gitFileDenies(pointer, false).denyPaths).toThrow( + GitMetadataError, + ) + } finally { + chmodSync(commondir, 0o644) + } + }, + ) + + it.if(process.getuid?.() !== 0)( + 'denies a git directory it cannot read whole, rather than refusing', + () => { + // Refusing every command in the checkout would be a brick one chmod + // plants; the directory is denied whole instead, and nothing under it + // stays writable. The repository that keeps it is as writable as it was, + // and its `commondir` cannot be read to name it (0o000), or was not since + // the directory could not be listed (0o311, through which git still opens + // it): where the directory is names it. + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(main, 'worktrees', 'wt')) + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + try { + for (const mode of [0o000, 0o311]) { + chmodSync(worktreeGitDir, mode) + expect(gitFileDenies(pointer, false)).toMatchObject({ + denyPaths: [ + pointer, + worktreeGitDir, + ...gitDirDenyPaths(main, false), + ], + gitDirs: [main], + }) + } + } finally { + chmodSync(worktreeGitDir, 0o755) + } + // By where it really is, where the value ends in a link: the name of a + // link holds the link, and on Linux is no deny at all. + const behind = makeGitDir(join(dir, 'repos', 'behind')) + mkdirSync(join(dir, 'links')) + symlinkSync(behind, join(dir, 'links', 'gd')) + const linked = makePointer('linked-checkout', join(dir, 'links', 'gd')) + chmodSync(behind, 0o311) + try { + expect(gitFileDenies(linked, false).denyPaths).toContain(behind) + } finally { + chmodSync(behind, 0o755) + } + // Nowhere else does git keep what belongs to the directory two up. + const elsewhere = makeGitDir(join(main, 'kept', 'wt')) + const other = makePointer('other-checkout', elsewhere) + chmodSync(elsewhere, 0o000) + try { + expect(gitFileDenies(other, false).denyPaths).toEqual([ + other, + elsewhere, + ]) + } finally { + chmodSync(elsewhere, 0o755) + } + }, + ) + + it.if(isLinux)( + 'carries an unresolvable pointer to the wrap as a typed refusal', + async () => { + // A sandboxed command can write one of these, and the command that + // would delete it is refused too, so the caller has to be able to tell + // this case from the others and the message has to name the file. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + Buffer.concat([ + Buffer.from(`gitdir: ${gitDir}`), + Buffer.from([0xff]), + Buffer.from('\n'), + ]), + ) + process.chdir(join(dir, 'checkout')) + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_git_metadata_unreadable', + ) + expect((error as Error).message).toContain(pointer) + }, + ) + + it('names itself, and carries a code, wherever it surfaces', () => { + // macOS has no profile-error type of its own, so this IS the refusal the + // caller branches on there; it is exported from the package root. + const error = new GitMetadataError('x') + + expect(error.name).toBe('GitMetadataError') + expect(error.code).toBe('git_metadata_unreadable') + }) + + it('does not block on a FIFO left where a git directory keeps its commondir', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('does not block on a FIFO left where a pointer file goes', () => { + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + expect(spawnSync('mkfifo', [pointer]).status).toBe(0) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + }) + + it('recognises a submodule git directory without a HEAD', () => { + const gitDir = join(dir, 'modules', 'lib') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it('walks a submodule name that spans several segments, and nested ones', () => { + const outer = makeGitDir(join(dir, 'modules', 'vendor', 'lib')) + const inner = makeGitDir(join(outer, 'modules', 'dep')) + + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs.sort()).toEqual([outer, inner].sort()) + }) + + it('follows a symlinked entry under modules', () => { + const gitDir = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(gitDir, join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([ + join(dir, 'modules', 'lib'), + ]) + }) + + it.if(process.getuid?.() !== 0)( + 'denies a directory under modules it could not list', + () => { + const locked = join(dir, 'modules', 'locked') + makeGitDir(join(locked, 'deep')) + chmodSync(locked, 0o000) + try { + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs).toEqual([]) + expect(scan.unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + /** + * A submodule name is a path and submodules nest, so how deep a real tree + * can go is a question about the repository and not about this walk: what + * bounds the walk is the time it is given, and what stops it looping is + * where each directory really is. + */ + it('walks a chain hundreds of directories deep, and denies every level', () => { + // Submodules each nested inside the one above, two directories of + // `/modules` per level: deep enough to overflow a recursive walk's + // own stack on some runtimes. How many levels fit is what the platform's + // longest path leaves room for once the temporary directory has had its + // share - macOS stops at 1024 bytes, and a path past it cannot be opened + // at all, while Windows stops at 260 and is left out here. + const budget = getPlatform() === 'macos' ? 950 : 4000 + const levels = Math.floor((budget - dir.length) / '/x/modules'.length) + expect(levels).toBeGreaterThan(50) + + const gitDirs: string[] = [] + let at = join(dir, 'modules') + for (let level = 0; level < levels; level++) { + at = join(at, 'x') + makeGitDir(at) + gitDirs.push(at) + at = join(at, 'modules') + } + + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.unreadableDirs).toEqual([]) + expect(scan.gitDirs).toEqual([...gitDirs].sort()) + const denies = gitDirTreeDenyPaths(gitDirTreeDenies(dir, false)) + for (const gitDir of gitDirs) { + expect(denies).toContain(join(gitDir, 'hooks')) + expect(denies).toContain(join(gitDir, 'config')) + } + }) + + it('ends at a symlink pointing back into the tree', () => { + // modules/a/back -> modules, which the walk would follow for ever if it + // did not record where each directory really is. + mkdirSync(join(dir, 'modules', 'a'), { recursive: true }) + symlinkSync(join(dir, 'modules'), join(dir, 'modules', 'a', 'back')) + const gitDir = makeGitDir(join(dir, 'modules', 'a', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toMatchObject({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [join(dir, 'modules', 'a')], + }) + }) + + it('denies nothing for a symlink loop, which leads nowhere', () => { + // A loop cannot be stat'ed at all (ELOOP) and reaches no directory, so + // there is nothing behind it to deny - and denying what HOLDS it would + // hand every command a way to take every submodule beside it read-only. + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(join(dir, 'modules', 'b'), join(dir, 'modules', 'a')) + symlinkSync(join(dir, 'modules', 'a'), join(dir, 'modules', 'b')) + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }) + + it('costs one command nothing to plant thousands of loops', () => { + mkdirSync(join(dir, 'modules'), { recursive: true }) + for (let i = 0; i < 2800; i++) { + symlinkSync(`s${i}`, join(dir, 'modules', `s${i}`)) + } + const gitDir = makeGitDir(join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + linkedEntryDirs: [], + chainHops: [], + }) + }, 30000) + + it.if(process.getuid?.() !== 0)( + 'denies where a symlinked entry leads, and the directory holding the link', + () => { + // Two links into a directory this process cannot search. What is BEHIND + // them is unknown, so that deny is the deepest directory that can be + // reached towards them - the locked directory itself - and not the + // `modules` that holds the links, which would take every submodule + // beside them over one unreadable target. One entry, not three: the + // same path is reached by the listing of `locked` and by each of the + // two links. The links THEMSELVES are another matter: neither deny + // lands on a link's own path, and `modules` is writable, so the + // directory holding them is denied for their sake. + const locked = join(dir, 'modules', 'locked') + const sibling = makeGitDir(join(dir, 'modules', 'lib')) + mkdirSync(locked, { recursive: true }) + symlinkSync(join(locked, 'one'), join(dir, 'modules', 'a')) + symlinkSync(join(locked, 'two'), join(dir, 'modules', 'b')) + chmodSync(locked, 0o000) + try { + expect(submoduleGitDirs(join(dir, 'modules'))).toMatchObject({ + gitDirs: [sibling], + unreadableDirs: [locked], + linkedEntryDirs: [join(dir, 'modules')], }) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + it('refuses rather than hand back a walk that ran out of its time', () => { + makeGitDir(join(dir, 'modules', 'a', 'lib')) + makeGitDir(join(dir, 'modules', 'b', 'lib')) + + // A deadline already past: the first directory the walk takes off its + // stack is where it stops. + expect(() => + submoduleGitDirs(join(dir, 'modules'), Date.now() - 1), + ).toThrow(SubmoduleWalkBudgetError) + const error = (() => { + try { + submoduleGitDirs(join(dir, 'modules'), Date.now() - 1) + return undefined + } catch (err) { + return err as SubmoduleWalkBudgetError + } + })() + expect(error?.code).toBe('submodule_walk_budget_exhausted') + expect(error?.message).toContain(join(dir, 'modules')) + }) - // --- Concurrent sandbox mount point cleanup --- - // - // When two sandboxed commands run concurrently and one finishes first, - // cleanupBwrapMountPoints() must NOT delete mount point files that the - // still-running sandbox depends on. Deleting a mountpoint's dentry on the - // host detaches the bind mount in the child namespace, so the deny rule - // stops applying inside the still-running sandbox. + it('looks at the clock inside one wide directory, not only between directories', () => { + // A `.git/modules` a command filled is one directory, and none of these + // entries is one to walk into. + const modules = join(dir, 'modules') + mkdirSync(modules, { recursive: true }) + for (let i = 0; i < 300; i++) writeFileSync(join(modules, `f${i}`), '') + const deadline = Date.now() + 60000 + let looks = 0 + const now = spyOn(Date, 'now').mockImplementation(() => + looks++ === 0 ? deadline - 1 : deadline + 1, + ) + try { + expect(() => submoduleGitDirs(modules, deadline)).toThrow( + SubmoduleWalkBudgetError, + ) + } finally { + now.mockRestore() + } + }) - it('defers mount point cleanup while another sandbox is still running', async () => { - const raceDir = join(TEST_DIR, 'race-test') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) + it.if(isLinux)( + 'refuses the wrap where the modules walk runs out of its time', + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + // Enough submodules that the walk cannot get through them inside the + // millisecond below, whatever the machine: it is the walk that must + // run out here, not the scan, which runs after it. + for (let i = 0; i < 400; i++) { + makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(4, '0')}`)) + } - const originalDir = process.cwd() - process.chdir(raceDir) + process.chdir(checkout) + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + // The walk shares the scan's budget, so one millisecond of it is + // all the walk gets. + ripgrepConfig: { command: 'rg', timeoutMs: 1 }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe('deny_scan_failed') + expect((error as Error).message).toContain(join(gitDir, 'modules')) + }, + 30000, + ) + + it.if(isLinux)( + 'gives a walk made after the scan a budget of its own', + async () => { + // The scan has a timeout of its own and may use all of it. What a slow one + // left would refuse a repository whose submodules a walk gets through in + // microseconds. Here the clock jumps past the first budget as the scan + // ends. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const nestedGitDir = makeGitDir(join(checkout, 'nested', '.git')) + const nestedSub = makeGitDir(join(nestedGitDir, 'modules', 'lib')) + // A stand-in for ripgrep that reports the nested repository and exits. + const scan = join(dir, 'scan.sh') + const scanned = join(dir, 'scanned') + writeFileSync( + scan, + `#!/bin/sh\nprintf '%s\\0' ${JSON.stringify(join(nestedGitDir, 'config'))}\n: > ${JSON.stringify(scanned)}\n`, + { mode: 0o755 }, + ) + const realNow = Date.now + const now = spyOn(Date, 'now').mockImplementation( + () => realNow() + (existsSync(scanned) ? 60000 : 0), + ) - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [protectedFile], - } + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: scan, timeoutMs: 30000 }, + }) + expect(command).toContain(join(nestedSub, 'hooks')) + } finally { + now.mockRestore() + cleanupBwrapMountPoints({ force: true }) + process.chdir(originalCwd) + } + }, + 60000, + ) + + it.if(isLinux)( + 'keeps a deeply nested git directory writable except for its hooks and config', + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const deep = join( + gitDir, + 'modules', + ...Array.from({ length: 12 }, () => 'x'), + ) + makeGitDir(deep) + mkdirSync(join(deep, 'objects'), { recursive: true }) + // And one more level under it, so that what the deny of a git + // directory's own submodules covers is pinned as well. + const deeper = makeGitDir(join(deep, 'modules', 'lib')) + + process.chdir(checkout) + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - // Sandbox A: long-running command that sleeps then tries to write - // to the denied path. The write should be blocked. - // allowAllUnixSockets skips seccomp (environment-dependent) while - // keeping the filesystem isolation we're testing. - const wrappedA = await wrapCommandWithSandboxLinux({ - command: `sleep 2; echo '{"hooks":{}}' > .claude/settings.json`, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) + // No deny covering the git directory whole, which would take its + // objects, refs and index with it. An ancestor pin spells that same + // self-bind, so where it sits is what tells the two apart: pins are + // spliced in before the write root's own bind, which makes the tree + // writable again, and a deny bind is emitted after it. + const command = await wrap('true') + const writeRootBind = indexOfMount(command, '--bind', checkout, checkout) + expect(writeRootBind).toBeGreaterThan(-1) + expect(lastIndexOfMount(command, '--ro-bind', deep, deep)).toBeLessThan( + writeRootBind, + ) + expect(command).toContain(`--ro-bind ${join(deep, 'hooks')} `) + // Its own submodule is denied by path too, and neither it nor the + // `modules` directory holding it is denied whole: a bind there would + // take that submodule's objects, refs and index with it. Only the + // ancestor pins spell those, and they sit before the write root's + // own bind. + expect(command).toContain(`--ro-bind ${join(deeper, 'hooks')} `) + expect( + lastIndexOfMount(command, '--ro-bind', deeper, deeper), + ).toBeLessThan(writeRootBind) + const modules = join(deep, 'modules') + expect( + lastIndexOfMount(command, '--ro-bind', modules, modules), + ).toBeLessThan(writeRootBind) + + // Where bwrap can run, prove it: what git needs writable in that + // submodule still is, and what makes a write into code is not. + if (bwrapCanNamespace()) { + const result = spawnSync( + await wrap( + `sh -c 'echo o > ${join(deep, 'objects', 'x')} && ` + + `! echo h > ${join(deep, 'hooks', 'x')} && ` + + `! echo c > ${join(deep, 'config')} && ` + + `! echo h > ${join(deeper, 'hooks', 'x')} && ` + + `echo SRT_DEEP_OK'`, + ), + { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, + ) + expect(result.stdout).toContain('SRT_DEEP_OK') + } + }, + 60000, + ) + + it('denies a deeply nested git directory by literal path on macOS', () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const deep = join( + gitDir, + 'modules', + ...Array.from({ length: 12 }, () => 'x'), + ) + makeGitDir(deep) + const deeper = makeGitDir(join(deep, 'modules', 'lib')) + + process.chdir(checkout) + // Profile generation is pure string building, so this runs anywhere. + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect(profile).toContain(`(subpath "${join(deep, 'hooks')}")`) + expect(profile).toContain(`(subpath "${join(deeper, 'hooks')}")`) + // No deny covering either git directory whole, which would take its + // objects, refs and index with it. Seatbelt has no argument cap, so + // nothing is ever collapsed there. + expect(profile).not.toContain(`(subpath "${deep}")`) + expect(profile).not.toContain(`(subpath "${deeper}")`) + }) +}) - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', code => resolve(code)) - }) +/** + * bubblewrap parses at most 9000 words and takes three for each mount, so a + * repository with enough submodules asks for more than the profile can carry. + * The ceiling is not this library's to lift; refusing every command in such a + * repository is the wrong answer to it, so the denies are degraded instead - + * precise while the profile fits, then a whole git directory at a time, then + * the `modules` directory whole. What decides is the profile the wrap built, + * so the wraps below are Linux's; Seatbelt has no cap of the kind. + */ +describe.if(isSupportedPlatform)( + 'Submodule denies past the argument cap', + () => { + /** Everything the live arms need: a sandbox that starts, and a git. */ + const LIVE = isLinux && bwrapCanNamespace() && Bun.which('git') !== null + let dir: string + const savedCwd = process.cwd() - // Wait for bwrap A to start and create the mount point on the host - await new Promise(r => setTimeout(r, 500)) - expect(existsSync(protectedFile)).toBe(true) + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-submodule-budget-'))) + }) - // Sandbox B: short command. When it finishes, the caller invokes - // cleanupBwrapMountPoints() — simulating the real-world race. - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - spawnSync(wrappedB, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) - // This is what the caller does after every command completes. - // Without deferral, this would delete sandbox A's mount point too. - cleanupBwrapMountPoints() + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir + } - // Wait for sandbox A to attempt its write - await exitA + /** A repository whose .git/modules holds `count` submodule git + * directories, named so that sorted order is the order they were made + * in. `checkouts` gives each one the `.git` pointer file a checked-out + * submodule has, which costs a bind and a pin no collapse can save. */ + function makeSuperproject( + count: number, + options: { checkouts?: boolean } = {}, + ): { checkout: string; subs: string[] } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < count; i++) { + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', name))) + if (options.checkouts !== true) continue + mkdirSync(join(checkout, name), { recursive: true }) + writeFileSync( + join(checkout, name, '.git'), + `gitdir: ../.git/modules/${name}\n`, + ) + } + return { checkout, subs } + } - // The deny rule must have held — the file should not contain the - // write from sandbox A. If cleanup had deleted the mount point - // early, A's bind mount would have detached and the write would - // have landed on the host. - const content = existsSync(protectedFile) - ? readFileSync(protectedFile, 'utf8') - : '' - expect(content).not.toContain('hooks') + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) + describe('what a degraded level denies', () => { + /** A plan as the wrap builds one: the deny paths in the order they were + * found, and the submodule git directories they can be folded into. */ + function planFor( + modulesDir: string, + gitDirNames: string[], + extra: string[] = [], + escaping: (gitDir: string) => string[] = () => [], + ): SubmoduleDenyPlan { + const gitDirs = gitDirNames.map(name => { + const gitDir = join(modulesDir, name) + const escapingDenyPaths = escaping(gitDir) + return { + gitDir, + denyPaths: [ + join(gitDir, 'hooks'), + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ...escapingDenyPaths, + ], + escapingDenyPaths, + linkedEntryDirs: [], + chainHops: [], } - }, 15000) + }) + return { + denyPaths: [...extra, ...gitDirs.flatMap(sub => sub.denyPaths)], + repositories: [{ modulesDir, gitDirs, wholeDirDenies: [] }], + chainHops: [], + } + } - it('defers cleanup when two sandboxes share the same non-existent deny path', async () => { - const raceDir = join(TEST_DIR, 'race-test-2') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) + it('leaves the list exactly as it was where nothing is degraded', () => { + const plan = planFor(join(dir, '.git', 'modules'), ['a', 'b']) + expect(collapsedDenyPaths(plan, NO_COLLAPSE)).toEqual(plan.denyPaths) + }) - const originalDir = process.cwd() - process.chdir(raceDir) + it('folds a degraded git directory in wherever its deny paths came from', () => { + // A checked-out submodule's `.git` pointer names the same git + // directory the walk of `.git/modules` reached, so the pointer's own + // deny sits in the list beside denies that are the git directory's: + // collapsing it has to take those and leave the pointer, which names + // a file and has nothing to collapse into. + const modules = join(dir, '.git', 'modules') + const pointer = join(dir, 'b', '.git') + const plan = planFor(modules, ['a', 'b'], [pointer]) + + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [protectedFile], - } + expect(denies).toContain(pointer) + expect(denies).toContain(join(modules, 'a', 'hooks')) + // The last in sorted order is the one degraded, so two wraps of one + // tree degrade the same submodules. + expect(denies).toContain(join(modules, 'b')) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + expect(denies).not.toContain(join(modules, 'b', 'config')) + }) - // Generate both wrapped commands BEFORE spawning, so both see the - // deny path as non-existent and both add it to bwrapMountPoints. - const wrappedA = await wrapCommandWithSandboxLinux({ - command: `sleep 2; echo WRITTEN > .claude/settings.json`, - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'sleep 0.5', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) + it('keeps what a symlinked entry leads to when the git directory goes whole', () => { + // The one read-only bind that replaces a submodule's precise denies + // covers what is under the git directory. A symlinked entry leads + // out of it, and dropping that deny leaves one write through the + // link landing on hooks the host's git runs. + const modules = join(dir, '.git', 'modules') + const planted = join(dir, 'planted') + const plan = planFor(modules, ['a', 'b'], [], gitDir => + gitDir === join(modules, 'b') ? [planted] : [], + ) - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', code => resolve(code)) - }) + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) - // Sandbox B runs and finishes first - spawnSync(wrappedB, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - cleanupBwrapMountPoints() + expect(denies).toContain(join(modules, 'b')) + expect(denies).toContain(planted) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + }) - await exitA + it('keeps it under a modules directory denied whole as well', () => { + const modules = join(dir, '.git', 'modules') + const planted = join(dir, 'planted') + const plan = planFor(modules, ['a', 'b'], [], gitDir => + gitDir === join(modules, 'b') ? [planted] : [], + ) - const content = existsSync(protectedFile) - ? readFileSync(protectedFile, 'utf8') - : '' - expect(content).not.toContain('WRITTEN') + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) + expect(denies).toContain(modules) + expect(denies).toContain(planted) + expect(denies).not.toContain(join(modules, 'b', 'hooks')) + expect(denies).not.toContain(join(modules, 'a', 'hooks')) + }) + + it('counts a kept deny as a mount the step does not give back', () => { + // Three denies each, of which one leads out of the git directory and + // is kept: a step gives back two mounts, not three, so one that has + // to find three takes both git directories. + const modules = join(dir, '.git', 'modules') + const gitDirs = ['a', 'b'].map(name => { + const gitDir = join(modules, name) + const planted = join(dir, 'planted', name) + return { + gitDir, + denyPaths: [join(gitDir, 'hooks'), planted, join(gitDir, 'config')], + escapingDenyPaths: [planted], + linkedEntryDirs: [], + chainHops: [], } - }, 15000) + }) + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirs.flatMap(submodule => submodule.denyPaths), + repositories: [{ modulesDir: modules, gitDirs, wholeDirDenies: [] }], + chainHops: [], + } - it('deferred cleanup runs once all concurrent sandboxes finish', async () => { - const raceDir = join(TEST_DIR, 'race-test-3') - mkdirSync(raceDir, { recursive: true }) - mkdirSync(join(raceDir, '.claude'), { recursive: true }) + expect(collapseFurther(plan, NO_COLLAPSE, 2)).toEqual({ + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + expect(collapseFurther(plan, NO_COLLAPSE, 3)).toEqual({ + wholeGitDirs: 2, + wholeModulesDirs: 0, + }) + }) - const originalDir = process.cwd() - process.chdir(raceDir) + it('adds the bind of a git directory that is a deny path already once', () => { + // A git directory holding a symlinked entry is denied whole by the + // producer, so it is in the list before any degrade reaches it. + const modules = join(dir, '.git', 'modules') + const gitDir = join(modules, 'b') + const plan = planFor(modules, ['a', 'b']) + const withWhole: SubmoduleDenyPlan = { + chainHops: [], + denyPaths: [gitDir, ...plan.denyPaths], + repositories: plan.repositories.map(repository => ({ + ...repository, + gitDirs: repository.gitDirs.map(submodule => + submodule.gitDir === gitDir + ? { ...submodule, denyPaths: [gitDir, ...submodule.denyPaths] } + : submodule, + ), + })), + } - try { - const protectedFile = join(raceDir, '.claude', 'settings.json') - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [protectedFile], - } + const denies = collapsedDenyPaths(withWhole, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) - const wrappedA = await wrapCommandWithSandboxLinux({ - command: 'sleep 1', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) - const wrappedB = await wrapCommandWithSandboxLinux({ - command: 'true', - needsNetworkRestriction: false, - readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - }) + expect(denies.filter(deny => deny === gitDir)).toEqual([gitDir]) + }) - const childA = spawn(wrappedA, { shell: true }) - const exitA = new Promise(resolve => { - childA.on('exit', () => resolve()) - }) + it('runs out, which is what too_many_arguments is left for', () => { + const plan = planFor(join(dir, '.git', 'modules'), ['a', 'b']) + const everything = { wholeGitDirs: 2, wholeModulesDirs: 1 } + expect(collapseFurther(plan, NO_COLLAPSE, 1)).toEqual({ + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + expect(collapseFurther(plan, everything, 1)).toBeUndefined() + }) - await new Promise(r => setTimeout(r, 300)) - expect(existsSync(protectedFile)).toBe(true) + it('names the repositories it degraded, and counts every level', () => { + const modules = join(dir, '.git', 'modules') + const plan = planFor(modules, ['a', 'b']) - spawnSync(wrappedB, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) - cleanupBwrapMountPoints() + expect(describeCollapse(plan, NO_COLLAPSE)).toBe('') + const told = describeCollapse(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) + expect(told).toContain('2 of the 2 submodule git directories') + expect(told).toContain(modules) + }) - // Cleanup deferred — mount point still present while A runs - expect(existsSync(protectedFile)).toBe(true) + it('says nothing about submodules a whole-directory deny already took', () => { + // An entry of this `.git/modules` is a symlink, so the directory is + // denied whole before anything is degraded and every submodule under + // it is read-only already. A degrade of them takes nothing away, and + // saying that it did names a cost the profile never paid. + const modules = join(dir, '.git', 'modules') + mkdirSync(join(modules, 'a'), { recursive: true }) + mkdirSync(join(modules, 'b'), { recursive: true }) + const plan = planFor(modules, ['a', 'b']) + const held: SubmoduleDenyPlan = { + ...plan, + repositories: plan.repositories.map(repository => ({ + ...repository, + wholeDirDenies: [modules], + })), + } - await exitA - cleanupBwrapMountPoints() + expect( + describeCollapse(held, { wholeGitDirs: 2, wholeModulesDirs: 1 }), + ).toBe('') + }) - // Both sandboxes done — mount point now cleaned up - expect(existsSync(protectedFile)).toBe(false) - } finally { - process.chdir(originalDir) - rmSync(raceDir, { recursive: true, force: true }) + it('has nothing to degrade, and nothing to deny, for a repository with no submodules', () => { + // An absent `.git/modules` is not a directory to deny: a /dev/null + // mount point planted at one stops `git submodule add` working in + // that repository, and is left behind by a killed wrap. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const denies = gitDirTreeDenies(gitDir, false) + + expect(degradableGitDirs(denies)).toEqual([]) + expect(gitDirTreeDenyPaths(denies)).not.toContain( + join(gitDir, 'modules'), + ) + }) + + it.if(!isWindows)( + 'keeps a repository denied whole for its own symlinked entry out of the plan', + () => { + // The git directory is denied whole because its `hooks` is a link. + // That is not a submodule deny, and it is no reason to give a + // repository with no submodules a `.git/modules` to degrade. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const shared = join(dir, 'repo', 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const denies = gitDirTreeDenies(gitDir, false) + + expect(denies.linkedEntryDirs).toEqual([gitDir]) + expect(degradableGitDirs(denies)).toEqual([]) + }, + ) + + it.if(!isWindows)( + 'keeps the deny of an entry whose real path is outside a modules directory denied whole', + () => { + // A read-only bind of `modules` covers what is really under it. An + // entry the walk reached through a symlink can be anywhere, and + // leaving it to that bind would leave its hooks writable. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const inside = makeGitDir(join(gitDir, 'modules', 'a')) + const outside = makeGitDir(join(dir, 'elsewhere')) + const link = join(gitDir, 'modules', 'b') + symlinkSync(outside, link) + + const tree = gitDirTreeDenies(gitDir, false) + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirTreeDenyPaths(tree), + repositories: degradableGitDirs(tree), + chainHops: [], } - }, 15000) + expect(plan.repositories).toHaveLength(1) - // The caller runs no cleanup for a wrap that threw, so a removal - // deferred to that wrap has nobody else to run it. + const denies = collapsedDenyPaths(plan, { + wholeGitDirs: 2, + wholeModulesDirs: 1, + }) + + expect(denies).toContain(join(gitDir, 'modules')) + expect(denies).toContain(link) + expect(denies).not.toContain(inside) + expect(denies).not.toContain(join(inside, 'hooks')) + }, + ) + }) - const wrapInCwd = ( - command: string, - failing?: Partial, - ): Promise => + it.if(isLinux)( + 'wraps a command in a repository with 2000 submodules', + async () => { + const { checkout, subs } = makeSuperproject(2000) + process.chdir(checkout) + const wrap = (): Promise => wrapCommandWithSandboxLinux({ - command, + command: 'true', needsNetworkRestriction: false, readConfig: undefined, - writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, - enableWeakerNestedSandbox: true, - allowAllUnixSockets: true, - ...failing, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - it.each(['aborted', 'refused'])( - 'runs the cleanup deferred to a wrap that is then %s', - async how => { - const raceDir = join(TEST_DIR, 'race-test-4') - mkdirSync(raceDir, { recursive: true }) - process.chdir(raceDir) + const command = await wrap() + expect(command).toContain('bwrap') + + // The mounts are past what one command line carries at this size, so + // they are in the argument file bwrap reads - which is where its own + // cap on parsed words is spent. Under it, with room to spare for the + // handful still on the line. + const words = mountWords(command) + expect(words.length).toBeLessThan(9000) + + // The first submodules in sorted order keep their precise denies; the + // rest are denied whole, one read-only bind of the git directory each. + // (A git directory bound whole is spelled like its own ancestor pin, so + // what tells the two apart is whether the hooks have a deny of their + // own.) + const denied = new Set(words) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + expect(precise.length).toBeGreaterThan(0) + expect(precise.length).toBeLessThan(subs.length) + expect(precise).toEqual(subs.slice(0, precise.length)) + for (const sub of subs.slice(precise.length)) + expect(denied.has(sub)).toBe(true) + const last = subs[subs.length - 1] as string + expect(denied.has(last)).toBe(true) + expect(denied.has(join(last, 'hooks'))).toBe(false) + + // Two wraps of one tree agree, so a second command in it is sandboxed + // exactly as the first was. + const again = await wrap() + const profileAgain = /\/proc\/\d+\/fd\/\d+/.exec(again)?.[0] + expect(readFileSync(profileAgain as string, 'utf8')).toBe( + readFileSync( + /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] as string, + 'utf8', + ), + ) - try { - spawnSync(await wrapInCwd('true'), { - shell: true, - timeout: 10000, - }) - expect(readdirSync(raceDir)).toContain('.bashrc') + // The violation monitor works the working directory's own denies out + // for itself, once for the session, and degrades none of them: it + // reports the path inside a submodule git directory that a wrap has + // by then denied whole, which is a write refused either way. + const monitorDenies = new Set(linuxGetMonitorCwdDenyPaths(false)) + expect(monitorDenies.has(join(subs[0] as string, 'hooks'))).toBe(true) + expect(monitorDenies.has(join(last, 'hooks'))).toBe(true) + expect(monitorDenies.has(last)).toBe(false) + + // Seatbelt has no such cap: every submodule keeps its precise denies + // there, whatever this repository costs bubblewrap. Read off the + // entries rather than the profile text, which folds same-shaped + // literals into alternation regexes at this size. + const macEntries = new Set( + macGetMandatoryDenyEntries(false).map(entry => entry.path), + ) + for (const sub of [subs[0] as string, last]) { + expect(macEntries.has(join(sub, 'hooks'))).toBe(true) + expect(macEntries.has(sub)).toBe(false) + } + }, + 300000, + ) - // B is still looking for the mandatory denies when A's caller - // cleans up. - const controller = new AbortController() - const wrappedB = wrapInCwd('true', { - abortSignal: controller.signal, - binShell: how === 'refused' ? 'no-such-shell' : undefined, - }) - cleanupBwrapMountPoints() - expect(readdirSync(raceDir)).toContain('.bashrc') + it.if(isLinux)( + 'degrades a repository of checked-out submodules, and refuses where even that is not enough', + async () => { + // A checked-out submodule costs what a bare one does and two mounts + // more that no collapse saves: the `.git` pointer file's own bind and + // the pin of the directory holding it. So this shape, and not a bare + // `.git/modules`, is the one that can still reach the cap. + const wrap = (checkout: string): Promise => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - if (how === 'aborted') controller.abort(new Error('stopped')) - expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( - Error, - ) - expect(readdirSync(raceDir)).toEqual([]) - } finally { - process.chdir(TEST_DIR) - rmSync(raceDir, { recursive: true, force: true }) - } - }, + for (const count of [300, 700]) { + const { checkout, subs } = makeSuperproject(count, { + checkouts: true, + }) + process.chdir(checkout) + const words = mountWords(await wrap(checkout)) + expect(words.length).toBeLessThan(9000) + // Every pointer file is denied whatever was degraded under it. + const denied = new Set(words) + for (const sub of subs) { + expect(denied.has(join(checkout, basename(sub), '.git'))).toBe(true) + } + process.chdir(savedCwd) + rmSync(checkout, { recursive: true, force: true }) + cleanupBwrapMountPoints({ force: true }) + } + + const { checkout } = makeSuperproject(1600, { checkouts: true }) + process.chdir(checkout) + const error = await wrap(checkout).catch((e: unknown) => e) + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'too_many_arguments', ) + }, + 600000, + ) - it('forgets the mount points planned by a wrap that is then refused', async () => { - const raceDir = join(TEST_DIR, 'race-test-6') - mkdirSync(raceDir, { recursive: true }) - process.chdir(raceDir) + it.if(isLinux)( + 'degrades multi-segment and nested submodule names like any other', + async () => { + // A submodule's name is its path, so one can sit several segments + // down and hold submodules of its own. Each extra segment is another + // directory to pin, which the profile pays for and the model of what + // a submodule costs never saw. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < 700; i++) { + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', `vendor${i}`, name))) + } + let nested = subs[0] as string + for (let level = 0; level < 20; level++) { + nested = makeGitDir(join(nested, 'modules', 'inner')) + subs.push(nested) + } + process.chdir(checkout) - try { - expect( - await wrapInCwd('true', { binShell: 'no-such-shell' }).catch( - (e: unknown) => e, + const words = mountWords( + await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + expect(words.length).toBeLessThan(9000) + const denied = new Set(words) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + expect(precise.length).toBeGreaterThan(0) + expect(precise.length).toBeLessThan(subs.length) + }, + 300000, + ) + + it.if(LIVE)( + 'holds a collapsed submodule read-only while the rest of the tree works', + async () => { + const git = Bun.which('git') as string + // Seven hundred real submodule git directories, made here rather than + // cloned: past what precise denies fit in, so the tail is collapsed. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + writeFileSync(join(checkout, 'index.js'), 'console.log(1)\n') + run('-C', checkout, 'add', 'index.js') + run( + '-C', + checkout, + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '-q', + '-m', + 'one', + ) + const subs: string[] = [] + for (let i = 0; i < 700; i++) { + subs.push( + makeGitDir( + join( + checkout, + '.git', + 'modules', + `s${String(i).padStart(5, '0')}`, ), - ).toBeInstanceOf(Error) + ), + ) + } + process.chdir(checkout) + + const collapsed = subs[subs.length - 1] as string + const precise = subs[0] as string + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(collapsed, 'hooks', 'pre-commit')} || echo COLLAPSED_HOOK_WRITABLE; ` + + `! echo x > ${join(precise, 'hooks', 'pre-commit')} || echo PRECISE_HOOK_WRITABLE; ` + + // A rename of the collapsed git directory itself is what the + // ancestor pins are there to stop: the deny would otherwise move + // with it and a fresh directory take its place. + `! mv ${collapsed} ${collapsed}.moved || echo COLLAPSED_RENAMABLE; ` + + `echo ok > ${join(checkout, 'index.js')} || echo PROJECT_FILE_READONLY; ` + + `${git} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - // bwrap never ran, so this is no mount point: the host made it. - writeFileSync('.mcp.json', '') - spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) - cleanupBwrapMountPoints() - expect(readdirSync(raceDir)).toEqual(['.mcp.json']) - } finally { - process.chdir(TEST_DIR) - rmSync(raceDir, { recursive: true, force: true }) - } + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('COLLAPSED_HOOK_WRITABLE') + expect(result.stdout).not.toContain('PRECISE_HOOK_WRITABLE') + expect(result.stdout).not.toContain('COLLAPSED_RENAMABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + + // And the host's git still works in the superproject afterwards. + cleanupBwrapMountPoints({ force: true }) + const status = spawnSync(git, ['status', '--porcelain'], { + cwd: checkout, + encoding: 'utf8', + timeout: 60000, }) + expect(status.status).toBe(0) + }, + 300000, + ) - it.each([ - ['aborted', 'counted along with it'], - ['aborted', 'started since a forced cleanup'], - ['handed out', 'started since a forced cleanup'], - ])( - 'a wrap that is %s leaves the mount points of a running sandbox %s', - async (how, which) => { - const raceDir = join(TEST_DIR, 'race-test-5') - mkdirSync(raceDir, { recursive: true }) - process.chdir(raceDir) - // Stands in for ripgrep, and goes on until told to end. - const heldScan = join(raceDir, 'held-scan') - writeFileSync( - heldScan, - `#!/bin/sh\nuntil [ -e '${raceDir}/scanned' ]; do sleep 0.1; done\n`, - { mode: 0o755 }, - ) + it.if(LIVE)( + 'keeps the hooks of a symlinked submodule entry where `modules` is denied whole', + async () => { + // Three thousand submodules is past what one read-only bind each + // fits in, which is the level that puts the whole tree behind a + // single bind over `.git/modules`. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + for (let i = 0; i < 3000; i++) { + makeGitDir(join(gitDir, 'modules', `s${String(i).padStart(5, '0')}`)) + } + const outside = makeGitDir(join(dir, 'elsewhere')) + symlinkSync(outside, join(gitDir, 'modules', 'zz-linked')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(outside, 'hooks', 'pre-commit')} || echo LINKED_HOOK_WRITABLE; ` + + `! echo x > ${join(gitDir, 'modules', 's00000', 'hooks', 'x')} || echo MODULES_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout, outside], denyWithinAllow: [] }, + }) + // The whole tree behind one bind. The linked entry is not covered by + // it, since a deny lands where the link really points, and what it + // points at is a write root: binding that whole would take a + // directory the command was given away from it, so it keeps the + // denies of its own entries instead. + const words = mountWords(command) + const mounted = (source: string, dest: string): boolean => + words.some( + (word, at) => + word === '--ro-bind' && + words[at + 1] === source && + words[at + 2] === dest, + ) + expect(mounted(join(gitDir, 'modules'), join(gitDir, 'modules'))).toBe( + true, + ) + expect(mounted(outside, outside)).toBe(false) + expect(mounted(join(outside, 'hooks'), join(outside, 'hooks'))).toBe( + true, + ) + expect(mounted(join(outside, 'config'), join(outside, 'config'))).toBe( + true, + ) - try { - const controller = new AbortController() - const wrappedB = wrapInCwd('true', { - abortSignal: controller.signal, - ripgrepConfig: { command: heldScan }, - }) - if (which !== 'counted along with it') { - cleanupBwrapMountPoints({ force: true }) - } + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('LINKED_HOOK_WRITABLE') + expect(result.stdout).not.toContain('MODULES_WRITABLE') + }, + 300000, + ) - // C runs throughout, and tries a denied name once told to. - const childC = spawn( - await wrapInCwd( - 'until [ -e go ]; do sleep 0.1; done; echo WRITTEN > .bashrc', - ), - { shell: true }, - ) - const exitC = new Promise(resolve => { - childC.on('exit', () => resolve()) - }) - while (!existsSync('.bashrc')) { - await new Promise(r => setTimeout(r, 50)) - } + it.if(LIVE)( + 'holds a symlinked hooks and a symlinked modules entry where nothing is degraded', + async () => { + // One small repository: every deny is precise. The links are what + // the precise denies cannot land on, so the git directory holding + // one, and the `modules` holding the other, are denied whole - and + // the repository still reads. + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + const gitDir = join(checkout, '.git') + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const shared = join(checkout, 'shared-hooks') + mkdirSync(shared, { recursive: true }) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(shared, join(gitDir, 'hooks')) + const outside = makeGitDir(join(dir, 'elsewhere')) + const entry = join(gitDir, 'modules', 'lib') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + symlinkSync(outside, entry) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(shared, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo HOOKS_LINK_UNLINKABLE; ` + + `! mv ${join(gitDir, 'hooks')} ${join(gitDir, 'hooks.moved')} || echo HOOKS_LINK_RENAMABLE; ` + + `! rm -f ${entry} || echo MODULES_LINK_UNLINKABLE; ` + + `! echo x > ${join(outside, 'hooks', 'post-commit')} || echo LINKED_GIT_DIR_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout, outside], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - if (how === 'aborted') { - controller.abort(new Error('stopped')) - expect(await wrappedB.catch((e: unknown) => e)).toBeInstanceOf( - Error, - ) - } else { - writeFileSync('scanned', '') - spawnSync(await wrappedB, { shell: true, timeout: 10000 }) - cleanupBwrapMountPoints() - } - expect(existsSync('.bashrc')).toBe(true) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_LINK_UNLINKABLE') + expect(result.stdout).not.toContain('HOOKS_LINK_RENAMABLE') + expect(result.stdout).not.toContain('MODULES_LINK_UNLINKABLE') + expect(result.stdout).not.toContain('LINKED_GIT_DIR_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) - // C is still counted, so the next command to end defers to it. - spawnSync(await wrapInCwd('true'), { - shell: true, - timeout: 10000, - }) - cleanupBwrapMountPoints() - expect(existsSync('.bashrc')).toBe(true) + /** + * A chain between a git directory entry and what it leads to is held hop + * by hop: each hop's own path is a deny path, and on this backend, whose + * denies resolve, the directory holding each is denied whole as the git + * directory itself is. Where that directory is the working directory or + * a write root there is nothing to bind over it, and the wrap says so + * instead. + */ + describe('a chain with a hop in the middle', () => { + /** A repository whose `hooks` reaches `.githooks` through `hop`. */ + function makeChain(holder: string): { + checkout: string + gitDir: string + hop: string + landing: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const landing = join(checkout, '.githooks') + mkdirSync(landing, { recursive: true }) + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(landing, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(hop, join(gitDir, 'hooks')) + return { checkout, gitDir, hop, landing } + } - writeFileSync('go', '') - await exitC - expect(readFileSync('.bashrc', 'utf8')).toBe('') + it.if(isLinux)( + 'binds the directory holding the hop read-only where it is a subdirectory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir, landing } = makeChain(holder) + process.chdir(checkout) - cleanupBwrapMountPoints() - expect(existsSync('.bashrc')).toBe(false) - } finally { - process.chdir(TEST_DIR) - rmSync(raceDir, { recursive: true, force: true }) - } - }, - 15000, - ) + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - it('leaves nothing of a directory mount point that another sandbox was wrapped under', async () => { - const raceDir = join(TEST_DIR, 'race-test-7') - mkdirSync(raceDir, { recursive: true }) - process.chdir(raceDir) + // A deny bind is emitted after the write root's own bind; an + // ancestor pin spells the same words before it. + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The two ends keep what they had: the git directory whole, and + // the landing on its own. + expect( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', landing, landing), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) - try { - const childT = spawn( - await wrapInCwd('until [ -e go ]; do sleep 0.1; done'), - { shell: true }, - ) - const exitT = new Promise(resolve => { - childT.on('exit', () => resolve()) - }) - while (!existsSync('.claude')) { - await new Promise(r => setTimeout(r, 50)) - } + it.if(isLinux)( + 'holds a symlinked directory component by its holder, not by what it leads to', + async () => { + // hooks -> links/shared/hooks with shared -> shared-real. A bind + // at the hop's own path would land on `shared-real` and take a + // whole directory nothing asked to deny; the directory holding the + // link is what holds it. + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const real = join(checkout, 'shared-real') + mkdirSync(join(real, 'hooks'), { recursive: true }) + const holder = join(checkout, 'links') + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'shared') + symlinkSync(real, hop) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(join(hop, 'hooks'), join(gitDir, 'hooks')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The landing keeps its own deny; the directory above it is only + // ever the ancestor pin, which is spliced in before that bind. + expect( + lastIndexOfMount( + command, + '--ro-bind', + join(real, 'hooks'), + join(real, 'hooks'), + ), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', real, real), + ).toBeLessThan(writeRootBind) + }, + 60000, + ) + + it.if(isLinux)( + 'warns rather than bind where the directory holding the hop is the working directory', + async () => { + const checkout = join(dir, 'repo') + const { gitDir, hop } = makeChain(checkout) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + // Nothing read-only over the write root after its own bind: that + // would take the whole tree the sandbox exists to let a command + // write. Only the pins spell it, and they come before. + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + const told = warnings.join('\n') + expect(told).toContain(join(gitDir, 'hooks')) + expect(told).toContain(hop) + }, + 60000, + ) + + it.if(LIVE)( + 'refuses every way to move a hop a subdirectory holds', + async () => { + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + const holder = join(checkout, 'links') + const { gitDir, hop, landing } = makeChain(holder) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! ln -sfn ${evil} ${hop} || echo HOP_RETARGETABLE; ` + + `! mv ${hop} ${hop}.aside || echo HOP_RENAMABLE; ` + + `! rm -f ${hop} || echo HOP_UNLINKABLE; ` + + `! mkdir ${join(holder, 'other')} || echo HOLDER_WRITABLE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `! echo x > ${join(landing, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('HOP_RETARGETABLE') + expect(result.stdout).not.toContain('HOP_RENAMABLE') + expect(result.stdout).not.toContain('HOP_UNLINKABLE') + expect(result.stdout).not.toContain('HOLDER_WRITABLE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The host still resolves the chain where it did. + expect(readlinkSync(hop)).toBe(landing) + expect(existsSync(join(landing, 'post-commit'))).toBe(false) + }, + 300000, + ) + + /** + * A checkout whose `.git` is a POINTER FILE naming its git directory + * through `hop`: the same chain from the other producer, since a + * pointer's value is a path the kernel walks like any other. + */ + function makePointerChain(holder: string): { + checkout: string + pointer: string + hop: string + gitDir: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, 'gitdir-real')) + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(gitDir, hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${hop}\n`) + return { checkout, pointer, hop, gitDir } + } - // T's .claude is there by now, so X's mount points go inside it. - spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) - cleanupBwrapMountPoints() - expect(readdirSync('.claude')).not.toEqual([]) + it.if(isLinux)( + "binds the directory holding a hop of a pointer's value read-only", + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { pointer, gitDir } = makePointerChain(holder) + process.chdir(checkout) - writeFileSync('go', '') - await exitT - cleanupBwrapMountPoints() - expect(readdirSync(raceDir)).toEqual(['go']) + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) - cleanupBwrapMountPoints() - expect(readdirSync(raceDir)).toEqual(['go']) - } finally { - process.chdir(TEST_DIR) - rmSync(raceDir, { recursive: true, force: true }) - } - }, 15000) + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The two ends keep what they had: the pointer file itself, and the + // hooks of the git directory its value reaches — spelled where the + // deny RESOLVES to, which is what a bind can be put on. + expect( + lastIndexOfMount(command, '--ro-bind', pointer, pointer), + ).toBeGreaterThan(writeRootBind) + expect( + lastIndexOfMount( + command, + '--ro-bind', + join(gitDir, 'hooks'), + join(gitDir, 'hooks'), + ), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) - it('takes only its own where the host has written at a mount point or inside one', async () => { - const raceDir = join(TEST_DIR, 'race-test-8') - mkdirSync(raceDir, { recursive: true }) - process.chdir(raceDir) + it.if(isLinux)( + "warns where the working directory holds a hop of a pointer's value", + async () => { + const checkout = join(dir, 'repo') + const { pointer, hop } = makePointerChain(checkout) + process.chdir(checkout) - try { - // The second is wrapped with the first's .claude there. - spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) - spawnSync(await wrapInCwd('true'), { shell: true, timeout: 10000 }) - expect(readdirSync('.claude')).not.toEqual([]) - writeFileSync('.claude/kept', '') - rmSync('.bashrc') - writeFileSync('.bashrc', 'kept') + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) - cleanupBwrapMountPoints() - cleanupBwrapMountPoints() - expect(readdirSync(raceDir).sort()).toEqual(['.bashrc', '.claude']) - expect(readdirSync('.claude')).toEqual(['kept']) - expect(readFileSync('.bashrc', 'utf8')).toBe('kept') - } finally { - process.chdir(TEST_DIR) - rmSync(raceDir, { recursive: true, force: true }) - } - }) + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + const told = warnings.join('\n') + // Named as what it is: the path a pointer file leads through. + expect(told).toContain(pointer) + expect(told).toContain(hop) + }, + 60000, + ) - it('non-existent .git/hooks deny does not turn .git into a file, breaking git', async () => { - // When .git doesn't exist yet, denying .git/hooks causes - // findFirstNonExistentComponent to return .git itself. bwrap then does - // --ro-bind /dev/null .git, creating .git as a FILE (not a directory). - // Inside the sandbox, every git command fails because .git is a file. + it.if(LIVE)( + "refuses every way to move a hop of a pointer's value a subdirectory holds", + async () => { + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const run = (...args: string[]): void => { + const r = spawnSync(git, args, { encoding: 'utf8', timeout: 60000 }) + expect(r.status).toBe(0) + } + run('-c', 'init.defaultBranch=main', 'init', '-q', checkout) + // The repository git just made, reached through a link: its `.git` + // becomes the pointer file naming it. + const gitDir = join(checkout, 'gitdir-real') + renameSync(join(checkout, '.git'), gitDir) + const holder = join(checkout, 'links') + mkdirSync(holder, { recursive: true }) + const hop = join(holder, 'hop') + symlinkSync(gitDir, hop) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${hop}\n`) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = makeGitDir(join(checkout, 'evil-gitdir')) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! ln -sfn ${evil} ${hop} || echo HOP_RETARGETABLE; ` + + `! mv ${hop} ${hop}.aside || echo HOP_RENAMABLE; ` + + `! rm -f ${hop} || echo HOP_UNLINKABLE; ` + + `! mkdir ${join(holder, 'other')} || echo HOLDER_WRITABLE; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! echo x > ${join(gitDir, 'hooks', 'post-commit')} || echo HOOKS_WRITABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - // Use a clean directory with NO .git - const noGitDir = join(TEST_DIR, 'no-git-dir') - mkdirSync(noGitDir, { recursive: true }) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('HOP_RETARGETABLE') + expect(result.stdout).not.toContain('HOP_RENAMABLE') + expect(result.stdout).not.toContain('HOP_UNLINKABLE') + expect(result.stdout).not.toContain('HOLDER_WRITABLE') + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_WRITABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The host still resolves the pointer where it did. + expect(readlinkSync(hop)).toBe(gitDir) + }, + 300000, + ) - const originalDir = process.cwd() - process.chdir(noGitDir) + it.if(LIVE)( + "records that a hop of a pointer's value in the working directory can still be retargeted", + async () => { + // The same limit from the other producer, pinned as a fact: no bind + // holds a link the working directory itself holds, so the wrap warns + // and the retarget lands. The pointer file and the git directory it + // named are held all the same. + const checkout = join(dir, 'repo') + const { pointer, hop, gitDir } = makePointerChain(checkout) + const evil = makeGitDir(join(checkout, 'evil-gitdir')) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `ln -sfn ${evil} ${hop} || echo HOP_HELD; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! echo x > ${join(gitDir, 'hooks', 'post-commit')} || echo HOOKS_WRITABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - try { - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], - } + expect(warnings.join('\n')).toContain(hop) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('HOOKS_WRITABLE') + // Honestly recorded: the hop moved, so the host's git would now read + // the git directory the command chose. + expect(result.stdout).not.toContain('HOP_HELD') + expect(readlinkSync(hop)).toBe(evil) + }, + 300000, + ) - // This calls linuxGetMandatoryDenyPaths which unconditionally adds - // .git/hooks to the deny list. When .git doesn't exist, - // findFirstNonExistentComponent returns .git and bwrap mounts - // /dev/null there — making .git a file. - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'git init && git status', + it.if(LIVE)( + 'records that a hop the working directory holds can still be retargeted', + async () => { + // The limit, pinned as a fact rather than left to be found: there + // is no bind that holds this link, so the wrap warns and the + // retarget lands. Both ends of the chain are held all the same. + const checkout = join(dir, 'repo') + const { gitDir, hop, landing } = makeChain(checkout) + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `ln -sfn ${evil} ${hop} || echo HOP_HELD; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `! echo x > ${join(landing, 'post-commit')} || echo THROUGH_LINK_WRITABLE; ` + + 'echo DONE', needsNetworkRestriction: false, + allowAllUnixSockets: true, readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - }) + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - const result = spawnSync(wrappedCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }) + expect(warnings.join('\n')).toContain(hop) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + // Honestly recorded: the hop moved, so the host's git would now + // resolve `.git/hooks` to the directory the command chose. + expect(result.stdout).not.toContain('HOP_HELD') + expect(readlinkSync(hop)).toBe(evil) + }, + 300000, + ) + }) - // git init + git status should succeed — .git must be creatable as - // a directory, not blocked by a /dev/null file mount. - expect(result.status).toBe(0) + /** + * A chain that reaches NOTHING - a loop, or a name no file can occupy - + * has no landing to deny, so the links it goes through are the whole of + * what holds it. Each is a name a command replaces with a directory to + * make the chain reach one, which is why a hop this backend cannot bind + * is a refusal here rather than the warning a resolved chain gets. + */ + describe('a chain that reaches nothing', () => { + /** A repository whose `hooks` runs into a two-link loop in `holder`. */ + function makeLoop(holder: string): { + checkout: string + gitDir: string + first: string + second: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + mkdirSync(holder, { recursive: true }) + const first = join(holder, 'a') + const second = join(holder, 'b') + symlinkSync('b', first) + symlinkSync('a', second) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(first, join(gitDir, 'hooks')) + return { checkout, gitDir, first, second } + } - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(noGitDir, { recursive: true, force: true }) - } - }) + it.if(isLinux)( + 'binds the directory holding a loop read-only where it is a subdirectory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir } = makeLoop(holder) + process.chdir(checkout) - it('git worktree with .git as a file does not break sandboxed commands', async () => { - // Reproduces the bug reported by nvidia/netflix with git worktrees: - // In a worktree, .git is a FILE (e.g., "gitdir: /path/to/.git/worktrees/foo"), - // not a directory. The mandatory deny list includes .git/hooks, but since - // .git is a file, .git/hooks doesn't exist. The non-existent path handling - // tries to mount /dev/null at .git/hooks, but bwrap can't create a mount - // point under .git because it's a file — causing every command to fail. + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - const worktreeDir = join(TEST_DIR, 'fake-worktree') - mkdirSync(worktreeDir, { recursive: true }) + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', holder, holder), + ).toBeGreaterThan(writeRootBind) + // The entry keeps what it had: the git directory denied whole. + expect( + lastIndexOfMount(command, '--ro-bind', gitDir, gitDir), + ).toBeGreaterThan(writeRootBind) + }, + 60000, + ) - // Simulate a git worktree: .git is a file, not a directory - writeFileSync( - join(worktreeDir, '.git'), - 'gitdir: /tmp/fake-main-repo/.git/worktrees/my-branch', + it.if(isLinux)( + 'refuses the command where the working directory holds the loop', + async () => { + // There is no bind for the link and no far end to fall back on, so + // the answer a resolved chain gets - warn, and rely on both ends - + // holds nothing here. This is what such a repository got before any + // of these denies existed: the deny path could not be resolved and + // the wrap failed closed. + const checkout = join(dir, 'repo') + const { gitDir, first } = makeLoop(checkout) + process.chdir(checkout) + + const error = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_unresolvable_git_entry', + ) + // The entry, the link and the directory holding it: each of the + // three ways out the message names works on one of them. + expect((error as Error).message).toContain(join(gitDir, 'hooks')) + expect((error as Error).message).toContain(first) + expect((error as Error).message).toContain(checkout) + }, + 60000, + ) + + it.if(isLinux)( + 'never denies the working directory for a pointer value that loops', + async () => { + // A `.git` FILE is something a sandboxed command may write, so + // failing closed on the deepest directory still reachable towards + // its value would let one command lock every later one out of the + // project. The pointer file and the loop's links are denied; the + // checkout is not, and nothing is refused. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + const first = join(checkout, 'a') + symlinkSync('b', first) + symlinkSync('a', join(checkout, 'b')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: a/gd\n') + process.chdir(checkout) + + const { result: command, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), ) - const originalDir = process.cwd() - process.chdir(worktreeDir) + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', checkout, checkout), + ).toBeLessThan(writeRootBind) + expect( + lastIndexOfMount(command, '--ro-bind', pointer, pointer), + ).toBeGreaterThan(writeRootBind) + expect(warnings.join('\n')).toContain(pointer) + }, + 60000, + ) - try { - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], - } + it.if(LIVE)( + 'refuses every way to make a loop a subdirectory holds reach a directory', + async () => { + const checkout = join(dir, 'repo') + const holder = join(checkout, 'links') + const { gitDir, first, second } = makeLoop(holder) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + const evil = join(checkout, 'evil-hooks') + mkdirSync(evil, { recursive: true }) + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! rm -f ${first} || echo LINK_UNLINKABLE; ` + + `! mv ${second} ${second}.aside || echo LINK_RENAMABLE; ` + + `! ln -sfn ${evil} ${first} || echo LINK_RETARGETABLE; ` + + `! mkdir -p ${join(holder, 'a', 'x')} || echo LINK_REPLACEABLE_BY_DIR; ` + + `! echo x > ${join(holder, 'a')} || echo LINK_REPLACEABLE_BY_FILE; ` + + `! rm -f ${join(gitDir, 'hooks')} || echo ENTRY_UNLINKABLE; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - // linuxGetMandatoryDenyPaths adds .git/hooks to deny list. - // .git exists as a file, so .git/hooks doesn't exist. - // The code will try to mount /dev/null at .git/hooks, but bwrap - // can't create a mount point there because .git is a file. - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'echo hello', + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('LINK_UNLINKABLE') + expect(result.stdout).not.toContain('LINK_RENAMABLE') + expect(result.stdout).not.toContain('LINK_RETARGETABLE') + expect(result.stdout).not.toContain('LINK_REPLACEABLE_BY_DIR') + expect(result.stdout).not.toContain('LINK_REPLACEABLE_BY_FILE') + expect(result.stdout).not.toContain('ENTRY_UNLINKABLE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The loop is where it was: `.git/hooks` still reaches nothing. + expect(readlinkSync(first)).toBe('b') + expect(readlinkSync(second)).toBe('a') + }, + 300000, + ) + + it.if(LIVE)( + 'leaves the project writable for the command after one that plants a looping pointer', + async () => { + // Two wraps, as a session runs them. The first writes a `.git` file + // whose value runs into a loop in the working directory - an + // ordinary write inside the write root, and one creating a `.git` + // is deliberately allowed to make. The second still writes the + // project: what it cannot touch is the pointer file itself, which + // is removed from outside the sandbox. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + const pointer = join(checkout, '.git') + + const plant = await wrapCommandWithSandboxLinux({ + command: + `ln -sfn b ${join(checkout, 'a')} && ` + + `ln -sfn a ${join(checkout, 'b')} && ` + + `printf 'gitdir: a/gd\\n' > ${pointer} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const planted = spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) + expect(planted.stdout).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const { result: after, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `mkdir -p ${join(checkout, 'sub')} || echo SUBDIR_UNCREATABLE; ` + + `! echo x > ${pointer} || echo POINTER_WRITABLE; ` + + `! rm -f ${pointer} || echo POINTER_REMOVABLE; ` + + 'echo DONE', needsNetworkRestriction: false, + allowAllUnixSockets: true, readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, - }) + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }), + ) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - const result = spawnSync(wrappedCommand, { + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + // The project is the command's, as it was before the plant. + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('SUBDIR_UNCREATABLE') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + // The pointer file is not: it stays until it is removed from + // outside the sandbox, and the wrap says which file that is. + expect(result.stdout).not.toContain('POINTER_WRITABLE') + expect(result.stdout).not.toContain('POINTER_REMOVABLE') + expect(warnings.join('\n')).toContain(pointer) + }, + 300000, + ) + }) + + /** + * Every whole-directory deny is a stand-in read off paths a sandboxed + * command can write, so each is a way to aim one at the project itself. + * None of them may: the deny is dropped where what is beneath it is + * known, and the command refused where it is not. + */ + describe('a deny aimed at the project', () => { + const NOT_ROOT = isLinux && process.getuid?.() !== 0 + + it.if(LIVE)( + 'leaves the project writable after a pointer that names the checkout', + async () => { + // One command: a `.git` file naming the checkout, and an entry + // named `hooks` that is a link, which makes the checkout a git + // directory with a symlinked entry - denied WHOLE before this. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: + `printf 'ref: refs/heads/main\\n' > ${join(checkout, 'HEAD')} && ` + + `ln -sfn nowhere ${join(checkout, 'hooks')} && ` + + `printf 'gitdir: .\\n' > ${join(checkout, '.git')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { shell: true, encoding: 'utf8', - timeout: 10000, - }) + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `mkdir -p ${join(checkout, 'sub')} || echo SUBDIR_UNCREATABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - // A simple echo should succeed — the .git-as-file worktree layout - // should not cause the sandbox to fail. - expect(result.status).toBe(0) - expect(result.stdout.trim()).toBe('hello') + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('SUBDIR_UNCREATABLE') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) - cleanupBwrapMountPoints() - } finally { - process.chdir(originalDir) - rmSync(worktreeDir, { recursive: true, force: true }) - } - }) + it.if(LIVE)( + 'leaves the project writable after a modules entry linked at the checkout', + async () => { + // The same end state with no pointer file at all: the walk follows + // `.git/modules/x` to the checkout, which has an entry named + // `hooks`, so it reads as a git directory whose entry is a link. + const checkout = join(dir, 'repo') + const gitDir = join(checkout, '.git') + mkdirSync(join(gitDir, 'modules'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: + `ln -sfn nowhere ${join(checkout, 'hooks')} && ` + + `ln -sfn ../.. ${join(gitDir, 'modules', 'x')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - it('does not leave ghost dotfiles after command + cleanup cycle', async () => { - // This is the exact scenario from issue #85: running a sandboxed command - // should NOT leave .bashrc, .gitconfig, etc. in the working directory. - // - // The mandatory deny list includes paths like ~/.bashrc, ~/.gitconfig. - // When CWD is within an allowed write path and these dotfiles don't exist - // in CWD, the old code left empty mount point files behind. + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(readFileSync(join(checkout, 'f.txt'), 'utf8')).toBe('ok\n') + }, + 300000, + ) - // Use a clean subdirectory with no dotfiles - const cleanDir = join(TEST_DIR, 'clean-subdir') - mkdirSync(cleanDir, { recursive: true }) + it.if(NOT_ROOT)( + 'refuses rather than deny a working directory it cannot list', + async () => { + // The one whole-directory deny nothing stands in for: what is + // beneath an unlistable directory is unknown, so dropping it would + // run the command against an unscanned tree and keeping it locks + // the project. Refusing names the directory, and one chmod outside + // the sandbox lifts it. + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + process.chdir(checkout) + // Searchable, so chdir and the walk's own lstat still work, and + // unreadable, so neither rg nor the walk can list it. + chmodSync(checkout, 0o100) + try { + const error = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }).catch((e: unknown) => e) - const originalDir = process.cwd() - process.chdir(cleanDir) + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toContain(checkout) + } finally { + chmodSync(checkout, 0o755) + } + }, + 60000, + ) + it.if(LIVE && NOT_ROOT)( + 'keeps wrapping a tree holding a directory that is readable but not searchable', + async () => { + // mode 0400: its entries list, and nothing in it can be reached. + // The walk comes here for the CHILD whose own listing failed, and + // recording that child - a path the host cannot stat either - asks + // bubblewrap for a file mount at what is really a directory, which + // fails every later wrap with an untyped error. + const checkout = join(dir, 'repo') + const readable = join(checkout, 'a') + mkdirSync(join(readable, 'nested', '.git'), { recursive: true }) + writeFileSync( + join(readable, 'nested', '.git', 'HEAD'), + 'ref: refs/heads/main\n', + ) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + chmodSync(readable, 0o400) try { - // Run a simple command through the sandbox - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: [] as string[], - } - - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command: 'echo hello', + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `! mkdir ${join(readable, 'x')} || echo UNSEARCHABLE_WRITABLE; ` + + 'echo DONE', needsNetworkRestriction: false, + allowAllUnixSockets: true, readConfig: undefined, - writeConfig, - enableWeakerNestedSandbox: true, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - - spawnSync(wrappedCommand, { + const result = spawnSync(command, { shell: true, encoding: 'utf8', - timeout: 10000, + timeout: 120000, + cwd: checkout, }) - // Run cleanup (as the CLI / Claude Code would) - cleanupBwrapMountPoints() - - // Verify no ghost dotfiles were left behind - const { readdirSync } = await import('node:fs') - const files = readdirSync(cleanDir) - const ghostDotfiles = files.filter(f => f.startsWith('.')) - expect(ghostDotfiles).toEqual([]) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stderr).not.toContain('Is a directory') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + // The directory that cannot be seen through is denied whole. + expect(result.stdout).not.toContain('UNSEARCHABLE_WRITABLE') } finally { - process.chdir(originalDir) - rmSync(cleanDir, { recursive: true, force: true }) + chmodSync(readable, 0o755) } - }) - }, - ) - - describe.if(isLinux)( - 'Symlink replacement attack protection (Linux only)', - () => { - // This tests the fix for symlink replacement attacks where an attacker - // could delete a symlink and create a real directory with malicious content + }, + 300000, + ) - async function runSandboxedCommandWithDenyPaths( - command: string, - denyPaths: string[], - ): Promise<{ success: boolean; stdout: string; stderr: string }> { - const platform = getPlatform() - if (platform !== 'linux') { - return { success: true, stdout: '', stderr: '' } - } + it.if(NOT_ROOT)( + 'wraps a project holding an unreadable directory at the scan depth limit', + async () => { + // ripgrep opens a directory that sits AT its --max-depth to list + // it, and reports one it cannot read; this walk stops a level + // above, so a directory another user owns exactly that far down - + // a `docker/data/postgres`, mode 0700 - was an EACCES the walk + // found nothing for, and every command in the project refused. + const checkout = join(dir, 'repo') + const deep = join(checkout, 'docker', 'data', 'postgres') + mkdirSync(deep, { recursive: true }) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + chmodSync(deep, 0o000) + try { + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) - const writeConfig = { - allowOnly: ['.'], - denyWithinAllow: denyPaths, + expect(command).toContain('bwrap') + } finally { + chmodSync(deep, 0o755) } + }, + 60000, + ) + }) - const wrappedCommand = await wrapCommandWithSandboxLinux({ - command, + /** + * `modules` is where a git directory keeps the git directories of its + * submodules. It is no deny path of its own - that would take every one + * of them read-only - but it is an entry a command can replace, and + * replacing it aims every submodule this repository has at hooks nothing + * denied. It is held the way a chain hop is. + */ + describe('a modules directory that is a symlink', () => { + it.if(LIVE)( + "refuses a directory put in place of a submodule's modules link", + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const submodule = makeGitDir(join(gitDir, 'modules', 'foo')) + const outside = join(checkout, 'nm') + makeGitDir(join(outside, 'bar')) + symlinkSync('../../../nm', join(submodule, 'modules')) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const command = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `! echo x > ${join(outside, 'bar', 'hooks', 'post-checkout')} || echo THROUGH_LINK_WRITABLE; ` + + `! echo x > ${join(submodule, 'hooks', 'post-checkout')} || echo SUBMODULE_HOOKS_WRITABLE; ` + + `! rm ${join(submodule, 'modules')} || echo MODULES_LINK_REMOVABLE; ` + + // The reviewer's own sequence: replace the link with a real + // directory of the same name and write the hook into it. + `! { rm ${join(submodule, 'modules')} && ` + + `mkdir -p ${join(submodule, 'modules', 'bar', 'hooks')} && ` + + `echo x > ${join(submodule, 'modules', 'bar', 'hooks', 'post-checkout')}; } || echo MODULES_REPLACED; ` + + `echo ok > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + 'echo DONE', needsNetworkRestriction: false, + allowAllUnixSockets: true, readConfig: undefined, - writeConfig, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, }) - - const result = spawnSync(wrappedCommand, { + const result = spawnSync(command, { shell: true, encoding: 'utf8', - timeout: 10000, + timeout: 120000, + cwd: checkout, }) - return { - success: result.status === 0, - stdout: result.stdout || '', - stderr: result.stderr || '', - } - } - - // bwrap mount destinations resolve symlinks, so a deny path crossing - // a directory symlink is canonicalized and the deny applied to the - // resolved target (resolve-before-mask). Masking the raw symlink - // with /dev/null instead made bwrap abort at startup, failing every - // sandboxed command whenever .claude was a symlink. The guarantees - // around a deny path whose parent stays writable are unchanged from - // the non-symlink case. - it('denies writes through a symlinked .claude directory', async () => { - // Setup: Create a symlink .claude -> decoy (simulating malicious git repo) - const decoyDir = 'symlink-decoy' - const claudeSymlink = 'symlink-claude' - mkdirSync(decoyDir, { recursive: true }) - writeFileSync(join(decoyDir, 'settings.json'), '{}') - symlinkSync(decoyDir, claudeSymlink) - - try { - // The deny path is the settings.json through the symlink - const denyPath = join(TEST_DIR, claudeSymlink, 'settings.json') - - // The sandbox must start despite the directory symlink in the - // deny path (bwrap used to abort on the /dev/null symlink mask). - const benign = await runSandboxedCommandWithDenyPaths('true', [ - denyPath, - ]) - expect(benign.success).toBe(true) - - // Writing through the symlink must fail: the deny lands on the - // resolved target, the inode the write actually reaches. - const result = await runSandboxedCommandWithDenyPaths( - `echo '{"hooks":{}}' > ${claudeSymlink}/settings.json`, - [denyPath], - ) - expect(result.success).toBe(false) - expect(readFileSync(join(decoyDir, 'settings.json'), 'utf8')).toBe( - '{}', - ) - } finally { - // Cleanup - rmSync(claudeSymlink, { force: true }) - rmSync(decoyDir, { recursive: true, force: true }) - } - }) - - it('denies writes to a file reached through a directory symlink', async () => { - // Setup: Create a symlink - const targetDir = 'symlink-target-dir' - const symlinkPath = 'protected-symlink' - mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, 'file.txt'), 'content') - symlinkSync(targetDir, symlinkPath) - - try { - const denyPath = join(TEST_DIR, symlinkPath, 'file.txt') + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + expect(result.stdout).not.toContain('THROUGH_LINK_WRITABLE') + expect(result.stdout).not.toContain('SUBMODULE_HOOKS_WRITABLE') + expect(result.stdout).not.toContain('MODULES_LINK_REMOVABLE') + expect(result.stdout).not.toContain('MODULES_REPLACED') + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + // Nothing of it reached the host: the link is where it was, and + // there is no hook at either end of it. + expect(lstatSync(join(submodule, 'modules')).isSymbolicLink()).toBe( + true, + ) + expect( + existsSync(join(outside, 'bar', 'hooks', 'post-checkout')), + ).toBe(false) + }, + 300000, + ) - // Assert the sandbox starts first: a write-denied assertion alone - // also passes when bwrap aborts, which is the failure this deny - // path used to cause. - const benign = await runSandboxedCommandWithDenyPaths('true', [ - denyPath, - ]) - expect(benign.success).toBe(true) + it.if(LIVE)( + 'costs the repository its sandboxed git writes when a command plants the link', + async () => { + // The price of holding the link, recorded rather than left to be + // found: an ordinary repository has no `.git/modules`, and an + // absent one is deliberately not denied (a mount there would stop + // `git submodule add`). So one `ln -s` makes `.git` a git + // directory with a symlinked entry, which is denied whole from the + // next wrap on - and nothing inside the sandbox can remove the + // link, because `.git` is read-only by then. + const git = Bun.which('git') as string + const checkout = join(dir, 'repo') + mkdirSync(checkout, { recursive: true }) + expect( + spawnSync( + git, + ['-c', 'init.defaultBranch=main', 'init', '-q', checkout], + { encoding: 'utf8', timeout: 60000 }, + ).status, + ).toBe(0) + writeFileSync(join(checkout, 'f.txt'), 'one\n') + process.chdir(checkout) + + const plant = await wrapCommandWithSandboxLinux({ + command: `ln -sfn nowhere ${join(checkout, '.git', 'modules')} && echo PLANTED`, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect( + spawnSync(plant, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }).stdout, + ).toContain('PLANTED') + cleanupBwrapMountPoints({ force: true }) + + const after = await wrapCommandWithSandboxLinux({ + command: + 'echo BOOTED; ' + + `${git} -C ${checkout} status --porcelain > /dev/null || echo GIT_STATUS_FAILED; ` + + `echo two > ${join(checkout, 'f.txt')} || echo PROJECT_FILE_READONLY; ` + + `${git} -C ${checkout} add f.txt && ${git} -C ${checkout} -c user.email=a@b -c user.name=n commit -q -m x && echo GIT_COMMIT_WORKED; ` + + `! rm ${join(checkout, '.git', 'modules')} || echo LINK_REMOVABLE; ` + + `! echo x > ${join(checkout, '.git', 'hooks', 'post-commit')} || echo HOOK_PLANTABLE; ` + + 'echo DONE', + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + const result = spawnSync(after, { + shell: true, + encoding: 'utf8', + timeout: 120000, + cwd: checkout, + }) - // The file is write-protected both through the symlink and via - // its resolved path. - for (const writePath of [ - `${symlinkPath}/file.txt`, - `${targetDir}/file.txt`, - ]) { - const result = await runSandboxedCommandWithDenyPaths( - `echo tampered > ${writePath}`, - [denyPath], - ) - expect(result.success).toBe(false) - } - expect(readFileSync(join(targetDir, 'file.txt'), 'utf8')).toBe( - 'content', - ) - } finally { - rmSync(symlinkPath, { force: true }) - rmSync(targetDir, { recursive: true, force: true }) - } - }) - }, - ) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).toContain('DONE') + // The work tree is the command's, and reading the repository works. + expect(result.stdout).not.toContain('PROJECT_FILE_READONLY') + expect(result.stdout).not.toContain('GIT_STATUS_FAILED') + // What it costs: everything that writes the git directory. + expect(result.stdout).not.toContain('GIT_COMMIT_WORKED') + expect(result.stdout).not.toContain('LINK_REMOVABLE') + expect(result.stdout).not.toContain('HOOK_PLANTABLE') + }, + 300000, + ) + }) }, ) - -describe('macGetMandatoryDenyEntries - Unit Tests', () => { - it('includes .git/config in deny patterns when allowGitConfig is false', () => { - const patterns = macGetMandatoryDenyEntries(false).map(e => e.path) - - // Should include .git/config pattern - const hasGitConfigPattern = patterns.some( - p => p.includes('.git/config') || p.endsWith('.git/config'), - ) - expect(hasGitConfigPattern).toBe(true) - }) - - it('excludes .git/config from deny patterns when allowGitConfig is true', () => { - const patterns = macGetMandatoryDenyEntries(true).map(e => e.path) - - // Should NOT include .git/config pattern - const hasGitConfigPattern = patterns.some( - p => p.includes('.git/config') || p.endsWith('.git/config'), - ) - expect(hasGitConfigPattern).toBe(false) - }) - - it('always includes .git/hooks in deny patterns regardless of allowGitConfig', () => { - const patternsWithoutGitConfig = macGetMandatoryDenyEntries(false).map( - e => e.path, - ) - const patternsWithGitConfig = macGetMandatoryDenyEntries(true).map( - e => e.path, - ) - - // Both should include .git/hooks pattern - const hasHooksPatternFalse = patternsWithoutGitConfig.some(p => - p.includes('.git/hooks'), - ) - const hasHooksPatternTrue = patternsWithGitConfig.some(p => - p.includes('.git/hooks'), - ) - - expect(hasHooksPatternFalse).toBe(true) - expect(hasHooksPatternTrue).toBe(true) - }) - - it('defaults to blocking .git/config when no argument provided', () => { - const patterns = macGetMandatoryDenyEntries().map(e => e.path) - - const hasGitConfigPattern = patterns.some( - p => p.includes('.git/config') || p.endsWith('.git/config'), - ) - expect(hasGitConfigPattern).toBe(true) - }) -}) diff --git a/test/sandbox/mandatory-deny-time-budget.test.ts b/test/sandbox/mandatory-deny-time-budget.test.ts new file mode 100644 index 000000000..3174fae63 --- /dev/null +++ b/test/sandbox/mandatory-deny-time-budget.test.ts @@ -0,0 +1,780 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, + LinuxSandboxProfileError, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenies, + gitDirTreeDenies, + gitFileDenies, +} from '../../src/sandbox/mandatory-deny-paths.js' +import type { GitDirDenies } from '../../src/sandbox/mandatory-deny-paths.js' +import { + NO_COLLAPSE, + collapseEverything, + collapseFloor, + collapseFurther, + describeCollapse, +} from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir +} + +/** + * The passes a profile that does not fit is built in. Each one builds the + * whole profile again, so what bounds the passes bounds the wrap: a + * repository a command can make must not decide how many there are. + */ +describe.if(isLinux)('Passes over a profile that does not fit', () => { + let dir: string + /** A write root of its own with nothing under it, which is what makes the + * passes countable: see `wrapCountingPasses`. */ + let passMarker: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-collapse-passes-'))) + passMarker = join(dir, 'pass-marker') + mkdirSync(passMarker) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A repository with `count` submodule git directories, named so that + * sorted order is the order they were made in. */ + function makeSuperproject( + checkout: string, + count: number, + ): { checkout: string; subs: string[] } { + const gitDir = makeGitDir(join(checkout, '.git')) + const subs: string[] = [] + for (let i = 0; i < count; i++) { + const name = `s${String(i).padStart(5, '0')}` + subs.push(makeGitDir(join(gitDir, 'modules', name))) + } + return { checkout, subs } + } + + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } + + /** + * One wrap from the working directory with `writeRoot` what the command may + * write, and how many times its profile was built: every pass looks once at + * whether each write root exists, and nothing else in a wrap asks that of a + * write root that holds nothing, which is what the marker is. + * + * `spentWords` is how the budget is set: each name to unset is two words + * bubblewrap parses before the mounts, counted as they are, so what is left + * for the mounts shrinks by exactly that much. + */ + async function wrapCountingPasses( + writeRoot: string, + spentWords: number, + ): Promise<{ outcome: unknown; passes: number; warnings: string[] }> { + const realExistsSync = fs.existsSync + let passes = 0 + const spy = spyOn(fs, 'existsSync').mockImplementation((( + p: fs.PathLike, + ) => { + if (String(p) === passMarker) passes++ + return realExistsSync(p) + }) as typeof fs.existsSync) + try { + const { result, warnings } = await withCapturedWarnings(() => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + // No seccomp helper, whose presence differs by machine. + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { + allowOnly: [writeRoot, passMarker], + denyWithinAllow: [], + }, + unsetEnvVars: Array.from( + { length: spentWords / 2 }, + (_, i) => `SRT_SPENT_${i}`, + ), + // A refusal is an outcome like any other here: what it was is for + // the caller to say. + }).catch((refusal: unknown) => refusal), + ) + return { outcome: result, passes, warnings } + } finally { + spy.mockRestore() + } + } + + const didNotFit = (warnings: string[]): boolean => + warnings.some(warning => warning.includes('did not fit')) + + it('stops stepping where a deeper level gives no words back', async () => { + // Four hundred submodule git directories the command may not write: no + // deny of theirs takes a mount, since the read-only root already holds + // them, so degrading one gives back nothing. The words that do not fit + // are the root's and the write root's own binds, which nothing degrades. + // Stepping through such a plan an entry at a time builds the profile four + // hundred times over to arrive where it was always going to. + const writeRoot = join(dir, 'work') + mkdirSync(writeRoot) + + // Where the profile stops fitting is the same for one submodule as for + // four hundred - none of them costs a word - so it is found on the small + // repository: the least that can be spent before the mounts for the wrap + // to say its profile did not fit. + const probe = makeSuperproject(join(dir, 'probe'), 1) + process.chdir(probe.checkout) + let fits = 0 + let doesNotFit = 9000 + while (doesNotFit - fits > 2) { + const spent = 2 * Math.floor((fits + doesNotFit) / 4) + if (didNotFit((await wrapCountingPasses(writeRoot, spent)).warnings)) { + doesNotFit = spent + } else { + fits = spent + } + } + expect((await wrapCountingPasses(writeRoot, fits)).passes).toBe(1) + + const { checkout } = makeSuperproject(join(dir, 'repo'), 400) + process.chdir(checkout) + const { outcome, passes, warnings } = await wrapCountingPasses( + writeRoot, + doesNotFit, + ) + + // One pass that does not fit, one a step deeper that is no shorter, and + // one at the last level, which is what is returned. + expect(didNotFit(warnings)).toBe(true) + expect(passes).toBeGreaterThan(1) + expect(passes).toBeLessThanOrEqual(3) + // Wrapped all the same: the few words the mounts are over by are inside + // what is kept back for the words that come after them. + expect(typeof outcome).toBe('string') + }, 120000) + + it('goes to the level the steps would have run out at', () => { + // Two repositories, three submodule git directories and two: whatever a + // step is asked to give back, the steps end where every one of the five + // and both `modules` directories are denied whole, and nowhere short of + // it or past it. + const repository = ( + modulesDir: string, + names: string[], + ): SubmoduleDenyPlan['repositories'][number] => ({ + modulesDir, + wholeDirDenies: [], + gitDirs: names.map(name => ({ + gitDir: join(modulesDir, name), + denyPaths: [ + join(modulesDir, name, 'hooks'), + join(modulesDir, name, 'config'), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })), + }) + const repositories = [ + repository(join(dir, 'repo', '.git', 'modules'), ['a', 'b', 'c']), + repository(join(dir, 'repo', 'nested', '.git', 'modules'), ['d', 'e']), + ] + const plan: SubmoduleDenyPlan = { + denyPaths: repositories.flatMap(each => + each.gitDirs.flatMap(submodule => submodule.denyPaths), + ), + repositories, + chainHops: [], + } + + for (const mountsOver of [1, 3, 100]) { + let level = NO_COLLAPSE + for ( + let deeper = collapseFurther(plan, level, mountsOver); + deeper !== undefined; + deeper = collapseFurther(plan, level, mountsOver) + ) { + level = deeper + } + expect(collapseEverything(plan)).toEqual(level) + } + expect(collapseEverything(plan)).toEqual({ + wholeGitDirs: 5, + wholeModulesDirs: 2, + }) + }) + + it('does not count its way through entries that take no mount', () => { + // A thousand submodule git directories the command may not write, and + // eleven deny paths it may where ten fit. Nothing that can be degraded gives + // a mount back, and a step takes as few entries as would make up for one. + const modulesDir = join(dir, 'elsewhere', '.git', 'modules') + const gitDirs = Array.from({ length: 1000 }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: [join(modulesDir, `s${i}`, 'hooks')], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })) + const plan: SubmoduleDenyPlan = { + denyPaths: [ + ...Array.from({ length: 11 }, (_unused, i) => join(dir, `deny${i}`)), + ...gitDirs.flatMap(each => each.denyPaths), + ], + repositories: [{ modulesDir, gitDirs, wholeDirDenies: [] }], + chainHops: [], + } + let asked = 0 + + const level = collapseFloor(plan, 10, denyPath => { + asked++ + return !denyPath.startsWith(modulesDir) + }) + + expect(level).toEqual(collapseEverything(plan)) + expect(asked).toBeLessThanOrEqual(4 * plan.denyPaths.length) + }) + + it('steps past entries that take no mount to those that do', () => { + // Served last, so taken first: seventy git directories the command may not + // write. Degrading them gives nothing back, and is no reason to degrade + // every one of those it may. + const repository = ( + modulesDir: string, + count: number, + ): SubmoduleDenyPlan['repositories'][number] => ({ + modulesDir, + wholeDirDenies: [], + gitDirs: Array.from({ length: count }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: ['hooks', 'config'].map(name => + join(modulesDir, `s${i}`, name), + ), + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })), + }) + const repositories = [ + repository(join(dir, 'repo', '.git', 'modules'), 10), + repository(join(dir, 'elsewhere', '.git', 'modules'), 70), + ] + const plan: SubmoduleDenyPlan = { + denyPaths: repositories.flatMap(each => + each.gitDirs.flatMap(kept => kept.denyPaths), + ), + repositories, + chainHops: [], + } + + // Twenty deny paths that take a mount, and room for eighteen: two of the + // ten, each two paths for one bind, make up for it. + expect( + collapseFloor(plan, 18, denyPath => + denyPath.startsWith(join(dir, 'repo')), + ), + ).toEqual({ wholeGitDirs: 72, wholeModulesDirs: 0 }) + + // And so for the directories that hold them: the one that takes no mount + // gives nothing back, and takes nothing from what the next one gives, nine + // binds of its ten. The first is left as it is. + const further = [ + repository(join(dir, 'repo', 'first', '.git', 'modules'), 10), + ...repositories, + ] + expect( + collapseFurther( + { ...plan, repositories: further }, + { wholeGitDirs: 90, wholeModulesDirs: 0 }, + 9, + denyPath => denyPath.startsWith(join(dir, 'repo')), + ), + ).toEqual({ wholeGitDirs: 90, wholeModulesDirs: 2 }) + }) + + it('resolves each path once to say what was degraded', () => { + // Both numbers are a command's to choose, and their product is minutes. + const modulesDir = join(dir, 'repo', '.git', 'modules') + const gitDirs = Array.from({ length: 200 }, (_unused, i) => ({ + gitDir: join(modulesDir, `s${i}`), + denyPaths: [join(modulesDir, `s${i}`, 'hooks')], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + })) + const wholeDirDenies = Array.from({ length: 50 }, (_unused, i) => + join(modulesDir, `whole${i}`), + ) + const plan: SubmoduleDenyPlan = { + denyPaths: gitDirs.flatMap(each => each.denyPaths), + repositories: [{ modulesDir, gitDirs, wholeDirDenies }], + chainHops: [], + } + const resolve = spyOn(fs.realpathSync, 'native') + try { + expect(describeCollapse(plan, collapseEverything(plan))).toContain( + '200 of the 200 submodule git directories', + ) + expect(resolve.mock.calls.length).toBeLessThanOrEqual( + 2 * (gitDirs.length + wholeDirDenies.length), + ) + } finally { + resolve.mockRestore() + } + }) + + it('degrades once a git directory that two repositories lead to', async () => { + // A nested submodule's is reached from the repository that keeps it and + // through the `.git` file of the checkout it is nested in. + const { checkout, subs } = makeSuperproject(join(dir, 'repo'), 2) + makeGitDir(join(subs[0]!, 'modules', 'nested')) + mkdirSync(join(checkout, 'sub')) + writeFileSync(join(checkout, 'sub', '.git'), `gitdir: ${subs[0]!}\n`) + process.chdir(checkout) + + const { warnings } = await wrapCountingPasses(checkout, 8_900) + + const modules = join(checkout, '.git', 'modules') + const told = warnings.join('\n') + expect(told).toContain( + `3 of the 3 submodule git directories under ${modules} are`, + ) + expect(told).toContain(`; ${modules} is denied whole`) + }) + + it('still stops at the first level that fits where stepping does give words back', async () => { + // A hundred submodule git directories the command MAY write, in a budget + // that holds some of them precise and not all: each one degraded gives + // back the twelve words of its four denies, so every twelve words less + // to spend on mounts degrades exactly one more, and never the lot. + const { checkout, subs } = makeSuperproject(join(dir, 'repo'), 100) + process.chdir(checkout) + + const preciseAt = async ( + spentWords: number, + ): Promise<{ precise: string[]; passes: number }> => { + const { outcome, passes } = await wrapCountingPasses(checkout, spentWords) + expect(outcome).toBeTypeOf('string') + const denied = new Set(mountWords(outcome as string)) + const precise = subs.filter(sub => denied.has(join(sub, 'hooks'))) + // The first in sorted order keep their precise denies, and every one + // after them is one read-only bind of the git directory. + expect(precise).toEqual(subs.slice(0, precise.length)) + for (const sub of subs.slice(precise.length)) { + expect(denied.has(sub)).toBe(true) + } + return { precise, passes } + } + + const spent = 8000 + const first = await preciseAt(spent) + expect(first.precise.length).toBeGreaterThan(1) + expect(first.precise.length).toBeLessThan(subs.length - 1) + // Measured, not predicted: the level the count alone starts from is not + // the one that fits, and the passes after it are what find that. + expect(first.passes).toBeGreaterThan(1) + expect(first.passes).toBeLessThanOrEqual(4) + + const tighter = await preciseAt(spent + 12) + expect(tighter.precise.length).toBe(first.precise.length - 1) + const looser = await preciseAt(spent - 12) + expect(looser.precise.length).toBe(first.precise.length + 1) + }, 120000) +}) + +/** + * A symlink chain, and the path a `.git` pointer or a `commondir` names, are + * walked a component at a time, and how many components there are is up to + * whoever wrote them: a link's target runs to four thousand bytes and a + * pointer file to a megabyte. The walk of them spends the same budget the + * rest of the enumeration does, and running out of it is the same refusal. + */ +describe.if(!isWindows)( + 'Chains and pointer values inside the time budget', + () => { + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'srt-chain-budget-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** What a wrap was refused with, or undefined where it was not refused: + * the command it would have returned is not what these tests look at. */ + async function refusalOf( + params: Parameters[0], + ): Promise { + try { + await wrapCommandWithSandboxLinux(params) + return undefined + } catch (err) { + if (err instanceof LinuxSandboxProfileError) return err + throw err + } + } + + /** One step of a path that goes nowhere: into `a` and back out. */ + const DETOUR = 'a/../' + + /** + * As many detours as a link's target has room for: a target runs to 4095 + * bytes on Linux and to 1023 on macOS, and a longer one is refused with + * ENAMETOOLONG when the link is made. + */ + const DETOURS_PER_LINK = process.platform === 'darwin' ? 200 : 800 + + /** + * Sixteen links in `holder`, each leading to the next through that many + * detours and the last to a real directory: a chain the kernel follows in + * one stat, and a walk by hand takes thousands of lstats for (some + * thirteen thousand on Linux). Sixteen is well inside the forty links the + * kernel follows, so that a stat through the chain answers the same way + * every time. Returns the first link. + */ + function makeLongChain(holder: string): string { + const links = 16 + mkdirSync(join(holder, 'a'), { recursive: true }) + mkdirSync(join(holder, 'end')) + for (let i = 0; i < links; i++) { + symlinkSync( + DETOUR.repeat(DETOURS_PER_LINK) + + (i + 1 < links ? `l${i + 1}` : 'end'), + join(holder, `l${i}`), + ) + } + return join(holder, 'l0') + } + + /** A repository whose `.git/modules` holds `count` git directories with + * every entry that is denied a link into one long chain. */ + function makeChainedSubmodules(count: number): { + checkout: string + gitDir: string + } { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const chain = makeLongChain(join(gitDir, 'chain')) + for (let i = 0; i < count; i++) { + const sub = join(gitDir, 'modules', `s${String(i).padStart(3, '0')}`) + mkdirSync(sub, { recursive: true }) + writeFileSync(join(sub, 'HEAD'), 'ref: refs/heads/main') + for (const entry of [ + 'hooks', + 'config', + 'commondir', + 'config.worktree', + ]) { + symlinkSync(chain, join(sub, entry)) + } + } + return { checkout, gitDir } + } + + it('stops in the middle of a chain rather than at the end of the last one', () => { + // Forty-eight git directories, four chains each: some two and a half + // million lstats, which is seconds of this process doing nothing else. + const { gitDir } = makeChainedSubmodules(48) + + const started = Date.now() + const error = (() => { + try { + gitDirTreeDenies(gitDir, false, { deadline: Date.now() + 100 }) + return undefined + } catch (err) { + return err + } + })() + const took = Date.now() - started + + expect(error).toBeInstanceOf(SubmoduleWalkBudgetError) + expect((error as SubmoduleWalkBudgetError).code).toBe( + 'submodule_walk_budget_exhausted', + ) + expect((error as Error).message).toContain(join(gitDir, 'modules')) + expect(took).toBeLessThan(2000) + }, 120000) + + it.if(isLinux)( + 'refuses the wrap where the chains outlast its budget', + async () => { + const { checkout, gitDir } = makeChainedSubmodules(48) + process.chdir(checkout) + + const started = Date.now() + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: 'rg', timeoutMs: 100 }, + }) + const took = Date.now() - started + + expect(refusal?.code).toBe('deny_scan_failed') + expect(refusal?.message).toContain(join(gitDir, 'modules')) + expect(took).toBeLessThan(2000) + }, + 120000, + ) + + it('stops in the middle of the path a pointer names', () => { + // A megabyte of detours, just under the size git accepts for a `.git` + // file: two hundred thousand lstats for one pointer. + const checkout = join(dir, 'checkout') + mkdirSync(join(checkout, 'a'), { recursive: true }) + makeGitDir(join(checkout, 'gd')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${DETOUR.repeat(200000)}gd\n`) + + // Time enough to start on it and not to finish: what stops the walk is + // the look at the clock it takes as the components go by. + const error = (() => { + try { + gitFileDenies(pointer, false, Date.now() + 25) + return undefined + } catch (err) { + return err + } + })() + expect(error).toBeInstanceOf(SubmoduleWalkBudgetError) + expect((error as Error).message).toContain('path components still to go') + // The value itself is not what the message carries. + expect((error as Error).message.length).toBeLessThan(2000) + + // A budget already spent stops it before the file is read at all, and + // stops at a `.git` file that is no pointer as well: that one starts no + // walk, and a tree full of them is still as many reads as there are. + expect(() => gitFileDenies(pointer, false, Date.now() - 1)).toThrow( + SubmoduleWalkBudgetError, + ) + const plain = join(dir, 'plain', '.git') + mkdirSync(join(dir, 'plain')) + writeFileSync(plain, 'not a pointer\n') + expect(gitFileDenies(plain, false).denyPaths).toEqual([plain]) + expect(() => gitFileDenies(plain, false, Date.now() - 1)).toThrow( + SubmoduleWalkBudgetError, + ) + // Given the time, it is walked to its end like any other. + expect(gitFileDenies(pointer, false).denyPaths).toContain( + join(checkout, 'gd', 'hooks'), + ) + }, 120000) + + it.if(isLinux)( + 'gives the pointer files a scan lists one budget between them', + async () => { + // One large pointer, and forty names for it: a hard link costs a + // command nothing, and each name is a `.git` file the scan lists. + const checkout = join(dir, 'repo') + makeGitDir(join(checkout, '.git')) + const first = join(checkout, 'w00') + mkdirSync(join(first, 'a'), { recursive: true }) + writeFileSync( + join(first, '.git'), + `gitdir: ${DETOUR.repeat(100000)}gd\n`, + ) + const pointers = [join(first, '.git')] + for (let i = 1; i < 40; i++) { + const worktree = join(checkout, `w${String(i).padStart(2, '0')}`) + mkdirSync(join(worktree, 'a'), { recursive: true }) + linkSync(join(first, '.git'), join(worktree, '.git')) + pointers.push(join(worktree, '.git')) + } + // A stand-in for ripgrep that lists them and exits, so that what the + // budget is spent on is the pointers and not the scan. + const scan = join(dir, 'scan.sh') + writeFileSync( + scan, + `#!/bin/sh\nprintf '%s\\0' ${pointers.map(p => JSON.stringify(p)).join(' ')}\n`, + { mode: 0o755 }, + ) + process.chdir(checkout) + + const started = Date.now() + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + ripgrepConfig: { command: scan, timeoutMs: 500 }, + }) + const took = Date.now() - started + + expect(refusal?.code).toBe('deny_scan_failed') + // The pointers' budget, not the scan's own timeout. + expect(refusal?.message).not.toContain('did not finish') + expect(refusal?.message).toContain(checkout) + expect(took).toBeLessThan(4000) + }, + 120000, + ) + + it('walks a value of as many components as its file can hold', () => { + // Half a million, which is more than one call takes as arguments: to a + // git directory that is there, and past a component that is not. + const gitDir = makeGitDir(join(dir, 'store')) + mkdirSync(join(dir, 'checkout')) + const pointer = join(dir, 'checkout', '.git') + const deadline = Date.now() + 60000 + + writeFileSync(pointer, `gitdir: ${'./'.repeat(500000)}../store\n`) + expect(gitFileDenies(pointer, false, deadline).gitDirs).toEqual([gitDir]) + + writeFileSync(pointer, `gitdir: absent/${'../'.repeat(300000)}\n`) + expect(gitFileDenies(pointer, false, deadline).gitDirs).toEqual([]) + }) + + it('takes what lies past a component that is not there as it is written', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync('../absent/a/b', join(gitDir, 'commondir')) + + expect(gitDirDenies(gitDir, false).denyPaths).toContain( + join(dir, 'repo', 'absent', 'a', 'b'), + ) + }) + + describe('a link whose target is no text', () => { + // A string made of those bytes names another path, or none. + const NO_TEXT = Buffer.from([0x2e, 0x2e, 0x2f, 0xff, 0xfe]) + + it('refuses, where it is an entry of a git directory', () => { + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + symlinkSync(NO_TEXT, join(gitDir, 'commondir')) + + expect(() => gitDirDenies(gitDir, false)).toThrow(GitMetadataError) + }) + + it.if(isLinux)('refuses the wrap with a code of its own', async () => { + // Left to bubblewrap, which cannot resolve the mount point either, the + // command would be handed out and never start. + const project = join(dir, 'project') + const gitDir = makeGitDir(join(project, 'nested', '.git')) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync(NO_TEXT, join(gitDir, 'hooks')) + process.chdir(project) + + const refusal = await refusalOf({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [project], denyWithinAllow: [] }, + }) + + expect(refusal?.code).toBe('deny_git_metadata_unreadable') + expect(refusal?.message).toContain(join(gitDir, 'hooks')) + }) + }) + + it('resolves an ordinary chain exactly as it always has', () => { + // hooks -> ../first -> second -> .githooks: three links, short targets. + const gitDir = makeGitDir(join(dir, 'repo', '.git')) + const landing = join(dir, 'repo', '.githooks') + const first = join(dir, 'repo', 'first') + const second = join(dir, 'repo', 'second') + mkdirSync(landing, { recursive: true }) + symlinkSync('.githooks', second) + symlinkSync('second', first) + rmSync(join(gitDir, 'hooks'), { recursive: true }) + symlinkSync('../first', join(gitDir, 'hooks')) + + const expected: GitDirDenies = { + denyPaths: [ + join(gitDir, 'hooks'), + landing, + first, + second, + join(gitDir, 'commondir'), + join(gitDir, 'config'), + join(gitDir, 'config.worktree'), + ], + escapingDenyPaths: [landing, first, second], + linkedEntryDirs: [gitDir], + chainHops: [first, second].map(link => ({ + kind: 'entry', + chain: 'resolved', + source: join(gitDir, 'hooks'), + link, + holder: join(dir, 'repo'), + })), + } + expect(gitDirDenies(gitDir, false)).toEqual(expected) + expect(gitDirDenies(gitDir, false, Date.now() + 60000)).toEqual(expected) + + // And the same chain in a pointer's value: through a linked directory + // to the git directory behind it. + const real = makeGitDir(join(dir, 'real', 'gd')) + symlinkSync(join(dir, 'real'), join(dir, 'repo', 'through')) + const pointer = join(dir, 'repo', 'worktree', '.git') + mkdirSync(join(dir, 'repo', 'worktree')) + writeFileSync(pointer, 'gitdir: ../through/gd\n') + const through = join(dir, 'repo', 'through') + const expectedPointer: ReturnType = { + gitDirs: [join(through, 'gd')], + denyPaths: [ + pointer, + through, + join(through, 'gd', 'hooks'), + join(through, 'gd', 'commondir'), + join(through, 'gd', 'config'), + join(through, 'gd', 'config.worktree'), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [ + { + kind: 'pointer', + chain: 'resolved', + source: pointer, + link: through, + holder: join(dir, 'repo'), + }, + ], + } + expect(real).toBe(join(dir, 'real', 'gd')) + expect(gitFileDenies(pointer, false)).toEqual(expectedPointer) + expect(gitFileDenies(pointer, false, Date.now() + 60000)).toEqual( + expectedPointer, + ) + }) + }, +) diff --git a/test/sandbox/mandatory-deny-worktrees.test.ts b/test/sandbox/mandatory-deny-worktrees.test.ts new file mode 100644 index 000000000..404504ce3 --- /dev/null +++ b/test/sandbox/mandatory-deny-worktrees.test.ts @@ -0,0 +1,1392 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'fs' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' +import { + macGetMandatoryDenyEntries, + wrapCommandWithSandboxMacOS, +} from '../../src/sandbox/macos-sandbox-utils.js' +import { + cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { + GitMetadataError, + SubmoduleWalkBudgetError, + gitDirDenyPaths, + gitDirTreeDenies, + gitDirTreeDenyPaths, + gitFileDenies, +} from '../../src/sandbox/mandatory-deny-paths.js' +import * as denyCollapse from '../../src/sandbox/linux-deny-collapse.js' +import type { SubmoduleDenyPlan } from '../../src/sandbox/linux-deny-collapse.js' + +/** + * A linked worktree's git directory, `
/.git/worktrees/`, is reached + * from two ends: through the `.git` pointer file of the worktree's checkout, + * and from the repository that keeps it. Its `commondir` names the directory + * whose hooks and config git runs in that worktree, so both ends have to hold + * it, and neither may depend on an entry a wrapped command can move aside. + */ +describe.if(!isWindows)('Linked worktree git directories', () => { + /** Everything the live arms need: a sandbox that starts. */ + const LIVE = isLinux && bwrapCanNamespace() + const HAS_GIT = Bun.which('git') !== null + const NOT_ROOT = process.getuid?.() !== 0 + /** For a case that has git itself make its fixture, several spawns of it. */ + const GIT_FIXTURE_TIMEOUT_MS = 60000 + let dir: string + const savedCwd = process.cwd() + + beforeEach(() => { + // Real, so a temporary directory that is itself reached through a + // symlink does not make every path here two paths. + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktree-denies-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(dir, { recursive: true, force: true }) + }) + + /** A directory git would accept as a repository's git directory. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'config'), '[core]\n') + return gitDir + } + + /** + * What `git worktree add` leaves: the worktree's git directory under the + * main one, and the checkout whose `.git` file names it. + */ + function makeLinkedWorktree( + mainGitDir: string, + id: string, + checkout: string, + ): { worktreeGitDir: string; pointer: string } { + const worktreeGitDir = join(mainGitDir, 'worktrees', id) + mkdirSync(worktreeGitDir, { recursive: true }) + writeFileSync(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/wt\n') + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + writeFileSync(join(worktreeGitDir, 'gitdir'), `${join(checkout, '.git')}\n`) + mkdirSync(checkout, { recursive: true }) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktreeGitDir}\n`) + return { worktreeGitDir, pointer } + } + + function git(cwd: string, args: string[]): void { + const result = spawnSync( + 'git', + [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'init.defaultBranch=main', + ...args, + ], + { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { + ...process.env, + HOME: dir, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + LC_ALL: 'C', + }, + }, + ) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + } + + /** + * A repository with one commit and a linked worktree checked out BESIDE it, + * both made by git itself: the worktree's `.git` file is outside the main + * checkout, and only its git directory is inside. + */ + function gitRepoWithWorktreeBeside(): { + main: string + worktreeGitDir: string + } { + const main = join(dir, 'main') + mkdirSync(main, { recursive: true }) + git(main, ['init', '-q', '.']) + writeFileSync(join(main, 'index.js'), 'console.log(1)\n') + git(main, ['add', 'index.js']) + git(main, ['commit', '-q', '-m', 'one']) + git(main, ['worktree', 'add', '-q', '-b', 'wt', join(dir, 'wt')]) + return { main, worktreeGitDir: join(main, '.git', 'worktrees', 'wt') } + } + + function wrap(command: string, allowOnly: string[]): Promise { + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly, denyWithinAllow: [] }, + }) + } + + function run(wrapped: string, cwd: string): string { + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 60000, + cwd, + }) + return `${result.stdout}${result.stderr}` + } + + describe('reached through the pointer', () => { + it('keeps the denies behind the pointer with HEAD moved aside', () => { + // HEAD is the one marker a worktree's git directory holds, and it is + // no deny path: a command can rename it. Where the directory is says + // what it is all the same. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + // And the commondir is still followed: the hooks a commit in the + // worktree runs are the main repository's. + ...gitDirDenyPaths(mainGitDir, false), + ]) + }) + + it("keeps them for a bare repository's worktree, by the commondir it still holds", () => { + // `main.git/worktrees/wt` is nowhere only git puts a directory, so what + // it holds has to say: `commondir` is there beside HEAD, and unlike + // HEAD it is a deny path, which a command cannot move aside. + const bare = makeGitDir(join(dir, 'main.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + bare, + 'wt', + join(dir, 'wt'), + ) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(bare, false), + ]) + }) + + it('carries them into the macOS entries where the working directory is the worktree', () => { + // Profile generation is pure string building, so this runs anywhere. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const checkout = join(dir, 'wt') + const { worktreeGitDir } = makeLinkedWorktree(mainGitDir, 'wt', checkout) + renameSync(join(worktreeGitDir, 'HEAD'), join(worktreeGitDir, 'H')) + process.chdir(checkout) + + const literals = macGetMandatoryDenyEntries(false, [dir]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toContain(join(worktreeGitDir, 'commondir')) + expect(literals).toContain(join(mainGitDir, 'hooks')) + expect(literals).toContain(join(mainGitDir, 'config')) + }) + + /** + * A repository that keeps a submodule and two linked worktrees, and what is + * denied of those from the checkout of the first: a command there is + * commonly let write the repository's git directory, to commit. + */ + function repositoryThatKeepsMore(): { checkout: string; kept: string[] } { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const checkout = join(dir, 'wt') + const own = makeLinkedWorktree(mainGitDir, 'wt', checkout) + const other = makeLinkedWorktree(mainGitDir, 'other', join(dir, 'other')) + return { + checkout, + kept: [ + ...gitDirDenyPaths( + makeGitDir(join(mainGitDir, 'modules', 'lib')), + false, + ), + ...gitDirDenyPaths( + makeGitDir(join(own.worktreeGitDir, 'modules', 'lib')), + false, + ), + join(other.worktreeGitDir, 'commondir'), + join(other.worktreeGitDir, 'gitdir'), + ], + } + } + + it.each([ + { code: 'EACCES', refuses: false }, + { code: 'EMFILE', refuses: true }, + { code: 'EIO', refuses: true }, + ])( + 'goes by why the pointer could not be read ($code)', + ({ code, refuses }) => { + // What this user may not read, the host's git cannot read either. Any + // other failure is this process's, now, and git may well read the file. + const { pointer } = makeLinkedWorktree( + makeGitDir(join(dir, 'main', '.git')), + 'wt', + join(dir, 'wt'), + ) + const real = fs.openSync + const open = spyOn(fs, 'openSync').mockImplementation((( + ...args: Parameters + ) => { + if (args[0] === pointer) + throw Object.assign(new Error(code), { code }) + return real(...args) + }) as typeof fs.openSync) + try { + if (refuses) { + expect(() => gitFileDenies(pointer, false)).toThrow( + GitMetadataError, + ) + } else { + expect(gitFileDenies(pointer, false).denyPaths).toEqual([pointer]) + } + } finally { + open.mockRestore() + } + }, + ) + + it('names the git directories it reached, for what they keep', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + + expect(gitFileDenies(pointer, false).gitDirs).toEqual([ + worktreeGitDir, + mainGitDir, + ]) + }) + + it.if(isLinux)('carries what they keep into the Linux deny list', () => { + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + expect(linuxGetCwdMandatoryDenyPaths(false, [dir])).toEqual( + expect.arrayContaining(kept), + ) + }) + + it.if(isLinux)('carries it where a write root lies inside', () => { + const { checkout, kept } = repositoryThatKeepsMore() + const modules = join(dir, 'main', '.git', 'modules') + process.chdir(checkout) + + expect(linuxGetCwdMandatoryDenyPaths(false, [checkout, modules])).toEqual( + expect.arrayContaining(kept), + ) + }) + + it('carries it into the macOS profile where a write root is a pattern', () => { + // What a pattern reaches is not worked out. + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { + allowOnly: [checkout, join(dir, 'ma*')], + denyWithinAllow: [], + }, + }) + + for (const denyPath of kept) expect(profile).toContain(denyPath) + }) + + it('carries what they keep into the macOS entries', () => { + const { checkout, kept } = repositoryThatKeepsMore() + process.chdir(checkout) + + const literals = macGetMandatoryDenyEntries(false, [dir]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toEqual(expect.arrayContaining(kept)) + expect(literals.length).toBe(new Set(literals).size) + }) + + it('walks nothing of a repository the command may write none of', () => { + // The default for a linked worktree. There is nothing to deny there, and + // one stale sibling would refuse every command in every worktree. + const { checkout, kept } = repositoryThatKeepsMore() + writeFileSync( + join(dir, 'main', '.git', 'worktrees', 'other', 'commondir'), + Buffer.from([0xff]), + ) + process.chdir(checkout) + + const denied = [ + ...(isLinux ? linuxGetCwdMandatoryDenyPaths(false, [checkout]) : []), + ...macGetMandatoryDenyEntries(false, [checkout]).map( + entry => entry.path, + ), + ] + + expect(denied).toContain(join(dir, 'main', '.git', 'hooks')) + expect(denied.filter(denyPath => kept.includes(denyPath))).toEqual([]) + }) + + it.if(isLinux)( + 'denies what the git directory behind a nested pointer keeps', + async () => { + // Under no name the scan looks for, so the pointer is the only way in. + const store = makeGitDir(join(dir, 'project', 'store')) + const hooks = join(makeGitDir(join(store, 'modules', 'lib')), 'hooks') + mkdirSync(join(dir, 'project', 'checkout')) + writeFileSync( + join(dir, 'project', 'checkout', '.git'), + 'gitdir: ../store\n', + ) + process.chdir(join(dir, 'project')) + + const wrapped = await wrap('true', [join(dir, 'project')]) + + expect(lastMountAt(wrapped, hooks)).toBe(`--ro-bind ${hooks} ${hooks}`) + }, + ) + + it.if(isLinux)( + 'walks nothing behind a nested pointer that the command may write none of', + async () => { + const store = makeGitDir(join(dir, 'store')) + const stale = makeLinkedWorktree(store, 'w', join(dir, 'w')) + writeFileSync( + join(stale.worktreeGitDir, 'commondir'), + Buffer.from([0xff]), + ) + mkdirSync(join(dir, 'project', 'checkout'), { recursive: true }) + writeFileSync( + join(dir, 'project', 'checkout', '.git'), + 'gitdir: ../../store\n', + ) + process.chdir(join(dir, 'project')) + + expect(await wrap('true', [join(dir, 'project')])).toBeTypeOf('string') + }, + ) + + it('keeps them with nothing left in the directory at all', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + for (const name of ['HEAD', 'commondir', 'gitdir']) { + rmSync(join(worktreeGitDir, name)) + } + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ]) + }) + + it('keeps the denies of a submodule git directory with its markers moved aside', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'vendor', 'lib')) + renameSync(join(submodule, 'HEAD'), join(submodule, 'H')) + renameSync(join(submodule, 'objects'), join(submodule, 'o')) + const checkout = join(dir, 'main', 'vendor', 'lib') + mkdirSync(checkout, { recursive: true }) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: ../../.git/modules/vendor/lib\n') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(submodule, false), + ]) + }) + + it('goes by where the directory really is, not by how the pointer spells it', () => { + // `x/.git` is a link a command made, so the path the pointer writes has + // the shape of a worktree's git directory and the directory it reaches + // is an ordinary one of the project. + const project = join(dir, 'project') + mkdirSync(join(project, 'web', 'worktrees', 'a', 'config'), { + recursive: true, + }) + mkdirSync(join(project, 'x'), { recursive: true }) + symlinkSync('../web', join(project, 'x', '.git')) + mkdirSync(join(project, 'y'), { recursive: true }) + const pointer = join(project, 'y', '.git') + writeFileSync(pointer, 'gitdir: ../x/.git/worktrees/a\n') + + const denyPaths = gitFileDenies(pointer, false).denyPaths + + expect(denyPaths).toContain(pointer) + expect( + denyPaths.filter(denyPath => + realPathOrSelf(denyPath).startsWith(join(project, 'web') + '/'), + ), + ).toEqual([]) + }) + + it('leaves a worktrees directory that no .git holds alone', () => { + mkdirSync(join(dir, 'data', 'worktrees', 'a', 'config'), { + recursive: true, + }) + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../data/worktrees/a\n') + + expect(gitFileDenies(pointer, false)).toMatchObject({ + denyPaths: [pointer], + gitDirs: [], + }) + }) + + for (const mainGitDirName of ['.git', 'main.git']) { + it.if(LIVE)( + `holds commondir for the command after one that moved HEAD aside (${mainGitDirName})`, + async () => { + // The working directory is the linked worktree, and the write roots + // hold the main repository as well: the layout a commit from inside + // the sandbox needs. Once for a repository with a work tree of its + // own, once for a bare one. + const mainGitDir = makeGitDir(join(dir, 'main', mainGitDirName)) + const checkout = join(dir, 'wt') + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + checkout, + ) + const commondir = join(worktreeGitDir, 'commondir') + process.chdir(checkout) + + const first = run( + await wrap( + `mv ${join(worktreeGitDir, 'HEAD')} ${join(worktreeGitDir, 'H')} && echo MOVED`, + [dir], + ), + checkout, + ) + expect(first).toContain('MOVED') + expect(existsSync(join(worktreeGitDir, 'HEAD'))).toBe(false) + cleanupBwrapMountPoints({ force: true }) + + const second = run( + await wrap( + 'echo BOOTED; ' + + `echo ../../../planted > ${commondir} || echo COMMONDIR_HELD; ` + + `mkdir ${join(mainGitDir, 'hooks', 'x')} || echo MAIN_HOOKS_HELD; ` + + 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', + [dir], + ), + checkout, + ) + + expect(second).toContain('BOOTED') + expect(second).toContain('COMMONDIR_HELD') + expect(second).toContain('MAIN_HOOKS_HELD') + expect(second).toContain('WORK_TREE_WRITTEN') + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + expect(readFileSync(join(checkout, 'notes.txt'), 'utf8')).toBe('ok\n') + }, + 120000, + ) + } + }) + + describe('a git directory kept outside any .git', () => { + // Where it is says nothing about one of these, so what it holds has to, + // and one marker has to be enough: neither of the two below holds both a + // HEAD and an objects. + it("is followed where it is a bare repository's linked worktree", () => { + // HEAD and commondir, and no objects of its own. A repository made with + // --separate-git-dir keeps its linked worktrees the same way. + const bare = makeGitDir(join(dir, 'main.git')) + const { worktreeGitDir, pointer } = makeLinkedWorktree( + bare, + 'wt', + join(dir, 'wt'), + ) + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(bare, false), + ]) + }) + + it.each([ + { left: 'config', allowGitConfig: false }, + { left: 'hooks', allowGitConfig: true }, + ])( + 'is followed with all but its $left moved aside', + ({ left, allowGitConfig }) => { + // HEAD, objects and refs are no deny paths: a command can rename them + // aside, and back once the next wrap has left the hooks writable. What + // is denied stays where it is, so it has to be enough. + const gitDir = makeGitDir(join(dir, 'store')) + for (const name of ['HEAD', 'objects', 'refs', 'config', 'hooks']) { + if (name !== left) renameSync(join(gitDir, name), join(dir, name)) + } + mkdirSync(join(dir, 'checkout')) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../store\n') + + expect(gitFileDenies(pointer, allowGitConfig).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, allowGitConfig), + ]) + }, + ) + + it('is followed where its objects live elsewhere', () => { + // HEAD and refs, which is all git itself asks of the directory when the + // object store is named by the environment. + const gitDir = join(dir, 'store') + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + writeFileSync(pointer, 'gitdir: ../store\n') + + expect(gitFileDenies(pointer, false).denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + }) + + describe('reached from the repository that keeps it', () => { + /** What is denied inside a worktree's git directory from this end. */ + function heldFiles(worktreeGitDir: string): string[] { + return ['commondir', 'config.worktree', 'gitdir'].map(name => + join(worktreeGitDir, name), + ) + } + + it.if(HAS_GIT)( + 'denies commondir, gitdir and config.worktree of a worktree checked out elsewhere', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + + const denyPaths = gitDirTreeDenyPaths( + gitDirTreeDenies(join(main, '.git'), false), + ) + + for (const held of heldFiles(worktreeGitDir)) { + expect(denyPaths).toContain(held) + } + // Nothing else in there: HEAD, index and logs are what git in that + // worktree writes on every checkout and commit. + expect( + denyPaths.filter(denyPath => denyPath.startsWith(worktreeGitDir)), + ).toEqual(heldFiles(worktreeGitDir)) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT)( + 'leaves config.worktree where config writes are allowed', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + + const denyPaths = gitDirTreeDenyPaths( + gitDirTreeDenies(join(main, '.git'), true), + ) + + expect(denyPaths).toContain(join(worktreeGitDir, 'commondir')) + expect(denyPaths).toContain(join(worktreeGitDir, 'gitdir')) + expect(denyPaths).not.toContain(join(worktreeGitDir, 'config.worktree')) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT && isLinux)( + 'carries them into the Linux deny list', + () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + process.chdir(main) + + const denyPaths = linuxGetCwdMandatoryDenyPaths(false, [main]) + + for (const held of heldFiles(worktreeGitDir)) { + expect(denyPaths).toContain(held) + } + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT)( + 'carries them into the macOS entries as literals', + () => { + // Profile generation is pure string building, so this runs anywhere. + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + process.chdir(main) + + const literals = macGetMandatoryDenyEntries(false, [main]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + for (const held of heldFiles(worktreeGitDir)) { + expect(literals).toContain(held) + } + expect(literals.length).toBe(new Set(literals).size) + }, + GIT_FIXTURE_TIMEOUT_MS, + ) + + it.if(HAS_GIT && LIVE)( + 'refuses a rewrite of commondir, and leaves the rest of the repository writable', + async () => { + const { main, worktreeGitDir } = gitRepoWithWorktreeBeside() + const commondir = join(worktreeGitDir, 'commondir') + const before = readFileSync(commondir, 'utf8') + process.chdir(main) + + const output = run( + await wrap( + 'echo BOOTED; ' + + `echo ../../../planted > ${commondir} || echo COMMONDIR_HELD; ` + + `echo /elsewhere/.git > ${join(worktreeGitDir, 'gitdir')} || echo GITDIR_HELD; ` + + `echo '[core]' > ${join(worktreeGitDir, 'config.worktree')} || echo WORKTREE_CONFIG_HELD; ` + + `mv ${worktreeGitDir} ${worktreeGitDir}.aside || echo DIRECTORY_HELD; ` + + `echo x > ${join(worktreeGitDir, 'ORIG_HEAD')} && echo REST_WRITTEN; ` + + 'echo ok > notes.txt && echo WORK_TREE_WRITTEN', + [main], + ), + main, + ) + + expect(output).toContain('BOOTED') + expect(output).toContain('COMMONDIR_HELD') + expect(output).toContain('GITDIR_HELD') + expect(output).toContain('WORKTREE_CONFIG_HELD') + expect(output).toContain('DIRECTORY_HELD') + expect(output).toContain('REST_WRITTEN') + expect(output).toContain('WORK_TREE_WRITTEN') + expect(readFileSync(commondir, 'utf8')).toBe(before) + + // The mount point an absent config.worktree took goes with the rest. + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(worktreeGitDir, 'config.worktree'))).toBe(false) + }, + 120000, + ) + + it('denies what the commondir of one names, where that is another git directory', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + const other = makeGitDir(join(dir, 'other.git')) + writeFileSync(join(worktreeGitDir, 'commondir'), `${other}\n`) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + for (const held of gitDirDenyPaths(other, false)) { + expect(denyPaths).toContain(held) + } + }) + + it('refuses on a commondir whose target cannot be worked out, as from a pointer', () => { + // The directory whose hooks git runs in that worktree is then unknown, + // and sandboxing on the rest would leave it writable. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + writeFileSync( + join(worktreeGitDir, 'commondir'), + Buffer.from([0x2f, 0xff, 0xfe, 0x0a]), + ) + + expect(() => gitDirTreeDenies(mainGitDir, false)).toThrow( + GitMetadataError, + ) + }) + + it('names nothing twice for a commondir that names the repository itself', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + expect(denyPaths.length).toBe(new Set(denyPaths).size) + }) + + it('holds a worktree git directory that holds nothing yet', () => { + // Where it is says what it is, from this end as from the other. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const empty = join(mainGitDir, 'worktrees', 'empty') + mkdirSync(empty, { recursive: true }) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + for (const held of heldFiles(empty)) { + expect(denyPaths).toContain(held) + } + }) + + it('denies the directory holding an entry that is a symlink, and what it leads to', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktrees = join(mainGitDir, 'worktrees') + mkdirSync(worktrees, { recursive: true }) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(elsewhere, { recursive: true }) + symlinkSync(elsewhere, join(worktrees, 'linked')) + + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.linkedEntryDirs).toContain(worktrees) + expect(gitDirTreeDenyPaths(tree)).toContain( + join(worktrees, 'linked', 'commondir'), + ) + }) + + it.if(NOT_ROOT)('denies a worktrees directory it cannot list whole', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + const worktrees = join(mainGitDir, 'worktrees') + chmodSync(worktrees, 0o000) + try { + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.unreadableDirs).toContain(worktrees) + expect(gitDirTreeDenyPaths(tree)).toContain(worktrees) + } finally { + chmodSync(worktrees, 0o755) + } + }) + + it.if(NOT_ROOT)( + "denies whole a worktree's git directory it cannot list, and refuses nothing", + () => { + // One `mkdir` and one `chmod` away for a command, and git reads no + // `commondir` in there either. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + chmodSync(worktreeGitDir, 0o000) + try { + const tree = gitDirTreeDenies(mainGitDir, false) + + expect(tree.unreadableDirs).toEqual([worktreeGitDir]) + expect(tree.worktrees).toEqual([]) + } finally { + chmodSync(worktreeGitDir, 0o755) + } + }, + ) + + it.if(isLinux)( + 'hands the monitor the plain deny paths of a repository it cannot enumerate', + () => { + const checkout = join(dir, 'main') + const mainGitDir = makeGitDir(join(checkout, '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + writeFileSync(join(worktreeGitDir, 'commondir'), Buffer.from([0xff])) + process.chdir(checkout) + + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) + expect(linuxGetMonitorCwdDenyPaths(false)).toEqual( + expect.arrayContaining(gitDirDenyPaths(mainGitDir, false)), + ) + }, + ) + + it('refuses rather than hand back a listing that ran out of its time', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + makeLinkedWorktree(mainGitDir, 'wt', join(dir, 'wt')) + + expect(() => + gitDirTreeDenies(mainGitDir, false, { deadline: Date.now() - 1 }), + ).toThrow(SubmoduleWalkBudgetError) + }) + + it("denies the same for a linked worktree a submodule's git directory keeps", () => { + // `git worktree add` inside a submodule puts the worktree's git + // directory under the SUBMODULE's, `.git/modules//worktrees/`, + // and its `commondir` is as writable there as under the repository's + // own `.git/worktrees`. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + submodule, + 'wt', + join(dir, 'lib-wt'), + ) + + const denies = gitDirTreeDenies(mainGitDir, false) + const denyPaths = gitDirTreeDenyPaths(denies) + + for (const name of ['commondir', 'gitdir', 'config.worktree']) { + expect(denyPaths).toContain(join(worktreeGitDir, name)) + } + // They belong to the submodule, so a bind of its git directory, which + // they are all under, stands in for them where the profile is degraded. + const found = denies.submodules.find(each => each.gitDir === submodule) + expect(found?.denyPaths).toContain(join(worktreeGitDir, 'commondir')) + expect(found?.escapingDenyPaths).not.toContain( + join(worktreeGitDir, 'commondir'), + ) + }) + + it("carries a submodule's linked worktrees into the macOS entries", () => { + const checkout = join(dir, 'main') + const mainGitDir = makeGitDir(join(checkout, '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + submodule, + 'wt', + join(dir, 'lib-wt'), + ) + + process.chdir(checkout) + const literals = macGetMandatoryDenyEntries(false, [checkout]) + .filter(entry => !entry.glob) + .map(entry => entry.path) + + expect(literals).toContain(join(worktreeGitDir, 'commondir')) + expect(literals).toContain(join(worktreeGitDir, 'gitdir')) + }) + + it('denies the submodules a linked worktree keeps, and what those keep', () => { + // A linked worktree's submodules have their git directories under the + // WORKTREE's, inside the repository, while their checkouts are in the + // worktree, which can lie outside everything the command may write. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'wt', + join(dir, 'wt'), + ) + const submodule = makeGitDir(join(worktreeGitDir, 'modules', 'a', 'lib')) + const deeper = makeLinkedWorktree(submodule, 'wt2', join(dir, 'wt2')) + const deepest = makeGitDir(join(deeper.worktreeGitDir, 'modules', 'x')) + + const denies = gitDirTreeDenies(mainGitDir, false) + + // All under the worktree's git directory, so a bind of it stands in for + // them where the profile is degraded. + expect(denies.worktrees).toEqual([ + { + gitDir: worktreeGitDir, + denyPaths: [ + ...heldFiles(worktreeGitDir), + ...gitDirDenyPaths(submodule, false), + ...heldFiles(deeper.worktreeGitDir), + ...gitDirDenyPaths(deepest, false), + ], + escapingDenyPaths: [], + linkedEntryDirs: [], + chainHops: [], + }, + ]) + }) + + it('keeps through a degrade what a kept git directory reached through a link is denied by', () => { + // A bind of the submodule's git directory does not cover it. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(elsewhere) + mkdirSync(join(submodule, 'worktrees')) + symlinkSync(elsewhere, join(submodule, 'worktrees', 'linked')) + symlinkSync(join(dir, 'absent'), join(submodule, 'worktrees', 'dangling')) + + const [found] = gitDirTreeDenies(mainGitDir, false).submodules + + expect(found?.escapingDenyPaths).toEqual([ + ...heldFiles(join(dir, 'absent')), + ...heldFiles(join(submodule, 'worktrees', 'linked')), + ]) + }) + + it('ends where a link leads back to a git directory it has been into', () => { + // Each walk would find the other's tree anew, one spelling longer, for as + // long as a path can get. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodules = ['a', 'b', 'c'].map(name => + makeGitDir(join(mainGitDir, 'modules', name)), + ) + for (const submodule of submodules) { + mkdirSync(join(submodule, 'worktrees')) + symlinkSync(mainGitDir, join(submodule, 'worktrees', 'back')) + } + + const denies = gitDirTreeDenies(mainGitDir, false, { + deadline: Date.now() + 5000, + }) + + expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) + }) + + it('ends where the modules of a linked worktree is the link back', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodules = ['a', 'b', 'c'].map(name => + makeGitDir(join(mainGitDir, 'modules', name)), + ) + for (const submodule of submodules) { + mkdirSync(join(submodule, 'worktrees', 'w'), { recursive: true }) + symlinkSync( + join(mainGitDir, 'modules'), + join(submodule, 'worktrees', 'w', 'modules'), + ) + } + + const denies = gitDirTreeDenies(mainGitDir, false, { + deadline: Date.now() + 5000, + }) + + expect(denies.submodules.map(each => each.gitDir)).toEqual(submodules) + }) + + it('walks once more at most what many links lead to', () => { + // Both numbers are a command's to choose, and their product is what runs + // a walk out of its time. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'm')) + for (const id of ['1', '2', '3']) { + mkdirSync(join(submodule, 'worktrees', id), { recursive: true }) + } + for (const name of ['a', 'b', 'c', 'x', 'y', 'z']) { + // To the git directory, and to its `worktrees` out of one made up. + symlinkSync('m', join(mainGitDir, 'modules', name)) + const madeUp = makeGitDir(join(mainGitDir, 'modules', `${name}2`)) + symlinkSync('../m/worktrees', join(madeUp, 'worktrees')) + } + + const commondirs = gitDirTreeDenyPaths( + gitDirTreeDenies(mainGitDir, false), + ).filter(denyPath => /worktrees\/\d\/commondir$/.test(denyPath)) + + expect(commondirs).toEqual( + expect.arrayContaining( + ['1', '2', '3'].map(id => + join(submodule, 'worktrees', id, 'commondir'), + ), + ), + ) + expect(commondirs.length).toBeLessThanOrEqual(6) + }) + + it('walks once what several git directories lead to, for a caller that has them all', () => { + // A submodule's is reached from the repository that keeps it and through + // the `.git` file of its checkout, and a command can write as many `.git` + // files as it likes. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const nested = makeGitDir(join(submodule, 'modules', 'nested')) + mkdirSync(join(submodule, 'worktrees', 'w'), { recursive: true }) + mkdirSync(join(mainGitDir, 'worktrees', 'w'), { recursive: true }) + symlinkSync(mainGitDir, join(dir, 'alias')) + const visited = new Map() + + expect( + gitDirTreeDenies(mainGitDir, false, { visited }).submodules.map( + each => each.gitDir, + ), + ).toEqual([submodule, nested]) + const list = spyOn(fs, 'readdirSync') + try { + for (const again of [submodule, mainGitDir, join(dir, 'alias')]) { + expect(gitDirTreeDenies(again, false, { visited })).toMatchObject({ + ownDenyPaths: gitDirDenyPaths(again, false), + submodules: [], + worktrees: [], + }) + } + // Not so much as listed: how much is there is a command's to choose. But + // for the `modules` a walk starts from, which the first walk read as one + // that may be a git directory itself. + expect(list.mock.calls.map(call => String(call[0]))).toEqual([ + join(submodule, 'modules'), + ]) + } finally { + list.mockRestore() + } + }) + + it('records the modules of a git directory that a walk started from earlier', () => { + // `sub/modules` is a submodule's name too. A walk that starts from it + // takes it for no git directory, and a command can have one start there: a + // `.git` file that names `sub`, under a name that sorts first. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const sub = makeGitDir(join(mainGitDir, 'modules', 'sub')) + const named = makeGitDir(join(sub, 'modules')) + const visited = new Map() + gitDirTreeDenies(sub, false, { visited }) + + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false, { visited })), + ).toEqual(expect.arrayContaining(gitDirDenyPaths(named, false))) + }) + + it('reads once what many links that lead nowhere fold to', () => { + // `nope/../w` is nothing to the kernel, and on paper the directory beside + // the link. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktree = join(mainGitDir, 'worktrees', 'w') + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, 'commondir'), '../..\n') + for (const name of ['a', 'b', 'x', 'y']) { + symlinkSync('nope/../w', join(mainGitDir, 'worktrees', name)) + } + const open = spyOn(fs, 'openSync') + try { + gitDirTreeDenies(mainGitDir, false) + + expect( + open.mock.calls.filter(call => + String(call[0]).endsWith('/w/commondir'), + ).length, + ).toBeLessThanOrEqual(2) + } finally { + open.mockRestore() + } + }) + + it('walks from where it is what an earlier git directory only linked to', () => { + // The one a command made up sorts first. + const madeUp = makeGitDir(join(dir, 'a', '.git')) + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), + ) + symlinkSync(join(mainGitDir, 'modules'), join(madeUp, 'modules')) + symlinkSync(join(mainGitDir, 'worktrees'), join(madeUp, 'worktrees')) + const visited = new Map() + gitDirTreeDenies(madeUp, false, { visited }) + + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false, { visited })), + ).toEqual( + expect.arrayContaining([ + ...gitDirDenyPaths(submodule, false), + ...heldFiles(worktreeGitDir), + ]), + ) + }) + + it('walks what really lies under a git directory, whatever links lead there', () => { + // A command can link any directory into a `worktrees` or a `modules`, to + // have it visited from there first, as what is denied the least: a + // `modules` as a linked worktree, a `worktrees` as a name, a submodule as + // the `modules` of a worktree it made up. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'vendor', 'lib')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), + ) + const kept = makeGitDir(join(worktreeGitDir, 'modules', 'kept')) + mkdirSync(join(mainGitDir, 'worktrees', 'b')) + symlinkSync('../modules', join(mainGitDir, 'worktrees', 'a')) + symlinkSync(submodule, join(mainGitDir, 'worktrees', 'b', 'modules')) + symlinkSync('../worktrees', join(mainGitDir, 'modules', 'a')) + // The submodules are walked before the linked worktrees, so this one is + // how the `modules` of `w` is walked first. + mkdirSync(join(submodule, 'worktrees', 'x'), { recursive: true }) + symlinkSync( + join(worktreeGitDir, 'modules'), + join(submodule, 'worktrees', 'x', 'modules'), + ) + + expect(gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false))).toEqual( + expect.arrayContaining([ + ...gitDirDenyPaths(submodule, false), + ...heldFiles(worktreeGitDir), + ...gitDirDenyPaths(kept, false), + ]), + ) + }) + + it('holds by its own name each link that leads to a linked worktree', () => { + // Which is what holds it on macOS. What it leads to is read once. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const { worktreeGitDir } = makeLinkedWorktree( + mainGitDir, + 'w', + join(dir, 'w'), + ) + const links = ['a', 'b', 'x', 'y'].map(name => + join(mainGitDir, 'worktrees', name), + ) + for (const link of links) symlinkSync('w', link) + + const denyPaths = gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)) + + expect(denyPaths).toEqual( + expect.arrayContaining([...heldFiles(worktreeGitDir), ...links]), + ) + expect( + denyPaths.filter(denyPath => denyPath.endsWith('/commondir')).length, + ).toBeLessThanOrEqual(3) + }) + + it.if(NOT_ROOT)( + 'goes by whether a linked worktree can be searched, as git does', + () => { + // git opens `commondir` by name. Without leave to list the directory it + // still does, so what that names stays denied. Without leave to search + // it git opens nothing: denied whole, and no command is refused. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const other = makeGitDir(join(dir, 'other', '.git')) + const worktree = join(mainGitDir, 'worktrees', 'w') + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, 'commondir'), `${other}\n`) + try { + chmodSync(worktree, 0o311) + expect( + gitDirTreeDenyPaths(gitDirTreeDenies(mainGitDir, false)), + ).toEqual(expect.arrayContaining(gitDirDenyPaths(other, false))) + for (const mode of [0o444, 0o000]) { + chmodSync(worktree, mode) + expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ + worktree, + ]) + } + } finally { + chmodSync(worktree, 0o755) + } + }, + ) + + // The macOS file systems take no such name. + it.if(isLinux)( + 'denies a worktrees whole that holds a name that is no text', + () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const worktrees = join(mainGitDir, 'worktrees') + mkdirSync(worktrees) + mkdirSync( + Buffer.concat([Buffer.from(`${worktrees}/caf`), Buffer.from([0xe9])]), + ) + + expect(gitDirTreeDenies(mainGitDir, false).unreadableDirs).toEqual([ + worktrees, + ]) + }, + ) + + it('holds a worktrees that is a link, as it holds a modules that is one', () => { + // Remove the link, put a directory where it was, and every `.git` file + // that names a worktree's git directory through it opens a planted one. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const elsewhere = join(dir, 'elsewhere') + mkdirSync(join(elsewhere, 'wt'), { recursive: true }) + const worktrees = join(mainGitDir, 'worktrees') + symlinkSync(elsewhere, worktrees) + + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(denies.linkedEntryDirs).toEqual([mainGitDir]) + expect(denies.ownDenyPaths).toContain(worktrees) + expect(gitDirTreeDenyPaths(denies)).toEqual( + expect.arrayContaining(heldFiles(join(worktrees, 'wt'))), + ) + }) + + it('costs a submodule with no linked worktrees nothing', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const submodule = makeGitDir(join(mainGitDir, 'modules', 'lib')) + + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(gitDirTreeDenyPaths(denies)).toEqual([ + ...gitDirDenyPaths(mainGitDir, false), + ...gitDirDenyPaths(submodule, false), + ]) + }) + + it('costs a repository with no linked worktrees nothing', () => { + // An absent `.git/worktrees` is never denied: a mount point planted at + // one stops `git worktree add` working in that repository. + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const denies = gitDirTreeDenies(mainGitDir, false) + + expect(gitDirTreeDenyPaths(denies)).toEqual( + gitDirDenyPaths(mainGitDir, false), + ) + expect(denyCollapse.degradableGitDirs(denies)).toEqual([]) + }) + }) + + describe('past what the profile can carry', () => { + /** The plan a wrap builds for one repository that has no submodules. */ + function planFor(mainGitDir: string): SubmoduleDenyPlan { + const denies = gitDirTreeDenies(mainGitDir, false) + const repositories = denyCollapse.degradableGitDirs(denies) + expect(repositories).toMatchObject([{ linkedWorktrees: true }]) + return { + denyPaths: gitDirTreeDenyPaths(denies), + repositories, + chainHops: denies.chainHops, + } + } + + it("folds a worktree's git directory into one bind, and keeps what its commondir names", () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const first = makeLinkedWorktree(mainGitDir, 'a', join(dir, 'a')) + const last = makeLinkedWorktree(mainGitDir, 'b', join(dir, 'b')) + const other = makeGitDir(join(dir, 'other.git')) + writeFileSync(join(last.worktreeGitDir, 'commondir'), `${other}\n`) + const plan = planFor(mainGitDir) + + const denies = denyCollapse.collapsedDenyPaths(plan, { + wholeGitDirs: 1, + wholeModulesDirs: 0, + }) + + // The last in sorted order is the one degraded. + expect(denies).toContain(last.worktreeGitDir) + expect(denies).not.toContain(join(last.worktreeGitDir, 'commondir')) + expect(denies).toContain(join(first.worktreeGitDir, 'commondir')) + expect(denies).not.toContain(first.worktreeGitDir) + // No bind of the worktree's git directory covers these. + for (const kept of [ + ...gitDirDenyPaths(other, false), + ...gitDirDenyPaths(mainGitDir, false), + ]) { + expect(denies).toContain(kept) + } + }) + + it('takes every one of them with a bind of worktrees, and says which it was', () => { + const mainGitDir = makeGitDir(join(dir, 'main', '.git')) + const first = makeLinkedWorktree(mainGitDir, 'a', join(dir, 'a')) + makeLinkedWorktree(mainGitDir, 'b', join(dir, 'b')) + const plan = planFor(mainGitDir) + const level = { wholeGitDirs: 2, wholeModulesDirs: 1 } + + const denies = denyCollapse.collapsedDenyPaths(plan, level) + + expect(denies).toContain(join(mainGitDir, 'worktrees')) + expect(denies).not.toContain(first.worktreeGitDir) + expect(denies).not.toContain(join(first.worktreeGitDir, 'commondir')) + for (const kept of gitDirDenyPaths(mainGitDir, false)) { + expect(denies).toContain(kept) + } + const told = denyCollapse.describeCollapse(plan, level) + expect(told).toContain('2 of the 2 linked worktree git directories') + expect(told).toContain(join(mainGitDir, 'worktrees')) + expect(told).not.toContain('submodule') + }) + + it.if(isLinux)( + 'degrades a worktrees directory a command filled rather than refusing every command after it', + async () => { + // Nine hundred, each three denies and a pin: past what bubblewrap + // parses, so the tail is one read-only bind each. + const main = join(dir, 'main') + const mainGitDir = makeGitDir(join(main, '.git')) + const worktreeGitDirs: string[] = [] + for (let i = 0; i < 900; i++) { + const id = `w${String(i).padStart(5, '0')}` + worktreeGitDirs.push( + makeLinkedWorktree(mainGitDir, id, join(dir, 'checkouts', id)) + .worktreeGitDir, + ) + } + const precise = worktreeGitDirs[0] as string + const collapsed = worktreeGitDirs[worktreeGitDirs.length - 1] as string + process.chdir(main) + + const command = await wrap('true', [main]) + + const words = mountWords(command) + expect(words.length).toBeLessThan(9000) + const mounted = (dest: string): boolean => + words.some( + (word, at) => + word === '--ro-bind' && + words[at + 1] === dest && + words[at + 2] === dest, + ) + expect(mounted(join(precise, 'commondir'))).toBe(true) + expect(mounted(join(collapsed, 'commondir'))).toBe(false) + expect(mounted(collapsed)).toBe(true) + }, + 300000, + ) + }) + + /** The mount words of a wrap: the argument file where the profile is past + * what a command line carries, the line itself where it is not. */ + function mountWords(command: string): string[] { + const profile = /\/proc\/\d+\/fd\/\d+/.exec(command)?.[0] + if (profile === undefined) return command.split(/\s+/) + const words = readFileSync(profile, 'utf8').split('\0') + words.pop() + return words + } + + function realPathOrSelf(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } + } +}) diff --git a/test/sandbox/mount-point-liveness.test.ts b/test/sandbox/mount-point-liveness.test.ts new file mode 100644 index 000000000..e9ba7b146 --- /dev/null +++ b/test/sandbox/mount-point-liveness.test.ts @@ -0,0 +1,3481 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { liveMountPoints } from '../../src/index.js' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + countMounts, + indexOfMount, + lastMountAt, + manifestOf as recordedBy, + RECORD_STEP, + STEP_SHELL, +} from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + namedBy, + processAt, + refusal, + run, + sleep, + startOf, + until, + waitFor, + wrapIn, + wrapLine, + writeManifest, + writeScript as writeScriptIn, +} from '../helpers/mount-point-fixtures.js' +import { isLinux } from '../helpers/platform.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 + * bwrap makes on the host. Unlinking one while a sandbox is bound over it + * detaches the mount inside that sandbox, and the denied path can then be + * created on the host. So every wrap names its mount points in a manifest, and + * the command it hands out puts the process it starts bubblewrap in on that + * manifest's record: a mount point goes only when no process on the record of + * a manifest that names it is running. + */ +describe.if(isLinux)('A mount point a running sandbox relies on', () => { + // These tests list the manifests, attack them from inside a sandbox and + // assert on what is left: in a directory of their own. + const runtime = usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + let BASE: string + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let SIGNAL: string // written from inside the sandbox once it is up + let GO: string // written by the test to let the sandbox carry on + let OUT: string // where a child process leaves what its sandbox printed + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-liveness-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + OUT = join(BASE, 'out.txt') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + const wrap = (command: string, denyPaths = [LOCK]): Promise => + wrapIn(AREA, command, denyPaths) + + /** Waits for the sandbox to report it is up, then tries the denied write. */ + function heldCommand(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` + } + + /** A child process that wraps `command`, runs it and writes what it said. */ + function wrapperScript( + command: string, + out: string, + after: string[] = [], + ): string { + return [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + `const out = ${JSON.stringify(out)}`, + wrapLine([AREA], command, [LOCK]), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(out, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ...after, + ].join('\n') + } + + const writeScript = (name: string, source: string): string => + writeScriptIn(BASE, name, [source]) + + /** What is at `file`, or '' where nothing is: a mount point holds nothing. */ + function contentAt(file: string): string { + return existsSync(file) ? readFileSync(file, 'utf8') : '' + } + + /** The set a caller is given of the live mount points, as a list. */ + function live(): string[] | undefined { + const set = liveMountPoints() + return set && [...set] + } + + /** The manifest the wrap recorded its mount points in. */ + function manifestOf(command: string): string { + const file = recordedBy(command) + expect(file).toBeDefined() + return file! + } + + /** One that `writer` wrote: by default a process that is gone. */ + const manifestNaming = (paths: string[], writer?: number): string => + writeManifest( + runtime.manifestDir(), + paths, + writer === undefined ? {} : processAt(writer), + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'survives another process cleaning up in the same directory, and the deny holds', + async () => { + const holder = spawn( + process.execPath, + [writeScript('holder.ts', wrapperScript(heldCommand(), OUT))], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + await waitFor(SIGNAL) + // The mount point bwrap made for the sandbox that is running, on + // record where this test and the process below look. + expect(lstatSync(LOCK).size).toBe(0) + expect(readdirSync(runtime.manifestDir())).toHaveLength(2) + + // A second process wraps the same deny path, runs a command and cleans + // up. + const second = spawnSync( + process.execPath, + [ + writeScript( + 'second.ts', + wrapperScript('true', join(BASE, 'second-out.txt'), [ + 'cleanupBwrapMountPoints()', + ]), + ), + ], + { env: { ...process.env }, encoding: 'utf8', timeout: 60000 }, + ) + expect(second.status).toBe(0) + expect(existsSync(LOCK)).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await waitFor(OUT) + expect(readFileSync(OUT, 'utf8')).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // Once nothing is running under it, it goes: on the holder's own cleanup + // as it exits, or on this one. + await holderExited + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'survives a cleanup, twice over, while its own process still has the sandbox running', + async () => { + const command = await wrap(heldCommand()) + const child = spawn(command, { shell: true }) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(SIGNAL) + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + // `force` is what the exit handler and reset() pass. A sandbox running + // under a manifest is not this process's to end, so that manifest and + // what it names stay. + cleanupBwrapMountPoints({ force: true }) + expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(manifestOf(command))).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await exited + expect(said).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // And goes on the next cleanup, now that nothing is running under it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is in the set another process is given of the live mount points, for as long as the sandbox runs', + async () => { + // For a caller that removes paths itself after a command: what it is + // about to remove may be what another process's sandbox is bound over. + expect(live()).toEqual([]) + const holder = spawn( + process.execPath, + [writeScript('holder.ts', wrapperScript(heldCommand(), OUT))], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + await waitFor(SIGNAL) + expect(live()).toEqual([LOCK]) + } finally { + writeFileSync(GO, '') + } + await holderExited + expect(live()).toEqual([]) + }, + 90000, + ) + + it('is given in that set as the wrap recorded it: where the directories above it really are', async () => { + // A caller that holds its own paths against the set has to spell them the + // same way. + const through = join(AREA, 'through') + symlinkSync(join(AREA, 'repo', '.git'), through) + const command = await wrap('true', [join(through, 'config.lock')]) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + // What bubblewrap makes for it, once the command runs. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + + expect(live()).toEqual([LOCK]) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while a second sandbox that denies it runs, after the one it was made for has ended and been cleaned up after', + async () => { + // The second wrap finds the file there. A manifest names it, so the wrap + // covers and names it too, and its own manifest keeps it once the first + // is gone. + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const holder = spawn( + process.execPath, + [ + writeScript( + 'holder.ts', + wrapperScript(heldCommand(), OUT, ['cleanupBwrapMountPoints()']), + ), + ], + { stdio: 'ignore' }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + let said = '' + try { + await waitFor(SIGNAL) + const command = await wrap( + `echo up > ${upSecond}; while [ ! -e ${goSecond} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?`, + ) + expect(command).toContain(`--ro-bind /dev/null ${LOCK}`) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + const second = spawn(command, { shell: true }) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => second.on('exit', resolve)) + await waitFor(upSecond) + + writeFileSync(GO, '') + await holderExited // and its own clean-up has run + cleanupBwrapMountPoints() + expect(lstatSync(LOCK).size).toBe(0) + + writeFileSync(goSecond, '') + await secondExited + expect(said).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + } finally { + writeFileSync(GO, '') + writeFileSync(goSecond, '') + await holderExited + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is collected, manifest and all, when the process that made it never cleans up', + async () => { + const killed = spawnSync( + process.execPath, + [ + writeScript( + 'killed.ts', + wrapperScript('true', OUT, [ + `process.kill(process.pid, 'SIGKILL')`, + ]), + ), + ], + { env: { ...process.env }, encoding: 'utf8', timeout: 60000 }, + ) + expect(killed.signal).toBe('SIGKILL') + // Its sandbox ran, and left what bubblewrap made for it: without this + // everything below holds of a path nothing was ever made at. + expect(readFileSync(OUT, 'utf8')).not.toContain('bwrap:') + expect(lstatSync(LOCK).size).toBe(0) + + const directory = dirname(manifestOf(await wrap('true'))) + cleanupBwrapMountPoints() + + expect(existsSync(LOCK)).toBe(false) + const naming = readdirSync(directory).filter( + name => + name.endsWith('.json') && + readFileSync(join(directory, name), 'utf8').includes(LOCK), + ) + expect(naming).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE).each([ + ['names a manifest of its own', true], + // The manifests in the directory are then other sandboxes'. + ['names none, having no absent deny path', false], + ] as const)( + 'cannot be disowned from inside a sandbox that %s: the manifests are read-only there', + async (_, namesOne) => { + // This caller allows writes to the manifest directory itself, so only + // the read-only bind the wrap emits last keeps the command out of it. A + // collect on the host believes what is in there. + const directory = dirname(manifestOf(await wrap('true'))) + const before = readdirSync(directory).filter(n => n.endsWith('.json')) + expect(before.length).toBeGreaterThan(0) + + const command = await wrapCommandWithSandboxLinux({ + command: [ + `rm -f ${directory}/*.json; echo rm=$?`, + `echo mine > ${directory}/planted.json; echo plant=$?`, + `echo mine > ${directory}/${before[0]}; echo rewrite=$?`, + ].join('; '), + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { + allowOnly: [AREA, directory], + denyWithinAllow: namesOne ? [LOCK] : [], + }, + }) + expect(recordedBy(command) !== undefined).toBe(namesOne) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + const said = `${result.stdout}${result.stderr}` + expect(said).toMatch(/rm=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/rewrite=[1-9]/) + expect(existsSync(join(directory, 'planted.json'))).toBe(false) + for (const name of before) { + expect(readFileSync(join(directory, name), 'utf8')).toContain('"paths"') + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'are read-only, too, where a process that was started without a runtime directory keeps them', + async () => { + // A process started with $XDG_RUNTIME_DIR keeps its manifests under it, + // one started without under the temp dir. Every wrap binds both names + // read-only and makes the one that is missing, so a sandbox of the first + // kind cannot forge the manifests of the second. + const RUN = join(BASE, 'run') + const TMP = join(BASE, 'tmp') + mkdirSync(RUN, { mode: 0o700 }) + chmodSync(RUN, 0o700) + mkdirSync(TMP) + const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + const attacker = (script: string, attack: string): string => + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([TMP, AREA], attack, []), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const withARuntimeDirectory = (script: string) => + spawnSync(process.execPath, [script], { + env: { ...process.env, XDG_RUNTIME_DIR: RUN, TMPDIR: TMP }, + encoding: 'utf8', + timeout: 60000, + }) + + // Nobody has made the other directory yet: the command may not either. + const early = withARuntimeDirectory( + attacker( + 'early.ts', + `mkdir -m 700 ${theirs} 2>/dev/null; echo forged > ${theirs}/planted.json; echo plant=$?`, + ), + ) + expect(early.stdout).toMatch(/plant=[1-9]/) + expect(lstatSync(theirs).isDirectory()).toBe(true) + expect(readdirSync(theirs)).toEqual([]) + + // What a process without a runtime directory has recorded there. + const manifest = join(theirs, '4242-0123456789abcdef.json') + writeFileSync( + manifest, + '{"version":1,"pid":4242,"start":"1","created":0,"paths":[],"sources":[]}', + { mode: 0o600 }, + ) + const late = withARuntimeDirectory( + attacker( + 'late.ts', + `rm -f ${theirs}/*.json; echo rm=$?; echo forged > ${theirs}/planted.json; echo plant=$?`, + ), + ) + expect(late.stdout).toMatch(/rm=[1-9]/) + expect(late.stdout).toMatch(/plant=[1-9]/) + expect(readdirSync(theirs)).toEqual(['4242-0123456789abcdef.json']) + expect(readFileSync(manifest, 'utf8')).toContain('"paths"') + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'refuses to start a command wrapped before a cleanup that has since let go of its manifest, and makes nothing for it', + async () => { + const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) + const manifest = manifestOf(command) + expect(existsSync(manifest)).toBe(true) + // The one clean-up this process owes, made before the command is run: + // nothing is outstanding any more, so what the wrap prepared is let go. + cleanupBwrapMountPoints() + expect(existsSync(manifest)).toBe(false) + + const result = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + // The manifest is the first thing bubblewrap binds, so it has made no + // mount point by then: nothing is on the host that no manifest names. + expect(result.status).not.toBe(0) + expect(`${result.stdout}${result.stderr}`).toContain( + `Can't find source path ${manifest}`, + ) + expect(existsSync(LOCK)).toBe(false) + // What the command line recorded of itself goes at the next clean-up. + expect(readdirSync(dirname(manifest))).toEqual([ + basename(manifest).replace(/json$/, 'started'), + ]) + cleanupBwrapMountPoints() + expect(readdirSync(dirname(manifest))).toEqual([]) + }, + 90000, + ) + + it('hands out a command that puts itself on record and then binds its manifest, before any other mount', async () => { + const command = await wrap('true') + const manifest = manifestOf(command) + const record = manifest.replace(/json$/, 'started') + // One shell, which bubblewrap takes the place of: the pid on record is + // bubblewrap's own, and the sandbox dies with it. + expect(command).toStartWith( + `${STEP_SHELL} '${RECORD_STEP} && exec "$@"' srt ${record} bwrap --new-session --die-with-parent `, + ) + expect(command).toContain( + ` --ro-bind / / --ro-bind ${manifest} ${manifest} --`, + ) + // A wrap that names no mount point has nothing to vouch for. + expect(await wrap('true', [])).toStartWith( + 'bwrap --new-session --die-with-parent ', + ) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'puts bubblewrap itself on record, each time it is run', + async () => { + const command = await wrap(heldCommand()) + const child = spawn(command, { shell: true, stdio: 'ignore' }) + const exited = new Promise(resolve => child.on('exit', resolve)) + let pid: string + try { + await waitFor(SIGNAL) + const lines = onRecord(command) + expect(lines).toHaveLength(1) + pid = lines[0]!.split(' ')[0]! + // The shell that wrote the line has become bubblewrap. + const now = readFileSync(`/proc/${pid}/stat`, 'utf8') + expect(lines[0]).toMatch(/^\d+ \((ba)?sh\) /) + expect(now).toStartWith(`${pid} (bwrap) `) + expect(startOf(now)).toBe(startOf(lines[0]!)!) + } finally { + writeFileSync(GO, '') + } + await exited + expect(existsSync(`/proc/${pid}`)).toBe(false) + + expect(spawnSync(command, { shell: true, timeout: 60000 }).status).toBe(0) + expect(onRecord(command)).toHaveLength(2) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'puts itself on record by the number /proc has for it, where the shell has another for itself', + async () => { + // A PID namespace of its own under the outer /proc, as in a nested + // sandbox. Only the steps the command takes before bubblewrap are run. + const command = await wrap('true') + const steps = /^.*? srt \S+\.started /.exec(command)![0] + const nested = spawn( + 'bwrap', + [ + ...['--dev-bind', '/', '/', '--unshare-pid', '--die-with-parent'], + ...['--', '/bin/sh', '-c', `${steps} sleep 60`], + ], + { stdio: 'ignore' }, + ) + try { + const deadline = Date.now() + 20000 + while (!existsSync(manifestOf(command).replace(/json$/, 'started'))) { + if (Date.now() > deadline) throw new Error('nothing was recorded') + await sleep(25) + } + while (onRecord(command).length === 0 && Date.now() < deadline) { + await sleep(25) + } + const [line] = onRecord(command) + const pid = line!.split(' ')[0] + while ( + !contentAt(`/proc/${pid}/stat`).includes('(sleep)') && + Date.now() < deadline + ) { + await sleep(25) + } + const now = readFileSync(`/proc/${pid}/stat`, 'utf8') + expect(now).toStartWith(`${pid} (sleep) `) + expect(startOf(now)).toBe(startOf(line!)!) + } finally { + nested.kill('SIGKILL') + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'makes nothing on the host when its manifest is claimed after bubblewrap has looked its sources up', + async () => { + // bubblewrap looks up every bind source before it mounts anything, so a + // claim made before that refuses the start wherever the manifest is + // bound. Here bubblewrap is held up after that look, with only the root + // bound (`--file` copies from a FIFO nobody has closed), and the claim + // lands then: the manifest has to be the next thing it binds. + const command = await wrap('echo ran') + const manifest = manifestOf(command) + const fifo = join(BASE, 'fifo') + const gate = join(AREA, 'gate') + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + const hold = fs.openSync(fifo, fs.constants.O_RDWR) + const root = ' --ro-bind / / ' + expect(command.split(root)).toHaveLength(2) + const child = spawn( + `${command.replace(root, `${root}--bind ${AREA} ${AREA} --file 8 ${gate} `)} 8<${fifo}`, + { shell: true }, + ) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(gate) + fs.renameSync(manifest, manifest.replace(/json$/, 'claimed')) + } finally { + fs.closeSync(hold) + } + expect(await exited).not.toBe(0) + expect(said).toContain(`bwrap: Can't`) + expect(said).toContain(manifest) + expect(said).not.toContain('ran') + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + // ---- the shell the command takes its own steps in, on the host ---- + + const BASH = ['/bin/bash', '/usr/bin/bash'].find(shell => existsSync(shell)) + + it.if(BASH !== undefined)( + 'takes its steps in bash, where that is at one of its usual places, and has it import nothing', + async () => { + expect(await wrap('true')).toStartWith(`${BASH} -p -c '`) + }, + ) + + for (const [sh, begins] of [ + ['/nix/store/bash-5.2/bin/bash', `/bin/sh -p -c '`], + ['/usr/bin/dash', `/bin/sh -c '`], + ] as const) { + it(`takes them in /bin/sh where bash is at neither, which is ${sh}: ${begins}`, async () => { + const access = fs.accessSync + const realpath = fs.realpathSync.native + const spies = [ + spyOn(fs, 'accessSync').mockImplementation((( + file: unknown, + mode: unknown, + ) => { + if (file === '/bin/bash' || file === '/usr/bin/bash') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return (access as (...args: unknown[]) => void)(file, mode) + }) as never), + spyOn(fs.realpathSync, 'native').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => + file === '/bin/sh' + ? sh + : (realpath as (...args: unknown[]) => string)( + file, + ...rest, + )) as never), + ] + try { + // The shell is found once, so in a copy of the library of its own. + const fresh = (await import( + `../../src/sandbox/linux-sandbox-utils.ts?${basename(sh)}` + )) as { + wrapCommandWithSandboxLinux: typeof wrapCommandWithSandboxLinux + } + expect( + await fresh.wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'sh', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + }), + ).toStartWith(begins) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + }) + } + + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( + 'hands the command every entry of the environment, whatever its name', + async () => { + // dash rebuilds the environment from its variables, and drops these. + const run = spawnSync(BASH!, ['-c', await wrap('env')], { + env: { ...process.env, 'a.b': 'dotted', 'FOO-BAR': 'dashed' }, + encoding: 'utf8', + timeout: 60000, + }) + expect(run.status).toBe(0) + expect(run.stdout.split('\n')).toEqual( + expect.arrayContaining(['a.b=dotted', 'FOO-BAR=dashed']), + ) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE).each([STEP_SHELL, '/bin/sh -c'])( + 'leaves an entry named like a variable of its own steps as it was (steps run by %s)', + async stepShell => { + // Else the command gets the host shell's /proc/self/stat line for it. The + // steps are run by /bin/sh where there is no bash. + const wrapped = await wrap('echo "s=[${s-unset}]"') + expect(wrapped).toStartWith(`${STEP_SHELL} `) + const command = `${stepShell}${wrapped.slice(STEP_SHELL.length)}` + for (const [s, said] of [ + ['precious', 's=[precious]\n'], + [undefined, 's=[unset]\n'], + ] as const) { + const run = spawnSync(command, { + shell: true, + env: { ...process.env, s }, + encoding: 'utf8', + timeout: 60000, + }) + expect(`${run.stdout}${run.stderr}`).toBe(said) + } + expect(onRecord(wrapped)).toHaveLength(2) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && BASH !== undefined)( + 'runs nothing on the host that the environment plants for a shell', + async () => { + // Each leaves a file where only the host can: the sandbox may not write + // outside AREA. + const functionRan = join(BASE, 'function-ran') + const fileRead = join(BASE, 'file-read') + const planted = join(BASE, 'bash-env.sh') + writeFileSync(planted, `touch ${fileRead} 2>/dev/null\n`) + const command = await wrap('echo started') + for (const [shell, entry, value] of [ + // Through bash, which hands an exported function on; dash drops it. + [ + BASH!, + 'BASH_FUNC_shift%%', + `() { touch ${functionRan} 2>/dev/null; builtin shift; }`, + ], + // Through sh, which does not read that file for itself. + ['/bin/sh', 'BASH_ENV', planted], + ] as const) { + const run = spawnSync(shell, ['-c', command], { + env: { ...process.env, [entry]: value }, + encoding: 'utf8', + timeout: 60000, + }) + expect(`${run.stdout}${run.stderr}`).toBe('started\n') + expect(run.status).toBe(0) + } + expect(existsSync(functionRan)).toBe(false) + expect(existsSync(fileRead)).toBe(false) + }, + 90000, + ) + + // ---- two commands of one process in flight ---- + // + // A call to clean up that does not say which command it is for lets go of + // those that have run and ended, and of no other: the command that has not + // started still needs its manifest and its mount point. + + it.if(BWRAP_CAN_NAMESPACE)( + 'a command wrapped and not yet started survives the clean-up after the one that finished first', + async () => { + const first = await wrap('echo first-ran') + // Removed first: the file's mode alone refuses a plain write. + const second = await wrap( + `echo second-ran; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, + ) + const run = (command: string) => + spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 }) + + expect(run(first).stdout).toContain('first-ran') + cleanupBwrapMountPoints() // after the first; the second is not started + // The call could have been for either, so what the first left stays. + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(manifestOf(second))).toBe(true) + + const result = run(second) + const said = `${result.stdout}${result.stderr}` + expect(said).not.toContain('bwrap:') + expect(result.status).toBe(0) + expect(result.stdout).toContain('second-ran') + expect(result.stdout).toMatch(/rc=[1-9]/) // and its deny held + expect(contentAt(LOCK)).toBe('') + + cleanupBwrapMountPoints() // after the second: nothing outstanding now + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + /** + * Runs `before` just before, and `after` just after, the next clean-up claims + * `manifest`. + */ + function atTheClaimOn( + manifest: string, + at: { before?: () => void; after?: () => void }, + ): { restore(): void } { + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + const meant = from === manifest && to.endsWith('.claimed') + if (meant) at.before?.() + rename(from, to) + if (meant) at.after?.() + }) as never) + return { restore: () => spy.mockRestore() } + } + + /** The lines on the started record of `command`. */ + function onRecord(command: string): string[] { + return readFileSync(manifestOf(command).replace(/json$/, 'started'), 'utf8') + .split('\n') + .filter(line => line !== '') + } + + /** Waits until no process on the record of `command` is left. */ + async function ended(command: string): Promise { + const pids = onRecord(command).map(line => line.split(' ')[0]) + const deadline = Date.now() + 20000 + while (pids.some(pid => existsSync(`/proc/${pid}`))) { + if (Date.now() > deadline) throw new Error('the sandbox never ended') + await sleep(25) + } + } + + /** + * Waits to be told, then tries the denied write the hard way: a mount point + * that was removed and made again is an empty file without a write bit, which + * refuses a plain write by its mode alone, so the file is removed first. Only + * a bind still in place refuses that. + */ + function heldCommandThatRemovesFirst(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?` + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'a sandbox that starts while a clean-up is about to claim its manifest keeps its manifest and its deny', + async () => { + // The interleaving made certain: the clean-up has read the manifest as + // finished, the command's sandbox then starts and binds over its mount + // point, and only then does the clean-up claim the manifest. + const command = await wrap(heldCommandThatRemovesFirst()) + const manifest = manifestOf(command) + let inode: number | undefined + const pass = atTheClaimOn(manifest, { + before: () => { + // `{ ...; true; }` keeps a shell as bubblewrap's parent. + spawnSync('sh', ['-c', `{ ${command}; true; } > ${OUT} 2>&1 &`], { + stdio: 'ignore', + }) + const deadline = Date.now() + 20000 + while (!existsSync(SIGNAL)) { + if (Date.now() > deadline) throw new Error('sandbox never came up') + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + inode = lstatSync(LOCK).ino + }, + }) + try { + // The one clean-up this wrap is owed, made too early: the command is + // taken for over, and nothing but a process on its record can speak + // for its manifest. + cleanupBwrapMountPoints() + } finally { + pass.restore() + } + expect(inode).toBeDefined() + // The claim was given back, and what the sandbox is bound over is what + // it was bound over. + expect(readdirSync(dirname(manifest)).sort()).toEqual([ + basename(manifest), + basename(manifest).replace(/json$/, 'started'), + ]) + expect(lstatSync(LOCK).ino).toBe(inode!) + + // A later command in the same repository names the same mount point. + const third = await wrap('true') + expect(spawnSync(third, { shell: true, timeout: 60000 }).status).toBe(0) + cleanupBwrapMountPoints() // after the third + expect(lstatSync(LOCK).ino).toBe(inode!) + + writeFileSync(GO, '') + const deadline = Date.now() + 20000 + while (!/rc=\d/.test(contentAt(OUT))) { + if (Date.now() > deadline) throw new Error('the command never finished') + await sleep(25) + } + expect(contentAt(OUT)).toMatch(/rc=[1-9]/) + expect(contentAt(LOCK)).toBe('') + expect(lstatSync(LOCK).ino).toBe(inode!) + + // Not left running for the next test to find on record. + await ended(command) + cleanupBwrapMountPoints() + expect(readdirSync(dirname(manifest))).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'a command started once a clean-up has claimed its manifest is refused, and nothing is removed or made under it', + async () => { + const command = await wrap(`echo pwned > ${LOCK}; echo rc=$?`) + let refused: { status: number | null; said: string } | undefined + const pass = atTheClaimOn(manifestOf(command), { + after: () => { + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 60000, + }) + refused = { status: run.status, said: `${run.stdout}${run.stderr}` } + }, + }) + try { + cleanupBwrapMountPoints() // too early, and the command starts under it + } finally { + pass.restore() + } + expect(refused).toBeDefined() + expect(refused?.status).not.toBe(0) + expect(refused?.said).toContain("Can't find source path") + expect(refused?.said).not.toContain('rc=') + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(dirname(manifestOf(command)))).toEqual([]) + }, + 90000, + ) + + // ---- which command a clean-up is for ---- + // + // A command that ran and has ended is let go of by any clean-up call of its + // process. Of one that never started, only the caller can say that it is + // over, so these commands never start. What each holds on to is a mount point + // a killed process left, which the wrap covers and names. + + function leftByAKilledProcess(mountPoint: string): void { + writeFileSync(mountPoint, '') + chmodSync(mountPoint, 0o444) + manifestNaming([mountPoint]) + } + + it('with the id its wrap was given, a command that never started lets go of its mount point at once, while others are outstanding', async () => { + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + leftByAKilledProcess(lockOfFirst) + const wrapAs = (commandId: string, deny: string) => + wrapCommandWithSandboxLinux({ + command: 'true', + commandId, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [deny] }, + }) + const first = await wrapAs('first-0123456789abcdef', lockOfFirst) + const others = [ + await wrapAs('second-0123456789abcdef', LOCK), + await wrapAs('third-0123456789abcdef', LOCK), + ] + + // The control: a call that does not say which command it is for releases + // nothing this process made. + cleanupBwrapMountPoints() + expect(existsSync(lockOfFirst)).toBe(true) + expect(existsSync(manifestOf(first))).toBe(true) + + cleanupBwrapMountPoints({ commandId: 'first-0123456789abcdef' }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(first))).toBe(false) + for (const other of others) { + expect(existsSync(manifestOf(other))).toBe(true) + } + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'lets go of it the same way through the public clean-up, and the others can still start', + async () => { + const lockOfFirst = join(AREA, 'repo', '.git', 'first.lock') + leftByAKilledProcess(lockOfFirst) + const config = (deny: string) => ({ + filesystem: { denyRead: [], allowWrite: [AREA], denyWrite: [deny] }, + }) + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + ...config(lockOfFirst), + }) + try { + const wrapAs = (commandId: string, deny: string) => + SandboxManager.wrapWithSandbox( + 'true', + undefined, + config(deny), + undefined, + { commandId }, + ) + const first = await wrapAs('first-0123456789abcdef', lockOfFirst) + const second = await wrapAs('second-0123456789abcdef', LOCK) + await wrapAs('third-0123456789abcdef', LOCK) + + SandboxManager.cleanupAfterCommand() + expect(existsSync(lockOfFirst)).toBe(true) + + SandboxManager.cleanupAfterCommand({ + commandId: 'first-0123456789abcdef', + }) + expect(existsSync(lockOfFirst)).toBe(false) + expect(existsSync(manifestOf(first))).toBe(false) + expect(spawnSync(second, { shell: true, timeout: 60000 }).status).toBe( + 0, + ) + } finally { + await SandboxManager.reset() + } + }, + 90000, + ) + + it('a wrap that produced no command is not waited for by the clean-up after the next', async () => { + // It gave its count back when it threw, so one clean-up is all that is owed. + const thrown: unknown = await wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'srt-no-such-shell', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + }).catch((error: unknown) => error) + expect(String(thrown)).toContain('srt-no-such-shell') + + leftByAKilledProcess(LOCK) + const command = await wrap('true') + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) + }) + + it('a clean-up too many, with nothing outstanding, is not held against the next wrap', async () => { + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + + leftByAKilledProcess(LOCK) + const command = await wrap('true') + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifestOf(command))).toBe(false) + }) + + // ---- what a wrap cannot tell, it does not guess ---- + + it("refuses to wrap when it cannot read another sandbox's manifest", async () => { + // Taken for the caller's own, the file would be bound onto itself and named + // nowhere, and would go from under this sandbox with the one that made it. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + const theirs = manifestNaming([LOCK], process.pid) + // However old it is: what this process is short of says nothing of the file. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(theirs, hoursAgo, hoursAgo) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === theirs) { + throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + const wrapping = wrap('true') + expect(await refusal(wrapping)).toBe('mount_points_unreadable') + // It refuses every wrap while it lasts, so it says which file and why. + const said = (await wrapping.catch((e: unknown) => e)) as Error + for (const where of [said.message, said.cause]) { + expect(where).toContain(theirs) + expect(where).toContain('EMFILE') + } + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(LOCK)).toBe(true) + } finally { + spy.mockRestore() + } + expect(readdirSync(dirname(theirs))).toEqual([basename(theirs)]) + expect(await wrap('true')).toContain(`--ro-bind /dev/null ${LOCK}`) + }) + + it('plans again, and does not call the manifests unreadable, when they keep moving while it reads them', async () => { + // Other processes are busy, in whatever project: listed, and gone when + // opened, is what a manifest claimed, given back or collected looks like. + writeFileSync(LOCK, '') + const theirs = manifestNaming([LOCK], process.pid) + const open = fs.openSync + let [opens, elusiveFor] = [0, Infinity] + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === theirs && opens++ < elusiveFor) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(await refusal(wrap('true'))).toBe('mount_points_changed') + expect(readdirSync(dirname(theirs))).toEqual([basename(theirs)]) + const inOnePlan = opens / 3 + // Which planning again does not mend where something else is amiss too. + const junk = join(dirname(theirs), '4242-00.json') + writeFileSync(junk, '{}') + expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + rmSync(junk) + ;[opens, elusiveFor] = [0, inOnePlan] + expect(await wrap('true')).toContain(`--ro-bind /dev/null ${LOCK}`) + } finally { + spy.mockRestore() + } + }) + + it('is not asked of a wrap that restricts no write, which has no mount point', async () => { + mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + writeFileSync(join(runtime.manifestDir(), '4242-00.json'), '{}') + expect(await refusal(wrap('true'))).toBe('mount_points_unreadable') + expect( + await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [AREA] }, + writeConfig: undefined, + }), + ).toContain(`--tmpfs ${AREA}`) + }) + + it('refuses to wrap what needs a manifest larger than can be read, and publishes none', async () => { + const many = Array.from({ length: 4000 }, (_, i) => + join(AREA, `${'x'.repeat(240)}-${i}`), + ) + expect(await refusal(wrap('true', many))).toBe('too_many_mount_points') + expect(readdirSync(runtime.manifestDir())).toEqual([]) + }, 30000) + + it("takes a path a live manifest names for the caller's own file once something has been written to it", async () => { + // Live: written by this process, which has not let go of it. A file with + // content is nothing bubblewrap made, and /dev/null over it would hide it + // from the command it belongs to. + const first = await wrap('true') + expect(first).toContain(`--ro-bind /dev/null ${LOCK}`) + writeFileSync(LOCK, 'somebody wrote this\n') + const second = await wrap('true') + expect(second).not.toContain(`--ro-bind /dev/null ${LOCK}`) + expect(second).toContain(`--ro-bind ${LOCK} ${LOCK}`) + // Nor does the second wrap name it: it is not its to remove. + expect(recordedBy(second)).toBeUndefined() + }) + + it("is named by a wrap that took it for the caller's own file, another sandbox having made it again meanwhile", async () => { + // No manifest named it when the wrap read them: the one that had was + // collected. Not named, it would go with the sandbox that made it again. + writeFileSync(LOCK, '') + const exists = fs.existsSync + let theirs: string | undefined + const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { + if (file === LOCK) theirs ??= manifestNaming([LOCK], process.pid) + return exists(file as string) + }) as never) + let command: string + try { + command = await wrap('true') + } finally { + spy.mockRestore() + } + expect(theirs).toBeDefined() + expect(command).toContain(`--ro-bind ${LOCK} ${LOCK}`) + expect(readFileSync(manifestOf(command), 'utf8')).toContain(LOCK) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + "is recorded in a directory of the process's own only when there is one to record, and that directory goes with the process", + async () => { + // Neither shared name will do: no runtime directory, and the name under + // the temp dir is somebody's file. + const TMP = join(BASE, 'tmp') + mkdirSync(TMP) + const squatted = join(TMP, `srt-mount-points-${process.getuid!()}`) + writeFileSync(squatted, '') + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR + const inThatPlace = (script: string, deny: string[]) => + spawnSync( + process.execPath, + [ + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { readdirSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([AREA], 'true', deny), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `console.log(JSON.stringify({ wrapped, status: r.status, said: String(r.stdout ?? '') + String(r.stderr ?? ''), made: readdirSync(${JSON.stringify(TMP)}) }))`, + ].join('\n'), + ), + ], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + }, + ) + const manifestDirectories = (names: string[]): string[] => + names.filter(name => /^srt-mount-points-[A-Za-z0-9]{6}$/.test(name)) + + // With no mount point there is nothing to record and nothing to keep + // out of the command's reach, and no directory is made for it. + const nothingToRecord = JSON.parse( + inThatPlace('nothing-to-record.ts', []).stdout, + ) as { wrapped: string; status: number; made: string[] } + expect(nothingToRecord.status).toBe(0) + expect(recordedBy(nothingToRecord.wrapped)).toBeUndefined() + expect(manifestDirectories(nothingToRecord.made)).toEqual([]) + expect(manifestDirectories(readdirSync(TMP))).toEqual([]) + + // With one, it is made, used, and gone again when the process ends. + const recorded = JSON.parse( + inThatPlace('recorded.ts', [LOCK]).stdout, + ) as { wrapped: string; status: number; said: string; made: string[] } + expect(recorded.said).not.toContain('bwrap:') + expect(recorded.status).toBe(0) + expect(manifestDirectories(recorded.made).length).toBe(1) + expect(dirname(manifestOf(recorded.wrapped))).toBe( + join(TMP, manifestDirectories(recorded.made)[0]!), + ) + expect(manifestDirectories(readdirSync(TMP))).toEqual([]) + expect(existsSync(LOCK)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE && process.getuid?.() !== 0)( + 'is removed by the process that made it, at its clean-up or as it ends, where it could be recorded nowhere', + async () => { + // No runtime directory, and nothing can be made under the temp dir: no + // other process can know of the mount point. + const TMP = join(BASE, 'read-only-tmp') + mkdirSync(TMP, { mode: 0o500 }) + const env: Record = { + ...process.env, + TMPDIR: TMP, + } + delete env.XDG_RUNTIME_DIR + const inThatPlace = (script: string, after: string) => + spawnSync( + process.execPath, + [ + writeScript( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { existsSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + wrapLine([AREA], `echo pwned > ${LOCK}; echo rc=$?`, [LOCK]), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `const made = existsSync(${JSON.stringify(LOCK)})`, + after, + `console.log(JSON.stringify({ wrapped, said: String(r.stdout ?? '') + String(r.stderr ?? ''), made, left: existsSync(${JSON.stringify(LOCK)}) }))`, + ].join('\n'), + ), + ], + { + env: env as Record, + encoding: 'utf8', + timeout: 60000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }, + ) + for (const [script, after, left] of [ + ['cleans-up.ts', 'cleanupBwrapMountPoints()', false], + ['only-ends.ts', '', true], + ] as const) { + const ran = JSON.parse(inThatPlace(script, after).stdout) as { + wrapped: string + said: string + made: boolean + left: boolean + } + expect(recordedBy(ran.wrapped)).toBeUndefined() + expect(ran.wrapped).toStartWith('bwrap ') + expect(ran.said).toMatch(/rc=[1-9]/) + expect(ran).toMatchObject({ made: true, left }) + expect(existsSync(LOCK)).toBe(false) + } + }, + 90000, + ) + + it('leaves no directory that its mount points bind from, when all the process did was a wrap that came to nothing', () => { + // Refused for a doubt that comes up once the directory is made. No plan + // held, which is what else has the clean-up at exit set up. + const TMP = join(BASE, 'tmp-of-its-own') + mkdirSync(TMP) + mkdirSync(runtime.manifestDir(), { recursive: true, mode: 0o700 }) + const junk = JSON.stringify(join(runtime.manifestDir(), '4242-00.json')) + const isOne = (name: string): boolean => name.startsWith('claude-empty-') + const ended = spawnSync( + process.execPath, + [ + writeScript( + 'comes-to-nothing.ts', + [ + `const fs = require('node:fs')`, + `const mkdtemp = fs.mkdtempSync`, + `fs.mkdtempSync = (prefix, ...rest) => {`, + ` if (String(prefix).includes('claude-empty-')) fs.writeFileSync(${junk}, '{}')`, + ` return mkdtemp(prefix, ...rest)`, + `}`, + `const { wrapCommandWithSandboxLinux } = await import(${LIBRARY})`, + `const refused = await (async () => {`, + wrapLine([AREA], 'true', [join(AREA, 'absent', 'config.lock')]), + `})().then(() => undefined, error => error)`, + `console.log(refused?.code, fs.readdirSync(${JSON.stringify(TMP)}).filter(${String(isOne)}).length)`, + `fs.rmSync(${junk})`, + ].join('\n'), + ), + ], + { + env: { ...process.env, TMPDIR: TMP }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, + ) + expect(ended.stdout).toBe('mount_points_unreadable 1\n') + expect(readdirSync(TMP).filter(isOne)).toEqual([]) + }) + + it('lets go of the mount points of a wrap that produced no command', async () => { + // Left where a process killed before its cleanup would leave it, with the + // manifest it would leave, so that both processes below name it. + writeFileSync(LOCK, '') + chmodSync(LOCK, 0o444) + manifestNaming([LOCK]) + + const wrapper = spawn( + process.execPath, + [ + writeScript( + 'throwing.ts', + [ + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `try {`, + ` await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` binShell: 'srt-no-such-shell',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, + ` })`, + `} catch {`, + ` writeFileSync(${JSON.stringify(OUT)}, 'threw')`, + `}`, + // Stays alive: a manifest of a running process counts as live, so + // only letting go of it lets the mount point be collected here. + `await new Promise(resolve => setTimeout(resolve, 30000))`, + ].join('\n'), + ), + ], + { stdio: 'ignore' }, + ) + try { + await waitFor(OUT) + expect(readFileSync(OUT, 'utf8')).toBe('threw') + // This process names the same mount point and asks for the cleanup. A + // wrap that threw must not have held on to its manifest. + await wrap('true') + cleanupBwrapMountPoints() + expect(wrapper.exitCode).toBe(null) + expect(existsSync(LOCK)).toBe(false) + } finally { + wrapper.kill() + } + }, 90000) +}) + +/** + * The process that wrapped the command is gone and the sandbox it started is + * still running: nothing but the process on the manifest's record keeps the + * mount point. + * + * The wrap always asks for --die-with-parent, so a sandbox outlives its wrapper + * only when something else is bubblewrap's parent: here a shell in a session of + * its own, as under a job runner, a terminal multiplexer or `nohup sh -c`. + */ +describe.if(isLinux)( + 'A mount point whose sandbox outlives the process that wrapped it', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + let BASE: string + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let SIGNAL: string // written from inside the sandbox once it is up + let GO: string // written by the test to let the sandbox try its write + let RESULT: string // what the denied write returned, from inside + let INFO: string // what the writer says about itself before it goes + let launcher: number | undefined // the shell bubblewrap is a child of + let writer: ChildProcess | undefined + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-orphan-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + RESULT = join(AREA, 'result') + INFO = join(BASE, 'info.json') + launcher = undefined + writer = undefined + }) + + afterEach(async () => { + writer?.kill('SIGKILL') + if (launcher !== undefined) { + await killTree(launcher) + } + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** "pid state ppid" of every process this one can see. */ + function processes(): { pid: number; state: string; ppid: number }[] { + const found: { pid: number; state: string; ppid: number }[] = [] + for (const name of readdirSync('/proc')) { + if (!/^\d+$/.test(name)) continue + try { + const stat = readFileSync(`/proc/${name}/stat`, 'utf8') + const [state, ppid] = stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(' ') + found.push({ + pid: Number(name), + state: state ?? '?', + ppid: Number(ppid), + }) + } catch { + // Went away while we were looking. + } + } + return found + } + + /** `root` and everything under it that is still running (not a zombie). */ + function runningTree(root: number): number[] { + const all = processes() + const tree = [root] + for (let i = 0; i < tree.length; i++) { + for (const p of all) { + if (p.ppid === tree[i] && !tree.includes(p.pid)) tree.push(p.pid) + } + } + return tree.filter(pid => + all.some(p => p.pid === pid && p.state !== 'Z' && p.state !== 'X'), + ) + } + + async function killTree(root: number): Promise { + const tree = runningTree(root) + for (const pid of tree) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Gone already. + } + } + await until( + `the sandbox under ${root} to be gone`, + () => + !processes().some( + p => tree.includes(p.pid) && p.state !== 'Z' && p.state !== 'X', + ), + ) + } + + /** + * Says it is up, waits to be told, tries the denied write, reports the + * result, and then stays: the sandbox is still running when it is killed. + */ + function heldCommand(): string { + return `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$? > ${RESULT}; sleep 600` + } + + /** + * A process that wraps the held command, starts it under a shell in a + * session of its own, waits for the sandbox to be up and then goes: `leave` + * is how. + */ + function writerScript(leave: string): string { + return [ + `import { spawn } from 'node:child_process'`, + `import { existsSync, writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([AREA], heldCommand(), [LOCK]), + // "; exit" so that no shell execs bubblewrap in its own place: the + // shell has to stay, as the parent --die-with-parent watches. + `const child = spawn('/bin/sh', ['-c', wrapped + '\\nexit $?'], { detached: true, stdio: 'ignore' })`, + `child.unref()`, + `writeFileSync(${JSON.stringify(INFO)}, JSON.stringify({ writer: process.pid, launcher: child.pid, wrapped }))`, + // Not for ever: a sandbox that never comes up fails the test at once. + `const deadline = Date.now() + 30000`, + `while (!existsSync(${JSON.stringify(SIGNAL)})) {`, + ` if (Date.now() > deadline) process.exit(3)`, + ` await new Promise(r => setTimeout(r, 25))`, + `}`, + leave, + ].join('\n') + } + + /** A process that only collects: it has wrapped nothing. */ + function collectorScript(): string { + return [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + `cleanupBwrapMountPoints({ force: true })`, + ].join('\n') + } + + /** A process that denies the same path, runs a command and cleans up. */ + function secondWrapperScript(): string { + return [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + wrapLine([AREA], 'true', [LOCK]), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + `process.exit(r.status ?? 1)`, + ].join('\n') + } + + function runScript(name: string, source: string): number | null { + const file = join(BASE, name) + writeFileSync(file, source) + return spawnSync(process.execPath, [file], { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + }).status + } + + /** The manifests in `dir` that name the deny path. */ + function manifestsNamingLock(dir: string): string[] { + return readdirSync(dir).filter( + name => + name.endsWith('.json') && + readFileSync(join(dir, name), 'utf8').includes(LOCK), + ) + } + + type Writer = { writer: number; launcher: number; wrapped: string } + + /** + * Has a process wrap the held command, start it and go, and checks the + * premise: that process is gone, its sandbox is running, and its manifest + * is past the grace. + */ + async function sandboxWhoseWriterHasGone( + leave: string, + signal: NodeJS.Signals | null, + ): Promise { + const writerFile = join(BASE, 'writer.ts') + writeFileSync(writerFile, writerScript(leave)) + writer = spawn(process.execPath, [writerFile], { stdio: 'ignore' }) + const left = await new Promise<{ + code: number | null + signal: NodeJS.Signals | null + }>(resolve => + writer!.on('exit', (code, signal) => resolve({ code, signal })), + ) + expect(left).toEqual({ code: signal === null ? 0 : null, signal }) + + const info = JSON.parse(readFileSync(INFO, 'utf8')) as Writer + launcher = info.launcher + expect(existsSync(`/proc/${info.writer}`)).toBe(false) + expect(existsSync(SIGNAL)).toBe(true) + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + // Past its grace, or the grace is what keeps the manifest live and not + // the process on its record. + const manifest = recordedBy(info.wrapped) + expect(manifest).toBeDefined() + const { created } = JSON.parse(readFileSync(manifest!, 'utf8')) as { + created: number + } + await until( + 'the manifest to be past its grace', + () => Date.now() - created > 750, + ) + return info + } + + /** Lets the sandbox try its write, and says what came of it. */ + async function theDeniedWrite(): Promise<{ + deniedWrite: string + onTheHost: string + }> { + writeFileSync(GO, '') + await until( + 'the sandbox to say what its write returned', + () => existsSync(RESULT) && readFileSync(RESULT, 'utf8').includes('\n'), + ) + return { + deniedWrite: readFileSync(RESULT, 'utf8').trim(), + onTheHost: existsSync(LOCK) + ? readFileSync(LOCK, 'utf8') + : '(no mount point)', + } + } + + const ways: [string, string, NodeJS.Signals | null][] = [ + [ + 'is killed before it can clean up', + `process.kill(process.pid, 'SIGKILL')`, + 'SIGKILL', + ], + // Its exit handler runs the cleanup with `force`: a sandbox still running + // under a manifest is not the process's to end. + ['exits, running its exit-time cleanup', `process.exit(0)`, null], + ] + + for (const [how, leave, signal] of ways) { + it.if(BWRAP_CAN_NAMESPACE)( + `stays, and the deny holds, while other processes collect after its writer ${how}; goes once the sandbox is gone`, + async () => { + const info = await sandboxWhoseWriterHasGone(leave, signal) + const manifest = recordedBy(info.wrapped) + expect(manifest).toBeDefined() + // Still naming the deny path, and the process that is gone. + const written = JSON.parse(readFileSync(manifest!, 'utf8')) as { + pid: number + paths: string[] + } + expect(written.pid).toBe(info.writer) + expect(written.paths).toContain(LOCK) + + // Whether the mount point and the manifest that names it are still + // there after each thing that might have taken them. Checked together + // further down, so a failure says all of what happened. + const BOTH = 'the mount point and its manifest' + const stayed: Record = {} + const look = (when: string): void => { + const mountPoint = existsSync(LOCK) && lstatSync(LOCK).size === 0 + const named = existsSync(manifest!) + stayed[when] = + mountPoint && named + ? BOTH + : mountPoint + ? 'the mount point, not its manifest' + : named + ? 'the manifest, not the mount point' + : 'neither' + } + look('after its writer went') + + expect(runScript('collector.ts', collectorScript())).toBe(0) + look('after a process that wrapped nothing collected') + expect(runScript('second.ts', secondWrapperScript())).toBe(0) + look('after a process that denies the same path ran and cleaned up') + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + look('after this process collected, twice') + + // The sandbox is still there. Its denied write must fail inside, and + // nothing may land on the host. (A mount point removed and made again + // by the second wrap is on the host but is no longer what this + // sandbox is bound over: the command removes the file before it + // writes, which only a bind still in place refuses.) + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + expect({ stayed, ...(await theDeniedWrite()) }).toEqual({ + stayed: { + 'after its writer went': BOTH, + 'after a process that wrapped nothing collected': BOTH, + 'after a process that denies the same path ran and cleaned up': + BOTH, + 'after this process collected, twice': BOTH, + }, + deniedWrite: expect.stringMatching(/^rc=[1-9]/), + onTheHost: '', + }) + + // Still running after its write, and a collect still leaves it. + expect(runningTree(info.launcher).length).toBeGreaterThan(1) + expect(runScript('collector.ts', collectorScript())).toBe(0) + expect(existsSync(LOCK)).toBe(true) + + // Kill the sandbox. The next collect by anyone removes the mount + // point and the manifest. + await killTree(info.launcher) + launcher = undefined + expect(runScript('collector.ts', collectorScript())).toBe(0) + expect(existsSync(LOCK)).toBe(false) + expect(manifestsNamingLock(join(manifest!, '..'))).toEqual([]) + }, + 120000, + ) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'holds its deny while another process collects without pause, with the process that wrapped it gone', + async () => { + // A collector that never has a candidate proves nothing. Here the + // writer is gone and the grace is over before the write is tried, so + // the process on record is all that stands between the collector and + // the mount point. + const loopFile = join(BASE, 'collect-loop.ts') + writeFileSync( + loopFile, + [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `const until = Date.now() + 90000`, + `while (Date.now() < until) cleanupBwrapMountPoints()`, + ].join('\n'), + ) + const loop = spawn(process.execPath, [loopFile], { stdio: 'ignore' }) + try { + for (let round = 0; round < 3; round++) { + const info = await sandboxWhoseWriterHasGone( + `process.kill(process.pid, 'SIGKILL')`, + 'SIGKILL', + ) + expect(loop.exitCode).toBe(null) // still collecting + expect(lstatSync(LOCK).size).toBe(0) + expect(await theDeniedWrite()).toEqual({ + deniedWrite: expect.stringMatching(/^rc=[1-9]/), + onTheHost: '', + }) + await killTree(info.launcher) + launcher = undefined + // With the sandbox gone the same collector takes it away. + await until( + 'the collector to take the mount point away', + () => !existsSync(LOCK), + ) + for (const file of [SIGNAL, GO, RESULT, INFO]) { + rmSync(file, { force: true }) + } + } + } finally { + loop.kill('SIGKILL') + } + }, + 120000, + ) + }, +) + +/** + * The mount point for a deny whose first missing component is not the leaf is + * an empty directory. On the host it looks like anyone's, so only a manifest + * says what it is. + */ +describe.if(isLinux)( + 'A directory mount point a running sandbox relies on', + () => { + const runtime = usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + let BASE: string + let AREA: string + let DIRECTORY: string // the first missing component of LEAF + let LEAF: string + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-directory-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'proj'), { recursive: true }) + DIRECTORY = join(AREA, 'proj', '.claude') + LEAF = join(DIRECTORY, 'commands') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + const wrap = (command: string, deny: string[]): Promise => + wrapIn(AREA, command, deny) + + /** + * Another process: denies LEAF, holds its sandbox until `go` is there, + * and cleans up after it. + */ + function holder(up: string, go: string): ChildProcess { + const file = join(BASE, 'holder.ts') + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints } from ${LIBRARY}`, + wrapLine( + [AREA], + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`, + [LEAF], + ), + `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `cleanupBwrapMountPoints()`, + ].join('\n'), + ) + return spawn(process.execPath, [file], { stdio: 'ignore' }) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be denied by a second wrap whose deny path is exactly that directory', + async () => { + // A live manifest names it. /dev/null cannot be bound over a directory: + // bubblewrap would refuse to start. + const up = join(AREA, 'up-holder') + const go = join(AREA, 'go-holder') + const held = holder(up, go) + const heldExited = new Promise(resolve => held.on('exit', resolve)) + try { + await waitFor(up) + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + const command = await wrap(`touch ${DIRECTORY}/x; echo rc=$?`, [ + DIRECTORY, + ]) + expect(command).not.toContain(`--ro-bind /dev/null ${DIRECTORY}`) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + const second = run(command) + expect(second.said).not.toContain('bwrap:') + expect(second.status).toBe(0) + expect(second.stdout).toMatch(/rc=[1-9]/) + } finally { + writeFileSync(go, '') + await heldExited + } + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'can be denied again by the same list, with the earlier wrap of this process not yet cleaned up after', + async () => { + // No second process: a manifest is live while the process that wrote it + // has not let go of it. A deny list naming a missing directory and + // something beneath it makes the directory the mount point, and the + // same list again finds it named by a live manifest. + const first = run(await wrap('echo first-ran', [DIRECTORY, LEAF])) + expect(first.stdout).toContain('first-ran') + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + + const command = await wrap( + `echo second-ran; touch ${DIRECTORY}/x; echo rc=$?`, + [DIRECTORY, LEAF], + ) + expect(command).not.toContain(`--ro-bind /dev/null ${DIRECTORY}`) + const second = run(command) + expect(second.said).not.toContain('bwrap:') + expect(second.stdout).toContain('second-ran') + expect(second.stdout).toMatch(/rc=[1-9]/) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while that second sandbox runs, after the sandbox that made it has ended and been cleaned up after', + async () => { + // Bound onto itself and not named, it would go with the sandbox that + // made it, and the denied directory could be made again inside the + // second sandbox. + const upHolder = join(AREA, 'up-holder') + const goHolder = join(AREA, 'go-holder') + const upSecond = join(AREA, 'up-second') + const goSecond = join(AREA, 'go-second') + const held = holder(upHolder, goHolder) + const heldExited = new Promise(resolve => held.on('exit', resolve)) + let said = '' + try { + await waitFor(upHolder) + const second = spawn( + await wrap( + `echo up > ${upSecond}; while [ ! -e ${goSecond} ]; do sleep 0.05; done; mkdir -p ${LEAF}; echo pwned > ${LEAF}/x.md; echo rc=$?`, + [DIRECTORY], + ), + { shell: true }, + ) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => + second.on('exit', resolve), + ) + await waitFor(upSecond) + + writeFileSync(goHolder, '') + await heldExited // and its own clean-up has run + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(true) + + writeFileSync(goSecond, '') + await secondExited + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(join(LEAF, 'x.md'))).toBe(false) + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + } finally { + writeFileSync(goHolder, '') + writeFileSync(goSecond, '') + await heldExited + } + }, + 90000, + ) + + // ---- left by a process that was killed ---- + // + // Its manifest is finished, not live. The next clean-up in any process + // removes the directory on that manifest's word, so a wrap that denies the + // directory in the meantime has to name it for itself. + + /** What a process killed a minute ago left in the manifest directory. */ + const manifestOfAKilledProcess = (paths: string[]): string => + writeManifest(runtime.manifestDir(), paths) + + it('is named by a wrap that denies it, and stays until that wrap is cleaned up after', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + + // The directory itself, beside something beneath it, which needs no mount + // point of its own under a read-only directory. + const command = await wrap('true', [DIRECTORY, LEAF]) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(command).not.toContain(LEAF) + expect(namedBy(command)).toEqual([DIRECTORY]) + + // A second command of this process and the clean-up after it: the first + // is still outstanding, so nothing of this process's is released, and + // what the killed process left is collected. + await wrap('true', []) + cleanupBwrapMountPoints() + expect(existsSync(left)).toBe(false) + expect(existsSync(DIRECTORY)).toBe(true) + + // And it goes like any other mount point, once nothing relies on it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }) + + // ---- relied on, not denied: what a second wrap nests under it ---- + // + // The second wrap finds the directory there, so its own mount point is made + // inside it and its pin is on it. It has to name the directory too, or that + // goes with the sandbox it was made for. + + it('is named by a wrap whose own mount point is made in it, and kept for that wrap', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + + const command = await wrap('true', [LEAF]) + expect(command).toContain(`--ro-bind /dev/null ${LEAF}`) + expect(countMounts(command, '--ro-bind', DIRECTORY, DIRECTORY)).toBe(1) + expect(namedBy(command).sort()).toEqual([DIRECTORY, LEAF].sort()) + + // What the killed process left is collected; the directory stays. + await wrap('true', []) + cleanupBwrapMountPoints() + expect(existsSync(left)).toBe(false) + expect(existsSync(DIRECTORY)).toBe(true) + + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'is still there for a second command of this process, wrapped before and started after the clean-up for the first', + async () => { + const first = await wrap('echo first', [LEAF]) + expect(run(first).stdout).toBe('first\n') + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + const second = await wrap('echo second', [LEAF]) + expect(namedBy(second)).toContain(DIRECTORY) + + cleanupBwrapMountPoints() // for the first, which has ended + expect(existsSync(DIRECTORY)).toBe(true) + expect(run(second)).toMatchObject({ status: 0, said: 'second\n' }) + + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'keeps its pin in a sandbox that is starting when the sandbox it was made for is cleaned up after', + async () => { + // bubblewrap is held up after its pins and before it makes the mount + // point inside the directory: `--file` copies from a FIFO nobody has + // closed. Removed then, the directory would be made again, unpinned. + expect(run(await wrap('true', [LEAF])).status).toBe(0) + const inode = lstatSync(DIRECTORY).ino + const mountsAt = (p: string): string => + `$(grep -c ' ${p} ' /proc/self/mountinfo)` + const second = await wrap( + `echo "${mountsAt(DIRECTORY)} ${mountsAt(LEAF)}"`, + [LEAF], + ) + const placeholder = `--ro-bind /dev/null ${LEAF}` + expect(second.split(placeholder)).toHaveLength(2) + const fifo = join(BASE, 'fifo') + const gate = join(AREA, 'gate') + expect(spawnSync('mkfifo', [fifo]).status).toBe(0) + const hold = fs.openSync(fifo, fs.constants.O_RDWR) + const child = spawn( + `${second.replace(placeholder, `--file 8 ${gate} ${placeholder}`)} 8<${fifo}`, + { shell: true }, + ) + let said = '' + child.stdout.on('data', chunk => (said += String(chunk))) + child.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => child.on('exit', resolve)) + try { + await waitFor(gate) + cleanupBwrapMountPoints() // for the first, which has ended + expect(lstatSync(DIRECTORY).ino).toBe(inode) + } finally { + fs.closeSync(hold) + } + expect(await exited).toBe(0) + // One mount at the directory, its pin, and one at what is denied in it. + expect(said).toBe('1 1\n') + expect(lstatSync(DIRECTORY).ino).toBe(inode) + + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + 'covers all that is denied in it, though another sandbox makes it while the wrap goes from one deny to the next', + async () => { + // Absent for the first deny and there for the second, it would be + // planned as an empty read-only directory and, inside that, a mount + // point bubblewrap cannot make. + const OTHER = join(DIRECTORY, 'agents') + const first = await wrap('true', [LEAF, OTHER]) + const lstat = fs.lstatSync + let ran = false + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + // The first deny is judged: the wrap looks at what it will bind from. + if (String(file).includes('claude-empty-') && !ran) { + ran = true + expect(run(first).status).toBe(0) + expect(existsSync(DIRECTORY)).toBe(true) + } + return (lstat as (...args: unknown[]) => unknown)(file, ...rest) + }) as never) + let second: string + try { + second = await wrap(`touch ${LEAF} ${OTHER}; echo rc=$?`, [ + LEAF, + OTHER, + ]) + } finally { + spy.mockRestore() + } + expect(ran).toBe(true) + expect(namedBy(second)).toEqual([DIRECTORY]) + expect(run(second)).toMatchObject({ status: 0, stdout: 'rc=1\n' }) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + }, + 90000, + ) + + /** Runs `then` each time the wrap is about to publish a manifest. */ + function atEachPublish(then: () => void): { restore(): void } { + const write = fs.writeFileSync + const spy = spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.json.tmp')) then() + return (write as (...args: unknown[]) => void)(file, ...rest) + }) as never) + return { restore: () => spy.mockRestore() } + } + + /** + * Runs `then` when the wrap has decided where its mount points go and has + * yet to pin what lies above them: as it asks where the manifest directory + * really is, which is pinned too. It asks once in a plan. + */ + function beforeThePins(then: () => void): { restore(): void } { + const native = fs.realpathSync.native + const spy = spyOn(fs.realpathSync, 'native').mockImplementation((( + dir: unknown, + ...rest: unknown[] + ) => { + if (dir === runtime.manifestDir()) then() + return (native as (...args: unknown[]) => unknown)(dir, ...rest) + }) as never) + return { restore: () => spy.mockRestore() } + } + + for (const [when, early] of [ + ['by the time its manifest is published', false], + ['between its decision to nest in it and the pin', true], + ] as const) { + it(`plans again when the directory has gone ${when}`, async () => { + // Until it has published nothing of this wrap's names it, and a clean-up + // in another process takes it away with the sandbox it was made for. + // That pass spares the file, which the new manifest names by then, and + // drops the manifest it was collecting. + const spared = join(AREA, 'proj', '.bashrc') + writeFileSync(spared, '') + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([spared, DIRECTORY]) + const goes = (now: boolean): void => { + if (now && existsSync(left)) { + fs.rmdirSync(DIRECTORY) + rmSync(left) + } + } + let published = 0 + const publishing = atEachPublish(() => + goes(published++ === 0 && !early), + ) + const pinning = beforeThePins(() => goes(early)) + let command: string + try { + command = await wrap('true', [spared, LEAF]) + } finally { + pinning.restore() + publishing.restore() + } + // The plan that was handed out found it absent, and covers it itself. + expect(published).toBe(2) + expect(command).not.toContain(`--ro-bind /dev/null ${LEAF}`) + expect(command).toMatch( + new RegExp(`--ro-bind \\S+/claude-empty-\\S+ ${DIRECTORY} `), + ) + // Nothing is left of the plan it gave up, nor of what only that named: + // found there, the file would be the caller's own from now on. + expect(command).toContain(`--ro-bind /dev/null ${spared}`) + expect(namedBy(command).sort()).toEqual([spared, DIRECTORY].sort()) + expect(readdirSync(join(AREA, 'proj'))).toEqual([]) + expect(readdirSync(runtime.manifestDir())).toEqual([ + basename(recordedBy(command)!), + ]) + }) + } + + for (const [what, meanwhile, names] of [ + [ + 'a directory it mounts a tmpfs on has gone', + () => fs.rmdirSync(join(AREA, 'hidden')), + () => [LEAF], + ], + [ + // Of a sandbox that has ended: the directory goes with the plan. + 'a manifest has come that names the directory it nests in', + () => manifestOfAKilledProcess([DIRECTORY]), + () => [DIRECTORY], + ], + ] as const) { + it(`plans again when ${what} by the time its manifest is published`, async () => { + mkdirSync(DIRECTORY) + mkdirSync(join(AREA, 'hidden')) + let published = 0 + const publishing = atEachPublish(() => { + if (published++ === 0) meanwhile() + }) + let command: string + try { + command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(AREA, 'hidden')] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LEAF] }, + }) + } finally { + publishing.restore() + } + expect(published).toBe(2) + expect(namedBy(command)).toEqual(names()) + }) + } + + it('takes what it cannot look at again for there', async () => { + // bubblewrap does not start on what it cannot bind. + let published = 0 + const publishing = atEachPublish(() => published++) + const stat = fs.statSync + const spy = spyOn(fs, 'statSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (file === AREA && published > 0) { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + } + return (stat as (...args: unknown[]) => unknown)(file, ...rest) + }) as never) + try { + await wrap('true', [LEAF]) + } finally { + spy.mockRestore() + publishing.restore() + } + expect(published).toBe(1) + }) + + it('plans again when its manifest went to a directory settled on since the pins', async () => { + // Nothing is pinned above that one. Both shared names are taken as the + // wrap reads the manifests again, with the pins made, so it publishes in + // a directory of its own. + const shared = [ + runtime.manifestDir(), + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ] + const readdir = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + ...rest: unknown[] + ) => { + const names = (readdir as (...args: unknown[]) => unknown)(dir, ...rest) + if (pinned && dir === shared[1]) { + for (const name of shared) { + fs.rmdirSync(name) + writeFileSync(name, '') + } + } + return names + }) as never) + let pinned = false + const pinning = beforeThePins(() => (pinned = true)) + let published = 0 + const publishing = atEachPublish(() => published++) + try { + const command = await wrap('true', [LEAF]) + expect(published).toBe(2) + const own = dirname(recordedBy(command)!) + expect(shared).not.toContain(own) + expect(countMounts(command, '--ro-bind', own, own)).toBe(1) + expect(readdirSync(own)).toEqual([basename(recordedBy(command)!)]) + } finally { + publishing.restore() + pinning.restore() + spy.mockRestore() + shared.forEach(name => rmSync(name, { recursive: true, force: true })) + } + }) + + it("is recorded in a directory of the process's own that the same plan binds, whether or not the manifest can be written", async () => { + // Neither shared name will do, from the start. + const shared = [ + runtime.manifestDir(), + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ] + for (const name of shared) { + rmSync(name, { recursive: true, force: true }) + writeFileSync(name, '') + } + const own = (): string[] => + readdirSync(tmpdir()) + .filter(name => name.startsWith('srt-mount-points-')) + .map(name => join(tmpdir(), name)) + .filter(name => !shared.includes(name)) + let published = 0 + let full = false + const publishing = atEachPublish(() => { + published++ + if (full) throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + }) + try { + // The forced clean-up removes the directory, so each wrap settles anew. + for (full of [false, true]) { + published = 0 + const command = await wrap('true', [LEAF]) + expect(published).toBe(1) + expect(own()).toHaveLength(1) + expect(recordedBy(command) === undefined).toBe(full) + expect(lastMountAt(command, own()[0]!)).toBe( + `${full ? '--ro-bind-try' : '--ro-bind'} ${own()[0]} ${own()[0]}`, + ) + cleanupBwrapMountPoints({ force: true }) + expect(own()).toEqual([]) + } + } finally { + publishing.restore() + shared.forEach(name => rmSync(name, { recursive: true, force: true })) + } + }) + + it('is recorded among directories that the wrap naming it looks at again before it binds them, so a link put at a name since is not followed', async () => { + // Bound last, where the link leads would lie on top of the read denies + // beneath it. + const shared = join(tmpdir(), `srt-mount-points-${process.getuid!()}`) + const elsewhere = join(BASE, 'elsewhere') + mkdirSync(elsewhere) + // As the wrap makes what its mount point binds from, in the deny loop: + // it makes one only with none left from an earlier case. + emptySources().forEach(source => fs.rmdirSync(source)) + const mkdtemp = fs.mkdtempSync + const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( + prefix: unknown, + ...rest: unknown[] + ) => { + if (String(prefix).includes('claude-empty-')) { + rmSync(shared, { recursive: true }) + symlinkSync(elsewhere, shared) + } + return (mkdtemp as (...args: unknown[]) => unknown)(prefix, ...rest) + }) as never) + try { + const command = await wrap('true', [LEAF]) + expect(lstatSync(shared).isSymbolicLink()).toBe(true) + expect(lastMountAt(command, elsewhere)).toBeUndefined() + expect(lastMountAt(command, shared)).toBeUndefined() + } finally { + spy.mockRestore() + rmSync(shared, { recursive: true, force: true }) + } + }) + + it('is let go of when a process exits whose wrap made no mount point of its own', () => { + mkdirSync(DIRECTORY) + manifestOfAKilledProcess([DIRECTORY]) + const file = join(BASE, 'exits.ts') + writeFileSync( + file, + [ + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([AREA], 'true', [DIRECTORY]), + ].join('\n'), + ) + const exited = spawnSync(process.execPath, [file], { + env: { ...process.env }, + timeout: 60000, + cwd: import.meta.dir, + }) + expect(exited.status).toBe(0) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + expect(existsSync(DIRECTORY)).toBe(false) + }, 90000) + + const emptySources = (): string[] => + readdirSync(tmpdir()) + .filter(name => name.startsWith('claude-empty-')) + .map(name => join(tmpdir(), name)) + + it('leaves nothing behind when it throws, having published or not', async () => { + const thrown: unknown = await wrapCommandWithSandboxLinux({ + command: 'true', + binShell: 'srt-no-such-shell', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LEAF] }, + }).catch((error: unknown) => error) + expect(String(thrown)).toContain('srt-no-such-shell') + expect(readdirSync(runtime.manifestDir())).toEqual([]) + expect(emptySources()).toEqual([]) + + // The manifests are read first: in doubt from the start, it makes nothing. + const junk = join(runtime.manifestDir(), '4242-00.json') + writeFileSync(junk, '{}') + expect(await refusal(wrap('true', [LEAF]))).toBe( + 'mount_points_unreadable', + ) + expect(emptySources()).toEqual([]) + rmSync(junk) + + // In doubt from when it made something: that goes once a clean-up can tell. + const mkdtemp = fs.mkdtempSync + const spy = spyOn(fs, 'mkdtempSync').mockImplementation((( + ...args: unknown[] + ) => { + writeFileSync(junk, '{}') + return (mkdtemp as (...args: unknown[]) => unknown)(...args) + }) as never) + try { + expect(await refusal(wrap('true', [LEAF]))).toBe( + 'mount_points_unreadable', + ) + } finally { + spy.mockRestore() + } + expect(emptySources()).toHaveLength(1) + rmSync(junk) + cleanupBwrapMountPoints() + expect(emptySources()).toEqual([]) + }) + + it('gives up, with a code of its own and nothing left behind, when that happens to every plan', async () => { + let published = 0 + const publishing = atEachPublish(() => { + published++ + // What the mount point is bound from, as a clean-up elsewhere takes it. + emptySources().forEach(source => fs.rmdirSync(source)) + }) + try { + expect(await refusal(wrap('true', [LEAF]))).toBe('mount_points_changed') + } finally { + publishing.restore() + } + expect(published).toBe(3) + expect(readdirSync(runtime.manifestDir())).toEqual([]) + + // And it is not counted: one clean-up is all the next wrap is owed. + const command = await wrap('true', [LEAF]) + cleanupBwrapMountPoints() + expect(existsSync(recordedBy(command)!)).toBe(false) + }) + + it('is named by a wrap that denies it though another sandbox made it after that wrap last read the manifests', async () => { + // An empty file at an earlier deny path has the manifests read. The + // directory is made, by a sandbox that published first, as the wrap comes + // to look at it. + const earlier = join(AREA, 'proj', 'earlier.lock') + writeFileSync(earlier, '') + const exists = fs.existsSync + let made = false + const spy = spyOn(fs, 'existsSync').mockImplementation((( + file: unknown, + ) => { + if (file === DIRECTORY && !made) { + made = true + manifestOfAKilledProcess([DIRECTORY]) + mkdirSync(DIRECTORY) + } + return exists(file as string) + }) as never) + let command: string + try { + command = await wrap('true', [earlier, DIRECTORY]) + } finally { + spy.mockRestore() + } + expect(made).toBe(true) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(namedBy(command)).toEqual([DIRECTORY]) + }) + + it('is never removed once something has been put into it', async () => { + mkdirSync(DIRECTORY) + const left = manifestOfAKilledProcess([DIRECTORY]) + const command = await wrap('true', [DIRECTORY]) + expect(namedBy(command)).toEqual([DIRECTORY]) + + writeFileSync(join(DIRECTORY, 'settings.json'), '{}') + cleanupBwrapMountPoints() + cleanupBwrapMountPoints() + + expect(readFileSync(join(DIRECTORY, 'settings.json'), 'utf8')).toBe('{}') + // Nothing names it any more: it is the user's directory from here on. + expect(existsSync(left)).toBe(false) + expect(existsSync(recordedBy(command)!)).toBe(false) + expect(namedBy(await wrap('true', [DIRECTORY]))).toEqual([]) + }) + + it('is left alone, by the wrap and by the clean-up, where no manifest names it', async () => { + // An empty directory looks like anyone's: this one is the user's. + mkdirSync(DIRECTORY) + manifestOfAKilledProcess([join(AREA, 'proj', '.vscode')]) + + const command = await wrap('true', [DIRECTORY, LEAF]) + expect(command).toContain(`--ro-bind ${DIRECTORY} ${DIRECTORY}`) + expect(namedBy(command)).toEqual([]) + + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(true) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'stays while a sandbox that denies it runs, with the process that made it killed and another cleaning up', + async () => { + // Bound onto itself and not named, it would go at the next clean-up in + // any process, from under the running sandbox. + const script = (name: string, lines: string[]): string => + writeScriptIn(BASE, name, lines) + const killed = spawnSync( + process.execPath, + [ + script('killed.ts', [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([AREA], 'true', [LEAF]), + `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `process.kill(process.pid, 'SIGKILL')`, + ]), + ], + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, + ) + expect(killed.signal).toBe('SIGKILL') + // Its sandbox ran, and left what bubblewrap made for it. + expect(lstatSync(DIRECTORY).isDirectory()).toBe(true) + expect(readdirSync(DIRECTORY)).toEqual([]) + expect( + readdirSync(runtime.manifestDir()).filter(name => + name.endsWith('.json'), + ), + ).toHaveLength(1) + const up = join(AREA, 'up-second') + const go = join(AREA, 'go-second') + let said = '' + const second = spawn( + await wrap( + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done; mkdir -p ${LEAF}; echo pwned > ${LEAF}/x.md; echo rc=$?`, + [DIRECTORY], + ), + { shell: true }, + ) + second.stdout.on('data', chunk => (said += String(chunk))) + second.stderr.on('data', chunk => (said += String(chunk))) + const secondExited = new Promise(resolve => second.on('exit', resolve)) + try { + await waitFor(up) + const collector = spawnSync( + process.execPath, + [ + script('collector.ts', [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ]), + ], + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, + ) + expect(collector.status).toBe(0) + expect(existsSync(DIRECTORY)).toBe(true) + } finally { + writeFileSync(go, '') + await secondExited + } + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(join(LEAF, 'x.md'))).toBe(false) + + // And goes once nothing runs under it. + cleanupBwrapMountPoints() + expect(existsSync(DIRECTORY)).toBe(false) + }, + 90000, + ) + }, +) + +/** + * The manifest directories, the fake-file store and the empty placeholder + * source are bound read-only in a sandbox, and what is found at their names is + * believed on the host. The bind keeps a command out of the directory, not the + * name leading to it: where the temp dir lies strictly inside a write root the + * directories between the two are pinned. + */ +describe.if(isLinux)( + 'The directories above the ones this library keeps on the host', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + const UID = isLinux ? process.getuid!() : 0 + let BASE: string + let HOME: string // the allowed write root + let TMP: string // the temp dir, strictly inside it + let GUARD: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pins-'))) + HOME = join(BASE, 'home') + TMP = join(HOME, 'tmp') + mkdirSync(TMP, { recursive: true }) + GUARD = join(HOME, 'guard.lock') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** + * Runs `lines` in a process of its own whose temp dir is TMP. An `env` + * entry that is undefined is removed from its environment. + */ + function inAChild( + name: string, + lines: string[], + env: Record = {}, + ): { status: number | null; stdout: string } { + const file = join(BASE, name) + writeFileSync(file, lines.join('\n')) + const merged: Record = { + ...process.env, + TMPDIR: TMP, + ...env, + } + for (const [key, value] of Object.entries(merged)) { + if (value === undefined) delete merged[key] + } + const r = spawnSync(process.execPath, [file], { + env: merged as Record, + encoding: 'utf8', + timeout: 60000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + return { status: r.status, stdout: r.stdout } + } + + /** What a process with that environment wraps `true` as. */ + function wrapped( + options: Record, + env: Record = {}, + ): string { + const child = inAChild( + 'wrap.ts', + [ + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `console.log(await wrapCommandWithSandboxLinux({`, + ` command: 'true',`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` ...${JSON.stringify(options)},`, + `}))`, + ], + env, + ) + expect(child.status).toBe(0) + return child.stdout + } + + /** Whether `dir` is pinned, beneath the allow bind of the write root. */ + function expectPinned(command: string, dir: string): void { + const pin = indexOfMount(command, '--ro-bind', dir, dir) + expect(pin).toBeGreaterThan(-1) + expect(pin).toBeLessThan(indexOfMount(command, '--bind', HOME, HOME)) + } + + /** Puts the shared name under TMP out of use: a link is refused there. */ + function takeTheSharedName(): void { + mkdirSync(join(BASE, 'somebody-elses')) + symlinkSync( + join(BASE, 'somebody-elses'), + join(TMP, `srt-mount-points-${UID}`), + ) + } + + it('are pinned above the manifest directory a process with no runtime directory keeps', () => { + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(command).toContain(`--ro-bind-try ${TMP}/srt-mount-points-${UID}`) + expectPinned(command, TMP) + // The write root is a mount point already, and nothing above it is the + // command's to rename. + expect(countMounts(command, '--ro-bind', HOME, HOME)).toBe(0) + expect(countMounts(command, '--ro-bind', BASE, BASE)).toBe(0) + }, 30000) + + describe('under a read deny', () => { + let AREA: string // a write root outside HOME + let KEPT: string // where the manifests are kept, under TMP + const noRuntimeDirectory = { XDG_RUNTIME_DIR: undefined } + + beforeEach(() => { + AREA = join(BASE, 'area') + mkdirSync(AREA) + KEPT = `${TMP}/srt-mount-points-${UID}` + }) + + it.each([ + ['the one this wrap records in', (): string[] => [join(AREA, 'lock')]], + ['one it only keeps out of reach', (): string[] => []], + ] as const)( + 'are not bound on top of it, where they could be read after all: %s', + (_, denyWithinAllow) => { + const command = wrapped( + { + readConfig: { denyOnly: [TMP] }, + writeConfig: { + allowOnly: [AREA], + denyWithinAllow: denyWithinAllow(), + }, + }, + noRuntimeDirectory, + ) + expect(lastMountAt(command, TMP)).toBe(`--tmpfs ${TMP}`) + expect(lastMountAt(command, KEPT)).toBeUndefined() + }, + 30000, + ) + + it('are bound where a write root brings them back into reach', () => { + const command = wrapped( + { + readConfig: { denyOnly: [HOME] }, + writeConfig: { allowOnly: [TMP], denyWithinAllow: [] }, + }, + noRuntimeDirectory, + ) + expect(lastMountAt(command, KEPT)).toBe(`--ro-bind-try ${KEPT} ${KEPT}`) + }, 30000) + + it.if(BWRAP_CAN_NAMESPACE)( + 'show a command nothing of the manifests, its own included', + () => { + // Run by the process that wrapped it, whose manifest goes with it. + const child = inAChild( + 'run.ts', + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const command = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(`echo started; ls -A ${TMP}`)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [${JSON.stringify(TMP)}] },`, + ` writeConfig: {`, + ` allowOnly: [${JSON.stringify(AREA)}],`, + ` denyWithinAllow: [${JSON.stringify(join(AREA, 'lock'))}],`, + ` },`, + `})`, + `const run = spawnSync(command, { shell: true, encoding: 'utf8' })`, + `console.log(JSON.stringify([command, run.stdout + run.stderr]))`, + ], + noRuntimeDirectory, + ) + const [command, said] = JSON.parse(child.stdout) as [string, string] + expect(dirname(recordedBy(command)!)).toBe(KEPT) + expect(said).toBe('started\n') + }, + 30000, + ) + }) + + it('are pinned above the manifest directory under the runtime directory', () => { + const RUN = join(HOME, 'run', 'user') + mkdirSync(RUN, { recursive: true, mode: 0o700 }) + chmodSync(RUN, 0o700) + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR: RUN }, + ) + expect(dirname(recordedBy(command)!)).toBe(`${RUN}/srt-mount-points`) + expectPinned(command, RUN) + expectPinned(command, join(HOME, 'run')) + }, 30000) + + it('are pinned above the directory of its own a process settles on to record in', () => { + // No runtime directory, and the shared name will not do, so the wrap makes + // a directory only this process knows. + takeTheSharedName() + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [GUARD] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(dirname(recordedBy(command)!)).toMatch( + new RegExp(`^${TMP}/srt-mount-points-[A-Za-z0-9]{6}$`), + ) + expectPinned(command, TMP) + }, 30000) + + it('are pinned above the empty directory the placeholders bind from', () => { + // The manifests are out of the way, under a runtime directory outside + // the write root: the temp dir is pinned for the source alone. + takeTheSharedName() + const command = wrapped({ + writeConfig: { + allowOnly: [HOME], + denyWithinAllow: [join(HOME, 'absent', 'leaf')], + }, + }) + expect(command).toMatch( + new RegExp(`--ro-bind ${TMP}/claude-empty-\\S+ ${HOME}/absent `), + ) + expectPinned(command, TMP) + }, 30000) + + it('are pinned above the store of fake files, under a wrap that restricts no write', () => { + const store = join(TMP, 'srt-credmask-store') + mkdirSync(store, { mode: 0o700 }) + writeFileSync(join(store, '0.fake'), 'sentinel') + const secret = join(BASE, 'secret') + writeFileSync(secret, 'real') + const command = wrapped({ + maskedFileBinds: [ + { realPath: secret, fakePath: join(store, '0.fake') }, + ], + maskedFileStoreDir: store, + }) + expect(countMounts(command, '--ro-bind', store, store)).toBe(1) + // Everything is writable, so every directory above the store is + // pinned, on top of the root's own bind. + for (const dir of [TMP, HOME]) { + const pin = indexOfMount(command, '--ro-bind', dir, dir) + expect(pin).toBeGreaterThan(indexOfMount(command, '--bind', '/', '/')) + expect(pin).toBeLessThan( + indexOfMount(command, '--ro-bind', store, store), + ) + } + }, 30000) + + it('are pinned where the temp dir really is, when its name leads there through a link', () => { + // A pin is a mount, and a mount lands where its path resolves; on the + // name it would be a mount on a link, which bubblewrap refuses. + const real = join(HOME, 'real-tmp') + const through = join(HOME, 'tmp-link') + mkdirSync(real) + symlinkSync('real-tmp', through) + const command = wrapped( + { writeConfig: { allowOnly: [HOME], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined, TMPDIR: through }, + ) + expectPinned(command, real) + expect(countMounts(command, '--ro-bind', through, through)).toBe(0) + }, 30000) + + it('are not pinned where the temp dir is itself the write root, or outside every one', () => { + const inside = wrapped( + { writeConfig: { allowOnly: [TMP], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(countMounts(inside, '--bind', TMP, TMP)).toBe(1) + expect(countMounts(inside, '--ro-bind', TMP, TMP)).toBe(0) + + const AREA = join(BASE, 'area') + mkdirSync(AREA) + const outside = wrapped( + { writeConfig: { allowOnly: [AREA], denyWithinAllow: [] } }, + { XDG_RUNTIME_DIR: undefined }, + ) + expect(countMounts(outside, '--ro-bind', TMP, TMP)).toBe(0) + expect(countMounts(outside, '--ro-bind', HOME, HOME)).toBe(0) + }, 30000) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot be moved aside from inside a sandbox, to make the manifest directory again with a manifest of its own', + async () => { + // What the command is after: a clean-up on the host that reads the + // directory at the name, finds no live manifest claiming GUARD, and + // removes it from under the sandbox that relies on it. + const theirs = join(TMP, `srt-mount-points-${UID}`) + const up = join(HOME, 'up') + const go = join(HOME, 'go') + const out = join(BASE, 'out.txt') + const forged = JSON.stringify({ + version: 1, + pid: 4194000, + start: '0', + created: 0, + paths: [GUARD], + sources: [], + }) + const command = [ + `mv ${TMP} ${TMP}.aside; echo mv=$?`, + `mkdir -p -m 700 ${theirs}`, + `echo '${forged}' > ${theirs}/forged.json; echo plant=$?`, + `echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.05; done`, + `echo pwned > ${GUARD}; echo rc=$?`, + ].join('; ') + const env: Record = { ...process.env, TMPDIR: TMP } + delete env.XDG_RUNTIME_DIR + const script = join(BASE, 'holder.ts') + writeFileSync( + script, + [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([HOME], command, [GUARD]), + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(${JSON.stringify(out)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ].join('\n'), + ) + const holder = spawn(process.execPath, [script], { + env, + stdio: 'ignore', + cwd: import.meta.dir, + }) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + try { + const deadline = Date.now() + 30000 + while (!existsSync(up)) { + if (Date.now() > deadline) throw new Error('the sandbox never ran') + await new Promise(resolve => setTimeout(resolve, 25)) + } + expect(lstatSync(GUARD).size).toBe(0) + + const collector = inAChild( + 'collector.ts', + [ + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + `cleanupBwrapMountPoints()`, + ], + { XDG_RUNTIME_DIR: undefined }, + ) + expect(collector.status).toBe(0) + expect(existsSync(GUARD)).toBe(true) + } finally { + writeFileSync(go, '') + await holderExited + } + + const said = readFileSync(out, 'utf8') + expect(said).toMatch(/mv=[1-9]/) + expect(said).toMatch(/plant=[1-9]/) + expect(said).toMatch(/rc=[1-9]/) + expect(existsSync(`${TMP}.aside`)).toBe(false) + expect(existsSync(join(theirs, 'forged.json'))).toBe(false) + expect(existsSync(GUARD) ? readFileSync(GUARD, 'utf8') : '').toBe('') + }, + 90000, + ) + }, +) + +/** + * A bind is made where its destination really is: bubblewrap makes the + * destination by name inside the new root, and a link with an absolute target + * on the way leads out of that root. So with a temp dir reached through such a + * link, every manifest directory is bound at its real path. + */ +describe.if(isLinux)( + 'The manifest directory under a temp dir whose name is a link', + () => { + usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + const UID = isLinux ? process.getuid!() : 0 + let BASE: string + let HOME: string // the allowed write root + let GUARD: string // a denyWrite path, absent to begin with + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-linked-'))) + HOME = join(BASE, 'home') + mkdirSync(HOME) + GUARD = join(HOME, 'guard.lock') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** + * What a process whose temp dir is `through`, with no runtime directory, + * wraps `command` as with that deny list, and what the command said when + * run. + */ + function wrappedAndRun( + through: string, + command: string, + deny: string[], + store?: string, + ): { wrapped: string; status: number | null; said: string } { + const file = join(BASE, 'wrap-and-run.ts') + writeFileSync( + file, + [ + `import { spawnSync } from 'node:child_process'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + `const wrapped = await wrapCommandWithSandboxLinux({`, + ` command: ${JSON.stringify(command)},`, + ` needsNetworkRestriction: false,`, + ` readConfig: { denyOnly: [] },`, + ` writeConfig: { allowOnly: [${JSON.stringify(HOME)}], denyWithinAllow: ${JSON.stringify(deny)} },`, + ...(store === undefined + ? [] + : [` maskedFileStoreDir: ${JSON.stringify(store)},`]), + `})`, + `const r = ${BWRAP_CAN_NAMESPACE ? `spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })` : `{ status: null, stdout: '', stderr: '' }`}`, + `console.log(JSON.stringify({ wrapped, status: r.status, said: String(r.stdout ?? '') + String(r.stderr ?? '') }))`, + ].join('\n'), + ) + const env: Record = { ...process.env, TMPDIR: through } + delete env.XDG_RUNTIME_DIR + const child = spawnSync(process.execPath, [file], { + env, + encoding: 'utf8', + timeout: 90000, + // Outside every allowed write path, so no mount points for cwd. + cwd: import.meta.dir, + }) + expect(child.status).toBe(0) + return JSON.parse(child.stdout) as { + wrapped: string + status: number | null + said: string + } + } + + const variants = (['inside', 'outside'] as const).flatMap(where => + (['absolute', 'relative'] as const).map(target => ({ where, target })), + ) + for (const { where, target } of variants) { + /** The temp dir, and the name that leads to it. */ + const linked = (): { real: string; through: string } => { + const parent = where === 'inside' ? HOME : BASE + const real = join(parent, 'real-tmp') + const through = join(parent, 'tmp') + mkdirSync(real) + symlinkSync(target === 'absolute' ? real : 'real-tmp', through) + return { real, through } + } + + it(`is bound where it really is, with the link ${where} the write root and its target ${target}`, () => { + const { real, through } = linked() + const kept = join(real, `srt-mount-points-${UID}`) + const named = join(through, `srt-mount-points-${UID}`) + + const recording = wrappedAndRun(through, 'true', [GUARD]).wrapped + expect(countMounts(recording, '--ro-bind', kept, kept)).toBe(1) + expect(dirname(recordedBy(recording)!)).toBe(kept) + expect(recording).not.toContain(named) + + // A wrap with nothing to record keeps the command out of it as well. + const other = wrappedAndRun(through, 'true', []).wrapped + expect(other).toContain(`--ro-bind-try ${kept} ${kept}`) + expect(other).not.toContain(named) + }, 30000) + + it(`binds the empty directory and the store of fake files where they really are, with the link ${where} the write root and its target ${target}`, () => { + const { real, through } = linked() + const store = join(through, 'fake-files') + mkdirSync(store) + + // A deny beneath a directory that is not there makes that directory + // the mount point, bound from the empty directory under the temp dir. + const wrapped = wrappedAndRun( + through, + 'true', + [join(HOME, 'absent', 'inner.lock')], + store, + ).wrapped + const empty = /--ro-bind (\S+\/claude-empty-\S+) \1(?= |$)/.exec( + wrapped, + ) + expect(empty?.[1]).toStartWith(`${real}/`) + expect(countMounts(wrapped, '--ro-bind', empty![1]!, empty![1]!)).toBe( + 1, + ) + const kept = join(real, 'fake-files') + expect(countMounts(wrapped, '--ro-bind', kept, kept)).toBe(1) + // Neither is a destination under the name that leads there. + expect(wrapped).not.toMatch( + new RegExp(`--ro-bind \\S+ ${through}/(claude-empty-|fake-files)`), + ) + }, 30000) + + it.if(BWRAP_CAN_NAMESPACE)( + `does not keep a sandbox from starting, with the link ${where} the write root and its target ${target}`, + () => { + const { real, through } = linked() + const kept = join(real, `srt-mount-points-${UID}`) + const attempts = [ + `echo started`, + `echo pwned > ${GUARD}; echo rc=$?`, + `touch ${kept}/planted.json; echo real=$?`, + `touch ${through}/srt-mount-points-${UID}/planted.json; echo through=$?`, + ].join('; ') + + const recording = wrappedAndRun(through, attempts, [GUARD]) + expect(recording.said).not.toContain('bwrap:') + expect(recording.status).toBe(0) + expect(recording.said).toContain('started') + // Its deny holds, and the manifest directory is out of its reach by + // either name. + expect(recording.said).toMatch(/rc=[1-9]/) + expect(recording.said).toMatch(/real=[1-9]/) + expect(recording.said).toMatch(/through=[1-9]/) + expect(existsSync(GUARD) ? readFileSync(GUARD, 'utf8') : '').toBe('') + expect(existsSync(join(kept, 'planted.json'))).toBe(false) + + // And one with no mount point of its own, which binds the directory + // all the same. + const other = wrappedAndRun(through, attempts, []) + expect(other.said).not.toContain('bwrap:') + expect(other.status).toBe(0) + expect(other.said).toContain('started') + expect(other.said).toMatch(/real=[1-9]/) + expect(other.said).toMatch(/through=[1-9]/) + expect(existsSync(join(kept, 'planted.json'))).toBe(false) + }, + 90000, + ) + + it.if(BWRAP_CAN_NAMESPACE)( + `does not keep a sandbox that needs a directory mount point or a store of fake files from starting, with the link ${where} the write root and its target ${target}`, + () => { + const { real, through } = linked() + const store = join(through, 'fake-files') + mkdirSync(store) + const inner = join(HOME, 'absent', 'inner.lock') + const attempts = [ + `echo started`, + `mkdir -p ${dirname(inner)} 2>/dev/null; echo pwned > ${inner}; echo rc=$?`, + `touch ${join(real, 'fake-files')}/planted; echo store=$?`, + `touch ${store}/planted; echo through=$?`, + ].join('; ') + + const ran = wrappedAndRun(through, attempts, [inner], store) + expect(ran.said).not.toContain('bwrap:') + expect(ran.status).toBe(0) + expect(ran.said).toContain('started') + expect(ran.said).toMatch(/rc=[1-9]/) + expect(ran.said).toMatch(/store=[1-9]/) + expect(ran.said).toMatch(/through=[1-9]/) + expect(existsSync(inner)).toBe(false) + expect(existsSync(join(real, 'fake-files', 'planted'))).toBe(false) + }, + 90000, + ) + } + }, +) + +/** + * A manifest's witnesses are processes, and /proc/PID is relative to a PID + * namespace. A process cleaning up from another PID namespace that shares the + * manifest directory sees a running sandbox's manifest as it would see a dead + * process's. + */ +describe.if(isLinux)( + 'A mount point a running sandbox relies on, seen from another PID namespace', + () => { + usePrivateManifestDirectory() + // A second PID namespace with a /proc of its own, uid unchanged, no root. + const IN_NEW_PID_NAMESPACE = [ + 'unshare', + '--user', + '--map-current-user', + '--pid', + '--fork', + '--mount-proc', + ] + const CAN = + bwrapCanNamespace() && + spawnSync(IN_NEW_PID_NAMESPACE[0]!, [ + ...IN_NEW_PID_NAMESPACE.slice(1), + 'true', + ]).status === 0 + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + let BASE: string + let AREA: string + let LOCK: string + let SIGNAL: string + let GO: string + let OUT: string + let WRAPPED: string + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-pidns-'))) + AREA = join(BASE, 'area') + mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + LOCK = join(AREA, 'repo', '.git', 'config.lock') + SIGNAL = join(AREA, 'up') + GO = join(AREA, 'go') + OUT = join(BASE, 'out.txt') + WRAPPED = join(BASE, 'wrapped.txt') + }) + + afterEach(() => { + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + const writeScript = (name: string, lines: string[]): string => + writeScriptIn(BASE, name, lines) + + it.if(CAN)( + 'survives a cleanup run where neither its writer nor the process on its record can be seen', + async () => { + const command = `echo up > ${SIGNAL}; while [ ! -e ${GO} ]; do sleep 0.05; done; echo pwned > ${LOCK}; echo rc=$?` + const holder = spawn( + process.execPath, + [ + writeScript('holder.ts', [ + `import { spawnSync } from 'node:child_process'`, + `import { writeFileSync } from 'node:fs'`, + `import { wrapCommandWithSandboxLinux } from ${LIBRARY}`, + wrapLine([AREA], command, [LOCK]), + `writeFileSync(${JSON.stringify(WRAPPED)}, wrapped)`, + `const r = spawnSync(wrapped, { shell: true, encoding: 'utf8', timeout: 60000 })`, + `writeFileSync(${JSON.stringify(OUT)}, String(r.stdout ?? '') + String(r.stderr ?? ''))`, + ]), + ], + // Outside every allowed write path, so no mount points for cwd. + { stdio: 'ignore', cwd: import.meta.dir }, + ) + const holderExited = new Promise(resolve => holder.on('exit', resolve)) + let manifest: string + try { + await waitFor(SIGNAL) + manifest = recordedBy(readFileSync(WRAPPED, 'utf8'))! + expect(existsSync(manifest)).toBe(true) + expect(lstatSync(LOCK).size).toBe(0) + + const collector = spawnSync( + IN_NEW_PID_NAMESPACE[0]!, + [ + ...IN_NEW_PID_NAMESPACE.slice(1), + process.execPath, + writeScript('collector.ts', [ + `import { existsSync, readFileSync } from 'node:fs'`, + `import { cleanupBwrapMountPoints } from ${LIBRARY}`, + // The premise: from here the pid on record and the writer's + // are nobody's. + `const record = readFileSync(${JSON.stringify(manifest.replace(/json$/, 'started'))}, 'utf8')`, + `if (existsSync('/proc/' + record.split(' ')[0])) process.exit(7)`, + `if (existsSync('/proc/${holder.pid}')) process.exit(8)`, + `cleanupBwrapMountPoints()`, + ]), + ], + { + env: { ...process.env }, + encoding: 'utf8', + timeout: 60000, + cwd: import.meta.dir, + }, + ) + expect(collector.status).toBe(0) + + // The sandbox is still running: both must still be there. + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(manifest)).toBe(true) + } finally { + writeFileSync(GO, '') + } + + await holderExited + expect(readFileSync(OUT, 'utf8')).toMatch(/rc=[1-9]/) + expect(existsSync(LOCK) ? readFileSync(LOCK, 'utf8') : '').toBe('') + + // And it still goes once nothing runs under it, from the namespace + // that can tell: the holder's exit handler, or this cleanup. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(manifest)).toBe(false) + }, + 90000, + ) + }, +) + +/** + * 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))), + } + } + + 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 waitFor(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 held = (name: string): string => + `echo up > ${AREA}/up-${name}; while [ ! -e ${AREA}/end-${name} ]; do sleep 0.05; done` + const first = running('first', held('first'), [AREA]) + await waitFor(join(AREA, 'up-first')) + const second = running( + 'second', + `${held('second')}; echo pwned > ${LOCK}; echo rc=$?`, + [AREA], + ) + await waitFor(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 new file mode 100644 index 000000000..225fe9b91 --- /dev/null +++ b/test/sandbox/mount-point-manifests.test.ts @@ -0,0 +1,3136 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + lutimesSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { + collectMountPoints, + discardMountPointManifest, + forgetMountPointManifestDirectory, + keepTrackOfMountPoint, + keepTrackOfMountSource, + liveMountPoints, + mountPointManifestDirectories, + namedMountPoints, + type NamedMountPoints, + publishMountPointManifest, + removePrivateManifestDirectory, + setMountPointManifestPlacesForTesting, +} from '../../src/sandbox/bwrap-mount-manifests.js' +import { + cleanupBwrapMountPoints, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { mountsOf } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + aDeadProcess, + deadPid, + leftover, + namedBy, + recordOf, + refusal, + started, + startOf, + statLine, + thisProcess, + writeManifest, +} from '../helpers/mount-point-fixtures.js' +import { isLinux } from '../helpers/platform.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 + * sandbox: which process a pass asks after, what it claims and looks at again + * before it removes anything, and that what it cannot tell it leaves. + */ +describe.if(isLinux)('The mount point manifests', () => { + const runtime = usePrivateManifestDirectory() + const MODULE = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/manifests.ts'), + ) + const LIBRARY = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/library.ts'), + ) + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const NOT_ROOT = process.getuid?.() !== 0 + const OWN_NAMESPACE = isLinux ? readlinkSync('/proc/self/ns/pid') : '' + + let BASE: string + let DIR: string // where the manifests go + let X: string // a mount point an earlier sandbox left + let TMP: string // the temp dir of every child process + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-manifests-'))) + TMP = join(BASE, 'tmp') + mkdirSync(TMP) + DIR = runtime.manifestDir() + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + chmodSync(DIR, 0o700) + X = join(BASE, 'config.lock') + }) + + afterEach(() => { + for (const name of readdirSync(DIR)) { + const file = join(DIR, name) + try { + chmodSync(file, 0o700) + } catch { + // A dangling link. + } + rmSync(file, { recursive: true, force: true }) + } + collectMountPoints('all') + rmSync(BASE, { recursive: true, force: true }) + }) + + /** What a killed process leaves in DIR. `fields` overrides what it says. */ + const manifestOfADeadProcess = ( + paths: string[], + fields: Record = {}, + ): string => writeManifest(DIR, paths, fields) + + /** The manifest this process publishes for a wrap that names `paths`. */ + function publish(paths: string[], sources: string[] = []): string { + const manifest = publishMountPointManifest(paths, sources) + if (manifest === undefined || manifest === 'too-large') { + throw new Error(`not published: ${manifest}`) + } + return manifest.file + } + + /** A reading that is not in doubt. */ + function reads( + reading: ReturnType, + ): NamedMountPoints { + if ('inDoubt' in reading) throw new Error(reading.inDoubt) + return reading + } + + /** What a wrap is told the manifests name, sorted; `undefined` in doubt. */ + function named(): string[] | undefined { + const reading = namedMountPoints() + return 'inDoubt' in reading ? undefined : [...reading.paths].sort() + } + + /** The claims in the manifest directory, by name. */ + function claims(): string[] { + return readdirSync(DIR).filter(name => /\.(claimed|finished)$/.test(name)) + } + + /** The name a pass gives `manifest` when it claims it. */ + function claimOf(manifest: string): string { + return basename(manifest).replace(/\.json$/, '.claimed') + } + + /** And the one it gives that claim once it has found no process behind it. */ + function finishedOf(manifest: string): string { + return basename(manifest).replace(/\.json$/, '.finished') + } + + /** + * Runs `source` with the module as `m` in a process of its own, which is + * killed if it does not end by itself: what these cases guard against blocks + * the thread. `first` runs before the module is loaded, and an `env` entry + * that is undefined is removed from the child's environment. + */ + function inAChild( + source: string, + options: { + launcher?: string[] + env?: Record + first?: string + } = {}, + ): { status: number | null; stdout: string } { + const script = join(BASE, `child-${Math.random().toString(16).slice(2)}.ts`) + writeFileSync( + script, + `${options.first ?? ''}\nconst m = await import(${MODULE})\n${source}\n`, + ) + const argv = [...(options.launcher ?? []), process.execPath, script] + // A temp dir of its own: it is where the manifests go when the runtime + // directory will not do, and the real one is every other process's. + const env: Record = { + ...process.env, + TMPDIR: TMP, + ...options.env, + } + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete env[name] + } + const run = spawnSync(argv[0]!, argv.slice(1), { + env: env as Record, + encoding: 'utf8', + // A read that blocks is killed, and has no status. + timeout: 6000, + killSignal: 'SIGKILL', + }) + return { status: run.status, stdout: run.stdout } + } + + const COLLECT = `m.collectMountPoints('all')` + const PUBLISH = `console.log(JSON.stringify(m.publishMountPointManifest(['/nonexistent/x'], []) ?? null))` + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'collects nothing on the word of a directory that is read-only from here', + () => { + // What a process inside a sandbox sees of the directory. It keeps its own + // manifests elsewhere and does not judge what is in this one. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const collected = inAChild(COLLECT, { + launcher: [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--ro-bind', + DIR, + DIR, + '--', + ], + }) + expect(collected.status).toBe(0) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }, + 15000, + ) + + // ---- what a pass removes ----------------------------------------------- + + /** + * Runs `during` once, in the middle of the next pass: when it has claimed + * the first finished manifest, before it reads that manifest's record again + * and before it removes anything. + */ + function duringTheNextPass(during: () => void): { restore(): void } { + const rename = fs.renameSync + let done = false + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + rename(from, to) + if (to.endsWith('.claimed') && !done) { + done = true + during() + } + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('removes what a finished manifest names once nothing else names it', () => { + leftover(X) + started(manifestOfADeadProcess([X]), aDeadProcess()) + expect(collectMountPoints('all')).toEqual([X]) + expect(existsSync(X)).toBe(false) + // The manifest, its record and the claim on it went with it. + expect(readdirSync(DIR)).toEqual([]) + }) + + it('keeps a mount point named by a manifest that is published while the pass is under way', () => { + // Nothing keeps a wrap from publishing while a pass is under way, naming a + // path the pass is about to remove, with a sandbox starting under it. + leftover(X) + manifestOfADeadProcess([X]) + let published: { status: number | null; stdout: string } | undefined + const pass = duringTheNextPass(() => { + published = inAChild( + `console.log(JSON.stringify(m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null))`, + ) + }) + let removed: string[] + try { + removed = collectMountPoints('all') + } finally { + pass.restore() + } + expect(published?.status).toBe(0) + const arrived = (JSON.parse(published!.stdout) as { file: string }).file + expect(readFileSync(arrived, 'utf8')).toContain(X) + expect(removed).toEqual([]) + expect(existsSync(X)).toBe(true) + }, 15000) + + it('removes what a pass spared on the word of a manifest whose wrap then comes to nothing', () => { + // The pass drops the finished manifest, so the newcomer alone names the + // path from then on: merely unlinked, it would leave the path for good. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X, Y]) + let newcomer: string | undefined + const pass = duringTheNextPass(() => (newcomer = publish([X, Y]))) + try { + expect(collectMountPoints('none')).toEqual([]) + } finally { + pass.restore() + } + expect(readdirSync(DIR)).toEqual([basename(newcomer!)]) + + const live = started(manifestOfADeadProcess([Y]), thisProcess()) + discardMountPointManifest(newcomer!) + expect(existsSync(X)).toBe(false) + // What another manifest names as well is that one's to keep. + expect(existsSync(Y)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual( + [live, recordOf(live)].map(file => basename(file)).sort(), + ) + }) + + it.if(NOT_ROOT).each([ + ["another process's", (p: string): string => manifestOfADeadProcess([p])], + ['its own', (p: string): string => publish([p])], + ] as const)( + 'keeps the manifest of a mount point it could not remove, for a pass that can: %s', + (_, manifestNaming) => { + // Seen read-only from here, as from inside a sandbox that may not write + // the directory it is in. The manifest is all that says the file is a + // mount point, so it must stay while the file does. + const readOnly = join(BASE, 'read-only-from-here') + mkdirSync(readOnly) + const inside = join(readOnly, 'config.lock') + leftover(inside) + const manifest = manifestNaming(inside) + chmodSync(readOnly, 0o555) + try { + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(inside)).toBe(true) + // Under its claim, which any later pass takes up. + expect(claims()).toEqual([finishedOf(manifest)]) + expect(named()).toEqual([inside]) + } finally { + chmodSync(readOnly, 0o755) + } + // Whatever the next pass is told to release. + expect(collectMountPoints('none')).toEqual([inside]) + expect(readdirSync(DIR)).toEqual([]) + }, + ) + + // ---- a manifest goes last, this process's own like anybody's ---- + // + // A manifest is all that names its mount points. It stays until a pass has + // removed what it names: after a pass that turned back, or a removal that was + // refused, a later pass still has it to go by. + + it('keeps the manifest of a wrap of its own when the pass turns back half way, for the next', () => { + leftover(X) + const manifest = publish([X]) + // Another version of this library publishes, in a layout this one cannot + // read, after the pass has claimed: the pass stops short of removing + // anything. + const stranger = join(DIR, '4242-0123456789abcdef.json') + const pass = duringTheNextPass(() => + writeFileSync(stranger, '{"version":2}'), + ) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([finishedOf(manifest)]) + + rmSync(stranger) + expect(collectMountPoints('none')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('lists the directory again before every removal of an ordinary pass', () => { + // A sandbox about to start on a path shows as a manifest that was not there + // when the pass began, at any point of the pass, with no time having to go + // by: each removal is judged on a listing made just before it. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X, Y]) + const unlink = fs.unlinkSync + let arrived: string | undefined + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + unlink(file) + if (file === Y && arrived === undefined) { + // Published the moment the first mount point has gone. + arrived = manifestOfADeadProcess([X], { + pid: process.pid, + created: Date.now(), + }) + } + }) as never) + let removed: string[] + try { + removed = collectMountPoints('all') + } finally { + spy.mockRestore() + } + expect(arrived).toBeDefined() + expect(removed).toEqual([Y]) + expect(existsSync(X)).toBe(true) + }) + + it('lists by the clock, not before every removal, in a pass of many mount points', () => { + // A directory of thousands is not listed thousands of times. + const many = Array.from({ length: 200 }, (_, i) => join(BASE, `m${i}.lock`)) + many.forEach(leftover) + manifestOfADeadProcess(many) + const readdir = fs.readdirSync + let listings = 0 + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => { + if (args[0] === DIR) listings++ + return readdir(...args) + }) as never) + let removed: string[] + try { + removed = collectMountPoints('all') + } finally { + spy.mockRestore() + } + expect(removed.sort()).toEqual([...many].sort()) + expect(listings).toBeGreaterThan(2) + expect(listings).toBeLessThan(many.length) + }) + + it('takes a manifest of a later version by the fields it knows', () => { + // Taken for unreadable, it would put everything in doubt, and every wrap of + // an older process would be refused while a newer one is at work beside it. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const later = { version: 2, added: { by: 'a later version' } } + manifestOfADeadProcess([X], { ...later, ...thisProcess() }) + const finished = manifestOfADeadProcess([Y], later) + + expect(liveMountPoints()).toEqual(new Set([X])) + expect(collectMountPoints('none')).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(finished)).toBe(false) + }) + + it('is live for half a second from when it was written, though its writer is gone, and no longer', () => { + // A command whose wrapping process was killed a moment ago is not refused + // its start for that. + leftover(X) + const now = Date.now() + const manifest = manifestOfADeadProcess([X], { created: now - 499 }) + const clock = spyOn(Date, 'now').mockReturnValue(now) + try { + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(manifest)).toBe(true) + clock.mockReturnValue(now + 1) + expect(collectMountPoints('all')).toEqual([X]) + } finally { + clock.mockRestore() + } + }) + + it('says when it was written, and by which process as /proc numbers it', () => { + // Where the process has a PID namespace of its own under an outer /proc, + // its own number for itself is nobody's there, or somebody else's. + const stat = readFileSync('/proc/self/stat', 'utf8') + const outer = deadPid() + const read = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => + file === '/proc/self/stat' + ? stat.replace(/^\d+/, String(outer)) + : (read as (f: unknown, o: unknown) => unknown)( + file, + options, + )) as never) + const before = Date.now() + let manifest: string + try { + manifest = publish([X]) + } finally { + spy.mockRestore() + } + const written = JSON.parse(readFileSync(manifest, 'utf8')) as { + created: number + } + 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()) + }) + + // ---- a sandbox vouches for itself ---- + // + // The command line a wrap hands out records the process it starts bubblewrap + // in. With a record, that process alone says whether the manifest is live. + + it('is live while a process on its record runs, whoever wrote it and whatever the caller has said', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const theirs = started(manifestOfADeadProcess([X]), thisProcess()) + const own = started(publish([Y]), thisProcess()) + + expect(collectMountPoints('all')).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) + for (const kept of [X, Y, theirs, own, recordOf(theirs), recordOf(own)]) { + expect(existsSync(kept)).toBe(true) + } + expect(live()).toEqual([X, Y].sort()) + }) + + it('is live while the process that wrote it runs, though no process on its record does: its command line may be run again', () => { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], thisProcess()), + aDeadProcess(), + ) + expect(live()).toEqual([X]) + expect(collectMountPoints('none')).toEqual([]) + expect(existsSync(manifest)).toBe(true) + expect(existsSync(recordOf(manifest))).toBe(true) + }) + + it('is finished once neither a process on its record nor the one that wrote it runs, however young', () => { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { created: Date.now() }), + aDeadProcess(), + ) + expect(live()).toEqual([]) + expect(collectMountPoints('none')).toEqual([X]) + expect(existsSync(manifest)).toBe(false) + expect(existsSync(recordOf(manifest))).toBe(false) + }) + + it('tells a process from a later one with its pid by when it started', () => { + leftover(X) + const later = String(Number(thisProcess().start) + 1) + started(manifestOfADeadProcess([X]), { pid: process.pid, start: later }) + expect(collectMountPoints('all')).toEqual([X]) + }) + + it('is live while any of the runs on its record lasts', () => { + // The same command line run again, before the first run is over or after. + leftover(X) + const gone = aDeadProcess() + started(manifestOfADeadProcess([X]), gone, thisProcess(), gone) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + }) + + it('removes a record left without its manifest once no process on it runs', () => { + // What a command line run after its manifest was collected leaves: it + // records itself, and bubblewrap then finds no manifest to bind. + const left = join(DIR, '4242-0123456789abcdef.started') + writeFileSync(left, statLine(aDeadProcess())) + collectMountPoints('all') + expect(existsSync(left)).toBe(false) + }) + + // ---- nothing is removed but under a claim ---- + // + // bubblewrap binds the manifest before it makes a mount point. A pass renames + // a finished manifest before it removes what that names, so a command that + // starts from then on fails having made nothing, and reads the directory + // after that, for a command that started just before. + + /** Runs `at` just before the pass removes `mountPoint`. */ + function atTheRemovalOf( + mountPoint: string, + at: () => void, + ): { restore(): void } { + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === mountPoint) at() + unlink(file) + }) as never) + return { restore: () => spy.mockRestore() } + } + + it('claims a finished manifest before it removes what that names', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const seen: unknown[] = [] + const removal = atTheRemovalOf(X, () => + seen.push({ manifest: existsSync(manifest), claims: claims() }), + ) + try { + expect(collectMountPoints('all')).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([{ manifest: false, claims: [finishedOf(manifest)] }]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('gives its claim back, and removes nothing, when a process has started under the manifest by then', () => { + // The command was started between the pass reading the manifest as finished + // and its claim: bubblewrap has bound the manifest and goes on to its mount + // points. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const pass = duringTheNextPass(() => started(manifest, thisProcess())) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + expect(claims()).toEqual([]) + }) + + /** Claims `manifest`, as a pass does. */ + function claimed(manifest: string): string { + const claim = join(DIR, claimOf(manifest)) + fs.renameSync(manifest, claim) + return claim + } + + // ---- a path is kept while any manifest that names it may have a sandbox ---- + + it('keeps a path that a live manifest names too, whichever manifest the pass is collecting', () => { + // The same project wrapped twice: both wraps name the mount point. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const finished = manifestOfADeadProcess([X, Y]) + const live = started(manifestOfADeadProcess([X]), thisProcess()) + expect(collectMountPoints('all')).toEqual([Y]) + expect(existsSync(X)).toBe(true) + expect(existsSync(finished)).toBe(false) + expect(existsSync(live)).toBe(true) + + // It goes once that sandbox has ended too. + started(live, aDeadProcess()) + expect(collectMountPoints('all')).toEqual([X]) + }) + + it('keeps a path that another manifest names when a command starts under that one during the pass', () => { + // Both read as finished, and both are claimed. The reading that follows the + // claims finds a process on the record of one: it keeps the path for both. + leftover(X) + const one = manifestOfADeadProcess([X]) + const other = manifestOfADeadProcess([X]) + const pass = duringTheNextPass(() => started(other, thisProcess())) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + pass.restore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(other)).toBe(true) + expect(existsSync(one)).toBe(false) + expect(claims()).toEqual([]) + }) + + it('keeps what a manifest at its own name names, finished or not', () => { + // A command can start under it at any moment: only a claim refuses one. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const refused = manifestOfADeadProcess([X]) + manifestOfADeadProcess([X, Y]) + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + if (from === refused) { + throw Object.assign(new Error(`EACCES: ${from}`), { code: 'EACCES' }) + } + rename(from, to) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([Y]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(refused)).toBe(true) + }) + + // ---- any number of passes at once ---- + // + // A pass takes no lock and waits for nobody. A claim is anybody's to act on: + // with no process on its record nothing can start under it again. + + it('collects on a claim that another pass made, or that a killed one left', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + // Though its writer runs: whoever claimed it knew its command to be over. + const claim = claimed(manifestOfADeadProcess([X], thisProcess())) + manifestOfADeadProcess([X, Y]) + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([]) + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) + expect(existsSync(claim)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('keeps what a claimed manifest names while a process on its record runs, and leaves the claim to the pass that made it', () => { + leftover(X) + const claim = claimed(started(manifestOfADeadProcess([X]), thisProcess())) + expect(live()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([basename(claim)]) + }) + + it('leaves a manifest and its record that are back at its own name while it removes what they name', () => { + // No pass gives back what it has found finished. Should the manifest be at + // its own name again all the same, a sandbox can start under it. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const manifest = manifestOfADeadProcess([X, Y]) + const removal = atTheRemovalOf(Y, () => { + fs.renameSync(join(DIR, finishedOf(manifest)), manifest) + started(manifest, thisProcess()) + }) + try { + // X stays: the manifest is at its own name by the time X's turn comes. + expect(collectMountPoints('all')).toEqual([Y]) + } finally { + removal.restore() + } + expect(existsSync(X)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual( + [basename(manifest), basename(recordOf(manifest))].sort(), + ) + }) + + it('finds a manifest that is claimed, or given back, between being listed and being opened', () => { + // Taken for gone, it would name nothing: a wrap would take its mount point + // for the caller's own file, and a pass would remove what it names. + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + const open = fs.openSync + let claim: string | undefined + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: string, + ...rest: unknown[] + ) => { + if (file === manifest && claim === undefined) { + claim = claimed(manifest) + } else if (file === claim && !existsSync(manifest)) { + fs.renameSync(claim, manifest) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(named()).toEqual([X]) + expect(claim).toBeDefined() + expect(live()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + }) + + it('removes nothing while a listed manifest keeps being gone when it is opened', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([Y]) + const elusive = started(manifestOfADeadProcess([X]), thisProcess()) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: string, + ...rest: unknown[] + ) => { + if (file === elusive) { + throw Object.assign(new Error(`ENOENT: ${file}`), { code: 'ENOENT' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + // What it names cannot be listed. + expect(live()).toBeUndefined() + expect(named()).toBeUndefined() + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(Y)).toBe(true) + }) + + // ---- what cannot be told is live ---- + // + // Only a process that is gone says its sandbox has ended. A sandboxed command + // can use up what this user may open or map, so a read that fails in a pass + // must never end in a removal. + + /** Has reading `target` fail with `code`: opening it, or reading what was opened. */ + function failing( + target: string, + code: string, + at: 'open' | 'read' = 'open', + ): { restore(): void } { + const fail = (): never => { + throw Object.assign(new Error(`${code}: ${target}`), { code }) + } + const inode = existsSync(target) ? statSync(target).ino : undefined + const open = fs.openSync + const read = fs.readFileSync + const fstat = fs.fstatSync + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => + at === 'open' && file === target + ? fail() + : (open as (...args: unknown[]) => number)(file, ...rest)) as never), + spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => + file === target || + (at === 'read' && typeof file === 'number' && fstat(file).ino === inode) + ? fail() + : (read as (f: unknown, o: unknown) => unknown)( + file, + options, + )) as never), + ] + return { restore: () => spies.forEach(spy => spy.mockRestore()) } + } + + /** What a pass removes, and the set a caller is given, with that in place. */ + function withThat(injected: { restore(): void }): { + removed: string[] + held: string[] | undefined + } { + try { + return { held: live(), removed: collectMountPoints('all') } + } finally { + injected.restore() + } + } + + const NOTHING_REMOVED = { removed: [], held: [expect.any(String)] } + + // One that lasts and one that passes: the code takes every errno but "not + // there" alike. + for (const code of ['EACCES', 'EMFILE']) { + it(`removes nothing when it cannot ask after the process on a record: ${code}`, () => { + leftover(X) + const gone = aDeadProcess() + started(manifestOfADeadProcess([X]), gone) + expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + + it(`removes nothing when it cannot ask after the writer of a manifest with no record: ${code}`, () => { + leftover(X) + const gone = aDeadProcess() + manifestOfADeadProcess([X], gone) + expect(withThat(failing(`/proc/${gone.pid}/stat`, code))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + + for (const at of ['open', 'read'] as const) { + it(`removes nothing at all when a manifest cannot be read: ${code} at the ${at}`, () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([Y]) + const manifest = manifestOfADeadProcess([X]) + // However old it is, and with no process on its record: this is no + // file to drop for being unreadable. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(manifest, hoursAgo, hoursAgo) + // What it names cannot be listed, so nobody can say what is held. + expect(withThat(failing(manifest, code, at))).toEqual({ + removed: [], + held: undefined, + }) + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + }) + + it(`keeps what a manifest names when its record cannot be read: ${code} at the ${at}`, () => { + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), aDeadProcess()) + expect(withThat(failing(recordOf(manifest), code, at))).toEqual( + NOTHING_REMOVED, + ) + expect(existsSync(X)).toBe(true) + }) + } + } + + it('removes nothing when what it reads of the process on a record is cut short', () => { + leftover(X) + started(manifestOfADeadProcess([X]), thisProcess()) + const read = fs.readFileSync + const spy = spyOn(fs, 'readFileSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const whole = (read as (f: unknown, o: unknown) => unknown)(file, options) + return file === `/proc/${process.pid}/stat` + ? String(whole).split(' ').slice(0, 12).join(' ') + : whole + }) as never) + expect(withThat({ restore: () => spy.mockRestore() })).toEqual( + NOTHING_REMOVED, + ) + }) + + const gone = (): string => statLine(aDeadProcess()) + const notARecord: [string, (record: string) => void][] = [ + ['cut short of the start time', r => write(r, `${gone().slice(0, 30)}\n`)], + ['not the line of a process', r => write(r, 'ended\n')], + [ + 'a line of a process and then something else', + r => write(r, `${gone()}?\n`), + ], + [ + 'a link to the record of a process that is gone', + r => { + write(join(BASE, 'elsewhere.started'), gone()) + symlinkSync(join(BASE, 'elsewhere.started'), r) + }, + ], + ['a directory', r => mkdirSync(r)], + [ + 'a FIFO nobody writes to', + r => expect(spawnSync('mkfifo', [r]).status).toBe(0), + ], + ] + function write(file: string, text = ''): void { + writeFileSync(file, text, { mode: 0o600 }) + } + if (NOT_ROOT) { + notARecord.push([ + 'one its owner may not read', + r => writeFileSync(r, gone(), { mode: 0 }), + ]) + } + for (const [what, plant] of notARecord) { + it(`keeps what a manifest names while its record is ${what}`, () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + plant(recordOf(manifest)) + // In a process of its own: opening a FIFO to read waits for a writer, on + // the one thread there is. + const looked = inAChild( + `console.log(JSON.stringify({ held: [...m.liveMountPoints()], removed: m.collectMountPoints('all') }))`, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ held: [X], removed: [] }) + expect(existsSync(manifest)).toBe(true) + }, 15000) + } + + // ---- a record with no whole line on it is no record ---- + // + // The command line execs bubblewrap only once its whole line is written. What + // a shell that was killed before that leaves must not vouch for ever, and + // nothing may become removable by its age alone. + + const MONTH_MS = 30 * 24 * 3600 * 1000 + const unwritten: [string, () => string][] = [ + ['empty, as its shell makes it', () => ''], + ['cut short of its end of line', () => gone().slice(0, -1)], + ] + for (const [what, text] of unwritten) { + it(`collects a manifest whose writer is gone and whose record is ${what}`, () => { + leftover(X) + write(recordOf(manifestOfADeadProcess([X])), text()) + expect(live()).toEqual([]) + expect(collectMountPoints('none')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + for (const [what, text] of [ + ['not there yet', undefined], + ...unwritten, + ] as const) { + it(`keeps, however old, the manifest of a running process that has not released it, its record ${what}`, () => { + // A wrap held up between publishing and its command putting itself on + // record, for longer than every threshold there is. + leftover(X) + const own = publish([X]) + const theirs = manifestOfADeadProcess([X], thisProcess()) + const longAgo = new Date(Date.now() - MONTH_MS) + for (const manifest of [own, theirs]) { + const body = JSON.parse(readFileSync(manifest, 'utf8')) as object + write(manifest, JSON.stringify({ ...body, created: longAgo.getTime() })) + if (text !== undefined) write(recordOf(manifest), text()) + for (const file of [manifest, recordOf(manifest)]) { + if (existsSync(file)) utimesSync(file, longAgo, longAgo) + } + } + expect(collectMountPoints('none')).toEqual([]) + expect(live()).toEqual([X]) + expect(claims()).toEqual([]) + + // Its own goes when the caller says the command is over; the other + // process's is not this one's to release. + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(own)).toBe(false) + expect(existsSync(theirs)).toBe(true) + expect(existsSync(X)).toBe(true) + }) + } + + it('goes by the whole lines of a record whose last line is cut short', () => { + // The same command line run again, its shell not yet done writing. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + write(recordOf(manifest), statLine(thisProcess()) + gone().slice(0, -1)) + expect(collectMountPoints('none')).toEqual([]) + write(recordOf(manifest), gone() + gone().slice(0, -1)) + expect(collectMountPoints('none')).toEqual([X]) + }) + + it('is not held up by a record with no whole line and no manifest, and drops it past the grace', () => { + // Nothing names a path, so it protects none. + leftover(X) + started(manifestOfADeadProcess([X]), aDeadProcess()) + const [young, old, older] = ['4242', '4243', '4244'].map(pid => + join(DIR, `${pid}-0123456789abcdef.started`), + ) as [string, string, string] + write(young) + write(old) + write(older, gone().slice(0, -1)) + // Its shell may be about to write to it: not by the clock of a slow run. + const soon = new Date(Date.now() + 60_000) + utimesSync(young, soon, soon) + const aSecondAgo = new Date(Date.now() - 1000) + utimesSync(old, aSecondAgo, aSecondAgo) + utimesSync(older, new Date(Date.now() - MONTH_MS), aSecondAgo) + + expect(live()).toEqual([]) + expect(named()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([basename(young)]) + }) + + for (const [what, plant] of notARecord) { + it(`is not held up by a record with no manifest that is ${what}`, () => { + // It refuses every wrap of the user for as long as it counts. + leftover(X) + manifestOfADeadProcess([X]) + const orphan = join(DIR, '4242-0123456789abcdef.started') + plant(orphan) + const looked = inAChild( + `console.log(JSON.stringify({ named: [...m.namedMountPoints().paths], held: [...m.liveMountPoints()], removed: m.collectMountPoints('all') }))`, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual({ + named: [X], + held: [], + removed: [X], + }) + // Dropped, where it can be. + expect(readdirSync(DIR)).toEqual( + what === 'a directory' ? [basename(orphan)] : [], + ) + }, 15000) + } + + it('removes nothing, however old it is, while a record with no manifest cannot be read for want of a descriptor', () => { + // That says nothing of the record: its manifest may be passing from one + // name to the other with a sandbox on it. + leftover(X) + manifestOfADeadProcess([X]) + const orphan = join(DIR, '4242-0123456789abcdef.started') + write(orphan, gone()) + const longAgo = new Date(Date.now() - MONTH_MS) + utimesSync(orphan, longAgo, longAgo) + expect(withThat(failing(orphan, 'EMFILE'))).toEqual({ + removed: [], + held: undefined, + }) + expect(existsSync(orphan)).toBe(true) + }) + + /** Hides `name` from the next listing of the manifest directory. */ + function missedByTheNextListing(name: string): { restore(): void } { + const readdir = fs.readdirSync + let hidden = false + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + const names = (readdir as (d: unknown, o: unknown) => string[])( + dir, + options, + ) + if (dir !== DIR || hidden) return names + hidden = true + return names.filter(listed => listed !== name) + }) as never) + return { restore: () => spy.mockRestore() } + } + + for (const [what, text] of [ + ...unwritten, + ['that of a process that runs', () => statLine(thisProcess())], + ] as const) { + it(`finds by name a manifest the listing missed, its record ${what}, and leaves that record`, () => { + // Published, or renamed, while the directory was being listed: the record + // shows and the manifest does not. Taken for a record without a manifest + // it would be dropped, from under the shell about to write to it. + leftover(X) + const manifest = manifestOfADeadProcess([X], thisProcess()) + write(recordOf(manifest), text()) + const longAgo = new Date(Date.now() - MONTH_MS) + utimesSync(recordOf(manifest), longAgo, longAgo) + for (const look of [named, live, () => collectMountPoints('none')]) { + const listing = missedByTheNextListing(basename(manifest)) + try { + expect(look()).toEqual(look === named || look === live ? [X] : []) + } finally { + listing.restore() + } + } + expect(existsSync(recordOf(manifest))).toBe(true) + expect(existsSync(X)).toBe(true) + }) + } + + it("keeps what a manifest names while its record is somebody else's", () => { + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), aDeadProcess()) + const theirs = asSomebodyElses(recordOf(manifest)) + expect(withThat(theirs)).toEqual(NOTHING_REMOVED) + expect(existsSync(X)).toBe(true) + }) + + it('removes nothing while a process that runs is on a record whose manifest is not to be found', () => { + // A claim is a rename, and a rename can hide a name from a listing that is + // under way: the manifest may be there all the same. + leftover(X) + manifestOfADeadProcess([X]) + const record = join(DIR, '4242-0123456789abcdef.started') + writeFileSync(record, statLine(thisProcess())) + // However old it is. + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + utimesSync(record, hoursAgo, hoursAgo) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(record)).toBe(true) + }) + + /** Has this process fail to write its next manifest, as on a full disk. */ + function unrecorded(paths: string[], sources: string[] = []): void { + const write = fs.writeFileSync + const spy = spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.json.tmp')) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + return (write as (...args: unknown[]) => void)(file, ...rest) + }) as never) + try { + expect(publishMountPointManifest(paths, sources)).toBeUndefined() + } finally { + spy.mockRestore() + } + } + + it('removes nothing when the manifests cannot be listed', () => { + // Not even what this process could not record and would remove on its own + // word: a manifest that cannot be listed may name it too. + leftover(X) + unrecorded([X]) + const listing = failingCall('readdirSync', DIR, 'EMFILE') + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + listing.restore() + } + expect(existsSync(X)).toBe(true) + expect(collectMountPoints('all')).toEqual([X]) + }) + + 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. However old: that + // is a condition of this process, which the age of a file does not end. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + aged(started(manifest, aDeadProcess()), 30 * 24 * 60) + aged(recordOf(manifest), 30 * 24 * 60) + forgetMountPointManifestDirectory() + const spy = spyOn(fs, 'readlinkSync').mockImplementation((() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) as never) + try { + expect(live()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + forgetMountPointManifestDirectory() + } + expect(collectMountPoints('all')).toEqual([X]) + }) + + for (const [what, then] of [ + [ + 'the directory cannot be listed again', + () => failingCall('readdirSync', DIR, 'EMFILE'), + ], + [ + 'a manifest that has arrived cannot be read', + () => planted('1-garbage.json', '{}'), + ], + ] as const) { + it(`stops removing, and keeps its claim, once ${what}`, () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const manifest = manifestOfADeadProcess([X, Y]) + let injected: { restore(): void } | undefined + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation((( + file: string, + ) => { + unlink(file) + // The moment the first mount point has gone. + injected ??= then() + }) as never) + try { + expect(collectMountPoints('all')).toEqual([Y]) + } finally { + spy.mockRestore() + injected?.restore() + } + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([finishedOf(manifest)]) + expect(collectMountPoints('all')).toEqual([X]) + }) + } + + // ---- a manifest is only a claim about a path --------------------------- + + // bubblewrap makes a file mount point 0444 from 0.5.0, and 0666 less the + // umask before. + for (const mode of [0o444, 0o644, 0o666, 0o600]) { + it(`takes an empty file of mode 0${mode.toString(8)} that a manifest names for a mount point`, () => { + writeFileSync(X, '') + chmodSync(X, mode) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + expect(live()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([]) + + started(manifest, aDeadProcess()) + expect(live()).toEqual([]) + expect(collectMountPoints('all')).toEqual([X]) + expect(existsSync(X)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + // ---- a directory mount point, and what lies in it ---- + + /** `.claude`, as one sandbox made it, and what others nested in it. */ + function nested(): { Q: string; inside: string[] } { + const Q = join(BASE, '.claude') + const inside = [join(Q, 'agents'), join(Q, 'commands')] + mkdirSync(Q) + inside.forEach(leftover) + return { Q, inside } + } + + for (const order of ['as listed', 'the other way round']) { + it(`removes what lies in a directory before the directory, the manifests read ${order}`, () => { + const { Q, inside } = nested() + manifestOfADeadProcess([Q, ...inside]) + manifestOfADeadProcess([Q]) + manifestOfADeadProcess(inside) + const readdir = fs.readdirSync + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + const names = (readdir as (d: unknown, o: unknown) => string[])( + dir, + options, + ) + return order === 'as listed' ? names : names.reverse() + }) as never) + try { + expect(collectMountPoints('all')).toEqual( + [...inside].reverse().concat(Q), + ) + } finally { + spy.mockRestore() + } + expect(existsSync(Q)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }) + } + + it('keeps its claim on a directory for as long as what lies in it is named by a manifest that is kept', () => { + // Dropped, nothing would name the directory once it is empty. + const { Q, inside } = nested() + const outer = manifestOfADeadProcess([Q]) + const inner = started(manifestOfADeadProcess(inside), thisProcess()) + expect(collectMountPoints('all')).toEqual([]) + expect(claims()).toEqual([finishedOf(outer)]) + + started(inner, aDeadProcess()) + expect(collectMountPoints('all')).toEqual([...inside].reverse().concat(Q)) + expect(readdirSync(DIR)).toEqual([]) + }) + + it.if(NOT_ROOT)( + 'keeps its claim on a directory when what lies in it could not be removed', + () => { + const { Q, inside } = nested() + const outer = manifestOfADeadProcess([Q]) + manifestOfADeadProcess(inside) + chmodSync(Q, 0o555) + try { + expect(collectMountPoints('all')).toEqual([]) + expect(claims()).toContain(finishedOf(outer)) + } finally { + chmodSync(Q, 0o755) + } + expect(collectMountPoints('all')).toEqual([...inside].reverse().concat(Q)) + expect(readdirSync(DIR)).toEqual([]) + }, + ) + + it('keeps the directory a live manifest binds its mount points from', () => { + const source = join(BASE, 'source') + mkdirSync(source, { mode: 0o700 }) + manifestOfADeadProcess([], { sources: [source] }) + const live = started( + manifestOfADeadProcess([], { sources: [source] }), + thisProcess(), + ) + expect(collectMountPoints('all')).toEqual([]) + started(live, aDeadProcess()) + expect(collectMountPoints('all')).toEqual([source]) + }) + + it('leaves what a finished manifest names when it no longer looks like a mount point', () => { + const written = join(BASE, 'written-to') + writeFileSync(written, 'mine') + chmodSync(written, 0o444) + const linked = join(BASE, 'linked') + leftover(linked) + linkSync(linked, join(BASE, 'linked-again')) + const pointer = join(BASE, 'pointer') + leftover(join(BASE, 'pointed-at')) + symlinkSync(join(BASE, 'pointed-at'), pointer) + const inUse = join(BASE, 'directory-in-use') + mkdirSync(inUse) + writeFileSync(join(inUse, 'file'), '') + const manifest = manifestOfADeadProcess([written, linked, pointer, inUse]) + expect(collectMountPoints('all')).toEqual([]) + for (const kept of [written, linked, pointer, inUse]) { + expect(existsSync(kept)).toBe(true) + } + expect(existsSync(join(BASE, 'pointed-at'))).toBe(true) + // None of them is a mount point any more, so the manifest has nothing + // left to say. + expect(existsSync(manifest)).toBe(false) + }) + + it("leaves a file, and an empty directory, that is somebody else's", () => { + const directory = join(BASE, 'empty-directory') + leftover(X) + mkdirSync(directory) + manifestOfADeadProcess([X, directory]) + // As another user's would look: nothing here can make one. + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file === X || file === directory) { + const theirs = Object.create(Object.getPrototypeOf(stat)) as fs.Stats + return Object.assign(theirs, stat, { uid: stat.uid + 1 }) + } + return stat + }) as never) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(existsSync(directory)).toBe(true) + }) + + it('takes away what a killed process left half written, once it is old', () => { + const old = [join(DIR, '4242-0123456789abcdef.json.tmp')] + const young = join(DIR, '4243-0123456789abcdef.json.tmp') + for (const file of [...old, young]) writeFileSync(file, '{}') + const hoursAgo = new Date(Date.now() - 2 * 3600 * 1000) + for (const file of old) utimesSync(file, hoursAgo, hoursAgo) + collectMountPoints('all') + for (const file of old) expect(existsSync(file)).toBe(false) + // This one may be on its way into place. + expect(existsSync(young)).toBe(true) + }) + + // ---- liveness is relative to a PID namespace ---- + // + // /proc/PID is local to a PID namespace. From another namespace a running + // sandbox's manifest looks like one a dead process left. + + it.each([ + ['written in another PID namespace', 'kept', { ns: 'pid:[1]' }], + [ + 'written in another PID namespace a long time ago', + 'kept', + { ns: 'pid:[1]', created: Date.now() - 7 * 24 * 3600 * 1000 }, + ], + ['that does not say where it was written', 'kept', { ns: undefined }], + [ + 'that does not say where it was written, from hours ago', + 'kept', + { ns: undefined, created: Date.now() - 2 * 3600 * 1000 }, + ], + ['written in this PID namespace', 'collected', {}], + ] as const)( + 'a manifest %s, with no writer and no process on record to be seen, is %s', + (_what, outcome, fields) => { + leftover(X) + const manifest = manifestOfADeadProcess([X], fields) + collectMountPoints('all') + expect(existsSync(X)).toBe(outcome === 'kept') + expect(existsSync(manifest)).toBe(outcome === 'kept') + }, + ) + + 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 }), + aDeadProcess(), + ) + 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('all')).toEqual([]) + } + aged(recordOf(manifest), 7 * DAYS + 1) + expect(live()).toEqual([]) + expect(collectMountPoints('all')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + + // With no record, from the manifest alone. + leftover(X) + aged(manifestOfADeadProcess([X], { ns }), 7 * DAYS + 1) + expect(collectMountPoints('all')).toEqual([X]) + } + }) + + // ---- what a pass that can judge says for those that cannot ---- + + /** Has `manifest` finished by its name, as a pass that could judge it does. */ + function finished(manifest: string): string { + const file = join(DIR, finishedOf(manifest)) + fs.renameSync(manifest, file) + return file + } + + it('keeps nothing for a manifest of another PID namespace that is finished by its name, and leaves that one to its own', () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const theirs = finished(manifestOfADeadProcess([X, Y], { ns: 'pid:[1]' })) + manifestOfADeadProcess([X]) + // A wrap still takes both for mount points, which they are. + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([]) + expect(collectMountPoints('all')).toEqual([X]) + // What it names alone may be another file in that namespace's mounts. + expect(existsSync(Y)).toBe(true) + expect(readdirSync(DIR)).toEqual([basename(theirs)]) + }) + + it('goes on no record for a manifest of another PID namespace that is finished by its name', () => { + leftover(X) + finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints('all')).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it('removes what such a manifest names alone once it is seven days old', () => { + leftover(X) + const theirs = finished(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + aged(theirs, 7 * 24 * 60 - 1) + expect(collectMountPoints('all')).toEqual([]) + aged(theirs, 7 * 24 * 60 + 1) + expect(collectMountPoints('all')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('does not say of a claim it cannot judge that it is finished', () => { + leftover(X) + const claim = claimed(manifestOfADeadProcess([X], { ns: 'pid:[1]' })) + manifestOfADeadProcess([X]) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(readdirSync(DIR)).toEqual([basename(claim)]) + }) + + it('says so of a claim another pass left, and collects it', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + claimed(manifest) + const seen: string[][] = [] + const removal = atTheRemovalOf(X, () => seen.push(claims())) + try { + expect(collectMountPoints('all')).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([[finishedOf(manifest)]]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it("takes the record beside a manifest that is finished by its name for that one's", () => { + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + aDeadProcess(), + ) + const theirs = finished(manifest) + aged(recordOf(manifest)) + expect(collectMountPoints('all')).toEqual([]) + expect(readdirSync(DIR).sort()).toEqual( + [basename(theirs), basename(recordOf(manifest))].sort(), + ) + }) + + it('does not say so of a manifest it only takes for finished by its age: whoever can ask goes on asking', () => { + leftover(X) + const theirs = aged( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + 7 * 24 * 60 + 1, + ) + const seen: string[][] = [] + const removal = atTheRemovalOf(X, () => seen.push(claims())) + try { + expect(collectMountPoints('all')).toEqual([X]) + } finally { + removal.restore() + } + expect(seen).toEqual([[claimOf(theirs)]]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('goes by what it finds, not by the name, where it can ask: a process on the record keeps what the manifest names', () => { + leftover(X) + const theirs = finished(started(manifestOfADeadProcess([X]), thisProcess())) + manifestOfADeadProcess([X]) + expect(live()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(claims()).toEqual([basename(theirs)]) + }) + + it('leaves a claim that could not be given the name to the next pass, with all it names', () => { + // Dropped at the claim's name, with X kept for a manifest of another + // namespace, a pass over that one would still take it for live. + leftover(X) + const manifest = manifestOfADeadProcess([X]) + const rename = fs.renameSync + const spy = spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + if (to.endsWith('.finished')) { + throw Object.assign(new Error('EIO'), { code: 'EIO' }) + } + rename(from, to) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(X)).toBe(true) + expect(named()).toEqual([X]) + expect(collectMountPoints('all')).toEqual([X]) + expect(readdirSync(DIR)).toEqual([]) + expect(existsSync(manifest)).toBe(false) + }) + + it('finds by name one that the listing missed, and leaves its record', () => { + // Renamed while the directory was being listed. Taken for a record without + // a manifest it would be dropped, and a wrap would take X for the caller's. + leftover(X) + const manifest = started( + manifestOfADeadProcess([X], { ns: 'pid:[1]' }), + aDeadProcess(), + ) + const theirs = finished(manifest) + for (const look of [named, () => collectMountPoints('none')]) { + const listing = missedByTheNextListing(basename(theirs)) + try { + expect(look()).toEqual(look === named ? [X] : []) + } finally { + listing.restore() + } + } + expect(existsSync(recordOf(manifest))).toBe(true) + }) + + it('leaves a path to neither of two clean-ups that cannot judge each other, each of which would keep it for the other', async () => { + // Two processes that take themselves to be in PID namespaces of their own, + // each with a manifest that names X. Each meets the other once it has made + // its claims, and again once it has read and is about to remove or drop. + leftover(X) + const side = (me: string, other: string, ns: string): string => { + const file = join(BASE, `${me}.ts`) + const at = (who: string, where: string): string => + JSON.stringify(join(BASE, `${who}.${where}`)) + const meet = (where: string): string => + `fs.writeFileSync(${at(me, where)}, ''); while (!fs.existsSync(${at(other, where)})) Bun.sleepSync(1)` + writeFileSync( + file, + [ + `import { spyOn } from 'bun:test'`, + `import * as fs from 'node:fs'`, + `const readlink = fs.readlinkSync`, + `spyOn(fs, 'readlinkSync').mockImplementation((p, ...rest) =>`, + ` p === '/proc/self/ns/pid' ? ${JSON.stringify(ns)} : readlink(p, ...rest))`, + `const m = await import(${MODULE})`, + `m.publishMountPointManifest([${JSON.stringify(X)}], [])`, + meet('published'), + `const open = fs.openSync`, + `const unlink = fs.unlinkSync`, + `let met = 0`, + `spyOn(fs, 'openSync').mockImplementation((p, ...rest) => {`, + ` if (met === 0 && String(p).endsWith('.pass')) { met = 1; ${meet('claimed')} }`, + ` return open(p, ...rest)`, + `})`, + `spyOn(fs, 'unlinkSync').mockImplementation(p => {`, + ` if (met === 1) { met = 2; ${meet('read')} }`, + ` return unlink(p)`, + `})`, + `m.collectMountPoints('all')`, + ].join('\n'), + ) + return file + } + const ended = [ + side('one', 'other', 'pid:[1]'), + side('other', 'one', 'pid:[2]'), + ].map( + file => + new Promise(resolve => + spawn(process.execPath, [file], { + env: { ...process.env, TMPDIR: TMP }, + stdio: 'inherit', + timeout: 10000, + killSignal: 'SIGKILL', + }).on('exit', resolve), + ), + ) + expect(await Promise.all(ended)).toEqual([0, 0]) + expect(existsSync(X)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, 15000) + + /** 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('all')).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('all')).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), aDeadProcess()) + } + 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('all')).toEqual([]) + bothAged(manifest, 6 * 24 * 60) + expect(collectMountPoints('all')).toEqual([]) + bothAged(manifest, 7 * 24 * 60 + 1) + expect(collectMountPoints('all')).toEqual([X]) + + // Where only this kernel reaches: at once, as ever. + spies.shift()!.restore() + spies.push(onAFileSystemOf(0xef53)) + left() + expect(collectMountPoints('all')).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('all')).toEqual([]) + started(manifest, aDeadProcess()) + expect(collectMountPoints('all')).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('all')).toEqual([]) + expect(readFileSync(publish([X]), 'utf8')).not.toContain('"boot"') + } finally { + failing.restore() + } + }) + + // ---- where the manifests are kept -------------------------------------- + + it.if(BWRAP_CAN_NAMESPACE)( + 'are never kept under /dev, which the sandbox mounts afresh over them', + () => { + // The wrap mounts a new /dev after every bind: a manifest directory under + // /dev could be neither bound nor kept out of the command's reach. Looked + // at with a /dev/shm of the child's own, not the one every process has. + const SHM = '/dev/shm' + const launcher = ['bwrap', '--dev-bind', '/', '/', '--tmpfs', SHM, '--'] + const publishAndList = `const { readdirSync } = await import('node:fs')\nconsole.log(JSON.stringify({ manifest: m.publishMountPointManifest(['/nonexistent/x'], []) ?? null, made: readdirSync(${JSON.stringify(SHM)}) }))` + const fellBack = inAChild(publishAndList, { + launcher, + env: { XDG_RUNTIME_DIR: SHM }, + }) + expect(fellBack.status).toBe(0) + const recorded = JSON.parse(fellBack.stdout) as { + manifest: { file: string } + made: string[] + } + expect(recorded.manifest.file.startsWith(`${TMP}/`)).toBe(true) + expect(recorded.made).toEqual([]) + + // With nowhere else to go it records nothing, rather than somewhere + // bubblewrap cannot reach. + const nowhere = inAChild(publishAndList, { + launcher, + env: { XDG_RUNTIME_DIR: undefined, TMPDIR: SHM }, + }) + expect(nowhere.status).toBe(0) + expect(JSON.parse(nowhere.stdout)).toEqual({ manifest: null, made: [] }) + }, + 15000, + ) + + it("are not kept behind a link planted at the directory's name, whose target is left as it was", () => { + // The name is predictable and sits where sandboxed commands commonly write. + // A link there is refused, not followed. + const target = join(BASE, 'somebody-elses') + mkdirSync(target) + chmodSync(target, 0o755) + symlinkSync(target, join(TMP, `srt-mount-points-${process.getuid!()}`)) + const published = inAChild(PUBLISH, { env: { XDG_RUNTIME_DIR: undefined } }) + expect(published.status).toBe(0) + expect(statSync(target).mode & 0o777).toBe(0o755) + expect(readdirSync(target)).toEqual([]) + // Recorded all the same, in a directory of this process's own. + const manifest = JSON.parse(published.stdout) as { file: string } + expect(dirname(manifest.file)).toMatch( + new RegExp(`^${TMP}/srt-mount-points-[A-Za-z0-9]{6}$`), + ) + }) + + it.if(BWRAP_CAN_NAMESPACE && NOT_ROOT)( + 'go to a directory this process can write when the usual one is read-only from here', + () => { + // A process inside a sandbox sees the directory bound read-only. It is + // ours in every other respect, and must not be settled on. + const elsewhere = TMP + const published = inAChild(PUBLISH, { + launcher: [ + 'bwrap', + '--dev-bind', + '/', + '/', + '--ro-bind', + DIR, + DIR, + '--', + ], + }) + expect(published.status).toBe(0) + const manifest = JSON.parse(published.stdout) as { file: string } | null + expect(manifest).not.toBe(null) + expect(dirname(manifest!.file)).not.toBe(DIR) + expect(manifest!.file.startsWith(`${elsewhere}/`)).toBe(true) + }, + 15000, + ) + + // ---- which directory is believed ---- + // + // A pass removes what it finds named in the directory, so the directory has + // to be the user's alone, and is looked at again at every use. + + /** Has `file` look like another user's: nothing here can make one. */ + function asSomebodyElses(file: string): { restore(): void } { + const inode = lstatSync(file).ino + const spies = (['lstatSync', 'fstatSync'] as const).map(name => { + const real = fs[name] as (f: unknown, o: unknown) => fs.Stats + return spyOn(fs, name).mockImplementation((( + target: unknown, + options: unknown, + ) => { + const stat = real(target, options) + if (stat.ino !== inode) return stat + const theirs = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(theirs, stat, { uid: stat.uid + 1 }) + }) as never) + }) + return { restore: () => spies.forEach(spy => spy.mockRestore()) } + } + + /** Where this process, having settled on nothing yet, records X. */ + function recordedIn(): string { + forgetMountPointManifestDirectory() + return dirname(publish([X])) + } + + it("are not kept in a directory that is somebody else's", () => { + const theirs = asSomebodyElses(DIR) + try { + expect(recordedIn()).toBe( + join(tmpdir(), `srt-mount-points-${process.getuid!()}`), + ) + } finally { + theirs.restore() + forgetMountPointManifestDirectory() + } + expect(readdirSync(DIR)).toEqual([]) + }) + + it("are kept in a directory that others could read or write only once it has been made the user's alone", () => { + chmodSync(DIR, 0o755) + expect(recordedIn()).toBe(DIR) + expect(statSync(DIR).mode & 0o777).toBe(0o700) + }) + + it("are not kept in a directory that cannot be made the user's alone", () => { + chmodSync(DIR, 0o750) + const spy = spyOn(fs, 'fchmodSync').mockImplementation((() => { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) + }) as never) + try { + expect(recordedIn()).not.toBe(DIR) + } finally { + spy.mockRestore() + forgetMountPointManifestDirectory() + } + expect(readdirSync(DIR)).toEqual([]) + }) + + it('are kept in a directory that is looked at again at every use', () => { + expect(recordedIn()).toBe(DIR) + rmSync(DIR, { recursive: true }) + + // Opened up since: made the user's alone again before it is used. + mkdirSync(DIR, { mode: 0o777 }) + chmodSync(DIR, 0o777) + expect(dirname(publish([X]))).toBe(DIR) + expect(statSync(DIR).mode & 0o777).toBe(0o700) + rmSync(DIR, { recursive: true }) + + // Swapped for a link since: not followed, and what it leads to left alone. + const target = join(BASE, 'somebody-elses') + mkdirSync(target, { mode: 0o700 }) + symlinkSync(target, DIR) + try { + expect(dirname(publish([X]))).not.toBe(DIR) + expect(readdirSync(target)).toEqual([]) + } finally { + rmSync(DIR) + mkdirSync(DIR, { mode: 0o700 }) + forgetMountPointManifestDirectory() + } + }) + + // ---- 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('all') + taken([place]) + } + expect(order).not.toContain(recordedIn()) + } finally { + collectMountPoints('all') + order.forEach(place => rmSync(place)) + mkdirSync(DIR, { mode: 0o700 }) + } + }) + + it.each([ + ['/var/tmp', 0, 2], + ['/tmp', 2, 0], + ] as const)( + 'are one directory where %s is a link to the other, read once and collected in like any', + (_, link, target) => { + // A pass cannot go on record twice under one name, and off the record it + // removes nothing. + rmSync(dirname(PLACES[link]!), { recursive: true }) + symlinkSync(dirname(PLACES[target]!), dirname(PLACES[link]!)) + forgetMountPointManifestDirectory() + leftover(X) + leftover(Y) + at(PLACES[target]!, () => manifestOfADeadProcess([X])) + expect(dirname(publish([Y]))).toBe(PLACES[0]!) + expect(named()).toEqual([X, Y].sort()) + expect(live()).toEqual([Y]) + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) + expect(readdirSync(PLACES[target]!)).toEqual([]) + // Both names are kept out of a command's reach all the same. + expect(mountPointManifestDirectories()).toEqual( + expect.arrayContaining([PLACES[0]!, PLACES[2]!]), + ) + }, + ) + + it.each([ + ['names a mount point', ['absent.lock']], + ['names none', []], + ])( + 'are bound once where two of the names lead to one place, by a wrap that %s', + async (_, denied) => { + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + forgetMountPointManifestDirectory() + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [BASE], + denyWithinAllow: denied.map(name => join(BASE, name)), + }, + }) + cleanupBwrapMountPoints({ force: true }) + expect( + mountsOf(command).filter(mount => mount.endsWith(` ${PLACES[2]!}`)), + ).toHaveLength(1) + }, + ) + + it('goes by the name it can be written at where the other is read-only, as a read-only bind of it is', () => { + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + // This process writes elsewhere, and knows neither name for its own. + expect(dirname(publish([Y]))).toBe(PLACES[0]!) + collectMountPoints('all') + forgetMountPointManifestDirectory() + leftover(X) + at(PLACES[2]!, () => manifestOfADeadProcess([X])) + const access = fs.accessSync + const spy = spyOn(fs, 'accessSync').mockImplementation((( + file: unknown, + mode: unknown, + ) => { + if (file === PLACES[0]) { + throw Object.assign(new Error('EROFS'), { code: 'EROFS' }) + } + ;(access as (f: unknown, m: unknown) => void)(file, mode) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([X]) + } finally { + spy.mockRestore() + } + expect(readdirSync(PLACES[2]!)).toEqual([]) + }) + + it('goes by the name it writes under where that is the later of the two', () => { + // Settled on /tmp while /var/tmp could not be written. + taken([PLACES[0]!, dirname(PLACES[1]!)]) + leftover(Y) + expect(dirname(publish([Y]))).toBe(PLACES[2]!) + rmSync(dirname(PLACES[0]!), { recursive: true }) + symlinkSync(dirname(PLACES[2]!), dirname(PLACES[0]!)) + expect(collectMountPoints('all')).toEqual([Y]) + expect(readdirSync(PLACES[2]!)).toEqual([]) + }) + + 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('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, aDeadProcess()) + expect(collectMountPoints('all')).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('all')).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('all')).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 made before a wrap reads any, which believes nothing the environment names that no clean-up would', async () => { + // Forged, or left by a process that could write nowhere else: it names a + // directory of the user's own. And what is no manifest. + const own = join(BASE, 'own') + mkdirSync(own) + at(NAMED[1]!, () => manifestOfADeadProcess([own])) + writeFileSync(join(NAMED[0]!, '4242-00.json'), '{}') + const command = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { + allowOnly: [BASE], + denyWithinAllow: [join(own, 'absent.lock')], + }, + }) + expect(namedBy(command)).toEqual([join(own, 'absent.lock')]) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(own)).toBe(true) + }) + + 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('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + started(relied, aDeadProcess()) + expect(collectMountPoints('all')).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('all')).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('all')).toEqual([]) + expect(PLACES.flatMap(each => at(each, claims))).toEqual([]) + rmSync(junk) + expect(collectMountPoints('all')).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('all')).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('all')).toEqual([]) + } finally { + look.restore() + } + expect(collectMountPoints('all').sort()).toEqual([X, Y].sort()) + }) + + it('are not left unbound when one of them cannot be looked at: no command is handed out', async () => { + // One that this process binds and does not believe, so that no reading + // of its own is in doubt: a process that can write none of the first + // three believes it, with the command in it. + mountPointManifestDirectories() + const look = failingCall('lstatSync', NAMED[1]!, 'EIO') + try { + expect(named()).toEqual([]) + expect(() => mountPointManifestDirectories()).toThrow('EIO') + expect( + await refusal( + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + writeConfig: { allowOnly: [BASE], denyWithinAllow: [] }, + }), + ), + ).toBe('mount_points_unreadable') + } finally { + look.restore() + } + expect(mountPointManifestDirectories()).toContain(NAMED[1]!) + }) + + it.each([ + ['/run/user/UID, which one of them is in', () => dirname(PLACES[1]!)], + [ + 'the one only this process knows', + () => { + taken([...PLACES, ...NAMED]) + return recordedIn() + }, + ], + ])('cannot be told while %s cannot be looked at', (_, which) => { + try { + const look = failingCall('lstatSync', which(), 'EIO') + try { + expect(() => mountPointManifestDirectories()).toThrow('EIO') + } finally { + look.restore() + } + } finally { + collectMountPoints('all') + removePrivateManifestDirectory() + rmSync(DIR, { recursive: true, force: true }) + mkdirSync(DIR, { mode: 0o700 }) + } + }) + + 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('all')).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('all')).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('all')).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('all')`, + `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 + // them removes them itself, once none of its wraps is outstanding. + + it.if(NOT_ROOT)( + 'keeps in memory what it could record nowhere, and removes it at its own clean-up', + () => { + const directory = join(BASE, 'empty-directory') + const source = join(BASE, 'source') + const readOnly = join(BASE, 'read-only-tmp') + leftover(X) + mkdirSync(directory) + mkdirSync(source, { mode: 0o700 }) + mkdirSync(readOnly, { mode: 0o500 }) + const nowhere = inAChild( + [ + `const at = ${JSON.stringify({ X, directory, source })}`, + `const { existsSync } = await import('node:fs')`, + `const there = () => [at.X, at.directory, at.source].map(p => existsSync(p))`, + `const published = m.publishMountPointManifest([at.X, at.directory], [at.source]) ?? null`, + `const named = [...m.namedMountPoints().paths].sort()`, + `const held = [...m.liveMountPoints()].sort()`, + `const some = { removed: m.collectMountPoints('none'), there: there() }`, + `const all = { removed: m.collectMountPoints('all').sort(), there: there() }`, + `console.log(JSON.stringify({ published, named, held, some, all, again: m.collectMountPoints('all') }))`, + ].join('\n'), + // No runtime directory, and nothing can be made under the temp dir. + { env: { XDG_RUNTIME_DIR: undefined, TMPDIR: readOnly } }, + ) + expect(nowhere.status).toBe(0) + expect(JSON.parse(nowhere.stdout)).toEqual({ + published: null, + named: [X, directory].sort(), + held: [X, directory].sort(), + some: { removed: [], there: [true, true, true] }, + all: { + removed: [X, directory, source].sort(), + there: [false, false, false], + }, + again: [], + }) + }, + 15000, + ) + + it('keeps what it could not record while a manifest of another process names it, where there is a directory to go by', () => { + // The manifest could not be written, as with the temp dir full, which a + // sandboxed command can bring about. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + started(manifestOfADeadProcess([X]), thisProcess()) + unrecorded([X, Y]) + expect(named()).toEqual([X, Y].sort()) + expect(collectMountPoints('none')).toEqual([]) + expect(collectMountPoints('all')).toEqual([Y]) + expect(existsSync(X)).toBe(true) + }) + + it.each([ + ['had no part in making', undefined], + ['had made itself', 'the first'], + ['had no part in making, and the other made,', 'the second'], + ])( + 'keeps what one wrap %s and could not record, while another wrap that names it too is cleaned up after', + (_, madeBy) => { + // The sandbox of the first may be running over it, and only this process + // knows: the manifest of the second is not all that names it. + const source = join(BASE, 'empty') + const planOf = (wrap: string): void => { + if (wrap !== madeBy) return + keepTrackOfMountPoint(X) + keepTrackOfMountSource(source) + } + leftover(X) + mkdirSync(source, { mode: 0o700 }) + planOf('the first') + unrecorded([X], [source]) + planOf('the second') + publishMountPointManifest([X], [source], 'the second') + expect(collectMountPoints({ commandKey: 'the second' })).toEqual([]) + expect([X, source].filter(existsSync)).toEqual([X, source]) + expect(named()).toEqual([X]) + expect(collectMountPoints('all').sort()).toEqual([X, source].sort()) + }, + ) + + it('publishes no manifest that it would not read', () => { + const paths = (count: number): string[] => + Array.from({ length: count }, (_, i) => + join(BASE, 'x'.repeat(80), String(i).padStart(6, '0')), + ) + // Each with its quotes and a comma, in a mebibyte less the other fields. + const fit = Math.floor((1024 * 1024 - 400) / (paths(1)[0]!.length + 3)) + expect(publishMountPointManifest(paths(fit + 10), [])).toBe('too-large') + // Nothing is written, or kept in memory: no command comes of that wrap. + expect(readdirSync(DIR)).toEqual([]) + expect(named()).toEqual([]) + // What it does publish, it reads. + expect(statSync(publish(paths(fit))).size).toBeGreaterThan(1_000_000) + expect(named()?.length).toBe(fit) + leftover(X) + manifestOfADeadProcess([X]) + expect(collectMountPoints('none')).toEqual([X]) + }) + + // ---- what is read as a manifest ---- + // + // Only a regular file of the user's, of a size a manifest can have, is read: + // a link is not followed and a FIFO is not opened. What is there instead + // names paths nobody can list, so while it may have a sandbox under it + // nothing at all is removed. + + /** + * `minutes` old, two hours unless said: past the hour for which what is no + * manifest counts as live. + */ + function aged(file: string, minutes = 120): string { + const then = new Date(Date.now() - minutes * 60 * 1000) + lutimesSync(file, then, then) + return file + } + + /** What a manifest of a dead process naming `paths` holds. */ + function finishedManifestText(paths: string[]): string { + const file = manifestOfADeadProcess(paths) + const text = readFileSync(file, 'utf8') + rmSync(file) + return text + } + + // 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) => { + const elsewhere = join(BASE, 'elsewhere.json') + writeFileSync(elsewhere, finishedManifestText(paths), { mode: 0o600 }) + symlinkSync(elsewhere, name) + }, + ], + [ + 'a manifest of more than a mebibyte', + (name, paths) => + writeFileSync( + name, + finishedManifestText(paths) + ' '.repeat(1024 * 1024), + { mode: 0o600 }, + ), + ], + [ + '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', + (name, paths) => + writeFileSync( + name, + finishedManifestText(paths) + .replace('"version":1', '"version":2') + .replace('"sources":[]', '"origins":[]'), + ), + ], + ['an empty file', name => writeFileSync(name, ''), 'of no version'], + ['a directory', name => mkdirSync(name)], + [ + 'a FIFO nobody writes to', + name => expect(spawnSync('mkfifo', [name]).status).toBe(0), + ], + ] + if (NOT_ROOT) { + notAManifest.push([ + 'a manifest its owner may not read', + (name, paths) => + writeFileSync(name, finishedManifestText(paths), { mode: 0 }), + ]) + } + 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) + const junk = join(DIR, '4242-0123456789abcdef.json') + plant(junk, [X]) + manifestOfADeadProcess([Y]) + // In a process of its own: opening a FIFO to read waits for a writer, on + // the one thread there is. + const look = (): unknown => { + const looked = inAChild( + [ + `const list = set => (set === undefined ? 'cannot tell' : [...set])`, + `console.log(JSON.stringify({ named: list(m.namedMountPoints()?.paths), held: list(m.liveMountPoints()), removed: m.collectMountPoints('all') }))`, + ].join('\n'), + ) + expect(looked.status).toBe(0) + return JSON.parse(looked.stdout) + } + // 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. + aged(junk) + writeFileSync(recordOf(junk), statLine(thisProcess())) + 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) + // Dropped, where it is a file that can be. + expect(existsSync(junk)).toBe(what === 'a directory') + }, 30000) + } + + it.each(['version', 'pid', 'start', 'created', 'paths', 'sources'])( + 'does not take for a manifest what holds no `%s`', + field => { + // A later version is read by the fields this one knows, so each of them + // has to be there: read without one, it would name nothing or be live + // for nobody. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X], { [field]: undefined }) + manifestOfADeadProcess([Y]) + + expect(named()).toBeUndefined() + expect(liveMountPoints()).toBeUndefined() + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + expect(existsSync(Y)).toBe(true) + }, + ) + + it("removes nothing beside a manifest that is somebody else's until it is an hour old, and passes over it then", () => { + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + const junk = manifestOfADeadProcess([X]) + manifestOfADeadProcess([Y]) + const theirs = asSomebodyElses(junk) + try { + expect(named()).toBeUndefined() + expect(collectMountPoints('all')).toEqual([]) + // An hour, not less and not much more. + aged(junk, 59) + expect(named()).toBeUndefined() + expect(collectMountPoints('all')).toEqual([]) + aged(junk, 61) + expect(collectMountPoints('all')).toEqual([Y]) + } finally { + theirs.restore() + } + expect(existsSync(X)).toBe(true) + }) + + // ---- which mount points a running sandbox relies on ---- + // + // A caller that removes paths itself after a command can ask first which to + // leave out. What it is told to leave out is never more than an empty + // placeholder. + + /** The set, as a sorted list; `undefined` where it cannot tell. */ + function live(): string[] | undefined { + const set = liveMountPoints() + return set && [...set].sort() + } + + it('holds a mount point while a live manifest names it, file or directory, and no other path', () => { + const directory = join(BASE, 'empty-directory') + const other = join(BASE, 'other.lock') + leftover(X) + mkdirSync(directory) + leftover(other) + // Of this process, and not released: live. + const manifest = publish([X, directory, join(BASE, 'never-there')]) + + expect(live()).toEqual([X, directory].sort()) + // Asking takes nothing away. + expect(existsSync(X)).toBe(true) + expect(existsSync(manifest)).toBe(true) + + expect(collectMountPoints('all').sort()).toEqual([X, directory].sort()) + expect(live()).toEqual([]) + }) + + it('no longer holds a path once something has been written to it, though a live manifest still names it', () => { + leftover(X) + publishMountPointManifest([X], []) + expect(live()).toEqual([X]) + + chmodSync(X, 0o644) + writeFileSync(X, 'x') + chmodSync(X, 0o444) + expect(live()).toEqual([]) + }) + + it('holds nothing a live manifest names that does not look like a mount point any more', () => { + const linked = join(BASE, 'linked') + leftover(linked) + linkSync(linked, join(BASE, 'linked-again')) + const pointer = join(BASE, 'pointer') + leftover(join(BASE, 'pointed-at')) + symlinkSync(join(BASE, 'pointed-at'), pointer) + const inUse = join(BASE, 'directory-in-use') + mkdirSync(inUse) + writeFileSync(join(inUse, 'file'), '') + const theirs = join(BASE, 'somebody-elses') + leftover(theirs) + const theirDirectory = join(BASE, 'somebody-elses-directory') + mkdirSync(theirDirectory) + leftover(X) + publish([linked, pointer, inUse, theirs, theirDirectory, X]) + // As another user's would look: nothing here can make one. + const lstat = fs.lstatSync + const spy = spyOn(fs, 'lstatSync').mockImplementation((( + file: unknown, + options: unknown, + ) => { + const stat = (lstat as (f: unknown, o: unknown) => fs.Stats)( + file, + options, + ) + if (file === theirs || file === theirDirectory) { + const other = Object.create( + Object.getPrototypeOf(stat) as object, + ) as fs.Stats + return Object.assign(other, stat, { uid: stat.uid + 1 }) + } + return stat + }) as never) + try { + expect(live()).toEqual([X]) + } finally { + spy.mockRestore() + } + }) + + it('holds what a finished manifest names only while a process on its record runs', () => { + leftover(X) + const manifest = manifestOfADeadProcess([X]) + expect(live()).toEqual([]) + started(manifest, aDeadProcess()) + expect(live()).toEqual([]) + started(manifest, thisProcess()) + expect(live()).toEqual([X]) + }) + + /** Has `call` on `target` fail with `code`. */ + function failingCall( + call: 'readdirSync' | 'lstatSync' | 'readFileSync', + target: string, + code: string, + ): { restore(): void } { + const real = fs[call] as (f: unknown, o: unknown) => unknown + const spy = spyOn(fs, call).mockImplementation((( + file: unknown, + options: unknown, + ) => { + if (file === target) { + throw Object.assign(new Error(`${code}: ${target}`), { code }) + } + return real(file, options) + }) as never) + return { restore: () => spy.mockRestore() } + } + + const planted = (name: string, text: string): { restore(): void } => { + writeFileSync(join(DIR, name), text) + return { restore: () => rmSync(join(DIR, name)) } + } + const cannotTell: [string, (manifest: string) => { restore(): void }][] = [ + [ + 'the directory cannot be looked at', + () => failingCall('lstatSync', DIR, 'EIO'), + ], + [ + 'the directory cannot be listed', + () => failingCall('readdirSync', DIR, 'EMFILE'), + ], + ['a manifest cannot be opened', manifest => failing(manifest, 'EMFILE')], + ['a manifest cannot be read', manifest => failing(manifest, 'EIO', 'read')], + ['something is no manifest', () => planted('1-garbage.json', '{}')], + [ + 'a process runs on a record without a manifest', + () => planted('1-0123456789abcdef.started', statLine(thisProcess())), + ], + ] + for (const [what, inject] of cannotTell) { + it(`says that it cannot tell, to a caller and to a wrap, when ${what}`, () => { + // An empty set would read as "nothing is live", and the caller would + // remove a mount point from under a sandbox that is running. + leftover(X) + const manifest = started(manifestOfADeadProcess([X]), thisProcess()) + expect(live()).toEqual([X]) + const earlier = reads(namedMountPoints()) + const injected = inject(manifest) + const doubt = { inDoubt: expect.stringContaining(DIR) } + try { + expect(liveMountPoints()).toBeUndefined() + expect(namedMountPoints()).toEqual(doubt) + // A manifest already read is not read again, so that is no doubt. + if (!what.startsWith('a manifest')) { + expect(namedMountPoints(earlier)).toEqual(doubt) + } + } finally { + injected.restore() + } + expect(live()).toEqual([X]) + }) + } + + it('holds a path a live manifest names that cannot be looked at', () => { + leftover(X) + started(manifestOfADeadProcess([X]), thisProcess()) + const look = failingCall('lstatSync', X, 'EACCES') + try { + expect(live()).toEqual([X]) + } finally { + look.restore() + } + }) + + it('answers a wrap with an earlier reading while the directory lists nothing new, and reads again when it does', () => { + const [Y, Z] = [join(BASE, 'second.lock'), join(BASE, 'third.lock')] + manifestOfADeadProcess([X]) + const first = reads(namedMountPoints()) + const open = spyOn(fs, 'openSync') + try { + expect(namedMountPoints(first)).toBe(first) + expect(open).not.toHaveBeenCalled() + } finally { + open.mockRestore() + } + // The caller knows what it has published itself. + const own = publish([Y]) + expect(namedMountPoints(first, own)).toBe(first) + expect([...reads(namedMountPoints(first)).paths].sort()).toEqual( + [X, Y].sort(), + ) + manifestOfADeadProcess([Z]) + expect([...reads(namedMountPoints(first, own)).paths].sort()).toEqual( + [X, Y, Z].sort(), + ) + }) + + it('names what a plan has made on the host itself from that moment, until a manifest names it', () => { + const first = reads(namedMountPoints()) + const source = join(BASE, 'empty') + keepTrackOfMountPoint(X) + keepTrackOfMountSource(source) + leftover(X) + mkdirSync(source, { mode: 0o700 }) + expect([...reads(namedMountPoints(first)).paths]).toEqual([X]) + + // From then on it is the manifest's, and goes with that. + publishMountPointManifest([X], [source], 'the command') + expect(collectMountPoints({ commandKey: 'the command' }).sort()).toEqual( + [X, source].sort(), + ) + // What the user puts there afterwards is the user's. + writeFileSync(X, '') + expect(named()).toEqual([]) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(X)).toBe(true) + }) + + it('is one listing of the directory, and changes nothing in it', () => { + leftover(X) + publishMountPointManifest([X], []) + started(manifestOfADeadProcess([join(BASE, 'other.lock')]), aDeadProcess()) + // What a pass would take away: a record whose manifest and process are gone. + writeFileSync( + join(DIR, '4242-0123456789abcdef.started'), + statLine(aDeadProcess()), + ) + const before = readdirSync(DIR).sort() + + const readdir = spyOn(fs, 'readdirSync') + const rename = spyOn(fs, 'renameSync') + let listed: string[] | undefined + let listings: number + try { + listed = live() + listings = readdir.mock.calls.filter(([dir]) => dir === DIR).length + expect(rename).not.toHaveBeenCalled() + } finally { + readdir.mockRestore() + rename.mockRestore() + } + expect(listed).toEqual([X]) + expect(listings).toBe(1) + expect(readdirSync(DIR).sort()).toEqual(before) + }) + + it('makes no manifest directory where there is none, and holds nothing then', () => { + const run = join(BASE, 'run') + mkdirSync(run, { mode: 0o700 }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()]))`, + { env: { XDG_RUNTIME_DIR: run } }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual([]) + expect(readdirSync(run)).toEqual([]) + expect(readdirSync(TMP)).toEqual([]) + }) + + it('reads the directory a process without a runtime directory keeps, where that is the one there is', () => { + const theirs = join(TMP, `srt-mount-points-${process.getuid!()}`) + mkdirSync(theirs, { mode: 0o700 }) + chmodSync(theirs, 0o700) + leftover(X) + writeManifest(theirs, [X], thisProcess()) + const run = join(BASE, 'run') + mkdirSync(run, { mode: 0o700 }) + const looked = inAChild( + `console.log(JSON.stringify([...m.liveMountPoints()]))`, + { env: { XDG_RUNTIME_DIR: run } }, + ) + expect(looked.status).toBe(0) + expect(JSON.parse(looked.stdout)).toEqual([X]) + expect(readdirSync(run)).toEqual([]) + }) + + // ---- all of it is bubblewrap's, which is Linux's ----------------------- + + it('does nothing at all where the platform is not Linux', () => { + // Every clean-up after a command, on every platform, comes through here. + // Off Linux it must make nothing. One manifest to collect, one to hold by. + const Y = join(BASE, 'second.lock') + leftover(X) + leftover(Y) + manifestOfADeadProcess([X]) + writeManifest(DIR, [Y], thisProcess()) + const before = readdirSync(DIR).sort() + const elsewhere = inAChild( + [ + `const u = await import(${LIBRARY})`, + `const published = m.publishMountPointManifest([${JSON.stringify(X)}], []) ?? null`, + `const live = [...m.liveMountPoints()]`, + `const directories = m.mountPointManifestDirectories()`, + `const collected = m.collectMountPoints('all')`, + `u.cleanupBwrapMountPoints()`, + `u.cleanupBwrapMountPoints({ force: true })`, + `console.log(JSON.stringify({ published, live, directories, collected }))`, + ].join('\n'), + { + first: `Object.defineProperty(process, 'platform', { value: 'darwin' })`, + }, + ) + expect(elsewhere.status).toBe(0) + expect(JSON.parse(elsewhere.stdout)).toEqual({ + published: null, + live: [], + directories: [], + collected: [], + }) + expect(existsSync(X)).toBe(true) + expect(readdirSync(DIR).sort()).toEqual(before) + expect(readdirSync(TMP)).toEqual([]) + }) +}) diff --git a/test/sandbox/mount-point-passes.test.ts b/test/sandbox/mount-point-passes.test.ts new file mode 100644 index 000000000..4124a423b --- /dev/null +++ b/test/sandbox/mount-point-passes.test.ts @@ -0,0 +1,964 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, extname, join } from 'node:path' +import { + collectMountPoints, + liveMountPoints, + passesToEnd, + passesUnderWay, + publishMountPointManifest, +} from '../../src/sandbox/bwrap-mount-manifests.js' +import { + cleanupBwrapMountPoints, + LinuxSandboxProfileError, + wrapCommandWithSandboxLinux, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { manifestOf } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { + aDeadProcess, + leftover, + type Process, + processAt, + recordOf, + run, + sleep, + started, + until, + wrapIn, + writeManifest, + writeScript, +} from '../helpers/mount-point-fixtures.js' +import { isLinux } from '../helpers/platform.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' + +/** + * A clean-up decides on a reading of the manifests and removes afterwards, and + * can be held up between the two for any length of time. So it puts itself on + * record before it reads, and a wrap that has published its manifest hands no + * command out while a clean-up is on record: whichever of the two came second + * has seen the other. + */ +describe.if(isLinux)('A clean-up of mount points, on record', () => { + const runtime = usePrivateManifestDirectory() + const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() + const MODULE = JSON.stringify( + join(import.meta.dir, '../helpers/isolated/manifests.ts'), + ) + const OWN_NAMESPACE = isLinux ? readlinkSync('/proc/self/ns/pid') : '' + const OWN_BOOT = isLinux + ? readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + : '' + /** Echoed first by every command that runs for real. */ + const BOOTED = 'BOOTED' + + let BASE: string + let DIR: string // where the manifests and the passes go + let AREA: string // the allowed write area + let LOCK: string // the denyWrite path, absent to begin with + let others: ChildProcess[] // ended after each test + + beforeEach(() => { + BASE = realpathSync(mkdtempSync(join(tmpdir(), 'mount-point-passes-'))) + AREA = join(BASE, 'area') + mkdirSync(AREA) + LOCK = join(AREA, 'config.lock') + DIR = runtime.manifestDir() + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + chmodSync(DIR, 0o700) + others = [] + }) + + afterEach(() => { + for (const other of others) { + if (other.pid !== undefined) { + try { + process.kill(other.pid, 'SIGCONT') + process.kill(other.pid, 'SIGKILL') + } catch { + // Gone already. + } + } + } + for (const name of readdirSync(DIR)) rmSync(join(DIR, name)) + cleanupBwrapMountPoints({ force: true }) + rmSync(BASE, { recursive: true, force: true }) + }) + + /** Another process of this user, which runs until the test is over. */ + function anotherProcess(): Process { + const other = spawn('sleep', ['600'], { stdio: 'ignore' }) + others.push(other) + return processAt(other.pid!) + } + + /** What a killed process leaves in DIR, or one that `writer` wrote. */ + const manifestOfADeadProcess = (paths: string[], writer?: Process): string => + writeManifest(DIR, paths, { boot: OWN_BOOT, ...writer }) + + /** Puts a pass of `who` on record, as the library names one. */ + function passOf( + who: Process, + where: { ns?: string; boot?: string } = {}, + ): string { + const ns = where.ns ?? /\d+/.exec(OWN_NAMESPACE)![0] + const boot = (where.boot ?? OWN_BOOT).replace(/-/g, '') + const file = join( + DIR, + `${who.pid}-${who.start}-${ns}-${boot}-0123456789abcdef.pass`, + ) + writeFileSync(file, '', { mode: 0o600 }) + return file + } + + function aged(file: string, ms: number): string { + const then = new Date(Date.now() - ms) + utimesSync(file, then, then) + return file + } + + const passes = (): string[] => + readdirSync(DIR).filter(name => name.endsWith('.pass')) + + const wrap = (command = 'true', deny = [LOCK]): Promise => + wrapIn(AREA, command, deny) + + const script = (name: string, lines: string[]): string => + writeScript(BASE, name, lines) + + // ---- the pass ------------------------------------------------------------ + + it('says what is finished and goes on record before the reading it decides on, stays there while it removes, and is off it afterwards', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const open = fs.openSync + const rename = fs.renameSync + const unlink = fs.unlinkSync + const seen: string[] = [] + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (typeof file === 'string' && /\.(pass|finished)$/.test(file)) { + seen.push(file.endsWith('.pass') ? 'on record' : 'reads the claim') + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never), + spyOn(fs, 'renameSync').mockImplementation((( + from: string, + to: string, + ) => { + seen.push(`renames to ${extname(to)}`) + rename(from, to) + }) as never), + spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === LOCK) + seen.push(`removes, with ${passes().length} on record`) + unlink(file) + }) as never), + ] + try { + expect(collectMountPoints('all')).toEqual([LOCK]) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + expect(seen).toEqual([ + 'renames to .claimed', + 'renames to .finished', + 'on record', + 'reads the claim', + 'removes, with 1 on record', + ]) + expect(readdirSync(DIR)).toEqual([]) + }) + + it('is named for its process, so that a listing is all it takes to ask after it', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const unlink = fs.unlinkSync + let name: string | undefined + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file === LOCK) [name] = passes() + unlink(file) + }) as never) + try { + collectMountPoints('all') + } finally { + spy.mockRestore() + } + const { pid, start } = processAt(process.pid) + expect(name).toStartWith( + `${pid}-${start}-${/\d+/.exec(OWN_NAMESPACE)![0]}-${OWN_BOOT.replace(/-/g, '')}-`, + ) + }) + + it('puts nothing on record where there is nothing to remove', () => { + manifestOfADeadProcess([LOCK], anotherProcess()) + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints('all')).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it("removes nothing on a manifest's word where it cannot be put on record, and the next pass does", () => { + leftover(LOCK) + const manifest = manifestOfADeadProcess([LOCK]) + const open = fs.openSync + const spy = spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + if (String(file).endsWith('.pass')) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + } + expect(existsSync(LOCK)).toBe(true) + // What it has found finished stays so, and named, for the next. + expect(readdirSync(DIR)).toEqual([ + basename(manifest).replace(/json$/, 'finished'), + ]) + expect(collectMountPoints('all')).toEqual([LOCK]) + }) + + it('still removes what this process could record nowhere, on a disk that takes neither a manifest nor a record', () => { + // No other process knows it for a mount point, or waits for a pass over it. + const full = (file: unknown): void => { + if (/\.(pass|tmp)$/.test(String(file))) { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }) + } + } + const open = fs.openSync + const write = fs.writeFileSync + const spies = [ + spyOn(fs, 'openSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + full(file) + return (open as (...args: unknown[]) => number)(file, ...rest) + }) as never), + spyOn(fs, 'writeFileSync').mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + full(file) + ;(write as (...args: unknown[]) => void)(file, ...rest) + }) as never), + ] + try { + expect(publishMountPointManifest([LOCK], [])).toBeUndefined() + leftover(LOCK) + expect(collectMountPoints('all')).toEqual([LOCK]) + } finally { + spies.forEach(spy => spy.mockRestore()) + } + }) + + it('goes on no record over manifests it cannot read, where it may remove nothing', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + writeFileSync(join(DIR, '1-0123456789abcdef.json'), '{"version":"?"}') + const open = spyOn(fs, 'openSync') + try { + expect(collectMountPoints('all')).toEqual([]) + expect( + open.mock.calls.filter(([file]) => String(file).endsWith('.pass')), + ).toEqual([]) + } finally { + open.mockRestore() + } + }) + + it('is off the record when the pass throws', () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const now = performance.now.bind(performance) + const spy = spyOn(performance, 'now').mockImplementation(() => { + if (passes().length > 0) throw new TypeError('a bug') + return now() + }) + try { + expect(() => collectMountPoints('all')).toThrow('a bug') + } finally { + spy.mockRestore() + } + expect(passes()).toEqual([]) + }) + + /** Has that clock go on by `ms` whenever `heldUpFor` is called. */ + function aClockThatIsHeldUp(clock: typeof performance | typeof Date): { + heldUpFor(ms: number): void + mockRestore(): void + } { + const now = clock.now.bind(clock) + let lost = 0 + const spy = spyOn(clock, 'now').mockImplementation(() => now() + lost) + return { + heldUpFor: ms => { + lost += ms + }, + mockRestore: () => spy.mockRestore(), + } + } + // The wall clock alone goes on while the host is suspended, and others go by + // it; it alone can be set back. + const CLOCKS = [ + ['the monotonic clock', (): typeof performance => performance], + ['the wall clock', (): typeof Date => Date], + ] as const + + it.each(CLOCKS)( + 'leaves the rest to the next pass once it has been on record for two seconds, by %s', + (_, which) => { + const second = join(AREA, 'second.lock') + leftover(LOCK) + leftover(second) + manifestOfADeadProcess([LOCK, second]) + const unlink = fs.unlinkSync + const clock = aClockThatIsHeldUp(which()) + const spy = spyOn(fs, 'unlinkSync').mockImplementation((( + file: string, + ) => { + unlink(file) + if (file === second) clock.heldUpFor(2_001) + }) as never) + try { + // Children first, which among siblings is the later name first. + expect(collectMountPoints('all')).toEqual([second]) + } finally { + spy.mockRestore() + clock.mockRestore() + } + expect(existsSync(LOCK)).toBe(true) + expect(passes()).toEqual([]) + expect(collectMountPoints('all')).toEqual([LOCK]) + }, + ) + + it.each(CLOCKS)( + 'counts the time it takes to go on record, and to list the directories before a removal, by %s', + (_, which) => { + for (const heldUpIn of ['openSync', 'readdirSync'] as const) { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const clock = aClockThatIsHeldUp(which()) + const real = fs[heldUpIn] as (...args: unknown[]) => unknown + let listings = 0 + const spy = spyOn(fs, heldUpIn).mockImplementation((( + file: unknown, + ...rest: unknown[] + ) => { + const result = real(file, ...rest) + if ( + heldUpIn === 'openSync' + ? String(file).endsWith('.pass') + : // The reading it decides on, then the one before the removal. + file === DIR && + (result as string[]).some(name => name.endsWith('.pass')) && + ++listings === 2 + ) { + clock.heldUpFor(2_001) + } + return result + }) as never) + try { + expect(collectMountPoints('all')).toEqual([]) + } finally { + spy.mockRestore() + clock.mockRestore() + } + if (heldUpIn === 'readdirSync') expect(listings).toBe(2) + expect(collectMountPoints('all')).toEqual([LOCK]) + } + }, + ) + + // ---- which passes are under way -------------------------------------------- + + it('is under way while its process runs, however old its record', () => { + const pass = aged(passOf(anotherProcess()), 24 * 60 * 60 * 1000) + expect(passesUnderWay()).toEqual([pass]) + }) + + it('is under way while its process is stopped', async () => { + const other = anotherProcess() + process.kill(other.pid, 'SIGSTOP') + await until('the process to be stopped', () => + / T /.test(readFileSync(`/proc/${other.pid}/stat`, 'utf8')), + ) + const pass = passOf(other) + expect(passesUnderWay()).toEqual([pass]) + }) + + it.each([ + ['its process is gone', (): string => passOf(aDeadProcess())], + [ + 'its pid is a later process', + (): string => { + const { pid, start } = anotherProcess() + return passOf({ pid, start: String(Number(start) - 1) }) + }, + ], + [ + 'it is of an earlier boot', + (): string => + passOf(anotherProcess(), { + boot: '00000000-0000-0000-0000-000000000000', + }), + ], + ])('is over when %s, and the next pass drops its record', (_what, plant) => { + const pass = plant() + expect(passesUnderWay()).toEqual([]) + collectMountPoints('all') + expect(existsSync(pass)).toBe(false) + }) + + it.each([ + [ + "another PID namespace's", + (): string => passOf(anotherProcess(), { ns: '1' }), + ], + [ + 'of a process that could read neither its namespace nor the boot', + (): string => passOf(anotherProcess(), { ns: '', boot: '' }), + ], + [ + 'at a name that is none of ours', + (): string => { + const file = join(DIR, 'whatever.pass') + writeFileSync(file, '') + return file + }, + ], + ])( + 'goes by the age of its record where it cannot be asked after: %s', + (_what, plant) => { + const pass = plant() + expect(passesUnderWay()).toEqual([pass]) + aged(pass, 59_000) + expect(passesUnderWay()).toEqual([pass]) + collectMountPoints('all') + expect(existsSync(pass)).toBe(true) + aged(pass, 61_000) + expect(passesUnderWay()).toEqual([]) + // Its record is not this process's to drop: its own may be stopped half + // way, and those who can ask after it still wait for it. + collectMountPoints('all') + expect(existsSync(pass)).toBe(true) + aged(pass, 8 * 24 * 60 * 60 * 1000) + collectMountPoints('all') + expect(existsSync(pass)).toBe(false) + }, + ) + + it('is over, to the copy of the library that made it, once that has gone on to something else', () => { + // What a record that could not be dropped would otherwise do: hold up every + // wrap of the user for as long as this process runs. + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const unlink = fs.unlinkSync + const spy = spyOn(fs, 'unlinkSync').mockImplementation(((file: string) => { + if (file.endsWith('.pass')) { + throw Object.assign(new Error('EIO'), { code: 'EIO' }) + } + unlink(file) + }) as never) + try { + expect(collectMountPoints('all')).toEqual([LOCK]) + } finally { + spy.mockRestore() + } + expect(passes()).toHaveLength(1) + expect(passesUnderWay()).toEqual([]) + collectMountPoints('all') + expect(passes()).toEqual([]) + }) + + it('is under way to another copy of the library in the same process', () => { + // A worker thread has the pid of the thread that started it. + const pass = passOf(processAt(process.pid)) + expect(passesUnderWay()).toEqual([pass]) + }) + + it('does not put a reading of the manifests in doubt', () => { + passOf(anotherProcess()) + leftover(LOCK) + manifestOfADeadProcess([LOCK], anotherProcess()) + expect([...(liveMountPoints() ?? [])]).toEqual([LOCK]) + }) + + // ---- the wait -------------------------------------------------------------- + + it('is waited for until its record goes', async () => { + const pass = passOf(anotherProcess()) + let ended: string | undefined | null = null + const waiting = passesToEnd([pass]).then(heldUp => (ended = heldUp)) + await sleep(150) + expect(ended).toBeNull() + rmSync(pass) + await waiting + expect(ended).toBeUndefined() + }) + + it('is waited for until its process goes', async () => { + const other = anotherProcess() + const waiting = passesToEnd([passOf(other)]) + await sleep(50) + process.kill(other.pid, 'SIGKILL') + expect(await waiting).toBeUndefined() + }) + + it('is named to the caller when it outlasts the wait', async () => { + const pass = passOf(anotherProcess()) + const began = performance.now() + expect(await passesToEnd([pass], undefined, 200)).toBe(pass) + expect(performance.now() - began).toBeGreaterThanOrEqual(200) + }) + + it('is not waited for once the caller has given up', async () => { + const gaveUp = new AbortController() + const waiting = passesToEnd([passOf(anotherProcess())], gaveUp.signal) + gaveUp.abort(new Error('gave up')) + expect( + await waiting.then( + () => undefined, + (error: unknown) => error, + ), + ).toEqual(new Error('gave up')) + }) + + it('is not waited for when it went on record after the wrap had listed them: it has read the manifest', async () => { + const first = passOf(anotherProcess()) + const waiting = passesToEnd([first]) + const later = passOf(anotherProcess()) + await sleep(20) + rmSync(first) + expect(await waiting).toBeUndefined() + expect(passesUnderWay()).toEqual([later]) + }) + + it('is waited for by whichever name its directory is listed', async () => { + // Two of the places may be one directory, and which of the names that goes + // by may differ from one listing to the next. + const listedAs = join( + BASE, + 'the-other-name', + basename(passOf(anotherProcess())), + ) + expect(await passesToEnd([listedAs], undefined, 200)).toBe(listedAs) + }) + + // ---- the wrap ---------------------------------------------------------------- + + it('holds back the command of a wrap that names a mount point, until it is over', async () => { + const pass = passOf(anotherProcess()) + let handedOut = false + const wrapping = wrap().then(command => { + handedOut = true + return command + }) + await until('the wrap to publish', () => + readdirSync(DIR).some(name => name.endsWith('.json')), + ) + await sleep(150) + expect(handedOut).toBe(false) + rmSync(pass) + expect(manifestOf(await wrapping)).toBeDefined() + }) + + it('holds back no wrap that relies on nothing a clean-up could remove', async () => { + passOf(anotherProcess()) + writeFileSync(LOCK, 'held by somebody') + expect(manifestOf(await wrap())).toBeUndefined() + }) + + it('has the wrap look at the host after it, not before', async () => { + // The mount point of a finished sandbox, which the wrap plans to cover + // again, in a directory that is one too, which the wrap plans to make its + // own in. The pass that is under way takes both. + const directory = join(AREA, '.claude') + const inside = join(directory, 'commands') + mkdirSync(directory) + manifestOfADeadProcess([directory]) + const pass = passOf(anotherProcess()) + const wrapping = wrap('true', [inside]) + await until('the wrap to publish', () => + readdirSync(DIR).some( + name => + name.endsWith('.json') && + readFileSync(join(DIR, name), 'utf8').includes(inside), + ), + ) + rmSync(directory, { recursive: true }) + rmSync(pass) + // Planned again: what is missing now is the directory. + const manifest = manifestOf(await wrapping) + const { paths } = JSON.parse(readFileSync(manifest!, 'utf8')) as { + paths: string[] + } + expect(paths).toEqual([directory]) + }) + + it('refuses the wrap when it is held up for longer than a wrap waits, and names its record', async () => { + const pass = passOf(anotherProcess()) + const refused = await wrap().then( + () => undefined, + (error: unknown) => error, + ) + expect(refused).toBeInstanceOf(LinuxSandboxProfileError) + expect((refused as LinuxSandboxProfileError).code).toBe( + 'mount_points_changed', + ) + expect((refused as LinuxSandboxProfileError).cause).toBe(pass) + expect((refused as LinuxSandboxProfileError).message).toContain(pass) + // The wrap has let go of what it named. + expect(readdirSync(DIR)).toEqual([basename(pass)]) + }, 15000) + + it('lets the caller give the wrap up while it waits', async () => { + passOf(anotherProcess()) + const gaveUp = new AbortController() + const wrapping = wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + abortSignal: gaveUp.signal, + }) + await until('the wrap to publish', () => + readdirSync(DIR).some(name => name.endsWith('.json')), + ) + gaveUp.abort(new Error('gave up')) + expect( + await wrapping.then( + () => undefined, + (error: unknown) => error, + ), + ).toEqual(new Error('gave up')) + }) + + it('plans again when a forced clean-up has let go of its manifest while it waited', async () => { + const pass = passOf(anotherProcess()) + const published = (): string[] => + readdirSync(DIR).filter(name => name.endsWith('.json')) + const wrapping = wrap() + await until('the wrap to publish', () => published().length > 0) + const [first] = published() + cleanupBwrapMountPoints({ force: true }) + rmSync(pass) + const manifest = manifestOf(await wrapping) + expect(basename(manifest!)).not.toBe(first) + expect(existsSync(manifest!)).toBe(true) + }) + + it('says nothing of the other commands of its process when it comes to nothing', async () => { + // One that has run, whose caller has not cleaned up after it and may run it + // again. + const kept = await wrap() + started(manifestOf(kept)!, aDeadProcess()) + const gaveUp = new AbortController() + gaveUp.abort(new Error('gave up')) + expect( + await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, + abortSignal: gaveUp.signal, + }).then( + () => undefined, + (error: unknown) => error, + ), + ).toBeInstanceOf(Error) + expect(existsSync(manifestOf(kept)!)).toBe(true) + cleanupBwrapMountPoints() + expect(existsSync(manifestOf(kept)!)).toBe(false) + }) + + // A clean-up in a process of its own, stopped between its last look at the + // manifests and the removal, for as long as the test likes. + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot take a mount point from under a sandbox that starts while it is stopped before the removal', + async () => { + leftover(LOCK) + manifestOfADeadProcess([LOCK]) + const about = join(BASE, 'about-to-remove') + const cleaner = spawn( + process.execPath, + [ + script('stopped-cleaner.ts', [ + `import { spyOn } from 'bun:test'`, + `import * as fs from 'node:fs'`, + `const m = await import(${MODULE})`, + `const unlink = fs.unlinkSync`, + `spyOn(fs, 'unlinkSync').mockImplementation((file) => {`, + ` if (file === ${JSON.stringify(LOCK)}) {`, + ` fs.writeFileSync(${JSON.stringify(about)}, '')`, + ` process.kill(process.pid, 'SIGSTOP')`, + ` }`, + ` unlink(file)`, + `})`, + `m.collectMountPoints('all')`, + ]), + ], + { stdio: 'ignore' }, + ) + others.push(cleaner) + const cleaned = new Promise(resolve => cleaner.on('exit', resolve)) + await until('the clean-up to reach the removal', () => existsSync(about)) + await until('the clean-up to be stopped', () => + / T /.test(readFileSync(`/proc/${cleaner.pid}/stat`, 'utf8')), + ) + + // What the sandbox tries once it is up: the removal first, which only a + // bind still in place refuses. + const up = join(AREA, 'up') + const go = join(AREA, 'go') + let command: string | undefined + const wrapping = wrap( + `echo ${BOOTED}; echo up > ${up}; while [ ! -e ${go} ]; do sleep 0.02; done; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, + ).then(wrapped => (command = wrapped)) + await sleep(1000) + // No command, so no sandbox, for as long as the removal is pending. + expect(command).toBeUndefined() + expect(existsSync(LOCK)).toBe(true) + + process.kill(cleaner.pid!, 'SIGCONT') + await cleaned + await wrapping + + const sandbox = spawn(command!, { shell: true }) + others.push(sandbox) + let said = '' + sandbox.stdout.on('data', chunk => (said += String(chunk))) + sandbox.stderr.on('data', chunk => (said += String(chunk))) + const exited = new Promise(resolve => sandbox.on('exit', resolve)) + await until('the sandbox to be up', () => existsSync(up)) + writeFileSync(go, '') + await exited + expect(said).toContain(BOOTED) + expect(said).toMatch(/rc=[1-9]/) + expect(readFileSync(LOCK, 'utf8')).toBe('') + + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) + + // ---- a command line run again ------------------------------------------------ + + /** A process that wrapped nothing, cleaning up. */ + function anotherProcessCleansUp(): void { + const cleaned = spawnSync( + process.execPath, + [ + script('cleaner.ts', [ + `const m = await import(${MODULE})`, + `m.collectMountPoints('all')`, + ]), + ], + { encoding: 'utf8', timeout: 30000 }, + ) + expect(cleaned.status).toBe(0) + } + + it.if(BWRAP_CAN_NAMESPACE)( + 'leaves a command line good for another run, with other processes cleaning up in between, until its own process lets go of it', + async () => { + const command = await wrap( + `echo ${BOOTED}; rm -f ${LOCK}; echo pwned > ${LOCK}; echo rc=$?`, + ) + for (let time = 0; time < 3; time++) { + const { said } = run(command) + expect(said).toContain(BOOTED) + expect(said).toMatch(/rc=[1-9]/) + anotherProcessCleansUp() + expect(existsSync(manifestOf(command)!)).toBe(true) + expect(readFileSync(LOCK, 'utf8')).toBe('') + } + + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + const refused = run(command) + expect(refused.status).not.toBe(0) + expect(refused.said).not.toContain(BOOTED) + expect(refused.said).toContain("Can't find source path") + // It made nothing, and the line it put on record goes with the next pass. + expect(existsSync(LOCK)).toBe(false) + collectMountPoints('none') + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) + + it.each(['first', 'last'] as const)( + 'asks after every run of a command line that has been run thousands of times: the %s may be the one that lasts', + which => { + leftover(LOCK) + const gone = Array(3000).fill(aDeadProcess()) + const manifest = manifestOfADeadProcess([LOCK]) + started( + manifest, + ...(which === 'first' + ? [anotherProcess(), ...gone] + : [...gone, anotherProcess()]), + ) + expect(collectMountPoints('all')).toEqual([]) + started(manifest, ...gone) + expect(collectMountPoints('all')).toEqual([LOCK]) + }, + ) + + it('takes a record too long to be read to vouch, like any that cannot be read', () => { + leftover(LOCK) + const manifest = manifestOfADeadProcess([LOCK]) + started(manifest, aDeadProcess()) + const record = recordOf(manifest) + const line = fs.readFileSync(record, 'utf8') + fs.writeFileSync( + record, + line.repeat(Math.ceil((4 * 1024 * 1024 + 1) / line.length)), + ) + expect(collectMountPoints('all')).toEqual([]) + expect(existsSync(LOCK)).toBe(true) + }) + + /** The manifest this process publishes for a wrap that names `paths`. */ + function publish(paths: string[], commandKey?: string): string { + const manifest = publishMountPointManifest(paths, [], commandKey) + if (manifest === undefined || manifest === 'too-large') { + throw new Error(`not published: ${manifest}`) + } + return manifest.file + } + + it('lets go of the commands that have run and ended at every clean-up the caller makes, and of no other', () => { + const [ended, running, notStarted] = ['ended', 'running', 'waiting'].map( + name => join(AREA, name), + ) as [string, string, string] + for (const mountPoint of [ended, running, notStarted]) leftover(mountPoint) + started(publish([ended]), aDeadProcess()) + started(publish([running]), anotherProcess()) + publish([notStarted]) + + // A wrap of this process that came to nothing says nothing of the others. + expect(collectMountPoints('none')).toEqual([]) + expect(collectMountPoints('ended')).toEqual([ended]) + expect(collectMountPoints('ended')).toEqual([]) + expect(collectMountPoints('all')).toEqual([notStarted]) + expect(existsSync(running)).toBe(true) + }) + + it('lets go of them along with the command the caller names', () => { + const [ended, named, other] = ['ended', 'named', 'other'].map(name => + join(AREA, name), + ) as [string, string, string] + for (const mountPoint of [ended, named, other]) leftover(mountPoint) + started(publish([ended], 'a'), aDeadProcess()) + publish([named], 'b') + publish([other], 'c') + expect(collectMountPoints({ commandKey: 'b' }).sort()).toEqual( + [ended, named].sort(), + ) + expect(existsSync(other)).toBe(true) + }) + + // ---- a process that has exited and is not waited for ------------------------- + + /** Whether the shell started as `pid` has become the `sleep` it execs. */ + const isSleep = (pid: number): boolean => + readFileSync(`/proc/${pid}/comm`, 'utf8') === 'sleep\n' + + it('takes a process that has exited for gone, though nothing has asked after it yet', async () => { + // The shell starts a child and becomes a program that never waits for one. + // The child ends only then: a shell may wait for one that ends before. + const go = join(BASE, 'go') + const parent = spawn( + 'sh', + [ + '-c', + `(while [ ! -e ${go} ]; do sleep 0.02; done) & echo $!; exec sleep 600`, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ) + others.push(parent) + let said = '' + parent.stdout.on('data', chunk => (said += String(chunk))) + await until('the pid of the child', () => said.includes('\n')) + const pid = Number(said) + await until('the shell to be gone', () => isSleep(parent.pid!)) + writeFileSync(go, '') + await until('the child to have exited', () => + / Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')), + ) + const exited = processAt(pid) + + leftover(LOCK) + started(manifestOfADeadProcess([LOCK]), exited) + const pass = passOf(exited) + expect(passesUnderWay()).toEqual([]) + expect([...(liveMountPoints() ?? [])]).toEqual([]) + expect(collectMountPoints('all')).toEqual([LOCK]) + expect(existsSync(pass)).toBe(false) + // Still there to be asked after: it is what it says that counts. + expect(existsSync(`/proc/${pid}`)).toBe(true) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cleans up after a sandbox that was killed and is never waited for', + async () => { + // As under a process 1 that reaps nothing: the sandbox is the child of a + // program that never waits for it. + const up = join(AREA, 'up') + const command = await wrap(`echo up > ${up}; sleep 600`) + const parent = spawn('sh', ['-c', `${command} & exec sleep 600`], { + stdio: 'ignore', + }) + others.push(parent) + await until('the sandbox to be up', () => existsSync(up)) + const record = readFileSync(recordOf(manifestOf(command)!), 'utf8') + const bwrap = Number(/^\d+/.exec(record)![0]) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) + + await until('the shell to be gone', () => isSleep(parent.pid!)) + process.kill(bwrap, 'SIGKILL') + await until('bubblewrap to have exited', () => + / Z /.test(readFileSync(`/proc/${bwrap}/stat`, 'utf8')), + ) + collectMountPoints('none') + expect(existsSync(LOCK)).toBe(false) + expect(readdirSync(DIR)).toEqual([]) + }, + 60000, + ) +}) 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/readonly-deny-dir-binds.test.ts b/test/sandbox/readonly-deny-dir-binds.test.ts index 87cb7eb08..0e6fe6d2a 100644 --- a/test/sandbox/readonly-deny-dir-binds.test.ts +++ b/test/sandbox/readonly-deny-dir-binds.test.ts @@ -1481,7 +1481,10 @@ describe.if(isLinux)('Deny binds under a read-only denied directory', () => { ] it('puts every one of them back after the bind, the masks last', async () => { - const mounts = mountsOf(await wrapAll([PROJ])) + // But for the manifest directories, which every wrap binds. + const mounts = mountsOf(await wrapAll([PROJ])).filter( + mount => !mount.startsWith('--ro-bind-try '), + ) expect( mounts.slice(mounts.indexOf(`--ro-bind ${PROJ} ${PROJ}`)), diff --git a/test/sandbox/readonly-deny-dir-stubs.test.ts b/test/sandbox/readonly-deny-dir-stubs.test.ts index ad0ed86d8..bab8770e0 100644 --- a/test/sandbox/readonly-deny-dir-stubs.test.ts +++ b/test/sandbox/readonly-deny-dir-stubs.test.ts @@ -18,7 +18,11 @@ import { } from '../../src/sandbox/linux-sandbox-utils.js' import { isLinux } from '../helpers/platform.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { countMounts, lastIndexOfMount } from '../helpers/bwrap-argv.js' +import { + countMounts, + lastIndexOfMount, + withoutManifestName, +} from '../helpers/bwrap-argv.js' import { withCapturedWarnings } from '../helpers/captured-warnings.js' /** @@ -352,6 +356,55 @@ describe.if(isLinux)('Deny stubs under a read-only denied directory', () => { expect(command).not.toContain(`--ro-bind /dev/null ${absentDeny}`) }) + it('emits the same deny mounts whichever order the deny paths are listed in', async () => { + // The pre-pass and the deny loop walk the same list and must reach the + // same verdict about each entry: a directory one records and the other + // does not re-bind read-only would suppress a stub the sandbox needs, + // and only under some orderings. Which mounts come out is what the + // pre-pass makes order-independent, so the two orderings must agree. + // + // `kept` is named by two spellings, one through a link, beneath a + // directory that is denied too: which of the two the loop meets first is + // the caller's ordering. + const kept = join(AREA, 'real', 'kept') + mkdirSync(kept, { recursive: true }) + symlinkSync(join(AREA, 'real'), join(AREA, 'link')) + mkdirSync(join(PROJ, 'sub'), { recursive: true }) + const denies = [ + PROJ, + join(PROJ, '.gitconfig'), + join(PROJ, 'sub'), + join(PROJ, 'sub', 'absent'), + join(AREA, 'real'), + kept, + join(AREA, 'link', 'kept'), + join(AREA, 'other', 'absent'), + '/dev/null', + ] + // Each wrap names a manifest of its own. + const mountsOf = (command: string) => + [...withoutManifestName(command).matchAll(/--(?:ro-)?bind (\S+) (\S+)/g)] + .map(mount => `${mount[1]} ${mount[2]}`) + .sort() + + const forward = mountsOf(await wrap(denies)) + const backward = mountsOf(await wrap([...denies].reverse())) + + expect(backward).toEqual(forward) + // Not vacuous, and the skip is specific rather than global: the + // covering directory is bound read-only, the absent entries beneath it + // are left unstubbed, and the absent entry outside it still gets its + // empty-directory placeholder. + expect(forward).toContain(`${PROJ} ${PROJ}`) + expect(forward).toContain(`${kept} ${kept}`) + expect( + forward.filter(mount => mount.startsWith(`/dev/null ${PROJ}/`)), + ).toEqual([]) + expect( + forward.some(mount => mount.endsWith(` ${join(AREA, 'other')}`)), + ).toBe(true) + }) + it('skips the stub when a denyRead directory sits under the covering deny dir with no allowed write path beneath it', async () => { // A read-denied directory strictly inside the write-denied dir is // re-applied as a tmpfs after that dir's read-only bind. All that adds diff --git a/test/sandbox/rules-through-links-on-every-runtime.test.ts b/test/sandbox/rules-through-links-on-every-runtime.test.ts index 3adb55508..70be4a807 100644 --- a/test/sandbox/rules-through-links-on-every-runtime.test.ts +++ b/test/sandbox/rules-through-links-on-every-runtime.test.ts @@ -26,7 +26,7 @@ import { isLinux, isMacOS } from '../helpers/platform.js' * hold bytes that are no text, which no rule and no mount can spell. */ -const CLI_PATH = join(process.cwd(), 'dist', 'cli.js') +const CLI_PATH = join(process.cwd(), 'test/helpers/isolated/dist-cli.mjs') const RUNTIMES = [ ['node', ['node', '--max-old-space-size=256']], ['bun', [process.execPath]], diff --git a/test/sandbox/seccomp-filter.test.ts b/test/sandbox/seccomp-filter.test.ts index 2d88ddbb2..60d8d0e5f 100644 --- a/test/sandbox/seccomp-filter.test.ts +++ b/test/sandbox/seccomp-filter.test.ts @@ -87,7 +87,7 @@ describe.if(isLinux)('Sandbox Integration', () => { seccompConfig: { applyPath: real }, }) - expect(wrappedCommand).toContain(real) + expect(wrappedCommand).toContain(real!) }) it('argv0 mode: builds ARGV0 prefix and uses applyPath verbatim', async () => { diff --git a/test/sandbox/stale-mount-point.test.ts b/test/sandbox/stale-mount-point.test.ts index a8895d3ab..f960099cb 100644 --- a/test/sandbox/stale-mount-point.test.ts +++ b/test/sandbox/stale-mount-point.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' +import * as fs from 'node:fs' import { chmodSync, existsSync, + linkSync, lstatSync, mkdirSync, mkdtempSync, - readFileSync, readdirSync, realpathSync, rmSync, @@ -20,13 +21,19 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' -import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + leftover, + namedBy, + type Process, + thisProcess, + wrapIn, + wrapLine, + writeManifest, +} from '../helpers/mount-point-fixtures.js' import { isLinux } from '../helpers/platform.js' import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' -import { - bwrapCanNamespace, - bwrapCanNamespaceNetwork, -} from '../helpers/bwrap-namespace.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { usePrivateManifestDirectory } from '../helpers/private-manifest-directory.js' /** Echoed first by every command that runs for real, so an assertion about * what the command did cannot pass on a sandbox that never started. */ @@ -47,112 +54,365 @@ function run( /** * A denyWrite path that does not exist gets `--ro-bind /dev/null `, and - * bwrap creates the mount point for it on the host: an empty file, mode 0444. - * The wrapping process removes it after the command, from a set it keeps in - * memory. A file that only looks like one is not in that set, and is not the - * process's to remove: it may be the caller's own, or the mount point of - * another process's sandbox that is still running, whose deny goes with it. + * bwrap creates the mount point for it on the host: an empty file, mode 0444. A + * process killed before it can clean up leaves the file behind. For a path + * whose existence is its meaning that is a lasting fault: a leftover + * `.git/config.lock` makes every `git config` write outside the sandbox fail. + * + * The killed process leaves its manifest as well, and that is what says the + * file is a leftover. The file alone does not: an empty read-only file is also + * what somebody keeps at such a path on purpose. */ -describe.if(isLinux)('A file at a write-deny path', () => { +describe.if(isLinux)('A mount point an earlier sandbox left behind', () => { + const runtime = usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area + let GIT_DIR: string let LOCK: string // the denyWrite path + let OUTSIDE: string // not under any allowed write path beforeEach(() => { BASE = realpathSync(mkdtempSync(join(tmpdir(), 'stale-mount-point-'))) AREA = join(BASE, 'area') - LOCK = join(AREA, 'repo', '.git', 'config.lock') - mkdirSync(join(AREA, 'repo', '.git'), { recursive: true }) + GIT_DIR = join(AREA, 'repo', '.git') + LOCK = join(GIT_DIR, 'config.lock') + OUTSIDE = join(BASE, 'outside') + mkdirSync(GIT_DIR, { recursive: true }) + mkdirSync(OUTSIDE, { recursive: true }) + writeFileSync(join(GIT_DIR, 'config'), '[core]\n') }) afterEach(() => { cleanupBwrapMountPoints({ force: true }) + // A control's 0444 file would survive rmSync's unlink only on a + // read-only directory; these directories are writable. rmSync(BASE, { recursive: true, force: true }) }) - async function wrap(command = 'true'): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: [LOCK] }, - }) + const wrap = (denyPaths: string[], command = 'true'): Promise => + wrapIn(AREA, command, denyPaths) + + /** One that `writer` wrote, by default a process that is gone. */ + const manifestNaming = (paths: string[], writer?: Process): string => + writeManifest(runtime.manifestDir(), paths, writer) + + /** What a killed process leaves: the file, and the manifest that names it. */ + function plantLeftover(p: string): string { + leftover(p) + return manifestNaming([p]) } - // The chmod, so that it is the bind that refuses and not the file's mode. - const WRITE = (): string => - `echo ${BOOTED}; chmod u+w ${LOCK}; echo x >> ${LOCK}; echo rc=$?` + // 0444 is what bubblewrap makes from 0.5.0, 0666 less the umask before. + it.each([0o444, 0o644, 0o666, 0o600])( + 'is covered with /dev/null like an absent path, named, and removed after the command, whatever its mode: %o', + async mode => { + const left = plantLeftover(LOCK) + chmodSync(LOCK, mode) + + const command = await wrap([LOCK]) + + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(countMounts(command, '--ro-bind', LOCK, LOCK)).toBe(0) + expect(namedBy(command)).toEqual([LOCK]) + + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + expect(existsSync(left)).toBe(false) + }, + ) + + it('stays while a wrap of this process is outstanding, and goes with the last of them', async () => { + plantLeftover(LOCK) + + await wrap([LOCK]) + await wrap([LOCK]) + + // Two wraps handed out, one cleaned up after. A call cannot tell which + // command it is for, so this process gives up nothing of its own until it + // has been called for both. + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) - const FILES: Array<[string, number, string]> = [ - ['empty and read-only, like a mount point bwrap made', 0o444, ''], - ['empty, with write bits', 0o644, ''], - ['read-only, with content', 0o444, 'x'], + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }) + + // Everything below only LOOKS like a leftover in one respect. Each is + // somebody's file: bound onto itself as an existing deny path, and still + // there afterwards. + const somebodysFiles: [string, (p: string) => void][] = [ + [ + 'a read-only file with content', + (p: string) => { + writeFileSync(p, 'x') + chmodSync(p, 0o444) + }, + ], + [ + 'an empty read-only file with a second link', + (p: string) => { + leftover(p) + linkSync(p, `${p}.other-name`) + }, + ], ] + it.each(somebodysFiles)('leaves %s alone', async (_what, plant) => { + plant(LOCK) - function plant(mode: number, content: string): void { - writeFileSync(LOCK, content) - chmodSync(LOCK, mode) - } + const command = await wrap([LOCK]) + + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(true) + }) - it.each(FILES)( - 'is bound onto itself and is not among what the clean-up removes: %s', - async (_what, mode, content) => { - plant(mode, content) + // A manifest is only a claim about a path: what it names is a leftover + // only while it also looks like one. + it.each(somebodysFiles)( + 'leaves %s alone though the manifest of a killed process names it', + async (_what, plant) => { + plant(LOCK) + manifestNaming([LOCK]) - const command = await wrap() + const command = await wrap([LOCK]) expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + expect(namedBy(command)).toEqual([]) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(true) + }, + ) + + // ---- an empty file that no manifest names ---- + // + // It is the caller's own, whatever it looks like. It is denied like any + // existing path, by a read-only bind onto itself, and nothing ever removes + // it. + + it.each([0o444, 0o644])( + "is the caller's own where no manifest names it: bound onto itself, named by the wrap nowhere, and never removed (mode %o)", + async mode => { + leftover(LOCK) + chmodSync(LOCK, mode) + // A manifest that names something else changes nothing about it. + manifestNaming([join(GIT_DIR, 'other.lock')]) + + const command = await wrap([LOCK]) + + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + expect(countMounts(command, '--ro-bind', '/dev/null', LOCK)).toBe(0) + expect(namedBy(command)).toEqual([]) + + cleanupBwrapMountPoints() cleanupBwrapMountPoints({ force: true }) - expect(existsSync(LOCK)).toBe(true) + const kept = lstatSync(LOCK) + expect(kept.size).toBe(0) + expect(kept.mode & 0o777).toBe(mode) }, ) - it.if(bwrapCanNamespaceNetwork() && Bun.which('socat') !== null).each(FILES)( - 'cannot be written in the sandbox, and is as it was after the clean-up and after reset(): %s', - async (_what, mode, content) => { - plant(mode, content) - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { denyRead: [], allowWrite: [AREA], denyWrite: [LOCK] }, - }) + it("is the caller's own at a mandatory deny path as well, beside the mount points the wrap makes for the others", async () => { + const project = join(AREA, 'repo') + const own = join(project, '.mcp.json') + leftover(own) + const cwd = process.cwd() + process.chdir(project) + let command: string + try { + command = await wrap([]) + } finally { + process.chdir(cwd) + } + + expect(lastMountAt(command, own)).toBe(`--ro-bind ${own} ${own}`) + // The mandatory denies that are absent get their mount points, and the + // manifest names those and not the file that was there. + const bashrc = join(project, '.bashrc') + expect(lastMountAt(command, bashrc)).toBe(`--ro-bind /dev/null ${bashrc}`) + expect(namedBy(command)).toContain(bashrc) + expect(namedBy(command)).not.toContain(own) + + cleanupBwrapMountPoints() + expect(lstatSync(own).mode & 0o777).toBe(0o444) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'cannot be written or removed by the command, and is still there, as it was, after the session', + async () => { + const project = join(AREA, 'repo') + const mandatory = join(project, '.mcp.json') + leftover(mandatory) + leftover(LOCK) + const before = [mandatory, LOCK].map(p => lstatSync(p).ino) + + const attempts = [mandatory, LOCK] + .map( + p => + `echo x >> ${p}; echo "write $?"; rm -f ${p}; echo "rm $?"; ` + + `mv ${p} ${p}.aside; echo "mv $?"; chmod 644 ${p}; echo "chmod $?"`, + ) + .join('; ') + const cwd = process.cwd() + process.chdir(project) + let command: string try { - const write = run(await SandboxManager.wrapWithSandbox(WRITE())) - expect(write.stdout).toContain(BOOTED) - expect(write.stdout).toMatch(/rc=[1-9]/) - SandboxManager.cleanupAfterCommand() - expect(readFileSync(LOCK, 'utf8')).toBe(content) + command = await wrap([LOCK], `echo ${BOOTED}; ${attempts}`) } finally { - await SandboxManager.reset() + process.chdir(cwd) + } + expect(namedBy(command)).not.toContain(mandatory) + expect(namedBy(command)).not.toContain(LOCK) + // The wrap has mount points of its own, for the mandatory denies that are + // absent: the clean-up below removes those and nothing else. + expect(namedBy(command)).toContain(join(project, '.bashrc')) + + const session = run(command, project) + expect(session.stdout).toContain(BOOTED) + expect(session.stdout).not.toMatch(/(write|rm|mv|chmod) 0/) + expect(session.stdout.match(/(write|rm|mv|chmod) [1-9]/g)).toHaveLength(8) + + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(project, '.bashrc'))).toBe(false) + for (const [i, p] of [mandatory, LOCK].entries()) { + const kept = lstatSync(p) + expect(kept.ino).toBe(before[i]!) + expect(kept.size).toBe(0) + expect(kept.mode & 0o777).toBe(0o444) + expect(existsSync(`${p}.aside`)).toBe(false) } - expect(readFileSync(LOCK, 'utf8')).toBe(content) - expect(lstatSync(LOCK).mode & 0o777).toBe(mode) }, + 30000, ) - it.if(BWRAP_CAN_NAMESPACE)( - 'is removed when this process had it made, though a later wrap found it there', - async () => { - expect(run(await wrap(`echo ${BOOTED}`)).stdout).toContain(BOOTED) - expect(existsSync(LOCK)).toBe(true) + it('is asked after again, where the manifests were read before another sandbox named it and made it', async () => { + // A wrap reads the manifests once. What was read before the file was there + // cannot say whose it is: a sandbox that started in between published its + // manifest first and had bubblewrap make the file after. + const earlier = join(GIT_DIR, 'earlier.lock') + plantLeftover(earlier) + const exists = fs.existsSync + let made = false + const spy = spyOn(fs, 'existsSync').mockImplementation(((file: unknown) => { + if (file === LOCK && !made) { + made = true + manifestNaming([LOCK], thisProcess()) + leftover(LOCK) + } + return exists(file as string) + }) as never) + let command: string + try { + // `earlier` comes first, and has the manifests read for it. + command = await wrap([earlier, LOCK]) + } finally { + spy.mockRestore() + } + expect(made).toBe(true) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command).sort()).toEqual([LOCK, earlier].sort()) + }) + + it('is taken for absent where it has gone, with the manifest that named it, while the wrap was looking', async () => { + // Seen with the shape of a mount point, and named by no manifest a moment + // later: the sandbox it was made for has ended and been cleaned up after in + // between. + const left = plantLeftover(LOCK) + const readdir = fs.readdirSync + let collected = false + const spy = spyOn(fs, 'readdirSync').mockImplementation((( + dir: unknown, + options: unknown, + ) => { + if (dir === runtime.manifestDir() && !collected) { + collected = true + rmSync(LOCK, { force: true }) + rmSync(left) + } + return (readdir as (d: unknown, o: unknown) => unknown)(dir, options) + }) as never) + let command: string + try { + command = await wrap([LOCK]) + } finally { + spy.mockRestore() + } + expect(collected).toBe(true) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command)).toEqual([LOCK]) + }) + + // ---- an empty read-only file that a live manifest names ---------------- - expect(lastMountAt(await wrap(), LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + it('stays, covered and named, for as long as the manifest of a running sandbox names it, and goes once none does', async () => { + leftover(LOCK) + // Of another wrap, whose writer is running: live. + const live = manifestNaming([LOCK], thisProcess()) + const command = await wrap([LOCK]) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + expect(namedBy(command)).toEqual([LOCK]) + + // This wrap is over; the other sandbox still relies on the file. + cleanupBwrapMountPoints() + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(LOCK)).toBe(true) + expect(existsSync(live)).toBe(true) + + // And that one is over too: what it leaves is a finished manifest. + rmSync(live) + manifestNaming([LOCK]) + cleanupBwrapMountPoints() + expect(existsSync(LOCK)).toBe(false) + }) + + it('leaves an empty read-only file alone where no sandbox could have made it: outside every allowed write path', async () => { + const outsideFile = join(OUTSIDE, 'config.lock') + leftover(outsideFile) + + const command = await wrap([outsideFile]) + + // Already read-only from --ro-bind / /: nothing is mounted there at all. + expect(lastMountAt(command, outsideFile)).toBeUndefined() + cleanupBwrapMountPoints() + expect(existsSync(outsideFile)).toBe(true) + }) + + it.if(BWRAP_CAN_NAMESPACE)( + 'still denies the write while covered, and the path stays denied once it is absent again', + async () => { + plantLeftover(LOCK) + + // Removed first: a file without a write bit refuses a plain write by its + // mode alone, and only a bind refuses the removal. + const write = `echo ${BOOTED}; rm -f ${LOCK}; echo x > ${LOCK}; echo rc=$?` + const overLeftover = run(await wrap([LOCK], write)) + expect(overLeftover.stdout).toContain(BOOTED) + expect(overLeftover.stdout).toMatch(/rc=[1-9]/) + expect(lstatSync(LOCK).size).toBe(0) cleanupBwrapMountPoints() - // Unlinking it now would detach the second sandbox's bind. - expect(existsSync(LOCK)).toBe(true) + expect(existsSync(LOCK)).toBe(false) + + // Now the ordinary absent case: nothing can create it, nothing stays. + const command = await wrap([LOCK], write) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) + const absent = run(command) + expect(absent.stdout).toContain(BOOTED) + expect(absent.stdout).toMatch(/rc=[1-9]/) cleanupBwrapMountPoints() expect(existsSync(LOCK)).toBe(false) + // The neighbour it protects is still what it was. + expect(lstatSync(join(GIT_DIR, 'config')).size).toBe(7) }, ) - // A real leftover, not a hand-made one: a process wraps a command against - // the absent path, runs it, and is killed before it can clean up. + // The leak itself, not a hand-made stand-in: a process wraps a command + // against the absent path, runs it, and is killed before it can clean up. it.if(BWRAP_CAN_NAMESPACE)( - 'stays when a killed process left it, and the next command starts with the path still denied', + 'is what a killed process leaves, and the next process to wrap takes it away', async () => { const script = join(BASE, 'killed-wrapper.ts') writeFileSync( @@ -160,34 +420,40 @@ describe.if(isLinux)('A file at a write-deny path', () => { [ `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',`, - ` needsNetworkRestriction: false,`, - ` readConfig: { denyOnly: [] },`, - ` writeConfig: { allowOnly: [${JSON.stringify(AREA)}], denyWithinAllow: [${JSON.stringify(LOCK)}] },`, - `})`, - `const r = spawnSync(command, { shell: true, timeout: 15000 })`, + wrapLine([AREA], 'true', [LOCK]), + `const r = spawnSync(wrapped, { shell: true, timeout: 15000 })`, `if (r.status !== 0) process.exit(3)`, `process.kill(process.pid, 'SIGKILL')`, ].join('\n'), ) const killed = spawnSync(process.execPath, [script], { + env: { ...process.env }, encoding: 'utf8', timeout: 30000, }) expect(killed.signal).toBe('SIGKILL') - expect(lstatSync(LOCK).isFile()).toBe(true) - const command = await wrap(WRITE()) - expect(lastMountAt(command, LOCK)).toBe(`--ro-bind ${LOCK} ${LOCK}`) + // What is left on the host, with no process that remembers it. + const left = lstatSync(LOCK) + expect(left.isFile()).toBe(true) + expect(left.size).toBe(0) + // Its mode is bubblewrap's own: 0444 from 0.5.0 on, with write bits before. + expect(left.nlink).toBe(1) + + const command = await wrap( + [LOCK], + `echo ${BOOTED}; rm -f ${LOCK}; echo x > ${LOCK}; echo rc=$?`, + ) + expect(lastMountAt(command, LOCK)).toBe(`--ro-bind /dev/null ${LOCK}`) const after = run(command) expect(after.stdout).toContain(BOOTED) expect(after.stdout).toMatch(/rc=[1-9]/) cleanupBwrapMountPoints() - expect(lstatSync(LOCK).size).toBe(0) + expect(existsSync(LOCK)).toBe(false) }, + 30000, ) }) @@ -200,6 +466,7 @@ describe.if(isLinux)('A file at a write-deny path', () => { describe.if(isLinux)( 'Mount points for deny paths that do not exist yet', () => { + usePrivateManifestDirectory() const BWRAP_CAN_NAMESPACE = bwrapCanNamespace() let BASE: string let AREA: string // allowed write area @@ -236,17 +503,10 @@ describe.if(isLinux)( rmSync(BASE, { recursive: true, force: true }) }) - async function wrap( + const wrap = ( denyPaths: string[] = [], command = 'echo hello', - ): Promise { - return wrapCommandWithSandboxLinux({ - command, - needsNetworkRestriction: false, - readConfig: { denyOnly: [] }, - writeConfig: { allowOnly: [AREA], denyWithinAllow: denyPaths }, - }) - } + ): Promise => wrapIn(AREA, command, denyPaths) /** * Like wrap(), but with the temp dir the sources are minted under writable @@ -313,6 +573,25 @@ describe.if(isLinux)( expect(existsSync(source!)).toBe(true) }) + it('binds each buffered placeholder from its own source', async () => { + // The placeholder at /.claude is a directory, for the mandatory + // /.claude/commands beneath it, and its bind is buffered between + // two that are files. + const before = join(PROJ, '.a-absent') + const after = join(PROJ, '.z-absent') + + const command = await wrap([before, DOT_CLAUDE, after]) + + const source = placeholderSourceAt(command, DOT_CLAUDE) + expect(source).toBeDefined() + expect(countMounts(command, '--ro-bind', source!, DOT_CLAUDE)).toBe(1) + expect(countMounts(command, '--ro-bind', '/dev/null', DOT_CLAUDE)).toBe(0) + expect(countMounts(command, '--ro-bind', '/dev/null', before)).toBe(1) + expect(countMounts(command, '--ro-bind', '/dev/null', after)).toBe(1) + expect(countMounts(command, '--ro-bind', source!, before)).toBe(0) + expect(countMounts(command, '--ro-bind', source!, after)).toBe(0) + }) + it('emits one placeholder per destination, as a directory when the kinds collide', async () => { // denyWrite names the missing /.claude itself, which asks for a // /dev/null placeholder, while the mandatory /.claude/commands asks @@ -418,7 +697,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++) {`, diff --git a/test/sandbox/temp-objects-at-exit.test.ts b/test/sandbox/temp-objects-at-exit.test.ts index 0dca4c22c..b6d3617a6 100644 --- a/test/sandbox/temp-objects-at-exit.test.ts +++ b/test/sandbox/temp-objects-at-exit.test.ts @@ -43,9 +43,16 @@ describe.if(isLinux || isMacOS)('temp objects when the process ends', () => { rmSync(BASE, { recursive: true, force: true }) }) - /** The library's objects among `names`, by kind: `srt-ca-Ab12Cd` is an `srt-ca`. */ + /** + * The library's objects among `names`, by kind: `srt-ca-Ab12Cd` is an + * `srt-ca`. Not the directory of the mount-point manifests: it is the + * user's, shared with other processes, and there to outlive this one. + */ const kinds = (names: string[]): string[] => - names.flatMap(name => /^(srt|claude)-[a-z]+/.exec(name)?.[0] ?? []).sort() + names + .filter(name => !name.startsWith('srt-mount-points')) + .flatMap(name => /^(srt|claude)-[a-z]+/.exec(name)?.[0] ?? []) + .sort() const START = `await SandboxManager.initialize(config) await SandboxManager.wrapWithSandbox('true')` @@ -64,7 +71,7 @@ describe.if(isLinux || isMacOS)('temp objects when the process ends', () => { const script = ` const { readdirSync } = await import('node:fs') const { SandboxManager } = await import(${JSON.stringify( - join(import.meta.dir, '../../src/sandbox/sandbox-manager.ts'), + join(import.meta.dir, '../helpers/isolated/manager.ts'), )}) const config = { network: { diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 1f351bc84..ab6b6deaa 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'bun:test' -import { writeFileSync, mkdtempSync, rmSync } from 'fs' +import { chmodSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { basename, join } from 'path' import { ripGrep, RipgrepError } from '../../src/utils/ripgrep.js' +import { isWindows } from '../helpers/platform.js' describe('ripGrep', () => { it('finds matches with default config', async () => { @@ -43,7 +44,7 @@ describe('ripGrep', () => { try { const script = join(dir, 'echo-argv0.cjs') // ripGrep appends target as the last arg; ignore it and print argv0 - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -60,7 +61,7 @@ describe('ripGrep', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-noargv0-')) try { const script = join(dir, 'echo-argv0.cjs') - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -74,13 +75,38 @@ describe('ripGrep', () => { } }) + it('leaves the ripgrep config file out of every run', async () => { + // RIPGREP_CONFIG_PATH routinely points inside a project, at a file a + // sandboxed command can write; one --max-filesize line in it is a scan + // that lists nothing and reports no error for it. + const dir = mkdtempSync(join(tmpdir(), 'rg-noconfig-')) + try { + const script = join(dir, 'echo-args.cjs') + writeFileSync( + script, + "process.stdout.write(process.argv.slice(2).join('\\0') + '\\0')", + ) + + const results = await ripGrep( + ['--files'], + dir, + new AbortController().signal, + { command: process.execPath, args: [script] }, + ) + + expect(results).toContain('--no-config') + } finally { + rmSync(dir, { recursive: true }) + } + }) + it('says what had been listed when it rejects', async () => { const dir = mkdtempSync(join(tmpdir(), 'rg-listed-')) try { const script = join(dir, 'lists-then-fails.cjs') writeFileSync( script, - "process.stdout.write('one\\ntwo\\nhalf a li'); process.stderr.write('went wrong'); process.exitCode = 2", + "process.stdout.write('one\\0two\\0half a pa'); process.stderr.write('went wrong'); process.exitCode = 2", ) const error: unknown = await ripGrep( @@ -103,4 +129,134 @@ describe('ripGrep', () => { ripGrep(['--invalid-flag-xyz'], '.', new AbortController().signal), ).rejects.toThrow(/ripgrep failed/) }) + + it.if(!isWindows)( + 'carries what a failed run listed and what it said, whatever the uid', + async () => { + // What the caller denies comes off these two, and the run that + // produces them for real needs a directory this process cannot read — + // which as root there is none of. A fake rg fails the same way + // everywhere; the arm below does it with the real one where it can. + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: [ + '-c', + 'printf "/found/a\\0"; ' + + 'printf "rg: /found/locked: Permission denied (os error 13)\\n" >&2; ' + + 'exit 2', + ], + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).listed).toEqual(['/found/a']) + expect((error as RipgrepError).stderr).toContain('/found/locked') + expect((error as RipgrepError).timedOut).toBe(false) + }, + ) + + it.if(!isWindows)( + 'drops a path a killed run was cut off in the middle of', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-timeout-')) + try { + const error = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + // A complete record, then a truncated one, then a run that outlives + // the timeout. exec so the kill reaches whatever holds stdout. The + // budget is far past what starting a shell and printing two records + // takes on any machine: cut fine, the output would be the empty + // prefix of a run that had not got to writing it yet. + args: ['-c', 'printf "/found/a\\0/trunc"; exec sleep 30'], + timeoutMs: 5_000, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).timedOut).toBe(true) + expect((error as RipgrepError).listed).toEqual(['/found/a']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + 30_000, + ) + + it.if(!isWindows)( + 'calls a kill that is not the timeout what it was', + async () => { + // Ctrl-C to the process group, an OOM kill: a run that ended inside + // the time it was given did not run out of it, and reporting one as + // the other tells the operator to look at the wrong thing. Both refuse + // the wrap upstream either way. + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'kill -TERM $$'], + timeoutMs: 60_000, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).timedOut).toBe(false) + // A caller deciding whether the listing can be used at all asks this, + // not `timedOut`: the run stopped where the kill arrived either way. + expect((error as RipgrepError).killed).toBe(true) + expect((error as RipgrepError).killedBy).toBe('SIGTERM') + expect((error as Error).message).toContain('SIGTERM') + expect((error as Error).message).toContain('inside the 60000 ms') + }, + ) + + it.if(!isWindows)( + 'says a run that exited on its own was not killed', + async () => { + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'printf "/found/a\\0"; exit 2'], + }).catch((e: unknown) => e) + + expect((error as RipgrepError).killed).toBe(false) + expect((error as RipgrepError).killedBy).toBeUndefined() + }, + ) + + it.if(!isWindows)( + 'keeps a path containing a newline in one piece', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-newline-')) + try { + const results = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'printf "/a/nl\\ndir/.git/HEAD\\0"'], + }) + + expect(results).toEqual(['/a/nl\ndir/.git/HEAD']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'hands back what rg listed before an unreadable directory failed the run', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-test-')) + mkdirSync(join(dir, 'locked')) + writeFileSync(join(dir, 'a.txt'), 'hello') + chmodSync(join(dir, 'locked'), 0o000) + try { + const error = await ripGrep( + ['--files'], + dir, + new AbortController().signal, + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).listed).toEqual([join(dir, 'a.txt')]) + // The caller denies what rg could not read, so the paths must survive. + expect((error as RipgrepError).stderr).toContain(join(dir, 'locked')) + expect((error as RipgrepError).timedOut).toBe(false) + } finally { + chmodSync(join(dir, 'locked'), 0o755) + rmSync(dir, { recursive: true }) + } + }, + ) })