feat(ffi): expose fs_mount_ro, and reject :ro/:rw in the Python profile parser - #180
Merged
Merged
Conversation
`fs_mount_ro` was the only mount-related builder field with no C ABI setter, so no binding could express a read-only mount at all — the gap behind multikernel#174. The export mirrors `sandlock_sandbox_builder_fs_mount`: same arity, same move semantics, same null handling. It differs in one place, deliberately. Where the existing setter degrades invalid UTF-8 to "" via `to_str().unwrap_or("")`, this one rejects empty and non-UTF-8 paths outright. An empty virtual path is a prefix of every guest path, so `ChrootCtx::is_mounted` would match the whole tree and `can_read` would return true everywhere, voiding the read allowlist from a single bad argument. Core's `parse_mount_spec` already enforces the same non-empty rule, so the C ABI was the only unchecked door. The existing `fs_mount` twin is left alone: tightening shipped surface is a separate decision. Tests drive the real contract rather than a non-null return: reads through a read-only mount return the host file's content, writes fail with EACCES and leave the host bytes untouched, and a control `fs_mount` write in the same run succeeds. The read-only path is granted an explicit `fs_write` so the ordering — read-only wins over a broader writable rule — is pinned rather than assumed. Verified negatively: pointing the export at `fs_mount` fails three of seven tests, including the end-to-end one.
The core strips the suffix before splitting (`profile.rs:319-324`); the SDK's own parser splits on the first colon instead, so `"/work:/host:ro"` produced the host path `/host:ro`. When no such directory exists the mount silently fails and the guest cannot even read; when one does exist, the SDK mounts *that* directory read-write while the CLI mounts the intended one read-only. A profile asking to tighten access could quietly widen it. Recognising the suffix and dropping it would be worse than the status quo, so both forms are refused — with different messages, because the reasons differ. `:ro` cannot be honoured at all: `Sandbox.fs_mount` is a plain virtual-to-host mapping with no read-only channel. `:rw` is merely outside this parser's grammar and means what the mapping already does; claiming it would mount read-write when refused would be false. Both messages name the offending spec and point at the CLI, which does honour the suffix — and which `sandlock inspect --toml` emits, so profiles containing `:ro` reach the SDK through a normal round trip. Tests cover both suffixes, the message contents, and two controls: an interior colon (`/v:/a:b`) and the near miss `/v2:/host:root` still parse as before, guarding against an over-broad any-colon check. Verified negatively: disabling either guard turns the matching tests red.
…romising
Review of the previous commit raised three things.
The empty/non-UTF-8 hole was only closed on the new setter, leaving its
twin open: `sandlock_sandbox_builder_fs_mount` still degraded both
arguments with `to_str().unwrap_or("")`, and Python and Go both reach the
sandbox through that symbol. The predicate now lives in one private
`mount_pair()` used by both, so there is no third copy of the rule to drift.
This does tighten shipped behaviour — a caller passing an empty path used
to get a tree-wide mount and now gets none — which is a deliberate choice
worth flagging rather than burying.
The doc comment claimed writes under the mount are denied, full stop. That
is not true per inode: a guest holding a writable mount on the same host
filesystem can hard-link a file out of the read-only mount and write
through the alias, because the `linkat` handler gates only the new path.
The wording now describes a mount-shaped access rule rather than an
immutability guarantee. The underlying handler is untouched — it is
pre-existing core behaviour that predates this export and needs its own
decision about the right predicate.
Both rejection messages pointed at `sandlock --profile-file`, which is not
a valid invocation: those flags live on the `run` subcommand. Corrected in
the messages and in the reference table. The new test extracts every quoted
`sandlock …` command from the message and requires the `run` subcommand, so
a future message cannot regress to an unrunnable suggestion.
congwang-mk
added a commit
that referenced
this pull request
Jul 31, 2026
The max_open_files docs quoted 17 as the measured descriptor floor under chroot, while the PR discussion for #181 reported 13; neither matched a fresh measurement here, where 11 fails with EIO and 12 runs. Since the figure clearly varies by host, the rustdoc, the build() error message and the reference table now say about 12 and state the variance explicitly, keeping the existing not-a-guarantee hedge. The plain-exec floor of 4 re-measured exactly and is unchanged. The same two merges introduced em-dashes into comments, docstrings and user-facing messages; project prose avoids them, so they are rewritten with colons, commas or parentheses. Only lines added by #180 and #181 are touched, and sandlock.h is regenerated with the pinned cbindgen 0.29.2 (one line changed, matching the lib.rs doc edit). Signed-off-by: Cong Wang <cwang@multikernel.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item (a) of #174, in the sequence you laid out: the
fs_mount_roABI export plus a loud SDK rejection of:ro/:rw. Item (b) —sandlock_profile_parseand deleting the Python parser — is not here.sandlock_sandbox_builder_fs_mount_roMirrors
sandlock_sandbox_builder_fs_mount: same arity, same move semantics, same null handling.fs_mount_rowas the only mount-related builder field without a C ABI setter, so no binding could express a read-only mount at all.Tests drive the real contract rather than a non-null return: a read through the mount returns the host file's content, a write fails with
EACCESand leaves the host bytes untouched, and a controlfs_mountwrite in the same run succeeds and lands on the host. The read-only path is granted an explicitfs_writeso that read-only winning over a broader writable rule is pinned rather than assumed.Verified negatively: pointing the export at
fs_mountturns three of the eight tests red, including the end-to-end one.Python:
:ro/:rware refusedThe core strips the suffix before splitting; the SDK's parser split on the first colon, so
"/work:/host:ro"produced the host path/host:ro. Where no such directory exists the mount silently fails; where one does exist, the SDK mounts that directory read-write while the CLI mounts the intended one read-only. A profile asking to tighten access could quietly widen it.Both forms are refused, with different messages, because the reasons differ.
:rocannot be honoured at all —Sandbox.fs_mountis a plain virtual-to-host mapping with no read-only channel.:rwis merely outside this parser's grammar and means what the mapping already does; a message claiming it would otherwise mount read-write would be false.Recognising the suffix and dropping it would be worse than the status quo, as you noted; the code says so at the guard.
Two things worth your explicit attention
1. I tightened
fs_mountas well, which is shipped behaviour.Review caught that the empty / non-UTF-8 guard on the new setter left its twin open:
sandlock_sandbox_builder_fs_mountstill degraded both arguments withto_str().unwrap_or(""). An empty virtual path is a prefix of every guest path, soChrootCtx::is_mountedmatches the whole tree and short-circuitscan_readandcan_writeto true — one bad argument voids the allowlists. Python and Go both reach the sandbox through that symbol, so leaving it open would have left the hole reachable from every binding.The predicate now lives in one private
mount_pair()used by both setters. This does change behaviour for a caller passing an empty path: previously a tree-wide mount, now none. Say the word and I will split it out or drop it.2. The doc comment no longer promises immutability, because of #179.
While reviewing this I found that a guest holding any writable mount on the same host filesystem can hard-link a file out of a read-only mount and write through the alias — the
linkathandler gates only the new path. The same trick defeatsfs_deny. Filed as #179 with a reproduction; it is pre-existing core behaviour that predates this export, and picking the right predicate looked like your call rather than a mechanical mirror ofrenameat2, so there is no core change here.What this PR does carry is honest wording: the doc describes a mount-shaped access rule, not an immutability guarantee for the host files behind it.
Not included, deliberately
No
sandlock_profile_parse, no Python parser removal, no Python or Go wiring of the new symbol, no version bump. Declaring the symbol in the SDK's eager_builder_fntable would break SDK import against an olderlibsandlock_ffi.so, so that belongs with (b), where the profile path changes anyway.The header is regenerated with the pinned cbindgen from
cbindgen.tomland is byte-identical to a fresh run.