Skip to content

feat(ffi): expose fs_mount_ro, and reject :ro/:rw in the Python profile parser - #180

Merged
congwang-mk merged 3 commits into
multikernel:mainfrom
dzerik:feat/fs-mount-ro-abi
Jul 30, 2026
Merged

feat(ffi): expose fs_mount_ro, and reject :ro/:rw in the Python profile parser#180
congwang-mk merged 3 commits into
multikernel:mainfrom
dzerik:feat/fs-mount-ro-abi

Conversation

@dzerik

@dzerik dzerik commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Item (a) of #174, in the sequence you laid out: the fs_mount_ro ABI export plus a loud SDK rejection of :ro / :rw. Item (b) — sandlock_profile_parse and deleting the Python parser — is not here.

sandlock_sandbox_builder_fs_mount_ro

Mirrors sandlock_sandbox_builder_fs_mount: same arity, same move semantics, same null handling. fs_mount_ro was 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 EACCES and leaves the host bytes untouched, and a control fs_mount write in the same run succeeds and lands on the host. The read-only path is granted an explicit fs_write so that read-only winning over a broader writable rule is pinned rather than assumed.

Verified negatively: pointing the export at fs_mount turns three of the eight tests red, including the end-to-end one.

Python: :ro / :rw are refused

The 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. :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; 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_mount as 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_mount still degraded both arguments with to_str().unwrap_or(""). An empty virtual path is a prefix of every guest path, so ChrootCtx::is_mounted matches the whole tree and short-circuits can_read and can_write to 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 linkat handler gates only the new path. The same trick defeats fs_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 of renameat2, 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_fn table would break SDK import against an older libsandlock_ffi.so, so that belongs with (b), where the profile path changes anyway.

The header is regenerated with the pinned cbindgen from cbindgen.toml and is byte-identical to a fresh run.

dzerik added 3 commits July 30, 2026 11:50
`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
congwang-mk merged commit d3867d7 into multikernel:main Jul 30, 2026
13 checks passed
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>
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