Skip to content

nvx-rs: export one directory to a template's clone, and map it before its start - #955

Merged
Pedro Henrique Penna (ppenna) merged 5 commits into
devfrom
esaurez/846-pr1-export-maps-20261025
Oct 29, 2026
Merged

Pedro Henrique Penna (ppenna) merged 5 commits into
devfrom
esaurez/846-pr1-export-maps-20261025

Conversation

@esaurez

@esaurez Enrique Saurez (esaurez) commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Chain: #951 → #954 → #957 → #959 → #960 → this PR → #958, on dev; #879, #881, #885, #886, #877, #932, #884, #887, #933, #880, #936, #937, #939, #940, #941, #888, #949, and #950 have merged.

Stacked on #960. Part of #846 (PR 1 of 2: one directory export, and MAPS), and of #845, whose export scenarios it holds. Since the final restack, it also carries the commits and the VM scenarios of #956, which folded into it and closed.

Commits

  1. nvx-rs: export one directory to a clone and map it before its start: the export, its record, and the mapping table after Ready, below. It also holds what was tests: start clones with exports in VMs #956's first commit, which lets the test adapter's restore sequence send a clone's mapping table: clone_order() builds CloneOrder { build }, a clone's request names no export yet, and the harness's Early framing gains a map() that it never reaches. runtime-adapter: start clones of templates #954 builds NVX's CloneRequest, the adapter's CloneOrder with a table, and a CloneControl without map(), so without those changes the test adapter's host configuration does not compile at this commit.
  2. nvx-rs: let an empty mapping source name the export's root.
  3. runtime-adapter: let a clone's start share host paths with its root: see Starting a clone with shares.
  4. runtime-adapter: start clones with exports, in VMs: the VM scenarios below.
  5. nvx-rs: resolve the template store's path in a clone's export check: the checks of the store's path and of the sandbox's directory, below.

A directory export maps its root with an empty source

A clone's export is one host directory, which OpenVMM binds to the template's dormant filesystem slot at the restore. The guest's mount of the export is that directory itself, not an aggregate whose root lists numbered children, so the directory's own bind has no child index to name. The clone's mapping table therefore maps the directory itself with an empty source, and the paths inside it with sources relative to it, such as work rather than 0/work.

The MAPS protocol and the guest now accept an empty source as "the export's root":

  • nvx::protocol::maps accepts it in the encoder and in both decoders.
  • nvx::guest::mappings::Mapping::new takes it for the source root itself. The mapper opens it as . beneath the root, with the same openat2 resolution as every other source (RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, RESOLVE_NO_MAGICLINKS, and RESOLVE_NO_XDEV), so the mapping is a copy of the export's own mount, without the file systems mounted below it.

What stays refused:

  • An absolute source, / included; an empty, ., or .. component inside a non-empty source; a NUL byte; and a path of more than 4,095 bytes.
  • Every target that was refused before. An empty source still needs an absolute target below the guest's root, never / itself.
  • NVX's managed agent still refuses an empty source, with one check before it mounts anything. Its export is always an aggregate whose root lists its children, and it never maps that root, so NVX's own sandboxes accept exactly what they accepted before.

Why: the template's slot binds a single directory, and an empty source is the only spelling of its root that keeps every other source rule intact. The host and the guest deploy together, and a template's key names its guest's build, so no feature bit or protocol version announces the change. Version 1's conformance vector of an empty source therefore moves, with the same bytes, from INVALID_MAPS|EMPTY_SOURCE to MAPS|ROOT_SOURCE. The vectors' README and design section 4.4.4 record it as the one exception to "a vector changes only with a protocol version": only the clone of a template whose export is a directory sends an empty source, to the template's own guest, so the C agent of earlier releases, which refuses it, never receives one.

Exporting one directory to a clone

  • CloneRequest takes zero or one ExportPlan that filesystem::plan made for the container root. The plan must export exactly one child, a directory, which becomes the export's root.
  • ClonePlan::new refuses, before any VMM starts: a plan that check() rejects, such as mappings without an export or an export without mappings; several children; a file; a reserved target; a directory inside the template store, or one that holds it; one that lets the guest write in a directory through which the store's path leads, links followed and .. included, where the guest could turn the host's later operations on the store elsewhere, or keep the host from the store; and one that is, or holds, the directory of the clone's sandbox, in which the host writes the clone's records. The store check compares the paths that the plan resolved with the store's directory, which it resolves with filesystem::canonicalize, so that no other spelling of its path hides an overlap. It resolves the store's path only for a clone with an export, so a clone without one reads nothing more. These checks compare paths, so whoever names an export must not name an alias of the store that an administrator configures, such as a bind mount, which no check of paths reveals, nor a directory that holds one, nor one inside a directory that a writable export of another sandbox or clone holds, since OpenVMM resolves an export's path again at each launch and restore.
  • The activation record names the export by its plan's identity, a SHA-256 digest of a domain and of the plan's metadata, as it names the image bindings, and announces its mappings, from 1 to 4,096. A clone without an export has neither, and the slot stays dormant.
  • ClonePlan::launch checks the plan with ExportPlan::verify before each restore, as a cold start does, and binds the directory with --mount, with its hidden, re-exposed, and writable paths and its owner.
  • ClonePlan::table returns the binds as the mapping table: each source is a path in the directory, without a child index, or empty for the directory itself.

Mapping before the start

  • CloneControl gains map(session, table, deadline). Activated::start sends the table once the clone is Ready, in order, and records and sends Start only once the guest applied every mapping, both by the Ready-to-Start deadline. Neither is retried, and no Start follows a failed table.
  • A partial or failed table ends the launch in the one place where a failed Start ends it: the failure is recorded as pending, the launch ends through Starting::abandon, and the record is written again with its teardown. It is recorded at the start's stage with the clone's progress still at Ready, while a failed Start has progress Starting, so the record tells the two apart without a new stage or progress step, and its format is unchanged.
  • A reattach never sends a table: a live clone applied it before its launch committed, and a reattach ends an interrupted launch. Activated::fail no longer gives the mapping of a host directory as an example of a caller's own step, since the sequence maps a clone's export itself.

How a clone's export fails

Every failure is a CloneFailure whose evidence follows #844's attribution:

Failure Evidence Outcome
A plan that check() rejects, several children, a file, or an export that overlaps the template store, lets the guest write in a directory through which the store's path leads, or is or holds the sandbox's directory, refused by ClonePlan::new InvalidMappings Request
A template store whose path cannot be resolved, for a clone with an export HostFailure, or ResourceExhaustion when the error proves it Host
An object that changed since the plan, or whose path no longer reaches it, or another refusal of ExportPlan::verify, before the launch InvalidMappings Request
A failure of ExportPlan::verify to inspect an object that shows nothing of it, such as an I/O error HostFailure, or ResourceExhaustion when the error proves it Host
A table that the guest refused, once the framing tells it through the new provided method CloneControl::refused InvalidMappings Request
Any other failure of the table: the Ready-to-Start deadline passed, the session was lost, or the protocol broke Timeout, Reset, or Protocol Unknown
The guest ended the VM with the export's status, 56, before the start InvalidMappings, with status 56 Request

Refusals before the launch come before the progress log, so the caller holds them and the sandbox records nothing. To tell a changed object from the host's own failure, ExportPlan::verify now keeps the cause of a failure to inspect an object, with ErrorKind::Io, unless the failure shows that the object's path no longer reaches it (not found, not a directory, or permission denied, as for the supplied images), which it still reports as a changed object. A cold start that verifies an export reports such an I/O error the same way; filesystem::plan is unchanged. CloneControl::refused returns false by default: a framing whose answers cannot tell a refusal of the table from a failure of the guest's own leaves it unknown rather than blaming the request. A guest status still counts after a failed table, since the start was not sent and no workload ran. No failure of the export is the template's, so none quarantines a generation.

The test adapter's CloneOrder takes only the guest agent's build. host::map_table, the table half of host::start_sandbox, implements map() and stops at the first part that the guest fails; start() sends Start alone; cold boots are unchanged. It keeps the default refused(), because the guest's MapFailed does not tell whether the request's mappings or a failure of the guest's own failed the table, so the sequence takes it for a broken protocol.

Starting a clone with shares

runtime-adapter-host start takes a launch's shares, --share HOST:TARGET[:ro], --deny, --reserved, and --share-root, with the same rules: --deny, --reserved, and --share-root need a --share. clone::Request carries them, as a launch's sharing::HostFsSharing, and Request::export plans their export as a launch plans it, with filesystem::plan for the container root, so a start refuses shares that it cannot export, with status 1, before it reads the template store, needs NVX's artifacts, or creates the sandbox's store. Request::clone_request takes the planned export into NVX's CloneRequest, and mapped= prints the number of mappings that the clone's activation record announces.

Tests

  • Clone planning and launch, with a fake VMM: the record, table, and arguments of a clone with and without an export; refusals of two directories, a file, mappings without an export, an export without mappings, a reserved target, and an export that overlaps the store, with their evidence; a mapped object or an exported directory that changed or went missing since the plan, refused before the launch; and an export that cannot be inspected, the host's failure. ExportPlan::verify's own tests tell a path that no longer reaches its object from a failure to inspect it.
  • The store's path: the test of an export that overlaps the store names the store in another spelling of its directory, through .. on Unix and with the verbatim prefix on Windows; another names it through two links, symbolic links on Unix and junctions on Windows, and refuses a writable export of the directory of either link, but not a read-only one, nor one whose writes the plan limits to a directory beside the link; a third names it through a directory and its .., whose writable export Unix refuses and Windows accepts; and a fourth refuses an export of the sandbox's directory, and of the directory that holds it, but not of a directory inside it.
  • Restore, with a fake control: the table after Ready and before Start; a partial, refused, failed, lost, or silent table, and a refusal past the deadline, each of which ends the launch with no Start and no retry, and is recorded at the start's stage, at Ready, with its evidence and a verified teardown; and a table that failed as the guest ended its VM with status 56, which prevails. The fake control carries each table through the MAPS payloads, so the root bind's empty source crosses the real encoder and decoder.
  • Protocol: an empty source round-trips through the encoder and both decoders, matches MAPS|ROOT_SOURCE, and still needs a valid target; / stays refused.
  • Guest mapper: the export's root mapped beneath it, with a fake kernel and, as root, with the kernel itself: a read-only copy of the export's own mount without its submounts, with symbolic links (ELOOP) and mount crossings (EXDEV) still refused.
  • Managed agent: a table that holds an empty source is refused, in direct and sandbox mode, before anything is mounted.
  • Test adapter: the order stops at a part that the guest fails, takes that failure for no refusal of the request's, and sends a table with the root bind over the fake broker. clone_start: the start's new usage errors (--deny, --reserved, and --share-root without a share, and malformed shares); shares that it cannot export, refused with status 1 before the template store; and the export that NVX's request carries: none without shares, and with them one directory, the root of a read-only share that holds a read-write one, mapped from an empty source and from its path.
  • VM scenarios (vm_clone), on KVM, MSHV, and WHP:
    • A clone over a scratch image that shares one directory: the directory itself, read-only, through the root bind's empty source, a read-write directory inside it, at its target there, a read-only file, and a read-write directory, with a file hidden. It starts with four mappings, its record names the export, a directory, by the identity of its plan, with four mappings, and on Linux its VMM has one --mount, of the directory, read-write. The guest mounted the export once, read-write, nosuid, and nodev, and the mappings below the root in the table's order, each with its access, showing what the host holds but for the hidden file. An execution finds the same, and the writable mappings alone take its writes, which the host's directory holds before and after the stop.
    • Four clones of one generation at once: one with a read-write export and a generated machine ID, two over the same read-only export, and one without an export, which has no export in its record, no mapping, and, on Linux, no --mount, so its slot stays dormant. Each sees only its own directory, and the records of one export name it by the same identity. An export is mounted read-write even where its mapping is read-only, the mount table lists the bind of the generated machine ID beside the mapping, and the helper of the entries probe, which writes the root's machine ID, finds that file read-only.
    • Starts that share two directories, or a file, are refused before any VMM starts, with status 8 and the record of a failure of the request, at the plan, by the evidence of invalid mappings.
    • A clone whose second mapping puts a directory onto a file of the image ends at its Ready, before its sandbox starts, with status 8, the guest's report that the table failed after its first mapping, and the record of a failure of the start, by the evidence of the protocol: nothing quarantined, the lease released, and the host's directory unchanged.
    • The scenarios' exports let anyone create entries in each of their directories, so that only a mapping's access refuses what an execution writes there: a Linux host shows the guest the owner and the mode of each host object, which the guest's kernel checks, and an execution as user ID 0 holds no capability that overrides the check.

What a VM cannot reach stays with the tests above: a mapped object that changed since the plan, a refused, lost, or silent table, and the export's status 56, which no VM provokes without fault injection.

Validation

At this PR's head 551ebda, the commits of the earlier head 08d111c, rebased onto #960's head efe51b4, include one fix to the first commit, from a review thread. ExportPlan::verify now reports a path whose links loop, such as a link that names itself in place of a pinned object, as a changed object, with the host's error as its cause, rather than as a failure of the host. Without that fix, this PR's tree, 02237d22, is the merge of 08d111c into dev at fbeb563, which holds #951, #954 and #957. CI at #958's head e19ceed, which holds this PR unchanged, in run 40901423035: 36 jobs passed, among them the microVM tests and the platform checks on KVM, MSHV and WHP, and OpenVMM's unit and VM tests; the six that run only for a pull request or on dev were skipped. CI at #958's head before the fix, 2a777a3, in run 40896659416, passed the same way, so it covers everything but the fix. A pre-check of 02237d22 passed on Linux and on Windows: the library's documentation with -D warnings; the test adapter's documentation in its protocol-only and host configurations, and in its musl guest configuration on Linux; validate-nvx; and the Markdown links. For the fix, on Windows: fmt; Clippy with -D warnings of all targets with all features, of the library with the default features and without them, and of all targets with all features for x86_64-unknown-linux-musl; the tests of host::filesystem and tests/host_modules.rs; the documentation with -D warnings; and the MSRV check. On Linux x86_64: Clippy of all targets with all features; the tests of host::filesystem, among them one that puts a link that names itself in place of a pinned path; and the aarch64-apple-darwin check. The rest of this section describes the validation at 08d111c, on #960's earlier head 2674fd2.

