Skip to content

linux: one mount per destination, so bubblewrap 0.4 starts - #580

Merged
ronleizrowice-ant merged 9 commits into
mainfrom
fix/linux-one-mount-per-destination
Oct 2, 2026
Merged

ronleizrowice-ant merged 9 commits into
mainfrom
fix/linux-one-mount-per-destination

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Spellings of one file (a trailing slash, a symlinked directory, a denyRead entry that is also a credential mask) each put a file mask at the same destination. Before 0.5.0 bubblewrap takes only a regular file as a file bind's mount point, and the first mask leaves /dev/null there, so 0.4.0 and 0.4.1 (RHEL 8, 9) refused to start: "Can't create file at : Permission denied". Each destination now gets one mask: the credential mask where a read deny names the same file, and the last of two credential masks.

Who sees a difference: a config naming one file twice now starts on 0.4.x. checkDependencies() runs bwrap --version once per process (5 s timeout; a failed probe is not cached) and warns, without refusing, below 0.5.0.

Limits before 0.5.0 (README and warning say so): a mask on a fifo, socket or device node cannot be applied; a mount point left by an interrupted sandbox stays on the host.

Merge order: conflicts with #589 and #592 in integration-tests.yml (steps inserted at one spot; keep all), and with #594 (README insertion point).

Testing: linux-one-mount-per-destination.test.ts checks the argument lists of 15 configurations and starts the bubblewrap on PATH on each; CI's 0.4.1 leg runs it. Locally, on 0.4.1 built from the pinned commit, all 15 start; with the fix reverted 8 are refused. On 0.11.2 that arm passes either way, so only the 0.4.1 leg guards this.

One file can be named more than once: in filesystem.denyRead under two
spellings, in filesystem.denyRead and again in credentials.files, or twice
in credentials.files. Every spelling resolves to one place, and the read-deny
loop skipped an entry only where a denyRead tmpfs already hid it, which a
file mask is not — so each spelling emitted its own file mount at that one
destination, and the credential masks added theirs on top.

bubblewrap before 0.5.0 refuses to start on the second of them: its
ensure_file() returns early only for a regular file and creates a file over
anything else, so the mount lands on the character device the first
/dev/null mask left and creat() hits the read-only mount that mask just
made ("Can't create file at <dest>: Permission denied").

The credential masks are now collected first, keyed by where they land, so
two spellings of one file keep one fake: the last, which is the one
bubblewrap left in force. The read-deny loop skips a file whose destination
a mask already covers or that an earlier entry already masked. The mount
that used to win is the mount still emitted.
…ound

Two things need 0.5.0, both of them changes to how bubblewrap prepares the
mount point for a file bind. Before it, ensure_file() takes only a regular
file for one and creates a file over anything else, so a denyRead entry or
credential mask naming a fifo, socket or device node blocks or fails instead
of binding over it; and it creates that file mode 0666 rather than 0444, so
the recovery that keys off a read-only leftover does not recognise a mount
point an interrupted sandbox left behind.

checkDependencies() probes bwrap --version once per binary and reports both,
naming the version found. A warning, not a refusal: every mount plan this
library builds starts on 0.4.0 and later.
…tions

A file mask is a --ro-bind of something other than the destination itself.
Two of them at one destination with nothing between that mounts at or above
it is a mount whose only effect is to replace the one before it, and the
shape bubblewrap before 0.5.0 refuses to start on. The corpus spells one
file through the routes a caller and the credentials block reach it by; a
mask re-applied above a write deny that re-exposed it has that deny in
between and is not one of these.

Also covers the version warning: a stub bwrap that reports 0.4.1 draws one
naming that version, a newer one draws none, and one that will not answer
--version draws none either.
RHEL 8 and 9 ship 0.4.0 and 0.4.1, the oldest bubblewrap in current
distribution use, and one of those refuses to start on a plan that mounts
twice at one destination. Built with autotools, which that release predates
meson for, and pinned by commit like the 0.12.0 job.

stale-mount-point is the one mount-plan suite left out: it asserts the
read-only mode bubblewrap gives a mount point it creates, which it does only
from 0.5.0 on.
Every mount plan starts on 0.4.0 and later; a read deny or credential mask
on a path that is not a regular file, and the recovery of a mount point an
interrupted sandbox left behind, need 0.5.0.
bwrap applies mounts in order, so the second of two masks at one destination
was the one in force before they were collapsed into one. Nothing else says
that is the one kept.
Array.prototype.findLastIndex is ES2023 and the tests compile against
the ES2020 library, so the last matching bind is found with map and
lastIndexOf instead. One filter callback gets its parameter typed.
No test changes what it checks.
Comments only: the JavaScript emitted with comments removed is
byte-identical for every file, and the test names are unchanged.

Kept: every invariant and what could be done without it, the
documentation of exported names, and the reasons for what is not done
the obvious way. Cut: how the code came to be as it is, narration of
the lines that follow, and explanations repeated at several sites.
…ersion again after a failed probe

The test of one mount per destination only read argument lists, so the
CI job on bubblewrap 0.4.1 passed with the fix reverted. It now also
starts bubblewrap on every configuration of the corpus: on 0.4.x eight
of them are refused without the fix.

A `bwrap --version` probe that failed or timed out was remembered for
the life of the process and silenced the warning for good. Only an
answer is kept now.

README: what 0.4.x supports, said without contradicting itself, and
that the dependency check runs `bwrap --version`.
@ronleizrowice-ant
ronleizrowice-ant merged commit 60e5e9f into main Oct 2, 2026
8 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the fix/linux-one-mount-per-destination branch October 2, 2026 16:49
ronleizrowice-ant added a commit that referenced this pull request Oct 2, 2026
…ady holds

main's squash is the head merged here before, so nothing changes: the four
files that conflict are the bundle's own, with their joins.
@ronleizrowice-ant ronleizrowice-ant mentioned this pull request Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants