Repository navigation
nvx-rs: hand the VMM the checked images of a sandbox launch - #752
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved path-resolution and claim-validation gaps can violate the checked-image handoff guarantee.
4 open issues
What changed in this PR
This PR adds checked-image handoff to NVX’s shared sandbox launch API and CLI, advancing the cold-launch integration in #622.
Changes:
- Adds metadata-only scratch bindings and exclusive sandbox claims.
- Introduces
PreparedLaunchto retain checked images and render platform-specific VMM arguments. - Integrates the handoff into CLI launches, with fake-VMM coverage and documentation.
| File | Description |
|---|---|
nvx-rs/tests/cli.rs |
Tests scratch bindings, claims, and checked-image launches. |
nvx-rs/tests/checked_handoff.rs |
Exercises public handoff APIs and inheritance isolation. |
nvx-rs/src/testing/fake_openvmm.rs |
Opens, retains, and reports sandbox block files. |
nvx-rs/src/host/sys/process.rs |
Shares descriptor restrictions across detached and foreground launches. |
nvx-rs/src/host/sys/mod.rs |
Exposes foreground inheritance restrictions internally. |
nvx-rs/src/host/seal.rs |
Makes file identity reusable by scratch bindings. |
nvx-rs/src/host/sandbox/scratch/tests.rs |
Tests metadata bindings and claim lifetimes. |
nvx-rs/src/host/sandbox/scratch.rs |
Implements scratch bindings and platform-specific claims. |
nvx-rs/src/host/sandbox/prepared/tests.rs |
Tests preparation, file matching, and metadata-only operation. |
nvx-rs/src/host/sandbox/prepared.rs |
Adds checked-file ownership and launch argument rendering. |
nvx-rs/src/host/sandbox/plan.rs |
Allows block arguments to use checked-file references. |
nvx-rs/src/host/sandbox/mod.rs |
Exports scratch and prepared-launch APIs. |
nvx-rs/src/host/sandbox/distro.rs |
Retains checked-layer path, format, and identity. |
nvx-rs/src/host/platform/other.rs |
Adds the fallback foreground inheritance hook. |
nvx-rs/src/host/platform/linux.rs |
Reuses allowlist validation for foreground launches. |
nvx-rs/src/host/mod.rs |
Documents bindings and checked handoff. |
nvx-rs/src/host/cli/sandbox/state.rs |
Persists and reads scratch bindings. |
nvx-rs/src/host/cli/sandbox/mod.rs |
Uses prepared launches for one-shot runs. |
nvx-rs/src/host/cli/sandbox/lifecycle.rs |
Checks both images and supervises prepared starts. |
nvx-rs/README.md |
Documents APIs, claim behavior, and compatibility changes. |
doc/run.md |
Explains checked-image launches and scratch ownership. |
Review effort: Balanced
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open issues
Review effort: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Security-sensitive image claims and process-inheritance lifetimes across Linux and Windows warrant final human review and CI confirmation on the final revision.
No open issues 🎉
2 resolved since last review
Review effort: Balanced
8d23ed4 to
68955c1
Compare
68955c1 to
0c603ba
Compare
f2cd00b to
fb4fdee
Compare
A launch checked its distro layer from metadata alone, then gave the VMM the layer's path, which something could replace between the check and the VMM's open of it, and it gave the VMM the scratch image's path unchecked. ScratchBinding records the identity, kind, and length of a scratch image without reading it. Its check compares an open of the image with the binding from metadata alone and claims the image for one sandbox at a time: on Linux with an exclusive flock of the checked descriptor, which the VMM inherits, and on Windows with a guard handle that denies deleting and renaming the image, after a probe that refuses an image that another process has open for writing. PreparedLaunch takes a SandboxPlan, the CheckedDistro of its image, and the CheckedScratch of its upper, refuses files that are not the plan's, and renders the plan's OpenVMM arguments and kernel command line. On Linux the arguments name the checked descriptors as /proc/self/fd/N, which the VMM inherits through the supervisor's allowlist. On Windows they name the paths, which the checked files hold in place until the caller drops the prepared launch, after authenticated Ready or after tearing down a failed launch. Staging, the checks, and the preparation read no content of the images. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
provision records the binding of the scratch image in config.json, under scratch_binding. start checks it after the distro binding and launches OpenVMM through a prepared launch, which it drops once the guest is ready, or once OpenVMM has ended after a failed start. A scratch image that changed needs a new provision, as a changed distro layer does, and one that another sandbox claims fails the start. run binds and claims its scratch image itself, and runs OpenVMM through a prepared launch; a dry run, which launches nothing, still prints the plan's paths. On Linux, the VMM of run and of start inherits only the checked descriptors beyond its standard streams: run's spawn now goes through the same allowlist as the supervisor's, in the caller's session. The fake OpenVMM opens the sandbox's block devices as OpenVMM does, holds them until it exits, and records what it held, so that the CLI tests check that OpenVMM gets the checked objects. The goldens change only where config.json records the scratch binding and where Linux launches name /proc/self/fd/N. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
Describe scratch bindings, prepared launches, and how the CLI's run, provision, and start use them, in the crate's README and in the run guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
A scratch check compared the image with its binding before it waited for another launch's claim, so a launch that resized the image meanwhile went unnoticed. The check now compares the image again once it holds the claim: on Linux after it takes the lock, and on Windows while its probe keeps other writers out. On Windows, a prepared launch named each image by the plan's path, which OpenVMM resolved again: through any link on the way, which nothing guards, and against its own directory for a relative path. It now names each image by the final path of its checked handle, an absolute path that goes through no link and that the checked files keep in place, since NTFS renames no directory while a file below it is open. On Linux, a check that opened a file on a standard stream's descriptor, as when the caller had closed that stream, left a file that no spawn can pass under its number. The prepared launch now moves such a file above the standard streams, with its open file description and so its claim. The handoff test's spawner stops when the test fails, its VMM fixture ends before it is let go, and each spawned process lists its own descriptors once its program runs: a listing taken when the spawn returned could still show descriptors that the exec was about to close. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
On Windows, a prepared launch names each checked image by the final path of its handle. A link on the way, the directory of a relative path, or the case of a name on disk can make that path differ from the plan's, which SandboxPlan::validated checked. OpenVMM splits the value of --microvm-sandbox-block at commas and semicolons, and chooses a container format by the extension of the path that it is given, so it would misread a final path with either delimiter, or one ending in .vhd, .vhdx, .vmgs, or .iso. Refuse such a final path as the plan's own paths are refused, with ErrorKind::Unsupported and a message that shows both paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
Two scratch tests failed now and then on Linux, where other tests of the same process spawn programs: - A check right after a claim's release could find the image still claimed: a process that another thread spawns holds a copy of every descriptor, and with it the flock of a claim, until it executes its program. Checks that should find the image free now wait CLAIM_RELEASE_WAIT, as launches do. - A directory created where the image had been removed could take the image's inode number, so the check reported a changed kind rather than a changed identity. The image now keeps its inode under another name. On Linux, the binding's file ID is the inode number, so a replacement of the same length that is created once the image is removed passes the check. Say so where the binding is described. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
On Windows, `run` started OpenVMM with std::process::Command, which lets the child inherit every inheritable handle of the CLI, such as a pipe that the CLI's own launcher left inheritable: OpenVMM could keep that pipe open after the CLI ended, and its reader then saw no end-of-file until the VM exited. A Linux run and every start already pass OpenVMM their allowlist alone. Start the one-shot OpenVMM through an explicit handle list, as starts do, holding copies of the CLI's standard handles, in the CLI's console and process group, so that Ctrl-C still reaches it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
seal::final_path asks GetFinalPathNameByHandleW again with a larger buffer for a path longer than its first one, and no test took that branch. Check a short and a long path against fs::canonicalize, and that neither holds the terminating NUL, which a successful call leaves out of the length that it returns. Say so in the comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
CreateProcessW runs a .bat or .cmd file through cmd.exe, which parses the command line by rules of its own: an argument that holds `&` or `|`, as an image's path may, starts another command, and the C runtime quoting of the spawns does not prevent that. std::process escapes the arguments of a batch file for cmd.exe, so the one-shot run lost that escaping when it moved to the handle list; start's detached spawn, and the library's launches through it, never had it. Both Windows spawns now refuse a batch file with InvalidInput, which they recognize as std::process does, by the last four characters of the program's full path name, so that a file named only .cmd, or a name with a trailing dot, is refused too. A wrapper that adds OpenVMM options must be an executable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
On Windows, the claim of a scratch image lived in the launcher's guard until OpenVMM opened the image itself. A launcher that ended in between, as when it crashed, released the claim, and another sandbox could then claim the image that the first VMM was about to write. Once its probe has found no other writer, the check now also opens the image for writing, sharing every access, as the claim's handle. The prepared launch passes that handle to the VMM through the supervisor's handle list, as Linux passes the claimed descriptor, so the VMM holds the claim from its start until it exits. The guard keeps its role, and is still released at authenticated Ready. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
On Windows, a launcher that ends after starting OpenVMM, and before OpenVMM has opened the images, as when it crashes, closes the checked files that hold their paths, so that a replacement at those paths would reach OpenVMM unchecked. Such a launch is never committed. Say so where the checked handoff is described, and link #780, which tracks holding the paths in that case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
The note of 29438d4 said that a launch whose launcher ends before OpenVMM has opened the images is never committed, which holds for start alone: run keeps no journal, and its OpenVMM runs until it exits. Say that start never commits such a launch, and that the sandbox's next transition ends its VMM, and name run beside start in doc/run.md, since it has the same window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c1991a51-971d-4e69-913e-5759545c5cd2
fb4fdee to
60d1839
Compare


A sandbox launch checked its distro layer from metadata alone, then gave OpenVMM the layer's path,
which something could replace between the check and OpenVMM's open of it. The scratch image went to
OpenVMM by path with no check at all, and nothing kept two sandboxes from writing the same scratch
image. With this change, a launch hands OpenVMM exactly the objects that its checks found.
What changed
Scratch binding (
nvx::host::sandbox::scratch):ScratchBindingrecords a scratch image'sidentity (device or volume, and file ID), kind, and length, in a versioned serde form without
times, and never reads the image.
ScratchBinding::checkopens the image, compares it with thebinding from metadata alone, claims it for one sandbox at a time, until that sandbox's VMM exits,
and compares it again, since the launch whose claim it waited for may have changed it. On Linux,
the file ID is the inode number, so a replacement of the same length that is created once the
image is removed can take it again and pass the check:
flockof the checked descriptor's open file description,which the VMM inherits and so keeps;
renaming the image, after a probe that refuses an image that another process, such as another
sandbox's VMM, has open for writing, and then a claim handle, open for writing and sharing
every access, which the VMM inherits, so that it holds the claim from its start, even if the
launcher ends before the VMM has opened the image, which would release the guard.
A check that finds the image claimed fails with
ErrorKind::TimedOutat the caller's deadline(
CLAIM_RELEASE_WAITfor the CLI).Prepared launch (
nvx::host::sandbox::PreparedLaunch): built from aSandboxPlan, theCheckedDistroof its image, and theCheckedScratchof its upper. It refuses files that are notthe plan's, renders the plan's OpenVMM arguments and kernel command line, and holds the checked
files.
/proc/self/fd/N, andPreparedLaunch::inheritedfeedsVmmCommand::inherit, so the VMM inherits exactly those descriptors through the supervisor'sallowlist. A checked descriptor that took a standard stream's number, as when the caller had
closed that stream, first moves above the standard streams, with its open file description and
so its claim.
The arguments name each checked image by the final path
of its handle (
GetFinalPathNameByHandleW, in\\?\form): absolute, through no link, andindependent of the VMM's working directory. The checked files hold that path in place (the
distro layer's file denies writing and deleting it, the scratch guard denies deleting and
renaming it, and NTFS renames no directory while a file below it is open) until the caller drops
the prepared launch, after authenticated Ready or after tearing down a failed launch. A final
path that OpenVMM could not take as the path of a raw image, with a comma or semicolon, or
ending in
.vhd,.vhdx,.vmgs, or.iso, which a link, the directory of a relative path,or the case of a name on disk can bring, fails the preparation with
ErrorKind::Unsupported,as such a path in the plan does.
/proc/self/fd/Nlacks.CLI:
provisionrecords the scratch image's binding inconfig.jsonunderscratch_binding; a configuration without one is refused, with no reader for older records.startchecks the distro and scratch bindings, launches OpenVMM throughsupervisor::launchwiththe prepared launch's arguments and descriptors, and drops the prepared launch once the guest is
ready, before the launch commits, or once a failed start's OpenVMM has ended and its launch was
abandoned.
runbinds and claims its scratch image and runs OpenVMM through a prepared launch, in thecaller's terminal and process group, passing it no descriptor or handle beyond its standard
streams and the launch's allowlist: on Linux through the same allowlist as
start, and on Windowsthrough an explicit handle list that holds copies of the CLI's standard handles and the scratch
image's claim alone, where
std::process::Commandhad passed OpenVMM every inheritable handle of the CLI.--dry-runstillprints the plan's paths.
Batch files on Windows: the spawn of
run, and the supervisor's spawns, whichsupervisor::launchand sostartuse, refuse a.bator.cmdfile as OpenVMM withErrorKind::InvalidInput. Windows runs one throughcmd.exe, which would read an&in animage's path as another command.
std::process::Command, whichrunused before, escapes a batchfile's arguments; the supervisor's detached spawn never did. A wrapper that adds OpenVMM options
must be an executable. The CLI reaches a batch file only through
NVX_OPENVMM, whichstartrefuses altogether.
Fake OpenVMM: opens the sandbox's block devices as OpenVMM does, holds them until it exits,
and records what it held, so that the CLI tests check that OpenVMM gets the checked objects.
Goldens:
tests/fixtures/cli.jsonchanges only whereconfig.jsonrecords the scratchbinding and where launches name their checked images:
/proc/self/fd/Non Linux, and\\?\final paths on Windows.
Staging, the checks, and the preparation of a launch read metadata only: no content reads, mappings,
or hashing.
Known limitation (#780): on Windows, a launcher, such as
startorrun, that ends afterstarting OpenVMM, and before OpenVMM has opened the images, as when it crashes, closes the checked
files that hold the images' final paths, so that a replacement at those paths would reach OpenVMM
unchecked.
startnever commits such a launch, and the sandbox's next transition ends its VMM;runkeeps no journal, and its OpenVMM runs until it exits. On Linux, OpenVMM opens thedescriptors that it inherited, so it uses the checked images whatever happens to the launcher.
Part of #622. Refs #620, #535.
Validation
Rebased onto
devat2971c638. The earlier rebase ontod4d00af8, which moved the CLI'slifecycle onto the sandbox store and
supervisor::launch(#772), had these conflicts:start_locked(nvx-rs/src/host/cli/sandbox/lifecycle.rs): the prepared launch's arguments,kernel command line, and descriptors go into the command that
supervisor::launchasks for, andthe prepared launch is dropped before the launch commits, once the guest is ready, or after
Starting::abandon, once a failed start's OpenVMM has ended. On an early return,Starting, whosedrop ends the VMM, drops before the prepared launch.
tests/fixtures/cli.json: each scenario merged token by token; the goldens change as before.tests/cli.rs: the claim test expects the store'ssandbox.lock, and the CLI test of astartthat refuses a batch file as OpenVMM is gone, since
startnow refuses anyNVX_OPENVMMbefore itspawns; the library's tests of the refusal remain.
nvx-rs/README.mdanddoc/run.md:dev's store beside the scratch binding.Onto
9b70ec97, the one conflict wasprocess::spawn, which #721 removed with the other helpersof the old instance, and to whose docs this PR had added that it fails for a batch file: the
sentence went with the function, and no other patch changed (
git range-diff).Onto
2971c638, which brought #753, whose plan holds the sandbox's network, and #773, the conflictswere with #753:
SandboxPlan::render_arguments, through which the prepared launch renders the plan's arguments,ends with the network's arguments, as
SandboxPlan::openvmm_argumentsdoes, so that a preparedlaunch passes OpenVMM the network that the plan holds.
start_locked: the network thatconfig.jsonrecords is read, and its policy checked again,after the distro layer's check and before the scratch image's check, which claims the image.
config.jsonrecordsscratch_bindingbesidenetwork, and the docs of the CLI's state, and theREADME, name both.
tests/fixtures/cli.json: the scenarios that nvx-rs: lower sandbox networks through one typed policy with a proxy-only mode #753 changed or added take this PR's changes, thescratch binding in every
config.jsonthatprovisionwrites and the checked files' names inlaunched commands,
/proc/self/fd/Non Linux and\\?\final paths on Windows, while dry runskeep the plan's paths. nvx-rs: lower sandbox networks through one typed policy with a proxy-only mode #753 replaced
run: OpenVMM refuses its optionswithrun: network options without --net, which launches nothing.After the rebase onto
d4d00af8, the final review found that, on Windows, a launcher that endedbetween OpenVMM's start and its open of the scratch image released the image's claim, which then
lived in the launcher's guard alone;
51b69fe2passes the VMM a claim handle, as Linux passes theclaimed descriptor. Copilot then noted that such an early end also releases the guards that hold
the images' final paths on Windows; that is left to #780, which the docs link (
33443f58,60d1839b).Every commit builds on Windows with
cargo check --locked --all-targets --all-features. Unlessnoted, the checks ran at
60d1839b, on Windows 11 (WHP) and on Linux in a virtual machine withMSHV.
python scripts/nvx.py verify.check-nvx-rs, on Windows and Linux, in
nvx-rswithNVX_BUNDLE=skip:cargo fmt --all --check,cargo clippy --locked --all-targets --all-features -- -D warnings,cargo clippy --locked --lib --no-default-features -- -D warnings,cargo test --locked --all-features,cargo test --locked,cargo doc --locked --no-deps --all-featureswithRUSTDOCFLAGS=-D warnings,cargo +1.89 check --locked --all-targets --all-features, and the artifact-bundling test; onLinux also the
aarch64-apple-darwincheck,nvx.py check-guest-binaries, andbuild-guest-binarieswithtest-guest-binaries.The
aci_edge_sandboxeschecks, on Windows and Linux: fmt, both Clippy configurations, both testconfigurations, docs, MSRV, and
nvx.py test-agent-bundling; the macOS check on Linux.tests/runtime-adapter: the protocol-only and host-only configurations on Windows and Linux, andthe guest-only (musl) configuration on Linux.
validate-nvx, on Windows and Linux, without its
pip install, whose PyYAML both hosts had:compileall, the unit-test suites,test_adversarial.py,test_hosts.py, and the--helpchecks.
New tests:
sandbox::scratch: binding without reads, serde round trip, refusals, claims while the VMMholds the image, changed identity, kind, and length failing without reads, a check that
waits for a claim while the holder resizes the image, which fails without the second
comparison on both Linux and Windows, and, on Windows, a claim that the claim handle keeps once
the guard is closed, while renames go through as after authenticated Ready.
sandbox::prepared: the handoff, refusals of files that are not the plan's, and staging,checking, and preparing that read no content, with explicit verification as the positive
control; on Windows, refusals of final paths that OpenVMM could not take: a semicolon behind a
junction to the distro layer, a comma behind a junction to the scratch image, and a scratch
image named
.vhdon disk that the plan's path names in capitals. Without the check, that testfails, the launch naming
distro:file:\\?\…\real;layers\distro.erofs,ro.tests/checked_handoff.rs, the public API end to end with a stand-in VMM: replacing the imageand, separately, the scratch between the check and the VMM's open; on Linux, the exact
descriptor set that the VMM gets while another thread spawns processes with inheritable pipes,
and an orchestrator that closed its standard input, whose scratch check got descriptor 0; on
Windows, the handle list, which holds the scratch image's claim alone, the guards against
writing, renaming, and deleting while the VMM reads and writes, released after the prepared
launch is dropped, and a VMM started in another directory after the junction that the plan's
relative paths went through was re-pointed to decoys. On both, a launcher whose checked files
close before its VMM has opened the images, as when it crashes, leaves the scratch image
claimed by the VMM until the VMM exits; on Windows, without the claim handle, another sandbox's
check claims the image instead.
binding, a running sandbox's claim, OpenVMM receiving the checked images, and, on Windows, a run
whose OpenVMM does not get a handle that the CLI inherited from its caller, which fails without
the handle list, and a run that refuses a batch file as OpenVMM, which fails without the
refusal,
cmd.exerunning the file.platform::windows: a foreground spawn passes its child the allowed handles and no otherinheritable handle of the caller, and both spawns refuse batch files, including one named only
.cmd, which an extension check misses, and one named with a trailing dot.seal::windows: final paths shorter and longer than the first buffer matchfs::canonicalizeand hold no NUL.Repeated runs, before the rebases: 40 consecutive runs of the Linux library tests
(
--all-features) pass at08e6fcfa. Before it, 7 of 30 failed: 5 in a scratch test whosedirectory could take the removed image's inode number, and 2 in
guest::sys::signal::tests::a_thread_that_blocks_sigpipe_keeps_only_a_signal_that_was_pending,which this PR does not touch, where a spawn's copy of the pipe's read end can let the write
succeed. A gate run also failed a check made right after a claim's release, which a process that
another test spawned held until it executed its program.
Real VMs, with the OpenVMM built from the pinned
7ff31ed7and the guest artifacts ofdev'sdevelopment release
v0.1.0-dev.39510661ffe6, whichnvx.py downloadinstalled on both:python scripts/nvx.py test-microvm --backend <whp|mshv> --scenario managed-exec-config --scenario sandbox-lifecyclepass on both. On WHP, the one-shot run nameddistro:file:\\?\D:\…\build\ubuntu-distro.erofs,roandscratch:file:\\?\D:\…\run-scratch.ext4; on MSHV,distro:file:/proc/self/fd/3,roandscratch:file:/proc/self/fd/4.cargo test --locked --features host -- --ignoredin
tests/runtime-adapterwith its musl guest agent built on Linux, pass on both: the 4layered scenarios of tests: launch the runtime adapter's VMs with a layered root #773 and the 2 restart scenarios.
python scripts/nvx.py test-aci-edge-sandboxes --backend whppasses, 18 tests.The MSHV host's CPU needs
--cpu-profile host, andstartruns the root's own OpenVMM alone, sothere the root's OpenVMM was a wrapper that adds the option and keeps
argv[0]. On KVM, the sametest-microvmscenarios passed at7c392ab4, before the rebases.Left to CI: KVM after the rebases,
test-aci-edge-sandboxeson MSHV and KVM, and the self-hostedKVM, MSHV, and WHP runners.