At the final restack, onto #960's head with OpenVMM 832d5bb4, each commit on its own:

  • Windows: nvx-rs's fmt; Clippy with -D warnings of all targets with all features, of the library with the default features and without them, and of all targets with all features for x86_64-unknown-linux-musl; tests/host_modules.rs; and the library tests of host::template, host::filesystem, host::network, and protocol. The test adapter's fmt; Clippy of its protocol-only, host, and x86_64-unknown-linux-musl guest configurations; and its protocol-only, host library, host_session, and clone_start tests. The check of the README's module table in scripts/test_nvx_tools.py.
  • Linux: the macOS check; the guest::mappings and guest::managed_agent tests, as the user and as root with the root-only tests; and the test adapter's guest library and guest_session tests for x86_64-unknown-linux-musl.
  • At this head, also: the docs with -D warnings, MSRV 1.89 of nvx-rs and of the test adapter's three configurations, and the Markdown link check.

At #958's head, which holds this pull request unchanged:

  • The CI workflow: every job passed, the VM tests on KVM, MSHV, and WHP included, where the test adapter's vm_clone passed 25 of 25, this pull request's export scenarios included, and vm_template 9, vm_layered 43, vm_restart 3, and vm_steps 3.
  • Windows: the nvx-rs checks (fmt; Clippy of all targets with all features, of the library with the default features, and of the library without them; tests with all features, the defaults, and without default features; docs; MSRV 1.89), Clippy of its guest code for x86_64-unknown-linux-musl, tests/host_modules.rs, the test adapter's protocol-only and host checks and Clippy of its guest, the check of the README's module table, scripts/nvx.py verify, and the Markdown link check.
  • Linux: the same nvx-rs checks, its macOS check, and Clippy of its guest code for x86_64-unknown-linux-musl; the lint and the tests of the guest binaries (scripts/nvx.py check-guest-binaries); the test adapter's protocol-only, host, and guest configurations, each with its Clippy, build, tests, and MSRV; and, as root, the tests that need it (198 of 198, the 13 that only root runs included).
  • On MSHV and WHP, with the OpenVMM of the development release v0.1.0-dev.2d14547b669f, which nvx-rs download installed, and the initramfs and the test adapter's guest agent built from the tree: vm_clone 25 of 25, vm_template 9, vm_layered 43, vm_restart 3, and vm_steps 3.

These runs hold for #958's head, e9e7fc6, by tree identity: the CI workflow ran at cfdc779, and the Windows, Linux, and VM runs at 954d098, before #958's fix-ups were squashed into its second commit, which changed no file. cfdc779 has the tree of #958's head, 72dea16c, and 954d098's tree differs from it in a comment alone.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Host/guest filesystem integration requires human confirmation of #956’s store-path canonicalization, the OpenVMM fix for #904, and privileged multi-backend VM validation on the final restacked head.

No open issues 🎉

What changed in this PR

Adds single-directory exports to template clones in nvx-rs, implementing the export and mapping portion of #846. The restore sequence now applies mappings after Ready and before Start, using the existing failure-recording and teardown machinery.

Changes:

  • Validates clone export plans, records their identities, and renders directory-mount arguments for restore.
  • Supports empty mapping sources for export roots while preserving the managed agent’s rejection of them.
  • Separates mapping from Start in the runtime adapter and adds protocol, planning, lifecycle, and guest-mapper tests.

The PR description tracks VM scenarios and store-path normalization in #956, plus the required OpenVMM fix in #904.

File Description
tests/​runtime-adapter/​tests/​host_session.rs Tests separate mapping and Start operations, failures, and root sources.
tests/​runtime-adapter/​src/​host.rs Extracts map_table and updates clone control framing.
tests/​runtime-adapter/​README.md Documents root sources and clone mapping order.
nvx-rs/​tests/​vectors/​README.md Explains the version-1 empty-source vector exception.
nvx-rs/​tests/​vectors/​nvxc-v1.txt Reclassifies the empty-source vector as valid.
nvx-rs/​src/​protocol/​tests.rs Tests empty-source encoding, decoding, and target validation.
nvx-rs/​src/​protocol/​maps.rs Accepts empty sources as export roots.
nvx-rs/​src/​host/​template/​mod.rs Documents clone exports and mapping-before-Start sequencing.
nvx-rs/​src/​host/​template/​clone/​tests/​restore.rs Tests mapping order, failure attribution, and teardown.
nvx-rs/​src/​host/​template/​clone/​tests/​launch.rs Tests export arguments and prelaunch verification failures.
nvx-rs/​src/​host/​template/​clone/​tests.rs Tests export planning, identities, policies, and refusals.
nvx-rs/​src/​host/​template/​clone/​restore.rs Sends mappings before Start and classifies mapping failures.
nvx-rs/​src/​host/​template/​clone/​record.rs Adds export identity and mapping count to activation records.
nvx-rs/​src/​host/​template/​clone/​reattach.rs Clarifies that reattach resends neither mappings nor Start.
nvx-rs/​src/​host/​template/​clone/​launch.rs Verifies exports and includes their restore arguments and tables.
nvx-rs/​src/​host/​template/​clone/​failure.rs Documents export evidence and mapping-stage progress.
nvx-rs/​src/​host/​template/​clone/​export.rs Implements single-directory export validation and rendering.
nvx-rs/​src/​host/​template/​clone.rs Adds optional exports to clone requests and plans.
nvx-rs/​src/​host/​filesystem.rs Preserves inspection errors separately from changed-object failures.
nvx-rs/​src/​guest/​mappings/​tests.rs Tests root-source mapping with the fake kernel.
nvx-rs/​src/​guest/​mappings/​privileged.rs Tests root-source mount isolation and restrictions on Linux.
nvx-rs/​src/​guest/​mappings.rs Opens empty sources as . beneath the export root.
nvx-rs/​src/​guest/​managed_agent/​mappings.rs Explicitly rejects empty sources before applying mappings.
nvx-rs/​README.md Updates clone lifecycle and guest-mapper documentation.
doc/​design/​4.4-host-guest-control-interface.md Documents root-source semantics and vector compatibility.

Review effort: Balanced

Enrique Saurez (esaurez) pushed a commit that referenced this pull request Oct 28, 2026
This branch exists only to run CI. It is never a pull request's head,
and this commit never goes onto a pull request's branch.

It is #958's head, 4a614d9, which holds #955, #956, and #958, with
the fixes that dev carries for the failures that their review bases,
pinned to OpenVMM 79a501b6, show in CI, and that the pull requests do
not change:

- #885's test-only 3fbaf8a, cherry-picked before this commit. On
  GitHub's hosted Windows runners an open with backup semantics
  bypasses the test's access list.
- This commit, which moves the openvmm gitlink 79a501b6 -> e68871cc,
  the virtio-fs fix of #904 that dev pins since #918, so that a clone's
  export serves requests after a gated restore. The platform tier's
  allowlist there admits maxcpus=, so the copy of it in
  nvx-rs/tests/vectors/openvmm, which the CLI jobs compare with the
  submodule, takes that line too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
Enrique Saurez (esaurez) pushed a commit that referenced this pull request Oct 28, 2026
This branch exists only to run CI. It is never a pull request's head,
and this commit never goes onto a pull request's branch.

It is #958's head, 4a614d9, which holds #955, #956, and #958, with
the fixes that dev carries for the failures that their review bases,
pinned to OpenVMM 79a501b6, show in CI, and that the pull requests do
not change:

- #885's test-only 3fbaf8a, cherry-picked before this commit. On
  GitHub's hosted Windows runners an open with backup semantics
  bypasses the test's access list.
- This commit, which moves the openvmm gitlink 79a501b6 -> e68871cc,
  the virtio-fs fix of #904 that dev pins since #918, so that a clone's
  export serves requests after a gated restore. The platform tier's
  allowlist there admits maxcpus=, so the copy of it in
  nvx-rs/tests/vectors/openvmm, which the CLI jobs compare with the
  submodule, takes that line too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
Enrique Saurez (esaurez) pushed a commit that referenced this pull request Oct 28, 2026
This branch exists only to run CI. It is never a pull request's head,
and this commit never goes onto a pull request's branch.

It is #958's head, 4a614d9, which holds #955, #956, and #958, with
the fixes that dev carries for the failures that their review bases,
pinned to OpenVMM 79a501b6, show in CI, and that the pull requests do
not change:

- #885's test-only 3fbaf8a, cherry-picked before this commit. On
  GitHub's hosted Windows runners an open with backup semantics
  bypasses the test's access list.
- This commit, which moves the openvmm gitlink 79a501b6 -> e68871cc,
  the virtio-fs fix of #904 that dev pins since #918, so that a clone's
  export serves requests after a gated restore. The platform tier's
  allowlist there admits maxcpus=, so the copy of it in
  nvx-rs/tests/vectors/openvmm, which the CLI jobs compare with the
  submodule, takes that line too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
Enrique Saurez (esaurez) pushed a commit that referenced this pull request Oct 28, 2026
This branch exists only to run CI. It is never a pull request's head,
and this commit never goes onto a pull request's branch.

It is #958's head, 4a614d9, which holds #955, #956, and #958, with
the fixes that dev carries for the failures that their review bases,
pinned to OpenVMM 79a501b6, show in CI, and that the pull requests do
not change:

- #885's test-only 3fbaf8a, cherry-picked before this commit. On
  GitHub's hosted Windows runners an open with backup semantics
  bypasses the test's access list.
- This commit, which moves the openvmm gitlink 79a501b6 -> e68871cc,
  the virtio-fs fix of #904 that dev pins since #918, so that a clone's
  export serves requests after a gated restore. The platform tier's
  allowlist there admits maxcpus=, so the copy of it in
  nvx-rs/tests/vectors/openvmm, which the CLI jobs compare with the
  submodule, takes that line too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
@esaurez
Enrique Saurez (esaurez) force-pushed the esaurez/846-pr1-export-maps-20261025 branch from 96b4fbd to 08d111c Compare October 29, 2026 06:40
@esaurez
Enrique Saurez (esaurez) changed the base branch from esaurez/846-pr1-base-snapshot-20261028 to esaurez/845-pr3-export-network-20261025 October 29, 2026 06:40
@esaurez
Enrique Saurez (esaurez) requested a balanced review from Copilot October 29, 2026 06:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Host-directory isolation and restore-time mount sequencing cross platform-specific security boundaries, requiring human confirmation of exact-head CI and KVM, MSHV, and WHP VM validation before approval.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

On Linux, replacing a pinned path with a self-referential symlink incorrectly produces HostFailure instead of InvalidMappings; symlink-loop errors need changed-object classification with the cause preserved and a regression test.

1 open issue

Review effort: Balanced

Comment thread nvx-rs/src/host/filesystem/mod.rs
Enrique Saurez and others added 5 commits October 29, 2026 08:19
A clone may now share host paths with its root. CloneRequest takes zero or
one ExportPlan that filesystem::plan made for the container root. A
template's filesystem slot is dormant, and OpenVMM binds one directory to it
at a restore, so the plan must export exactly one child, a directory, which is
then the export's root:

- ClonePlan::new refuses, before anything is opened, a plan that check()
  rejects, such as mappings without an export or an export without mappings,
  a plan of several directories or files, and a file. It also refuses a
  directory inside the template store, or one that holds it, comparing the
  paths that the plan resolved with the store's, without reading anything.
  Each refusal is a CloneFailure at the plan's stage, whose evidence is the
  request's mappings, InvalidMappings, and which the caller holds.
- The activation record names the export, a directory, by the identity of
  its plan, the SHA-256 digest of a domain and of the plan's serde form, and
  announces its mappings, the plan's binds, from 1 to 4,096. A clone without
  an export has neither, and its slot stays dormant.
- ClonePlan::launch checks the plan with ExportPlan::verify before it writes
  anything, as a cold start does, and binds the directory with --mount, its
  hidden, re-exposed, and writable paths, and its owner. A refusal of verify,
  such as an object that changed or went missing since the plan, is the
  request's mappings, InvalidMappings, and any other failure of it the host's
  own; both come before the progress log, so nothing records them. To tell
  them apart, verify now keeps the cause of a failure to inspect an object,
  with ErrorKind::Io, unless the failure shows that the object's path no
  longer reaches it, which it still takes for a changed object, at a cold
  start as at a restore.
- ClonePlan::table returns the binds as the mapping table, whose sources name
  paths in the directory without the child's index.
- Activated::start sends the table through a new CloneControl::map once the
  clone is ready, in order, and records and sends the start only once the
  guest applied every mapping, both by the Ready-to-Start deadline. Neither
  is sent again. A partial or failed table ends the launch in the one place
  where a failed start ends it: the failure is recorded as pending, the
  launch ends through Starting::abandon, and the record is written again
  with the teardown. It fails at the start's stage with the clone's progress
  still at Ready, which tells it from a failure of the start, whose progress
  is Starting.
- A table that the guest refused, once the framing tells it through the new
  provided method CloneControl::refused, is the request's mappings,
  InvalidMappings, whenever the refusal came. Any other failure of the table,
  every failure by default, is what the host saw of it: a passed deadline,
  Timeout; a lost session, Reset; or a broken protocol, Protocol. None is the
  template's, so none quarantines, and an activation status with which the
  guest ended its VM still prevails, since no workload ran before the start:
  the export's status, 56, makes the failure InvalidMappings.

A reattach never sends a mapping table: the launch of a live clone
committed once the clone applied it and started, and a reattach ends an
interrupted launch. Activated::fail no longer gives the mapping of a host
directory as an example of a step of the caller's own, since the restore
sequence maps a clone's export itself.

The test adapter's CloneOrder now takes only the guest agent's build:
host::map_table, the table half of host::start_sandbox, implements map(), and
start() sends Start alone. It keeps the default refused(): the guest's
MapFailed does not tell whether the request's mappings or a failure of the
guest's own failed the table, so the restore sequence takes it for a broken
protocol, unless the deadline passed or the session was lost. The adapter's
host program builds it in clone_order(), the one place that makes it, for a
clone's start and a reattach to it. The adapter's requests name no export
yet, CloneRequest.export: None, so its clones still map nothing, and the VM
tests' Early framing, which ends before the clone is ready, gains a map()
that it never reaches.

Tests, with a fake VMM and a fake control: the record, table, and arguments
of a clone with and without an export; refusals of two directories, a file,
mappings without an export, an export without mappings, a reserved target,
and an export that overlaps the store, with their evidence; a mapped object
and an exported directory that changed or went missing since the plan,
refused before the launch, and an object that cannot be inspected, the
host's failure; the table after Ready and before the start; a partial,
refused, failed, lost, or silent table, and a refusal past the deadline,
after which the launch ends, with no start and no retry, and the sandbox
records the failure at the start's stage, at Ready, with its evidence; and a
table that failed as the guest ended its VM with the export's status, which
prevails. ExportPlan::verify's own tests tell a path that no longer reaches
its object from a failure to inspect it. The adapter's fake broker checks
that the order stops the guest at a part that it fails, and takes that
failure for no refusal of the request's.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
A clone whose export is one directory gets that directory alone through
the template's dormant slot, so its mapping table maps the directory
itself with an empty source: the export's root has no child index, as
an aggregate's children do. The MAPS protocol and the guest now accept
an empty source as the export's root:

- nvx::protocol::maps accepts it in the encoder and both decoders.
- nvx::guest::mappings::Mapping::new takes it for the source root
  itself, which the mapper opens as "." beneath the root with the same
  RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, RESOLVE_NO_MAGICLINKS, and
  RESOLVE_NO_XDEV flags, so the mapping is a copy of the export's own
  mount, without its submounts.

Everything else stays refused: an absolute source ("/" included), an
empty, "." or ".." component inside a non-empty source, a NUL byte, a
path of more than 4,095 bytes, and every target that was refused
before. NVX's managed agent, whose export is always an aggregate whose
root lists its children, keeps refusing an empty source, with one check
before it maps anything, so NVX's own sandboxes accept no more than
before. The host and the guest deploy together, so no capability
discovery announces the change.

Version 1's conformance vector of an empty source moves from the
rejection vectors (INVALID_MAPS|EMPTY_SOURCE) to the mapping vectors
(MAPS|ROOT_SOURCE), with the same bytes. That is an exception to the
rule that a vector changes only with a protocol version, which the
vectors' README and design section 4.4.4 now record: only the clone of
a template whose export is a directory sends an empty source, to the
template's own guest, which deploys with the host, so the C agent of
earlier releases, which refuses it, never receives one.

The tests cover an empty source's round trips through the encoder and
both decoders, its vector, the targets that it still needs, the mapping
of the source root beneath it, with a fake kernel and as root with the
real one, the refusals of a symbolic link and of a mount crossing
beneath it, the managed agent's refusal, and a clone's table, with its
root bind, through the MAPS payloads of the fake transport and of the
test adapter's order.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
`runtime-adapter-host start` takes the shares of a launch, `--share
HOST:TARGET[:ro]`, `--deny`, `--reserved`, and `--share-root`, with the
same rules: `--deny`, `--reserved`, and `--share-root` need a `--share`.
`clone::Request` carries them as `sharing::HostFsSharing`, and
`Request::export` plans their export as a launch plans it, with NVX's
`filesystem::plan` for the container root, so the start refuses shares
that it cannot export, with status 1, before it reads the template
store, needs NVX's artifacts, or creates the sandbox's store.

`Request::clone_request` takes the planned export into NVX's
`CloneRequest`, whose `ClonePlan` takes only an export of one directory,
which the clone's restore binds to the template's dormant filesystem
slot, and refuses others, as a failure of the request, before any VMM
starts. The restore sequence sends the export's mapping table through
the adapter's clone order once the clone is ready, and `mapped` now
prints the number of mappings that the clone's activation record
announces, which the guest applied before its sandbox started.

Tests without a hypervisor cover the new usage errors, the refusals
before the template store, and the export that NVX's request carries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
The VM tests start clones that share host directories with their roots,
through `runtime-adapter-host start` and its shares, which NVX exports
as one directory, bound to the template's dormant filesystem slot, and
whose mapping table the restore sequence sends once the clone is ready.

