Skip to content

nvx-rs: decode the MAPS entries once for both payload decoders - #616

Merged
Pedro Henrique Penna (ppenna) merged 1 commit into
devfrom
nvx-rs-quality/maps-entries
Oct 20, 2026
Merged

Pedro Henrique Penna (ppenna) merged 1 commit into
devfrom
nvx-rs-quality/maps-entries

Conversation

@ppenna

Copy link
Copy Markdown
Contributor

Summary

decode_maps_payload, the text decoder that the fake OpenVMM uses, and decode_guest_maps_payload, the byte decoder that the guest agent uses, each carried a copy of the loop that reads and checks the entries of a MAPS payload. The guest agent's path rules were also written twice, as acceptable for text and acceptable_bytes for bytes. This change moves the loop into one private function and keeps a single byte-level acceptable, in the same way that #603 consolidated the EXEC header. The two decoders can no longer drift apart, and each public decoder now holds only what is its own.

Changes

All of the code changes are in nvx-rs/src/protocol/maps.rs. The test is in nvx-rs/src/protocol/tests.rs.

  • decode_maps_entries (new, private) reads and checks the entries once: the flags and lengths, the source and target, the path rules, and the trailing bytes. Each caller passes a function that converts a path as soon as it is read and a function that builds its entry. These sites now share it:

    • decode_maps_payload passes a UTF-8 conversion and builds HostMaps into Vec::new(), as before.
    • decode_guest_maps_payload passes a byte copy and builds GuestHostMaps. It keeps its own MAX_HOST_MAPPINGS bound ("too many entries"), which it checks before reading any entry, and the preallocation that it sizes from that bound.
  • acceptable now checks the bytes of a path and replaces acceptable_bytes. These sites now share it:

    • encode_maps_payloads, through as_bytes().
    • Both decoders, through decode_maps_entries.

    The extra checks in acceptable_bytes, !path.is_empty() and source != b".", are dropped because they were redundant: normal already refuses an empty source and any . component, and strip_prefix(b"/") refuses an empty target.

  • truncated() is a module-level function that replaces the two identical closures.

  • maps_decoders_refuse_entries_with_the_same_messages (new test) pins the messages that both decoders share: truncated header, truncated entry, trailing bytes, unknown flags, and refused path. It also pins the two messages that differ by design. The host decoder reports a non-UTF-8 source before it reads a truncated target, which the guest decoder reports as truncated. The guest decoder's bound refuses 4097 entries before it reads any. The existing tests only checked is_err(). The new test passes both on BASE and with this change.

Behavior

  • Accepted payloads, messages, and check order are unchanged for encode_maps_payloads, decode_maps_payload, and decode_guest_maps_payload. The path conversion still runs as soon as each path is read, so the host decoder still fails a non-UTF-8 source before it reads the target.
  • Arithmetic. The host decoder now advances its offset with the guest decoder's checked_add and saturating_add where it used +. Each of these follows a successful bounds check, because the offset never exceeds payload.len(). Neither can fail or produce a different value.
  • Path rules. / (0x2F) and NUL occur in UTF-8 only as themselves, never inside a multibyte character. Splitting a string's bytes on b'/' therefore yields the same components as splitting the text on '/', so text passes the byte rules exactly when it passed the text rules.
  • Allocation. The host decoder still starts from an empty Vec, and the guest decoder still preallocates at most 4096 entries.
  • Unchanged interfaces. The public API, the protocol bytes, the golden fixtures, and the guest binaries' command lines, statuses, and output are all unchanged.
  • Differential check. A throwaway differential test, kept outside the repository, compared the BASE and new maps.rs on 14,749,578 payloads and 332,256 mapping tables. The payloads included every payload of up to 2 bytes, every pair of 46 edge-case paths with every flag, length skew, and count, both truncated and with a trailing byte, and 5 million random payloads. Both decoders and the encoder produced identical results, messages included. Every refusal message of both decoders was exercised.

Follow-ups

  • decode_maps_payload does not bound the number of entries by MAX_HOST_MAPPINGS before reading them, while decode_guest_maps_payload refuses more than 4096 with "too many entries". The host decoder's documentation says that it refuses what the guest agent refuses of a payload, so a payload of more than 4096 valid entries decodes in the fake OpenVMM but not in the guest. The difference may be deliberate, so this change keeps it rather than unifying it. Consider whether the host decoder should share the bound.
  • A later run could take on another duplicate found during this survey: the managed agent's test fixtures. cgroup.rs's setup duplicates dispatch.rs's direct_system, mappings.rs's payload duplicates dispatch.rs's maps_payload, and both files carry the same host-export fixture.

Validation

  • BASE: 830536ed210e0d8bef0ca9166c13fb9636acb614 (origin/dev).
  • TREE: 18948e2900a2af24cb435f232cdc8ba6c889b7d4, the tree of this pull request's commit ec1c09d8f809e3be3943b2621ef0f339b7f2d2f6.
  • Release and OpenVMM: the hosts installed v0.1.0-dev.6c4171aa3166 with nvx.py download --hypervisor BACKEND. Its OpenVMM provenance is d0bdbed8f6e3b86271277dc22658109e2e633b2d, which matches the pinned gitlink.
  • Guest artifacts were rebuilt from TREE on prometheus30 with build-guest-binaries --output and test-guest-binaries --binaries (34 passed). The Alpine initramfs was then built with NVX_GUEST_BINARIES=… build-initramfs; its provenance records TREE's maps.rs. The initramfs (9c36d87c…), its package manifest, and its provenance were copied to the other two hosts with matching SHA-256 digests.
  • nvx-rs CLI: each host rebuilt it from TREE with cargo build --release --locked --features cli and recorded its provenance, so the microVM scenarios ran this change rather than the release's binary.
  • Local checks (Windows, Rust 1.95.0) on TREE:
    • rustfmt, Clippy with -D warnings (all features, and the library without default features), the --all-features and default tests, and rustdoc with -D warnings.
    • MSRV 1.89 check for nvx-rs and aci_edge_sandboxes, on behalf of the WHP host, which has no 1.89 toolchain.
    • The aarch64-apple-darwin check for both crates, on behalf of the KVM host, which lacks that target.
    • The new test against BASE's decoders, and the differential test.
Host profile Backend Host type check-nvx-rs (no hypervisor) check-aci-edge-sandboxes (no hypervisor) Guest binaries + initramfs test-aci-edge-sandboxes test-microvm (default suite)
prometheus32 kvm baremetal Pass: Linux steps, including check-guest-binaries (321 passed); tests 876 passed, 2 ignored Pass: Linux steps, including agent bundling; tests 242 passed, 62 ignored Not run (unshare --user is denied) Pass, 18/18 Pass, 31 scenarios
prometheus30 mshv baremetal Not run (the Linux steps run on the KVM host) Not run Pass: builder (build, 34 tests, initramfs) Pass, 18/18 Pass, 31 scenarios
prometheus28 whp baremetal Pass: Windows steps, including artifact bundling; tests 597 passed, 2 ignored Pass: Windows steps, including agent bundling; tests 242 passed, 62 ignored Not run Pass, 18/18 Pass, 31 scenarios

The macOS checks for the KVM host's steps and the MSRV checks for the WHP host's steps ran locally, as listed above.

Left to CI:

  • The virtual-machine runner matrix.
  • test-openvmm-unit and test-openvmm.
  • The test-microvm scenarios that run only when named: smp-lapic, managed-exec-config, and sandbox-lifecycle.
  • The Ubuntu and Azure Linux guests.
  • The Docker guest build and guest determinism.
  • Benchmarks and performance gates.
  • check-quality and validate-nvx. This change touches no script, Markdown, or file layout.

decode_maps_payload, the text decoder that the fake OpenVMM uses, and
decode_guest_maps_payload, the byte decoder that the guest agent uses,
each carried a copy of the loop that reads and checks the entries of a
MAPS payload: their flags and lengths, their sources and targets, the
path rules, and the trailing bytes. The copies differed only in how
they read a path, as UTF-8 text or as bytes, and in the entry that they
build. The path rules were written twice as well, as acceptable for
text and acceptable_bytes for bytes, whose checks that a path is not
empty and that a source is not `.` its other checks already made.

- decode_maps_entries reads and checks the entries once. Each decoder
  passes it a function that converts a path as soon as it is read and
  one that builds its entry, and keeps what is its own: the guest
  decoder's bound on the number of entries, checked before any entry is
  read, and the allocation that it sizes from that bound.
- acceptable checks the bytes of a path, so the encoder and both
  decoders share it. `/` and NUL occur in UTF-8 only as themselves, so
  text passes the byte rules exactly when it passed the text rules.

What the encoder and each decoder accept, their error messages, and the
order of their checks are unchanged: the host decoder still requires a
source to be UTF-8 before it reads the target. The host decoder now
advances its offset with the guest decoder's checked additions, which
follow successful bounds checks and so never fail. A new protocol test
pins the messages that both decoders share and the two that differ,
and passes before and after the change. A throwaway differential test
compared the old and new code on 14.7 million payloads and 332,000
mapping tables: the results of both decoders and of the encoder,
messages included, were identical.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 20, 2026 02:55

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

The focused refactor preserves existing behavior, adds regression coverage, and has no identified blocking issues.

No open issues 🎉

What changed in this PR

Consolidates MAPS decoding in nvx-rs, following the EXEC refactor in #603 while preserving existing behavior and intentional host/guest differences.

Changes:

  • Shares entry decoding, byte-level path validation, and truncation errors.
  • Adds regression coverage for shared error messages and decoder-specific validation order.
File Description
nvx-rs/​src/​protocol/​tests.rs Adds decoder error-message regression tests.
nvx-rs/​src/​protocol/​maps.rs Consolidates decoding and path validation without changing public interfaces.

Review effort: Balanced

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