Repository navigation
Track sandbox live share bugs: the time ABI bind hides or breaks shares at /run targets #486
Description
Summary
A focused bug hunt covered NVX's sandbox live shares (filesystem and single-file mappings from #459 and #475). It reviewed how nvx.py sandbox validates and lowers --mount targets in scripts/nvx_tools/sandbox.py, how the guest agent (guest/common/nvx-init-agent) mounts them, and how the container runtime (guest/alpine/nvx-container-launch and nvx-container-enter) enters the workload afterwards. It also skimmed the host telemetry added in #479 and the CI change classifier changed in #483. No open bug-hunt issue covers this component. The closed hunts tracked other components: #464 the managed sandbox lifecycle, #462 the egress policy compiler, and #374 release source collection.
The hunt found one reproducible bug. Line references are permalinks to dev at c29f3d1. No repository files were changed. The reproductions ran against a git archive export of that commit.
| # | Severity | Finding | Reproduced on |
|---|---|---|---|
| 1 | Medium | The container runtime creates /run, /run/nvx, and /run/nvx/time in the container root and bind-mounts the time ABI state over /run/nvx/time. It does this after the agent has mounted the live shares. Neither the host nor the guest reserves those paths as share targets. A share at or below /run/nvx/time is silently hidden, and the workload starts without it. A read-only share at /run or /run/nvx makes every workload start fail with status 1. A read-write share there gets runtime-created nvx/time directories in the host directory. |
c29f3d1b0: host side on Windows 11 with Python 3.14.7; guest side in an alpine:3.24 (3.24.2) privileged container |
- 1. Reserve
/run,/run/nvx, and the/run/nvx/timetree as sandbox share targets, innvx.pyand in the guest agent
1. The time ABI state bind hides or breaks live shares at /run targets (Medium)
Where
nvx.py sandboxrejects only the targets that the container runtime claimed when the reserved list was introduced in 13d2722 (2026-09-30). The list holds the/proc,/sys,/dev,/.nvx-agent, and/etc/machine-idtrees, plus/etcexactly (sandbox.pyL43-L48,validate_mount_targetL105-L126).- The guest agent applies the same list. Its comment states the invariant that this bug breaks: "The container runtime mounts or binds these paths after the share, so a share there would be shadowed or would expose host data to runtime writes" (
nvx-init-agentL151-L169). - The agent mounts the shares (L548) and only then starts the container runtime. The one-shot path runs it at L577-L579. The managed agent runs it for every exec (
nvx-managed-agent.cL1210). - 149dc68 (2026-10-04) added a runtime-owned bind to the runtime but did not add it to either list. The runtime creates each of
run,nvx, andtimebelow the container root withmkdir -p, then bind-mounts/run/nvx/timeover the result read-only, underset -eu(nvx-container-enterL49-L60). - The documentation promises that only the listed paths are reserved, and that "any validation or mount failure aborts the sandbox with status 125 instead of starting the workload without its shares" (
doc/run.mdL702-L711). - OpenVMM does not catch these targets either. At the pinned nanvix/openvmm@2130f24,
validate_microvm_guest_mount_targetchecks only the syntax: an absolute, canonical path without whitespace,\, or=.
Trigger
sandbox run or sandbox provision, followed by start and exec, with a --mount that matches either case:
- The target is
/runor/run/nvx. - The target is
/run/nvx/timeor a path below it, such as/run/nvx/time/x.
Other /run targets, such as /run/secrets or /run/nvx/other, are unaffected.
Expected
These targets are rejected before boot, as /etc and /etc/machine-id are. /etc is reserved exactly because the runtime writes /etc/machine-id inside it. A target that slips through should fail in the guest with status 125, rather than start the workload without its share.
Actual
nvx.py accepts the targets, and so do OpenVMM and the guest agent. The agent reports the share as mounted. The runtime then acts as follows:
| Target | Share mode | What the runtime does | Result |
|---|---|---|---|
/run/nvx/time or a path below it |
Any | Binds the guest's time state over the share | The workload sees state and daemon.pid instead of the host files. The share is invisible and the sandbox exits 0. This applies to a lone share and to a child of an aggregate alike. |
/run or /run/nvx |
ro |
mkdir -p fails with EROFS |
set -e exits with status 1 before the workload runs. The host sees status 1 rather than the documented 125, which looks like a workload failure. sandbox run fails this way. By code reading, so does every managed exec, because each one enters through the same script. |
/run or /run/nvx |
rw |
Creates nvx/ and nvx/time/ in the shared host directory, then binds the time state inside the share's tree |
The host directory gains directories that nobody asked for. Not reproduced: if --mount-write makes another path the share's only writable part, OpenVMM's documented semantics fail the mkdir with EROFS, as in the ro case. |
Reproduction
-
Host side, on Windows 11 (10.0.26300) with Python 3.14.7. The commands ran in a
git archiveexport of c29f3d1, with empty placeholder files foropenvmm.exe,build/vmlinux,build/initramfs.cpio.gz, the layer, and the scratch image.sandbox run --dry-runaccepts all three/runtargets and rejects the reserved controls:> python <export>\scripts\nvx.py sandbox run --dry-run --hypervisor whp --layer distro,distro.erofs,11111111-1111-1111-1111-111111111111 --scratch scratch.ext4 --mount /run/nvx/time,share,ro --entrypoint /bin/ls --arg /run/nvx/time >> <export>\openvmm\target\release\openvmm.exe --machine microvm ... --mount /run/nvx/time,share,ro --microvm-lifecycle one-shot ... --cmdline "nvx_sandbox=1 ... nvx_arg=/run/nvx/time" exit=0 (same for --mount /run,share,ro and --mount /run/nvx,share,ro: exit=0) --mount /proc,share,ro -> error: sandbox mount target /proc overlaps the reserved /proc tree (exit=1) --mount /etc,share,ro -> error: sandbox mount target /etc is reserved (exit=1) -
Guest side, in a privileged
alpine:3.24container (Alpine 3.24.2; Docker Engine 29.7.2 on WSL2 kernel 6.18.33.2). This is the guest's Alpine branch. The script below runs these parts verbatim from the export:mount_live_sharesand its helpers fromguest/common/nvx-init-agent- the one-shot launch sequence from
nvx-init-agent guest/alpine/nvx-container-launchandnvx-container-enter
Stand-ins replace the parts that need a VM:
- A bind mount of a host directory, holding
HOST_MARKER, replaces the virtio-fs mount. - A tmpfs with the Alpine userland replaces the overlay.
- A
/run/nvx/timewithstateanddaemon.pidreplaces the time ABI state.
SandboxLaunch.kernel_command_line()andmounts_command_line_fragment()from the export generated each scenario's kernel command line. The/srv/datacontrol shows that the harness exposes a share correctly. Scenarios A to C use one share, which the agent mounts withmount_live_share. Scenario D uses two shares, which the agent binds as aggregate children withbind_live_share.================ scenario control: virtfs_dir=/srv/data virtfs_tag=microvm virtfs_mode=ro NVX-SANDBOX-SHARE: target=/srv/data mode=ro tag=microvm workload: /bin/ls -a /srv/data . .. HOST_MARKER workload exit status: 0 host directory after the run: ./HOST_MARKER ================ scenario A: virtfs_dir=/run/nvx/time virtfs_tag=microvm virtfs_mode=ro NVX-SANDBOX-SHARE: target=/run/nvx/time mode=ro tag=microvm workload: /bin/ls -a /run/nvx/time . .. daemon.pid state workload exit status: 0 host directory after the run: ./HOST_MARKER ================ scenario B: virtfs_dir=/run virtfs_tag=microvm virtfs_mode=ro NVX-SANDBOX-SHARE: target=/run mode=ro tag=microvm workload: /bin/ls -a /run mkdir: can't create directory '/run/nvx/rootfs/run/nvx': Read-only file system workload exit status: 1 host directory after the run: ./HOST_MARKER ================ scenario C: virtfs_dir=/run virtfs_tag=microvm virtfs_mode=rw NVX-SANDBOX-SHARE: target=/run mode=rw tag=microvm workload: /bin/ls -a /run . .. HOST_MARKER nvx workload exit status: 0 host directory after the run: ./HOST_MARKER ./nvx ./nvx/time ================ scenario D: virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=ro NVX-SANDBOX-SHARE: target=/srv/data mode=ro child=0-96b53e47e3fc7c29db4c5a4c6977decf NVX-SANDBOX-SHARE: target=/run/nvx/time mode=ro child=1-aabbcbdff808cbd31c52ff745fe29f64 workload: /bin/ls -a /srv/data /run/nvx/time /run/nvx/time: . .. daemon.pid state /srv/data: . .. HOST_MARKER workload exit status: 0 host directory after the run: ./0-96b53e47e3fc7c29db4c5a4c6977decf ./0-96b53e47e3fc7c29db4c5a4c6977decf/HOST_MARKER ./1-aabbcbdff808cbd31c52ff745fe29f64 ./1-aabbcbdff808cbd31c52ff745fe29f64/HOST_MARKER ./HOST_MARKERReproduction script (
docker run --rm --privileged -v "$PWD:/repro:ro" alpine:3.24 sh /repro/repro.sh, with the export in./exportandcmdline-{control,A,B,C,D}.txtbeside it)#!/bin/sh set -eu exec 2>&1 apk add --no-cache -q util-linux setpriv libcap-ng libeconf >/dev/null [ -e /bin/setpriv ] || ln -s "$(command -v setpriv)" /bin/setpriv src=/repro/export/guest install -m 0755 "$src/alpine/nvx-container-launch" /sbin/nvx-container-launch install -m 0755 "$src/alpine/nvx-container-enter" /sbin/nvx-container-enter awk '/^(unmount_live_shares|cmdline_value|validate_share_target|make_share_mount_point|record_live_share|claim_share_target|mount_live_share|parse_share_child|bind_live_share|mount_live_shares)\(\) \{/,/^\}/' \ "$src/common/nvx-init-agent" >/tmp/agent-functions.sh # The time ABI state that nvx-time keeps in the guest at boot. mkdir -p /run/nvx/time echo "guest time ABI state" >/run/nvx/time/state echo 1 >/run/nvx/time/daemon.pid scenario() { name=$1 HOST_SHARE=/host/share-$name mkdir -p "$HOST_SHARE" echo "host data" >"$HOST_SHARE/HOST_MARKER" # An aggregate's root lists one directory per child. for token in $(cat "/repro/cmdline-$name.txt"); do case "$token" in nvx_share=*) child=${token#*=}; child=${child%%,*}; mkdir -p "$HOST_SHARE/$child"; echo "host data" >"$HOST_SHARE/$child/HOST_MARKER" ;; esac done echo "================ scenario $name: $(grep -o 'virtfs_dir=[^ ]* virtfs_tag=[^ ]* virtfs_mode=[^ ]*' "/repro/cmdline-$name.txt")" HOST_SHARE=$HOST_SHARE NAME=$name unshare --mount --propagation private sh -eu -c ' . /tmp/agent-functions.sh # As nvx-init-agent, except that the VM would power off instead. fatal() { echo "NVX-SANDBOX-ERROR: $*" >&2; unmount_live_shares || true; exit 125; } # Stand-in for the virtio-fs device: bind the host directory instead. mount() { if [ "$1" = -t ] && [ "$2" = virtiofs ]; then command mount --bind "$HOST_SHARE" "$6" && command mount -o "remount,bind,$4" "$6" else command mount "$@" fi } runtime=/run/nvx rootfs=$runtime/rootfs share_mountpoints= cmdline=$(cat "/repro/cmdline-$NAME.txt") # Stand-in for the container overlay: a tmpfs with the Alpine userland. mkdir -p "$rootfs" command mount -t tmpfs tmpfs "$rootfs" cp -a /bin /sbin /lib /usr /etc "$rootfs/" tr -d - </proc/sys/kernel/random/uuid >"$runtime/workload-machine-id" mount_live_shares # The one-shot launch of nvx-init-agent, without the cgroup placement. set -- "$(cmdline_value nvx_entrypoint)" set -f for token in $cmdline; do case "$token" in nvx_arg=*) set -- "$@" "${token#*=}" ;; esac done set +f echo "workload: $*" barrier=$runtime/container-start mkfifo "$barrier" /sbin/nvx-container-launch "$barrier" "$rootfs" nvx-sandbox 65534 65534 nobody / "$@" & container_pid=$! printf "start\n" >"$barrier" rm -f "$barrier" status=0 wait "$container_pid" || status=$? echo "workload exit status: $status" unmount_live_shares ' || echo "agent exit status: $?" echo "host directory after the run: $(cd "$HOST_SHARE" && find . -mindepth 1 | sort | tr '\n' ' ')" } for name in control A B C D; do scenario "$name" done
This script generated each
cmdline-<name>.txtfrom the export, and its output matches the command lines thatnvx.py sandbox run --dry-runprinted in step 1, plus thevirtfs_*tokens that OpenVMM appends:from pathlib import Path from nvx_tools.sandbox import SandboxLaunch, SandboxLayer, SandboxMount, mounts_command_line_fragment for name, target, mode in [("control", "/srv/data", "ro"), ("A", "/run/nvx/time", "ro"), ("B", "/run", "ro"), ("C", "/run", "rw")]: mount = SandboxMount(guest_target=target, host_path=Path("share"), access=mode) launch = SandboxLaunch(layers=(SandboxLayer("distro", Path("distro.erofs"), "11111111-1111-1111-1111-111111111111"),), scratch=Path("scratch.ext4"), entrypoint="/bin/ls", args=("-a", target), mounts=(mount,)) Path(f"cmdline-{name}.txt").write_text(launch.kernel_command_line() + mounts_command_line_fragment((mount,)) + "\n", newline="\n") # D: an aggregate of two shares. mounts = (SandboxMount(guest_target="/srv/data", host_path=Path("share-a"), access="ro"), SandboxMount(guest_target="/run/nvx/time", host_path=Path("share-b"), access="ro")) launch = SandboxLaunch(layers=(SandboxLayer("distro", Path("distro.erofs"), "11111111-1111-1111-1111-111111111111"),), scratch=Path("scratch.ext4"), entrypoint="/bin/ls", args=("-a", "/srv/data", "/run/nvx/time"), mounts=mounts) Path("cmdline-D.txt").write_text(launch.kernel_command_line() + mounts_command_line_fragment(mounts) + "\n", newline="\n")
Suggested fix
- In
scripts/nvx_tools/sandbox.py, add/run/nvx/timetoRESERVED_MOUNT_TARGETS(a tree), and/runand/run/nvxtoRESERVED_EXACT_MOUNT_TARGETS. This mirrors/etcand/etc/machine-id, because the runtime creates directories inside them. Extend the comment above the lists to mention the time state. - In
guest/common/nvx-init-agent, add/run | /run/nvx | /run/nvx/time | /run/nvx/time/*to the reservedcaseinvalidate_share_target. A configuration from an older host then fails with status 125 instead of starting without its share. - In
doc/run.md, add the new paths to the reserved list at L702-L706. - Extend
test_mount_target_validation_rejects_unsafe_and_reserved_targets(test_nvx_tools.pyL11878-L11911). Cover/run,/run/nvx,/run/nvx/time, and/run/nvx/time/xas reserved, and/run/secretsand/run/nvx/otheras accepted. - Optional defense in depth: before each runtime bind,
nvx-container-entercouldexit 125when the path or one of its ancestors below the container root is already a mount point. A runtime bind added later then could not hide a share silently.
Explored
The hunt read the following components:
- Sandbox live shares, from filesystem: map host paths from any volume, each with its own access #459 and filesystem: share single host files without their directories #475:
scripts/nvx_tools/sandbox.py- the
runandsandbox--mountwiring inscripts/nvx.py - the share config formats 2 to 6 in
scripts/nvx_tools/sandbox_lifecycle.py guest/common/nvx-hostmountguest/common/nvx-init-agentguest/alpine/nvx-container-launchandnvx-container-enterguest/common/nvx-virtio-restore-probeaci_edge_sandboxes/src/openvmm/filesystem.rs- OpenVMM's
--mountand--mount-childparsers and guest-target validation at the pinned revision
scripts/nvx_tools/host_telemetry.py, from ci: record benchmark host provenance and storage telemetry #479classify-ci-changes --null, from ci: classify both rename paths and unquoted filenames #483
Checked, not filed
aci_edge_sandboxeson Windows.plan()refuses a read-only file inside a read-write directory because Windows opens names case-insensitively. It does not refuse a read-only directory there, although the same aliasing applies. The crate's README documents that the guest's bind alone enforces a read-only path inside a read-write mapping, so this is a documented limitation.- Commas in
aci_edge_sandboxeshost paths. The crate never rejects them in--mount-child. OpenVMM'sMicrovmMountChildCliparses withsplit_onceandrsplit_once, and the crate always emits an explicit mode, so such paths round-trip correctly. nvx-virtio-restore-probe. It derives its I/O directory from the firstnvx_sharetoken as if the token named a directory child; a,filesuffix would yieldTARGET,MODE. The probe is a test helper used only with directory shares, so the impact is low.host_telemetry.py. TheSYSTEM_PERFORMANCE_INFORMATIONand x64SYSTEM_PROCESS_INFORMATIONoffsets match the NT layouts. The windowing (nearest samples and 32-bit wrap) and the busy, latency, and queue formulas are consistent.classify-ci-changes --null. The classifier drops the empty entry that the trailing NUL ofgit diff -zproduces. The regex anchors err toward running more suites.- Managed share configuration. Format selection and reload are symmetric, and relative policy paths are joined to the share again at launch.
nvx.py runaggregate targets. Their validation andnvx-hostmounttoken parsing agree with the Python producer.
Validation limits
- No microVM booted. The guest side ran in a privileged Linux container rather than a VM, with three substitutions:
- A read-only or read-write bind replaced the virtio-fs mount. OpenVMM's host-side
roenforcement also fails writes withEROFS. - A tmpfs replaced the EROFS and scratch overlay.
- The cgroup placement and the agent's earlier boot steps were skipped. They do not affect the order of the share mount and the runtime bind.
- A read-only or read-write bind replaced the virtio-fs mount. OpenVMM's host-side
- The managed lifecycle (
sandbox startandexec) is affected by code reading ofnvx-managed-agent.c. The hunt did not execute it. - Only the Alpine sandbox runtime was reproduced.
guest/common/nvx-container-enter-azurelinuxdoes not bind the time state. - No hypervisor tests (
test-microvm,filesystem-shares) ran.
2026-10-12 follow-up: Rust host-file mapping root budget
This issue now tracks two confirmed findings: the original container /run live-share problem above, and the Rust direct-OpenVMM provisioning problem below. The existing issue already inventories aci_edge_sandboxes/src/openvmm/filesystem.rs, so this finding is added here rather than opening a duplicate component tracker.
Hunt scope and revision
- Latest
origin/devfetched for this follow-up:9a2721a713a1bb78f0c94c189900cf89a728a26e. - All new NVX permalinks below use that SHA. The earlier report retains its historical reproduction revision and evidence.
- Areas: host-storage telemetry sampler/aggregation error handling, then the recent Rust direct-OpenVMM directory/regular-file mapping planner and its provision/start boundary. The previously reported
/runcontainer mount problem was excluded. - Searched all open and closed
repo:microsoft/nvx label:bug-huntissues before selection. No existing finding covers the aggregate UTF-8 root-size limit. - Investigation stopped at the first confirmed bug. No repository files were edited, and no commit was created. Tests and the external reproduction used an unmodified
git archiveexport of the SHA above.
Finding 2 - Medium: provisioning accepts more aggregate root bytes than pinned OpenVMM supports
Trigger: On Windows, map many distinct regular files below a directory whose name contains multi-byte Unicode characters. The mapped files become separate aggregate children. Their canonical host paths can exceed OpenVMM's aggregate 65,536 UTF-8 byte limit while remaining within the crate's child-count, per-path, and UTF-16 process-command-line limits.
The reproduced request has 115 existing, distinct regular files below a directory named with 180 repetitions of U+754C. Every file is mapped read-only; no denied paths, overlapping grants, symlinks, malformed requests, or hypervisor access are needed.
Expected: provision() must reject the unsupported aggregate with PolicyValidation before persisting a sandbox that cannot start. The separate UTF-16 process-command-line check must not substitute for the UTF-8 aggregate-root limit. Supporting a larger aggregate would instead require a coordinated OpenVMM contract change.
Actual, directly reproduced: validate_provision() and provision() both succeed. The persisted sandbox contains 115 file children whose canonical roots total 68,425 UTF-8 bytes, 2,889 bytes over the pinned OpenVMM limit. An ASCII control with equally long names also succeeds but uses only 27,025 bytes. Both cases use 27,025 UTF-16 code units for their roots, demonstrating the unit mismatch.
Impact: The caller receives a successful sandbox ID for an unsupported filesystem configuration, and only the later launch can reject it. This affects otherwise valid host-file mappings and prevents the sandbox from starting; it is not a telemetry/reporting-only discrepancy.
Trace and permalinks:
AciEdgeSandbox::provisionvalidates and dispatches to the backend.- The backend plans mappings, checks the process command line, and persists the record.
- The filesystem planner checks the child count, the bytes of denied/writable policies, and individual guest-path lengths, but never sums the canonical child-root bytes.
- Process-command-line sizing counts UTF-16 code units, which can be substantially fewer than UTF-8 bytes.
- Each child root is emitted as a separate
--mount-child. - The OpenVMM gitlink at this NVX revision pins
2130f24b5a51561cc6224fe5a0aff93cb121ac32. In that exact dependency revision,openvmm/openvmm_entry/src/microvm/filesystem.rsdefinesMAX_AGGREGATE_ROOT_BYTES = 64 * 1024at line 16, turns canonical host paths into Rust strings inmicrovm_filesystem_object_attachmentat lines 332-357, and checksroots.iter().map(|(root, _)| root.len()).sum::<usize>() <= MAX_AGGREGATE_ROOT_BYTESat lines 809-812. The failed guard's error isthe host paths of the --mount-child options exceed 65536 bytes.
The confirmed defect is the host-side acceptance/persistence of an aggregate outside that exact pinned contract. The subsequent production launch rejection was traced to the pinned guard, not claimed as a real VM launch observation.
Smallest reproduction
Platform used: Windows, Rust 1.95.0. Create a separate Cargo harness with a path dependency on an unmodified checkout/export of the recorded NVX SHA. Change only the example dependency path below to the location of that export. Use the normal Windows temporary directory; byte totals vary with its path.
The harness calls the real public validation/provision/deprovision APIs and reads the real persisted sandbox record. Runtime files are placeholders, and the testing feature skips the hardware probe because no start() or VM launch occurs.
Cargo.toml:
[package]
name = "nvx-mapping-root-budget-repro"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
aci_edge_sandboxes = { path = 'C:\src\nvx-dev-9a2721a7\aci_edge_sandboxes', features = ["testing"] }
serde_json = "1"
tempfile = "3"
[workspace]src/lib.rs:
#[cfg(test)]
mod tests {
use std::fs;
use aci_edge_sandboxes::openvmm::{Hypervisor, OpenVmmConfig};
use aci_edge_sandboxes::{AciEdgeSandbox, FilesystemPolicy, ProvisionRequest};
const ROOT_BUDGET: usize = 64 * 1024;
const FILES: usize = 115;
fn provision_and_measure(component: &str, label: &str) -> usize {
let directory = tempfile::tempdir().unwrap();
let mapped = directory.path().join(component);
fs::create_dir(&mapped).unwrap();
let mut paths = Vec::new();
for index in 0..FILES {
let path = mapped.join(format!("f{index:03}.txt"));
fs::write(&path, b"fixture").unwrap();
paths.push(path);
}
let kernel = directory.path().join("vmlinux");
let initrd = directory.path().join("initramfs.cpio.gz");
fs::write(&kernel, b"not used: no VM is launched").unwrap();
fs::write(&initrd, b"not used: no VM is launched").unwrap();
let state = directory.path().join("state");
let mut config = OpenVmmConfig::new(
std::env::current_exe().unwrap(),
kernel,
initrd,
Hypervisor::Whp,
&state,
);
config.skip_hypervisor_probe = true;
let client = AciEdgeSandbox::openvmm(config).unwrap();
let request = ProvisionRequest::new().with_filesystem(FilesystemPolicy {
readonly_paths: paths,
..FilesystemPolicy::default()
});
client.validate_provision(&request).unwrap();
let sandbox = client.provision(&request).unwrap().sandbox_id;
let record: serde_json::Value = serde_json::from_slice(
&fs::read(state.join(sandbox.token()).join("sandbox.json")).unwrap(),
)
.unwrap();
let children = record["filesystem"]["children"].as_array().unwrap();
assert_eq!(children.len(), FILES);
assert!(children.iter().all(|child| child["file"] == true));
let roots: Vec<&str> = children
.iter()
.map(|child| child["root"].as_str().unwrap())
.collect();
let bytes = roots.iter().map(|root| root.len()).sum::<usize>();
let utf16 = roots
.iter()
.map(|root| root.encode_utf16().count())
.sum::<usize>();
println!(
"{label}: validate=OK provision=OK children={} root_utf8_bytes={bytes} \
root_utf16_units={utf16} root_budget={ROOT_BUDGET} excess={}",
children.len(),
bytes.saturating_sub(ROOT_BUDGET),
);
client.deprovision(&sandbox).unwrap();
bytes
}
#[test]
fn ascii_control_fits_the_pinned_openvmm_root_budget() {
let bytes = provision_and_measure(&"a".repeat(180), "ASCII");
assert!(bytes <= ROOT_BUDGET);
}
#[test]
fn multibyte_roots_must_fit_the_pinned_openvmm_root_budget() {
let bytes = provision_and_measure(&"\u{754c}".repeat(180), "MULTIBYTE");
assert!(
bytes <= ROOT_BUDGET,
"BUG: provision accepted {bytes} UTF-8 root bytes, exceeding pinned \
OpenVMM's {ROOT_BUDGET}-byte aggregate limit"
);
}
}Run:
cargo +1.95.0 test -- --nocaptureObserved output (the external invariant test intentionally fails; no production test was edited):
ASCII: validate=OK provision=OK children=115 root_utf8_bytes=27025 root_utf16_units=27025 root_budget=65536 excess=0
MULTIBYTE: validate=OK provision=OK children=115 root_utf8_bytes=68425 root_utf16_units=27025 root_budget=65536 excess=2889
test tests::ascii_control_fits_the_pinned_openvmm_root_budget ... ok
BUG: provision accepted 68425 UTF-8 root bytes, exceeding pinned OpenVMM's 65536-byte aggregate limit
test tests::multibyte_roots_must_fit_the_pinned_openvmm_root_budget ... FAILED
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
Suggested fix
Add the pinned OpenVMM aggregate-root UTF-8 byte budget to filesystem::plan, independently of the existing policy-path and UTF-16 command-line budgets. Sum the canonical exported child-root strings, using the same representation as OpenVMM, and return PolicyValidation before state creation when the total exceeds 64 KiB. Add an exact-boundary regression test and a Windows Unicode regular-file mapping test. Preserve the separate child-count, policy-path, guest-path, and process-command-line checks.
Checked, not filed in this follow-up
- Host-storage telemetry start/stop, sampling/truncation, counter-read error collection, nearest-sample aggregation, disk request-count wrapping, memory/CPU/Defender calculations, and benchmark/report callers: no confirmed bug. The targeted suite also exercised real unprivileged Windows counter reads.
- A telemetry reader that never returns could block
stop()'s thread join, but no production counter-reader trigger was reproduced; this remains unconfirmed and was not filed. - Commas in aggregate host paths: checked against the exact pinned
MicrovmMountChildCliparser. It parses the mode/file suffix from the right, so the suspected delimiter problem is not a bug. - Existing regular-file isolation, mapped-file working-directory refusal, nested mapping access, denied-path handling, object-swap checks, child-count/policy-path limits, and path/case canonicalization tests: pass. Their passing cases do not exercise aggregate root UTF-8 bytes.
Validation and limits
python -B -m unittest scripts.test_host_telemetry -q: 27 tests run, 26 passed, 1 non-Windows test skipped on Windows.cargo +1.95.0 test --locked --lib filesystem --features testing: 14 passed.cargo +1.95.0 test --locked --features testing --test openvmm_fake mapped_files: 2 passed using the repository's real control-protocol test double, without a VM.- External public-API reproduction: 1 ASCII control passed; 1 Unicode invariant test failed as expected, with the precise byte totals above.
- Read the pinned OpenVMM parser, canonical attachment, aggregate-root validation, and byte-budget guard from its existing Git object database; did not initialize, change, or synchronize the worktree's submodule.
python -B scripts\nvx.py verifyon the exact-dev export exited 1 because its OpenVMM submodule is absent. This is a prerequisite limit, not a filed bug.- No real WHP/KVM/MSHV microVM, Linux reproduction, OpenVMM build, or performance/acceptance run was performed. The cached host OpenVMM executable was not used because its provenance did not identify the clean pinned revision.
- The original repository worktree was left untouched; all test/build artifacts and the reproduction were outside it.
Activity