In vm_clone:

- A clone over a scratch image shares one host directory: the directory
  itself, read-only, a read-write directory inside it, at its target
  there, a read-only file, and a read-write directory, with a file
  hidden. It starts with four mappings, and its activation record names
  the export, a directory, by the identity of its plan, with four
  mappings. On Linux, its VMM has one `--mount`, of the directory,
  read-write. The guest mounted the export once, read-write, nosuid and
  nodev, and the mappings below the root, in the table's order, each
  with its access, showing what the host holds but for the hidden file.
  An execution finds the same, and the writable mappings alone take its
  writes, which the host's directory holds, before and after the stop.
- Four clones of one generation at once: one with a read-write export
  of its own and a generated machine ID, two over the same read-only
  export, and one without an export. Each record names its export by
  the identity of its plan, the same for the clones of one export, and
  the clone without one has none, no mapping, and on Linux no `--mount`,
  which leaves the slot dormant. Each export is mounted read-write, even
  where its mapping is read-only; the mount table lists the bind of the
  generated machine ID beside the mapping; and the helper of the
  `entries` probe, which writes the root's machine ID, finds that file
  read-only.
- A start refuses two directories, or a file, before any VMM starts,
  with status 8 and the record of a failure of the request, at the plan,
  by the evidence of invalid mappings.
- A clone whose second mapping puts a directory onto a file of the
  image ends at its Ready, before its sandbox starts, with status 8, the
  guest's report that the table failed after its first mapping, and the
  record of a failure of the start, by the evidence of the protocol:
  nothing quarantined, the lease released, and the host's directory
  unchanged.

The harness reads a clone's activation record from its sandbox, and on
Linux the arguments of its VMM.

Part of #846
Part of #845

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
ClonePlan::new refuses an export that overlaps the template store,
whose leased payloads nothing may change. It compared the paths that
the export's plan resolved with the store's directory as the store
names it, so another spelling of the store's path hid an overlap: a
link; on Windows, another letter case, a short name, or the verbatim
prefix; and on Unix, a `..` component, which an absolute path keeps.

The plan now resolves the store's directory with
filesystem::canonicalize, as filesystem::plan resolves the export's
paths, before it compares them. The host still reaches the store, and
OpenVMM the snapshots in it, by the path that the store names, so the
plan also resolves the directories in which the host looks up the
names of that path, links followed and `..` included, and refuses an
export that lets the guest write in one of them: the guest could
otherwise replace a link of the path, and turn the host's later
operations on the store elsewhere, or make a directory of the path
unsearchable, and keep the host from the store. It does both only for
a clone with an export, so a clone without one reads nothing more. A
store whose path cannot be resolved fails the plan as the host's
failure.

The plan also refuses an export whose directory is, or holds, the
directory of the clone's sandbox, in which the host writes the clone's
records by their paths: the guest could otherwise change them, or
replace the directory with a link and turn the host's writes elsewhere,
into a leased snapshot, say. The lease names the sandbox by its path
with its links resolved, so the directories through which that path
leads are the ones above it, which only such an export could let the
guest change. A directory inside the sandbox's, among the caller's
files, may still be exported.

These checks compare paths, so an alias of the store that an
administrator configures, such as a bind mount of it, is outside them,
as the directories above the store are, and they cannot see what
changes after them: OpenVMM resolves an export's path again at each
launch and restore. The documentation of the export, in ClonePlan's
and in the README, now says that whoever names an export must not name
such an alias, nor a directory that holds one, nor one inside a
directory that a writable export of another sandbox or clone holds:
the directories above an export's directory must be ones that no guest
may change, as those above the clone's block devices must.

The test of the overlap names its store in another spelling of its
directory: through `..` on Unix, and with the verbatim prefix on
Windows. Another names it through two links, one to the other's
directory: it refuses a writable export of the directory of either
link, but not a read-only one, nor one whose writes the plan limits to
a directory beside the link. A third names it through a directory and
its `..`, which Unix looks up in that directory: Unix refuses a
writable export of the directory, and Windows, which resolves `..`
from the path's text, accepts it. A fourth refuses an export of the
sandbox's directory, and of the directory that holds it, but not of a
directory inside the sandbox's.

Part of #846

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for the single-directory clone export changes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@esaurez Enrique Saurez (esaurez) removed their assignment Oct 29, 2026
Base automatically changed from esaurez/845-pr3-export-network-20261025 to dev October 29, 2026 18:15
@ppenna
Pedro Henrique Penna (ppenna) merged commit 7cb31e6 into dev Oct 29, 2026
70 of 88 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the esaurez/846-pr1-export-maps-20261025 branch October 29, 2026 19:29
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.

3 participants