Skip to content

Track sandbox live share bugs: the time ABI bind hides or breaks shares at /run targets #486

Open

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/time tree as sandbox share targets, in nvx.py and in the guest agent

1. The time ABI state bind hides or breaks live shares at /run targets (Medium)

Where

  • nvx.py sandbox rejects 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-id trees, plus /etc exactly (sandbox.py L43-L48, validate_mount_target L105-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-agent L151-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.c L1210).
  • 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, and time below the container root with mkdir -p, then bind-mounts /run/nvx/time over the result read-only, under set -eu (nvx-container-enter L49-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.md L702-L711).
  • OpenVMM does not catch these targets either. At the pinned nanvix/openvmm@2130f24, validate_microvm_guest_mount_target checks 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 /run or /run/nvx.
  • The target is /run/nvx/time or 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

  1. Host side, on Windows 11 (10.0.26300) with Python 3.14.7. The commands ran in a git archive export of c29f3d1, with empty placeholder files for openvmm.exe, build/vmlinux, build/initramfs.cpio.gz, the layer, and the scratch image. sandbox run --dry-run accepts all three /run targets 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)
    
  2. Guest side, in a privileged alpine:3.24 container (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_shares and its helpers from guest/common/nvx-init-agent
    • the one-shot launch sequence from nvx-init-agent
    • guest/alpine/nvx-container-launch and nvx-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/time with state and daemon.pid replaces the time ABI state.

    SandboxLaunch.kernel_command_line() and mounts_command_line_fragment() from the export generated each scenario's kernel command line. The /srv/data control shows that the harness exposes a share correctly. Scenarios A to C use one share, which the agent mounts with mount_live_share. Scenario D uses two shares, which the agent binds as aggregate children with bind_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_MARKER
    
    Reproduction script (docker run --rm --privileged -v "$PWD:/repro:ro" alpine:3.24 sh /repro/repro.sh, with the export in ./export and cmdline-{control,A,B,C,D}.txt beside 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>.txt from the export, and its output matches the command lines that nvx.py sandbox run --dry-run printed in step 1, plus the virtfs_* 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/time to RESERVED_MOUNT_TARGETS (a tree), and /run and /run/nvx to RESERVED_EXACT_MOUNT_TARGETS. This mirrors /etc and /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 reserved case in validate_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.py L11878-L11911). Cover /run, /run/nvx, /run/nvx/time, and /run/nvx/time/x as reserved, and /run/secrets and /run/nvx/other as accepted.
  • Optional defense in depth: before each runtime bind, nvx-container-enter could exit 125 when 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:

Checked, not filed

  • aci_edge_sandboxes on 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_sandboxes host paths. The crate never rejects them in --mount-child. OpenVMM's MicrovmMountChildCli parses with split_once and rsplit_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 first nvx_share token as if the token named a directory child; a ,file suffix would yield TARGET,MODE. The probe is a test helper used only with directory shares, so the impact is low.
  • host_telemetry.py. The SYSTEM_PERFORMANCE_INFORMATION and x64 SYSTEM_PROCESS_INFORMATION offsets 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 of git diff -z produces. 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 run aggregate targets. Their validation and nvx-hostmount token 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 ro enforcement also fails writes with EROFS.
    • 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.
  • The managed lifecycle (sandbox start and exec) is affected by code reading of nvx-managed-agent.c. The hunt did not execute it.
  • Only the Alpine sandbox runtime was reproduced. guest/common/nvx-container-enter-azurelinux does 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/dev fetched 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 /run container mount problem was excluded.
  • Searched all open and closed repo:microsoft/nvx label:bug-hunt issues 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 archive export 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:

  1. AciEdgeSandbox::provision validates and dispatches to the backend.
  2. The backend plans mappings, checks the process command line, and persists the record.
  3. 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.
  4. Process-command-line sizing counts UTF-16 code units, which can be substantially fewer than UTF-8 bytes.
  5. Each child root is emitted as a separate --mount-child.
  6. The OpenVMM gitlink at this NVX revision pins 2130f24b5a51561cc6224fe5a0aff93cb121ac32. In that exact dependency revision, openvmm/openvmm_entry/src/microvm/filesystem.rs defines MAX_AGGREGATE_ROOT_BYTES = 64 * 1024 at line 16, turns canonical host paths into Rust strings in microvm_filesystem_object_attachment at lines 332-357, and checks roots.iter().map(|(root, _)| root.len()).sum::<usize>() <= MAX_AGGREGATE_ROOT_BYTES at lines 809-812. The failed guard's error is the 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 -- --nocapture

Observed 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 MicrovmMountChildCli parser. 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 verify on 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

  1. added
    bugSomething isn't working
    bug-huntFindings from focused bug hunts
    confirmedIssue affects multiple people.
    and removed
    bugSomething isn't working
    on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug-huntFindings from focused bug huntsconfirmedIssue affects multiple people.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions