diff --git a/.cargo/config.toml b/.cargo/config.toml index e6c7c6447f0..0711f9868de 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -90,8 +90,9 @@ rustc-link-search = [] [target.'cfg(all(windows, target_env = "msvc"))'] rustflags = [ - # Set up delayloads. - "-C", "link-args=dloadhelper.lib /delayload:ws2_32.dll /delayload:virtdisk.dll /delayload:user32.dll /delayload:winhvplatform.dll /delayload:bcrypt.dll /delayload:crypt32.dll", + # Keep Winsock eager: mixed raw-dylib and native import libraries can produce + # invalid delay-import thunks. Optional platform libraries remain delay-loaded. + "-C", "link-args=dloadhelper.lib /delayload:virtdisk.dll /delayload:user32.dll /delayload:winhvplatform.dll /delayload:bcrypt.dll /delayload:crypt32.dll", # Set up the CRT: use ucrtbase.dll but statically link the VC runtime. "-C", "link-args=/nodefaultlib onecore_apiset.lib ucrt.lib libvcruntime.lib libcmt.lib", # Enable CFG diff --git a/.config/nextest.toml b/.config/nextest.toml index 5c2e0ec8398..1877fc4a433 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -57,11 +57,26 @@ threads-required = 2 # Use the same slow timeout as ci, but never terminate slow-timeout = { period = "10m" } +[[profile.ci.overrides]] +# This test exhaustively models two writers racing a reader across wraparound +# and takes over a minute on Windows. Keep the larger budget scoped to it. +filter = 'package(atomic_ringbuf) and test(/^loom_tests::loom_multi_writer_wraparound$/)' +slow-timeout = { period = "60s", terminate-after = 2 } + +[[profile.agent.overrides]] +filter = 'package(atomic_ringbuf) and test(/^loom_tests::loom_multi_writer_wraparound$/)' +slow-timeout = { period = "60s", terminate-after = 2 } + [[profile.ci.overrides]] # allow loom based tests more time, as they take a while filter = 'test(loom)' slow-timeout = { period = "30s", terminate-after = 2 } +[[profile.agent.overrides]] +# Keep the same exhaustive loom test budget as CI. +filter = 'test(loom)' +slow-timeout = { period = "30s", terminate-after = 2 } + # Profile for AI agent runs. Minimizes output noise: only prints slow, failing, # and flaky tests. Agents should use: cargo nextest run -p --profile agent [profile.agent] @@ -88,4 +103,4 @@ fail-fast = false [profile.ci.junit] path = "junit.xml" -store-success-output = "true" \ No newline at end of file +store-success-output = "true" diff --git a/.github/instructions/doc-code-sync.instructions.md b/.github/instructions/doc-code-sync.instructions.md index fd097299a5b..cc2e29493be 100644 --- a/.github/instructions/doc-code-sync.instructions.md +++ b/.github/instructions/doc-code-sync.instructions.md @@ -13,6 +13,14 @@ does, the PR should update the Guide or flag a follow-up. - CLI args changed in `openvmm_entry` → `Guide/src/reference/openvmm/management/cli.md` - Device crate under `vm/devices/` changed → look for a matching page under `Guide/src/reference/` +- virtio-fs backends, queue/cache policy, save/restore, or microVM attachment + semantics changed (`vm/devices/virtio/virtiofs/`, + `vm/devices/virtio/virtio_resources/`, `vm/devices/support/fs/fuse/`) → + `Guide/src/reference/devices/virtio/virtio-fs.md` +- Control-session framing, record types, payload rules, receive credits, + authentication, or reconnect behavior changed + (`vm/devices/virtio/virtio_console/`) → + `Guide/src/reference/openvmm/management/control_session_protocol.md` - OpenHCL internals changed (`openhcl/`) → `Guide/src/reference/architecture/openhcl/` - Crate renamed or moved → grep `Guide/src/` for the old name - New crate under `vm/devices/` → consider whether it needs a reference page diff --git a/.github/workflows/dep-review.yml b/.github/workflows/dep-review.yml index e484fb1391d..32c2d59de6e 100644 --- a/.github/workflows/dep-review.yml +++ b/.github/workflows/dep-review.yml @@ -29,6 +29,7 @@ permissions: jobs: dep-review: + if: github.repository == 'microsoft/openvmm' runs-on: ubuntu-latest steps: - name: Checkout base branch (safe — our own code) diff --git a/.github/workflows/openvmm-ci.yaml b/.github/workflows/openvmm-ci.yaml index a4e2aa0629a..91965ca1839 100644 --- a/.github/workflows/openvmm-ci.yaml +++ b/.github/workflows/openvmm-ci.yaml @@ -362,7 +362,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -854,7 +854,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1127,7 +1127,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1511,7 +1511,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1871,7 +1871,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2161,7 +2161,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2540,7 +2540,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2934,7 +2934,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -3224,7 +3224,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -3579,7 +3579,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -4164,7 +4164,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4464,7 +4464,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4759,7 +4759,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5059,7 +5059,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5360,7 +5360,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5659,7 +5659,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5942,7 +5942,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -6274,7 +6274,7 @@ jobs: - job3 - job4 - job6 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -6596,7 +6596,7 @@ jobs: needs: - job4 - job9 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6871,7 +6871,7 @@ jobs: id-token: write needs: - job10 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6997,7 +6997,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -7186,7 +7186,7 @@ jobs: id-token: write needs: - job10 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -7364,7 +7364,7 @@ jobs: needs: - job7 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7488,7 +7488,7 @@ jobs: needs: - job7 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7998,7 +7998,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8399,7 +8399,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8634,7 +8634,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8954,7 +8954,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -9193,7 +9193,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -9521,7 +9521,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x diff --git a/.github/workflows/openvmm-docs-ci.yaml b/.github/workflows/openvmm-docs-ci.yaml index 3b60e8a4967..4f158f698fb 100644 --- a/.github/workflows/openvmm-docs-ci.yaml +++ b/.github/workflows/openvmm-docs-ci.yaml @@ -531,7 +531,7 @@ jobs: - job0 - job1 - job2 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/openvmm-pr-release.yaml b/.github/workflows/openvmm-pr-release.yaml index ff68ecbcf0a..b5810bd14ab 100644 --- a/.github/workflows/openvmm-pr-release.yaml +++ b/.github/workflows/openvmm-pr-release.yaml @@ -27,11 +27,7 @@ concurrency: jobs: job0: name: quick check [fmt, clippy x64-linux] - runs-on: - - self-hosted - - 1ES.Pool=openvmm-gh-amd-westus3-v7 - - 1ES.ImageOverride=ubuntu2404-amd64 - - JobId=job0-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + runs-on: ubuntu-latest permissions: contents: read id-token: write @@ -415,7 +411,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -857,7 +853,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1064,7 +1060,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1382,7 +1378,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1700,7 +1696,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -1992,7 +1988,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2297,7 +2293,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2649,7 +2645,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -2941,7 +2937,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -3297,7 +3293,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -3841,7 +3837,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4142,7 +4138,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4438,7 +4434,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4739,7 +4735,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5040,7 +5036,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5340,7 +5336,7 @@ jobs: - job10 - job3 - job4 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5624,7 +5620,7 @@ jobs: - job10 - job3 - job4 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -5957,7 +5953,7 @@ jobs: - job3 - job4 - job6 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -6280,7 +6276,7 @@ jobs: - job0 - job4 - job9 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6556,7 +6552,7 @@ jobs: needs: - job0 - job10 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6684,7 +6680,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -6874,7 +6870,7 @@ jobs: needs: - job0 - job10 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -7053,7 +7049,7 @@ jobs: - job0 - job7 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7178,7 +7174,7 @@ jobs: - job0 - job7 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7455,7 +7451,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7814,7 +7810,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8051,7 +8047,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8373,7 +8369,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8614,7 +8610,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8944,7 +8940,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/openvmm-pr.yaml b/.github/workflows/openvmm-pr.yaml index 67639f64d79..815eefadeee 100644 --- a/.github/workflows/openvmm-pr.yaml +++ b/.github/workflows/openvmm-pr.yaml @@ -26,11 +26,7 @@ concurrency: jobs: job0: name: quick check [fmt, clippy x64-linux] - runs-on: - - self-hosted - - 1ES.Pool=openvmm-gh-amd-westus3-v7 - - 1ES.ImageOverride=ubuntu2404-amd64 - - JobId=job0-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + runs-on: ubuntu-latest permissions: contents: read id-token: write @@ -414,7 +410,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -856,7 +852,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1059,7 +1055,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1270,7 +1266,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1584,7 +1580,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1795,7 +1791,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2113,7 +2109,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -2405,7 +2401,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2710,7 +2706,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -3062,7 +3058,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -3498,7 +3494,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -3855,7 +3851,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -4255,7 +4251,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4556,7 +4552,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4852,7 +4848,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5153,7 +5149,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5454,7 +5450,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5754,7 +5750,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6038,7 +6034,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6371,7 +6367,7 @@ jobs: - job3 - job4 - job6 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6692,7 +6688,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6882,7 +6878,7 @@ jobs: - job0 - job4 - job9 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7380,7 +7376,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7739,7 +7735,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -7976,7 +7972,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8298,7 +8294,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8539,7 +8535,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8869,7 +8865,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/refresh-mirror.yml b/.github/workflows/refresh-mirror.yml index d31fd40c0d6..168e6b30506 100644 --- a/.github/workflows/refresh-mirror.yml +++ b/.github/workflows/refresh-mirror.yml @@ -12,6 +12,7 @@ permissions: jobs: QueueBuild: + if: github.repository == 'microsoft/openvmm' runs-on: ubuntu-latest steps: - uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 diff --git a/.github/workflows/upload-petri-results.yml b/.github/workflows/upload-petri-results.yml index f86a2ea0e9e..c4106a966b8 100644 --- a/.github/workflows/upload-petri-results.yml +++ b/.github/workflows/upload-petri-results.yml @@ -17,12 +17,15 @@ permissions: jobs: upload-logs: + # Only the upstream repository has access to the Azure test results storage. # Only attempt upload if the workflow run could have actually produced some artifacts if: >- ${{ - github.event.workflow_run.conclusion == 'success' || - github.event.workflow_run.conclusion == 'failure' || - github.event.workflow_run.conclusion == 'timed_out' + github.repository == 'microsoft/openvmm' && ( + github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'timed_out' + ) }} runs-on: ubuntu-latest env: diff --git a/Cargo.lock b/Cargo.lock index 6a0255eb2fe..20bcf435626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,6 +579,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" name = "chipset" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "bitfield-struct 0.11.0", "chipset_device", @@ -593,7 +594,9 @@ dependencies = [ "mesh", "open_enum", "pal_async", + "parking_lot", "power_resources", + "serial_core", "test_with_tracing", "thiserror 2.0.16", "tracelimit", @@ -644,6 +647,7 @@ dependencies = [ name = "chipset_device_resources" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "chipset_device", "guestmem", @@ -688,6 +692,7 @@ dependencies = [ name = "chipset_legacy" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "chipset", "chipset_device", @@ -722,6 +727,7 @@ dependencies = [ "local_clock", "memory_range", "mesh", + "thiserror 2.0.16", "vm_resource", ] @@ -989,6 +995,21 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "cpu_profile" +version = "0.0.0" +dependencies = [ + "build_rs_guest_arch", + "hvdef", + "safe_intrinsics", + "serde", + "serde_json", + "sha2", + "test_with_tracing", + "thiserror 2.0.16", + "windows-sys 0.61.0", +] + [[package]] name = "cpubits" version = "0.1.0" @@ -2345,6 +2366,7 @@ dependencies = [ "serde", "serde_json", "target-lexicon", + "test_with_tracing", ] [[package]] @@ -2362,6 +2384,7 @@ dependencies = [ "sysinfo", "target-lexicon", "tempfile", + "test_with_tracing", "toml_edit", "which 8.0.0", "xshell", @@ -2388,6 +2411,7 @@ dependencies = [ "sha2", "target-lexicon", "tempfile", + "test_with_tracing", "toml_edit", "which 8.0.0", ] @@ -2473,6 +2497,7 @@ dependencies = [ "tempfile", "test_with_tracing", "thiserror 2.0.16", + "tracelimit", "tracing", "zerocopy", ] @@ -3751,6 +3776,7 @@ name = "hypervisor_resources" version = "0.0.0" dependencies = [ "anyhow", + "cpu_profile", "linkme", "mesh", "vm_resource", @@ -4593,6 +4619,7 @@ dependencies = [ "range_map_vec", "slab", "sparse_mmap", + "tempfile", "thiserror 2.0.16", "tracing", "virt", @@ -5040,6 +5067,7 @@ dependencies = [ "guid", "inspect", "mesh", + "test_with_tracing", "thiserror 2.0.16", "vm_resource", ] @@ -5051,6 +5079,7 @@ dependencies = [ "anyhow", "async-trait", "consomme", + "guestmem", "inspect", "inspect_counters", "mesh", @@ -5858,12 +5887,14 @@ dependencies = [ "build_rs_guest_arch", "cache_topology", "cfg-if", + "chipset", "chipset_device", "chipset_device_resources", "chipset_device_worker", "chipset_legacy", "chipset_resources", "closeable_mutex", + "cpu_profile", "crc32fast", "cxl_spec", "debug_ptr", @@ -5950,6 +5981,7 @@ name = "openvmm_defs" version = "0.0.0" dependencies = [ "anyhow", + "chipset_resources", "floppy_resources", "framebuffer", "get_resources", @@ -5963,6 +5995,7 @@ dependencies = [ "net_backend_resources", "openvmm_pcat_locator", "smbios_defs", + "state_unit", "thiserror 2.0.16", "tpm_resources", "unix_socket", @@ -5970,6 +6003,7 @@ dependencies = [ "virt_whp", "vm_resource", "vmbus_proxy", + "vmcore", "vmgs_resources", "vmm_core_defs", "vmotherboard", @@ -5986,6 +6020,7 @@ dependencies = [ "clap", "clap_dyn_complete", "console_relay", + "cpu_profile", "crossterm", "crypto", "cxl_spec", @@ -6009,6 +6044,7 @@ dependencies = [ "input_core", "inspect", "inspect_proto", + "libc", "memory_range", "mesh", "mesh_process", @@ -6030,6 +6066,8 @@ dependencies = [ "prost", "rustyline", "scsidisk_resources", + "serde", + "serde_json", "serial_16550_resources", "serial_core", "serial_socket", @@ -6052,6 +6090,7 @@ dependencies = [ "unix_socket", "vfio_assigned_device_resources", "video_core", + "virt", "virt_whp", "virtio_resources", "vm_manifest_builder", @@ -6124,6 +6163,7 @@ name = "openvmm_helpers" version = "0.0.0" dependencies = [ "anyhow", + "cpu_profile", "disk_backend_resources", "disk_vhd1", "disk_vhdmp", @@ -6133,11 +6173,18 @@ dependencies = [ "hypervisor_resources", "libc", "mesh", + "net_backend_resources", + "nix 0.30.1", "openvmm_defs", + "pal", + "sha2", "sparse_mmap", "tempfile", + "thiserror 2.0.16", "tracing", + "virt", "vm_resource", + "windows-sys 0.61.0", ] [[package]] @@ -6146,9 +6193,11 @@ version = "0.0.0" dependencies = [ "anyhow", "build_rs_guest_arch", + "cpu_profile", "fs-err", "hypervisor_resources", "test_with_tracing", + "virt_kvm", "virt_mshv", "virt_whp", "vm_resource", @@ -7751,15 +7800,20 @@ name = "serial_socket" version = "0.0.0" dependencies = [ "futures", + "getrandom 0.4.2", "inspect", + "libc", "mesh", "pal", "pal_async", + "pal_async_test", "serial_core", "socket2", + "test_with_tracing", "tracing", "unix_socket", "vm_resource", + "windows-sys 0.61.0", ] [[package]] @@ -8016,6 +8070,8 @@ dependencies = [ "libc", "pal", "parking_lot", + "tempfile", + "test_with_tracing", "thiserror 2.0.16", "trycopy", "windows-sys 0.61.0", @@ -9715,20 +9771,25 @@ dependencies = [ "aarch64defs", "anyhow", "build_rs_guest_arch", + "cpu_profile", "guestmem", "hvdef", "inspect", + "libc", "memory_range", "mesh_protobuf", "pal_event", "parking_lot", "pci_core", + "safe_intrinsics", "slab", + "test_with_tracing", "thiserror 2.0.16", "tracelimit", "tracing", "vm_topology", "vmcore", + "windows-sys 0.61.0", "x86defs", "zerocopy", ] @@ -9766,6 +9827,7 @@ dependencies = [ "bitfield-struct 0.11.0", "build_rs_guest_arch", "cfg-if", + "cpu_profile", "guestmem", "hv1_emulator", "hv1_hypercall", @@ -9798,6 +9860,7 @@ dependencies = [ "aarch64defs", "anyhow", "build_rs_guest_arch", + "cpu_profile", "fs-err", "guestmem", "headervec", @@ -9810,10 +9873,10 @@ dependencies = [ "mshv-bindings", "mshv-ioctls", "pal", + "pal_async", "pal_event", "parking_lot", "pci_core", - "signal-hook", "test_with_tracing", "thiserror 2.0.16", "tracelimit", @@ -9953,6 +10016,7 @@ dependencies = [ "arrayvec", "build_rs_guest_arch", "chipset_device", + "cpu_profile", "guestmem", "hv1_emulator", "hv1_hypercall", @@ -9967,6 +10031,7 @@ dependencies = [ "pci_core", "range_map_vec", "sparse_mmap", + "test_with_tracing", "thiserror 2.0.16", "tracelimit", "tracing", @@ -10006,6 +10071,7 @@ dependencies = [ "test_with_tracing", "thiserror 2.0.16", "tracelimit", + "tracing", "virtio_resources", "virtio_spec", "vm_resource", @@ -10018,18 +10084,22 @@ name = "virtio_blk" version = "0.0.0" dependencies = [ "anyhow", + "async-channel", "async-trait", "disk_backend", + "disk_file", "disklayer_ram", "futures", "guestmem", "inspect", "inspect_counters", + "mesh", "pal_async", "pal_event", "parking_lot", "scsi_buffers", "task_control", + "tempfile", "test_with_tracing", "tracelimit", "unicycle", @@ -10046,10 +10116,12 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "chipset_device", "futures", "futures-concurrency", "guestmem", "inspect", + "mesh", "pal_async", "pal_event", "parking_lot", @@ -10058,7 +10130,6 @@ dependencies = [ "test_with_tracing", "thiserror 2.0.16", "tracelimit", - "tracing", "virtio", "virtio_resources", "vm_resource", @@ -10072,6 +10143,7 @@ dependencies = [ "anyhow", "async-trait", "bitfield-struct 0.11.0", + "chipset_device", "futures", "guestmem", "inspect", @@ -10141,6 +10213,8 @@ version = "0.0.0" dependencies = [ "mesh", "net_backend_resources", + "serial_core", + "thiserror 2.0.16", "unix_socket", "vm_resource", ] @@ -10215,12 +10289,14 @@ name = "virtiofs" version = "0.0.0" dependencies = [ "anyhow", + "chipset_device", "fuse", "futures", "guestmem", "inspect", "lx", "lxutil", + "mesh", "ntapi", "pal", "pal_async", @@ -10229,6 +10305,8 @@ dependencies = [ "task_control", "tempfile", "test_with_tracing", + "thiserror 2.0.16", + "tracelimit", "tracing", "virtio", "virtio_resources", @@ -10828,6 +10906,7 @@ dependencies = [ "iced-x86", "input_core", "inspect", + "loader", "memory_range", "mesh", "pal_async", @@ -10837,6 +10916,7 @@ dependencies = [ "power_resources", "slab", "state_unit", + "test_with_tracing", "thiserror 2.0.16", "tracing", "virt", diff --git a/Cargo.toml b/Cargo.toml index 2030eeb1eec..c206e7aa4b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -450,6 +450,7 @@ vm_topology = { path = "vm/vmcore/vm_topology" } vmcore = { path = "vm/vmcore" } # vmm_core +cpu_profile = { path = "vmm_core/cpu_profile" } hyperv_dump = { path = "vmm_core/hyperv_dump" } state_unit = { path = "vmm_core/state_unit" } virt = { path = "vmm_core/virt" } diff --git a/Guide/src/SUMMARY.md b/Guide/src/SUMMARY.md index c27086aae6d..90a3578e0cc 100644 --- a/Guide/src/SUMMARY.md +++ b/Guide/src/SUMMARY.md @@ -88,6 +88,8 @@ - [OpenVMM Features](./reference/openvmm.md) - [Configuration and Management](./reference/openvmm/management.md) - [CLI](./reference/openvmm/management/cli.md) + - [Control-session Protocol](./reference/openvmm/management/control_session_protocol.md) + - [Host-control Protocol](./reference/openvmm/management/host_control_protocol.md) - [Interactive Console](./reference/openvmm/management/interactive_console.md) - [gRPC / ttrpc](./reference/openvmm/management/grpc.md) - [Graphical Console](./reference/openvmm/graphical_console.md) @@ -111,7 +113,7 @@ - [Devices]() - [PCI IDs](./reference/devices/pci_ids.md) - [Virtio]() - - [virtio-fs]() + - [virtio-fs](./reference/devices/virtio/virtio-fs.md) - [virtio-9p]() - [virtio-net]() - [virtio-pmem]() diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 809822ce089..ea6ae4a84b6 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -5,13 +5,15 @@ for developers working on the save/restore subsystem. ## Directory layout -A snapshot is stored as a directory containing three files: +A snapshot is stored as a directory containing three required files and, for +a mounted microVM scratch capture, one paired scratch image: ```text snapshot-dir/ ├── manifest.bin # Protobuf-encoded SnapshotManifest ├── state.bin # Protobuf-encoded device saved state -└── memory.bin # Hard link to the guest memory backing file +├── memory.bin # Exact automatic RAM file or independent supplied-RAM clone +└── scratch.img # Optional paired microVM writable scratch image ``` ## Manifest format @@ -21,6 +23,34 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. +Every snapshot uses manifest version 6 with format magic +`OPENVMM_SNAPSHOT_V6\0`. Restore rejects any other version or magic with +`E_SNAPSHOT_VERSION`, which asks for a recapture. A microVM snapshot's machine +contract carries the NVX time ABI records (the time contract and the CPU +profile record), and a host-driven standard-machine snapshot has no machine +contract. The `SnapshotManifest` tags 9 and 10 (the version 2 artifact +digests) and the machine contract tags 11 to 15, 17, and 20 (the clock fields +of versions 2 to 5) are retired and never reused. Tiered microVM snapshots +record capture tier, clone/resume policy, and consumed configuration sections. + +The default format is a local machine-state contract, not an authenticated +container. Every snapshot receives the same regular-file, no-follow/no-reparse, +bounded decoding, exact-length, inventory, and machine-contract validation, +but the on-disk format does not authenticate same-length payload changes. +By default, paired capture records SHA-256 and exact length for `scratch.img`, +because guest RAM and a mounted writable filesystem must be restored as one +exact pair. Version 6 also permits an explicit `generation` identity for all +bound sandbox blocks. Generation identity is a caller-authenticated immutable +storage version, not a byte digest; OpenVMM validates role, size, geometry, and +the shared generation without scanning block contents. Export or transport +layers must provide broader integrity and authentication outside this format. + +The optional microVM network contract stores a versioned SHA-256 digest of its +bound egress policy. Encoding version 2 includes the static prefix, guest MAC, +and canonical ARP next-hop set in addition to the policy rules. An absent +encoding-version field denotes version 1, allowing network snapshots produced +before endpoint next-hop binding to retain their original digest semantics. + ## Device state (`state.bin`) The device state contains every device's saved state, collected via the @@ -30,28 +60,66 @@ default values, forward/backward compatibility) apply. ## Memory (`memory.bin`) -`memory.bin` is a hard link to the file-backed guest RAM file. During a save, -`write_snapshot()` creates this hard link using `std::fs::hard_link`. - -```admonish note -The hard-link approach means the memory backing file and snapshot directory -must reside on the same filesystem. If they are on different filesystems, -`write_snapshot` returns an error with a suggestion to place the backing -file inside the snapshot directory. -``` - -### Same-file detection - -If the user passes `--memory file=/memory.bin`, the source and -target of the hard link are the same file. The code detects this by -canonicalizing both paths and comparing them. When they match, the hard-link -step is skipped. +For automatically allocated microVM RAM, `memory.bin` is a hard link to the +exact OpenVMM-owned backing handle. State and manifest are written and flushed +first; OpenVMM then flushes the stopped guest's shared RAM mappings and exact +backing handle, creates the link last, and verifies the linked handle's file +identity and EOF before the staging directory is renamed into place. Linux +prefers `linkat(AT_EMPTY_PATH)` and can use a `/proc/self/fd` link with a +device/inode proof. Windows uses handle-relative `FileLinkInformation` and +verifies `FILE_ID_INFO`. Filesystems that cannot create the link fall back to +an independent sparse-aware copy. + +User-supplied RAM always uses the independent-copy path, never a hard link. +Clone support is used when available, with allocated-range or zero-scan copying +as a fallback on Linux; Windows uses a dense copy. Both paths use an +already-open handle rather than reopening its pathname, so replacing the source +path cannot substitute different bytes. +Successful guest-requested capture is terminal for the source. If publication +fails after the automatic link exists, OpenVMM removes and durably flushes the +private staging directory before allowing rollback; uncertain cleanup makes +the source terminate instead of resume. + +Restore likewise opens the snapshot directory once and resolves its artifacts +relative to that handle. Windows uses read-only handles with `FILE_SHARE_READ` +only, rejects reparse points, compares `FILE_ID_INFO` and EOF before and after +creating the private COW section, and keeps the directory and artifact guards +in the VM worker until teardown. Linux keeps the exact `O_NOFOLLOW` directory +and regular-file descriptors and rejects observable metadata changes before +handoff, so renaming or replacing the original path cannot substitute another +generation. Linux file descriptors do not provide mandatory write exclusion; +deployments that need authenticated or write-proof local artifacts must add a +stronger mode such as a lease, fs-verity, or a verified artifact broker. + +## Scratch (`scratch.img`) + +The microVM block contract records every fixed role, access mode, geometry, +identity kind, identity, and paired-scratch restore mode. A paired capture +copies the exact opened writable scratch handle into the same staging +directory after device queues drain and publishes it atomically with VM state. +`sha256` identity verifies bytes before publication and restore. `generation` +identity instead requires every bound block to carry the same nonzero +caller-authenticated storage generation and performs structural and exact-size +validation without whole-file rehashing. + +Paired scratch defaults to `private-copy`, which preserves existing behavior +and may use a reflink or sparse copy. `copy-on-write` requires a filesystem +reflink and is restricted to reusable clone snapshots. `direct-claimed` +attaches the paired file only for a single-use resume snapshot after its +durable claim; the claimed artifact is then continuation storage rather than +an available snapshot. A missing restore-mode field retains `private-copy` +behavior. + +A pre-mount capture instead records the `fresh` scratch policy and geometry, +contains no `scratch.img`, and requires a new matching scratch file on restore. ## Code references - Manifest type and I/O: `openvmm/openvmm_helpers/src/snapshot.rs` +- Format validation, publication, restore-side access, file-system helpers, + and the microVM machine contract: `openvmm/openvmm_helpers/src/snapshot/` - Restore entry point: `prepare_snapshot_restore()` in - `openvmm/openvmm_entry/src/lib.rs` + `openvmm/openvmm_entry/src/snapshot_restore/prepare.rs` - File-backed memory: `SharedMemoryFd` type alias in `openvmm/openvmm_defs/src/worker.rs` @@ -110,10 +178,16 @@ Key unsupported categories: - **Pass-through PCI** — `AssignedPciDevice`, `RelayedVpciDevice`. - **VGA / GDMA** — marked `todo!()` (will panic on save). - **Virtio devices** — the `VirtioDevice` trait defaults - `supports_save_restore()` to `false`. Only `virtio-blk`, - `virtio-net`, `virtio-pmem`, and `virtio-rng` override it to `true`. - Devices with host-side session state (`virtio-9p`, `virtiofs`, - `virtio-console`) intentionally leave it `false`. + `supports_save_restore()` to `false`. `virtio-blk`, `virtio-console`, + `virtio-pmem`, and `virtio-rng` override it to `true`. `virtio-net` enables + it only for resources with an explicit static identity and feature contract, + such as the microVM NIC; ordinary virtio-net resources remain + disabled. + The transport stores an opaque typed device-private payload in addition to + common queue state. Devices with unsupported host-side session state, such + as `virtio-9p`, leave save/restore disabled. `virtiofs` enables typed + device-private state only for the constrained microVM HostFs profile; + ordinary, aggregate, and SectionFs resources remain disabled. - **Some VMBus devices** — `GuestCrashDevice`, `GuestEmulationDevice`, `VmbusSerialHost`, `Vmbfs` return `None` from `supports_save_restore()`. @@ -121,7 +195,8 @@ Key unsupported categories: ## Extending the format When adding new fields to `SnapshotManifest`, use the next available mesh -tag number. The protobuf encoding is forward-compatible: older readers will +tag number, never a retired one; the struct definitions list the retired tags. +The protobuf encoding is forward-compatible: older readers will ignore unknown fields. However, removing or reordering existing fields is a breaking change. See [Save State](contrib/save-state.md) for the full set of compatibility rules. diff --git a/Guide/src/dev_guide/tests/vmm.md b/Guide/src/dev_guide/tests/vmm.md index c05961022cf..c91a8a0031b 100644 --- a/Guide/src/dev_guide/tests/vmm.md +++ b/Guide/src/dev_guide/tests/vmm.md @@ -134,6 +134,37 @@ To run a **specific test** (or set of tests), use `--filter` with a cargo xflowey vmm-tests-run --filter "test(my_test_name)" --dir /tmp/vmm-tests-run ``` +`--dir` selects the artifact and log directory. Native local runs preserve +your existing temporary-directory settings instead of placing temporary +sockets under a potentially long checkout path. Keep any custom temporary +directory short enough for Unix socket names. CI and Windows-from-WSL2 runs +still place temporary files on the test-content disk, preserving scratch-disk +placement and Windows filesystem access. + +### MSHV run-entry cancellation + +On Linux, run the MSHV cancellation regression tests without requiring +`/dev/mshv`: + +```bash +cargo nextest run --profile agent -p virt_mshv -E 'test(run_vp::tests)' +``` + +These tests check early and same-thread cancellation, interruption of a +blocking syscall, and the boundary that preserves an already completed syscall. +MSHV must retain a wake delivered before entering `MSHV_RUN_VP`; an interrupt +signal alone can otherwise be consumed before the blocking ioctl begins. + +On an MSHV host, also exercise the Alpine boot and guest-agent handshake: + +```bash +test='multiarch::openvmm_uefi_x64_alpine_3_23_x64_boot_small' +cargo xflowey vmm-tests-run --release --filter "test(/^${test}$/)" +``` + +Keep the normal test watchdog enabled. A timeout or an unresponsive VP +inspection must not be hidden by retries or a longer deadline. + ### Targeting a Platform By default, `vmm-tests-run` builds for the current host. Use `--target` to diff --git a/Guide/src/reference/backends/consomme.md b/Guide/src/reference/backends/consomme.md index 33f78844462..73f92c7f463 100644 --- a/Guide/src/reference/backends/consomme.md +++ b/Guide/src/reference/backends/consomme.md @@ -38,6 +38,49 @@ flowchart TB NIC --> Internet((Internet)) ``` +## microVM portable profile + +OpenVMM microVMs select this backend with +`--net --network-profile portable`. This is the sole microVM +network profile and has the same Consomme behavior on Linux/KVM, Linux/MSHV, +and Windows/WHP. It provides gateway DNS over UDP and TCP, ICMP echo, and +outbound TCP/UDP subject to the microVM egress policy. IPv4 fragments are +rejected deterministically. `--net-tap` is incompatible. + +The portable profile accepts directional network defaults through +`--network-egress ` and `--network-ingress `. It +supports both egress actions and ingress `deny`. Stateful replies to an +allowed guest-initiated flow are not treated as new inbound connections. +Ingress `allow` is rejected before VM resources are opened because portable +NAT does not expose arbitrary guest listeners. + +The generic egress rule form accepts destination IPv4 addresses or CIDRs, +optionally restricted to TCP, UDP, or ICMP, and for TCP or UDP optionally to +one destination port or to an inclusive range of them. Deny rules take +precedence over allow rules, and the explicit directional default handles +traffic that matches neither list. Filtering and malformed-packet rejection +occur before Consomme creates a host socket. Port-specific policies reject IPv4 +fragments rather than allowing later fragments to bypass transport checks. + +The portable profile does not provide generic bidirectional host-loopback +connectivity. Explicit `--host-loopback allow` without any +`--host-loopback-forward` is rejected before VM resources are opened. +With deliberate TCP/UDP forwards, `allow` maps the guest-visible gateway to +host loopback, permits host-local destinations subject to egress policy, and +publishes only the specified localhost-to-guest ports. This is port publishing, +not a generic bidirectional allow contract. + +Omitting `--host-loopback` preserves the existing guest-to-host mapping but +does not publish guest ports. `deny` rejects general gateway socket traffic +and all host-to-guest forwards. One exact gateway TCP port may remain mapped +as a proxy exception; UDP on that same port and other host service ports stay +blocked even when ordinary egress is allowed. + +Snapshot restore creates a fresh endpoint generation. Host sockets and NAT +flow tables are not saved. Virtio-net capture drains descriptor ownership +before state is saved, and restored guest software must establish new +host-side flows. + ## Default network topology | Role | IPv4 Address | @@ -161,6 +204,10 @@ host-side UDP socket bound to an ephemeral port. Datagrams are forwarded in both directions. Idle bindings are cleaned up after a configurable timeout (5 minutes by default, per RFC 4787). +Consomme admits at most 256 active guest UDP flows, 128 active guest TCP flows, +and 16 guest ICMP source flows by default. It rejects an excess flow before +creating its host socket; it never evicts an active flow to make room. + UDP packets to the gateway on well-known ports are intercepted: - **Port 67** — handled by the built-in DHCP server @@ -193,8 +240,11 @@ are not relayed — see limitations). ### ARP and NDP -- **ARP** — Responds to requests for the gateway MAC address. All other - ARP traffic is dropped. +- **ARP** — Responds to requests for the gateway and for on-link next hops + admitted by exact endpoint policy, using the gateway MAC as the proxy + address. The shared egress policy accepts only guest-identity requests for + its canonical next-hop set, so resolving an endpoint does not authorize + unrelated IPv4 traffic. - **NDP** — Responds to Router Solicitations (advertising the IPv6 prefix and DNS servers) and Neighbor Solicitations for the gateway's link-local address. DAD is silently ignored. diff --git a/Guide/src/reference/backends/networking.md b/Guide/src/reference/backends/networking.md index c3956db77fd..577b8eea541 100644 --- a/Guide/src/reference/backends/networking.md +++ b/Guide/src/reference/backends/networking.md @@ -164,3 +164,11 @@ own async task. Currently `netvsp` and `net_mana` support multi-queue; `virtio_net` is limited to a single queue pair. + +## microVM portable profile + +The microVM `portable` network profile always selects Consomme on Linux/KVM, +Linux/MSHV, and Windows/WHP. It is explicitly required with +`--network-profile portable` whenever a microVM uses `--net`; `--net-tap` is +not part of this profile. Snapshot restore recreates a new endpoint generation +instead of serializing host sockets or protocol flows. diff --git a/Guide/src/reference/devices/firmware/linux_direct.md b/Guide/src/reference/devices/firmware/linux_direct.md index 5b6b084165d..6db44351d7e 100644 --- a/Guide/src/reference/devices/firmware/linux_direct.md +++ b/Guide/src/reference/devices/firmware/linux_direct.md @@ -22,6 +22,14 @@ the protected-mode code directly into guest memory and relies on the kernel's built-in decompressor to run at boot time. All standard bzImage compression formats are supported since decompression is handled by the kernel itself. +The x86-64 microVM profile uses the Linux zero-page protocol with an +uncompressed ELF kernel, but selects an ACPI-free platform mode. OpenVMM places +an Intel MP 1.4 floating pointer at `0x0`, its configuration table at `0x400`, +the bootstrap GDT at `0x1000`, `boot_params` at `0x2000`, and the command line +in the 64-KiB region starting at `0x20000`. The MP table describes contiguous +APIC IDs, the ISA bus, the IOAPIC, and legacy IRQ routing. No RSDP, ACPI table, +SMBIOS anchor, or SMBIOS structure table is imported. + On AArch64, pass the uncompressed `Image` file (not `Image.gz`). ## x86_64 Boot Flow @@ -32,12 +40,12 @@ On x86_64, OpenVMM follows the standard Linux boot protocol: 2. An initrd (if provided) is placed after the kernel. 3. A **zero page** is constructed containing the memory map, command line pointer, and initrd location. -4. ACPI tables (MADT, FADT, DSDT, SRAT, etc.) are built by OpenVMM's ACPI +4. For the standard profile, ACPI tables (MADT, FADT, DSDT, SRAT, etc.) are built by OpenVMM's ACPI builder and placed in low memory just above the boot metadata. The RSDP is placed at the fixed `0xE0000` and the kernel discovers it through its legacy firmware scan of `[0xE0000, 0x100000)`; the RSDP's XSDT pointer references the tables below it. -5. **SMBIOS (DMI) tables** are synthesized. Because there is no firmware to +5. For the standard profile, **SMBIOS (DMI) tables** are synthesized. Because there is no firmware to build them, OpenVMM constructs a SMBIOS 3.0 (64-bit) entry point (`_SM3_`) plus a minimal structure table (Type 0 BIOS, Type 1 System, Type 127 end-of-table) itself. ("SMBIOS 3.0 (64-bit) Entry Point" is the spec's name @@ -56,6 +64,12 @@ On x86_64, OpenVMM follows the standard Linux boot protocol: The DSDT includes whatever x86 chipset devices are configured (serial ports, IOAPIC, PCI bus, VMBus, virtio-mmio, RTC, etc.). +For `--machine microvm`, steps 4 and 5 are replaced by the MP-table layout +described above. The zero page leaves `acpi_rsdp_addr` zero, reserves the ISA +hole and the live shared-status page in e820, and leaves the fixed 3-to-4-GiB +MMIO aperture out of RAM. The loader enters the ELF kernel in long mode with +`RSI=0x2000`, `CR3=0x4000`, and paging enabled. + ## AArch64 Boot Flow On AArch64, OpenVMM supports two modes for presenting hardware descriptions to @@ -105,8 +119,8 @@ will result in an error. ## SMBIOS / DMI Identity -On both architectures the synthesized SMBIOS tables expose a default identity, -so `/sys/class/dmi/id/*` reads consistently regardless of boot path: +For standard-profile direct boot on both architectures, synthesized SMBIOS +tables expose a default identity: | sysfs file | Default value | |------------|---------------| @@ -120,6 +134,8 @@ The SMBIOS UUID defaults to the all-zero GUID, which Linux treats as not present and therefore does not expose as `product_uuid`. Pass `--smbios type=1,uuid=` (or `uuid=random`) to set it. +The microVM MP-table mode exposes no SMBIOS data and rejects SMBIOS overrides. + ## CLI Usage ```bash diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md new file mode 100644 index 00000000000..6c8f4046ed2 --- /dev/null +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -0,0 +1,350 @@ +# virtio-fs + +OpenVMM can expose a host directory to a Linux guest through `virtio-fs`. + +## Standard machine + +For the standard machine, `--virtio-fs` creates a HostFs device with a +caller-selected tag and host path: + +```bash +openvmm --virtio-fs myfs,path/to/share +``` + +Mount it in the guest with the same tag: + +```bash +mount -t virtiofs myfs /mnt/share +``` + +Standard-machine virtio-fs may use the normal PCI, VPCI, or MMIO placement +rules. It does not support snapshot and restore. + +## microVM + +The microVM profile defines two fixed virtio-fs slots. A cold boot always +exposes the first slot; without `--mount`, it is guest-discoverable but +dormant and has no HostFs backend or filesystem policy. Configure an active +attachment with: + +```bash +openvmm --machine microvm \ + --mount /mnt/share,path/to/share,ro \ + --mount-deny path/to/share/secrets \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +The format is `GUEST_TARGET,HOST_PATH[,ro|rw]`. The default mode is `ro`; +read-write access must be explicit. The profile fixes the remaining +guest-visible configuration: + +| Property | Value | +|---|---| +| Stable IDs | `fs:microvm0`, `fs:microvm1` | +| Tags | `microvm`, `microvm1` | +| Transport | virtio-mmio at `0xd0001000`, `0xd0008000` | +| Interrupts | IRQ 6, IRQ 13 | +| Queues | One high-priority and one request queue per slot | +| Features | Indirect descriptors, event index, version 1, access-platform | +| DAX window | None | +| Cache policy | Zero entry and attribute lifetimes | +| File policy | Direct I/O | +| Maximum write | 1 MiB payload plus protocol headers | +| Symbolic links | `rw`: created with the exact target; `ro`: `EROFS` | + +### Several shares + +Repeat `--mount` to attach a second host directory with its own guest target, +tag, and access mode. Attachments fill the slots in command-line order, so +the first uses `fs:microvm0` and tag `microvm`, and the second uses +`fs:microvm1` and tag `microvm1`: + +```bash +openvmm --machine microvm \ + --mount /workspace,path/to/workspace,rw \ + --mount /opt/hostedtoolcache,path/to/toolcache,ro \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +The second slot exists only while a directory is attached to it, so machines +with one or no share keep their device inventory and command line. Each slot +is a separate HostFs server, and it enforces its own access mode and denied +paths, so a guest cannot write to a read-only share through any mount of +either tag. OpenVMM rejects more than two attachments, guest targets that +equal or contain one another, and host directories that equal, contain, or +are contained in one another (compared after canonicalization and by root +object identity), because one share could otherwise reach files that the +other share denies or exposes with a different access mode. On Linux, +OpenVMM also compares the filesystem sources that each directory reaches, +from `/proc/self/mountinfo`, including the mounts below it, so a bind mount +or nested mount can't expose part of one share as, or inside, the other. +With several attachments, each `--mount-deny`, `--mount-allow`, and +`--mount-write` must be an absolute host path, and it applies to the share +whose directory contains it. `--mount-owner` applies to every share. + +For an active cold-boot attachment, the profile adds `virtfs_dir`, +`virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line, +one triplet per share in slot order. The first slot is always discoverable, +and the second slot's `virtio_mmio.device=` token follows the control +console's. Active attachment policies and their canonical absolute host paths +become snapshot-authoritative. + +### Access policy + +Three repeatable options refine what the guest can see and modify inside a +share. Each names an existing host file or directory. A relative path is +relative to the host directory of the only `--mount`; with several shares, +the path must be absolute, and it applies to the share whose directory +contains it. + +- `--mount-deny ` hides the path and everything below it. +- `--mount-allow ` exposes the path and everything below it again, + inside a denied path. The nearest denied or allowed path that contains an + allowed path must be a denied path, and the nearest one that contains a + denied path must be an allowed path, if any, so denied paths may nest inside + allowed paths. +- `--mount-write `, on a read-write share, makes the path and + everything below it one of the only parts of the share that the guest can + modify. The rest of the share is read-only. Writable paths must not + overlap or lie in a hidden part of the share. A read-only share rejects + them. + +```bash +openvmm --machine microvm \ + --mount /tmp/gh-aw,path/to/gh-aw,rw \ + --mount-deny path/to/gh-aw/mcp-logs \ + --mount-allow path/to/gh-aw/mcp-logs/payloads \ + --mount-write path/to/gh-aw/agent \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +A hidden directory on the way from a denied path to an allowed path is +*traverse-only*. The guest can look it up and list it, but the listing shows +only the directories that lead to allowed paths. Every other name in it fails +with `EACCES`, and the guest cannot modify the directory or its entries. In +the example above, the guest sees `mcp-logs` with only `payloads` in it. +Allowed paths follow the share's write policy: here `payloads` is read-only +because it is not inside a writable path, but without `--mount-write` it +would be writable like the rest of the read-write share. + +The guest's modifications of a traverse-only directory or its entries, and +with `--mount-write` of anything outside the writable paths, fail with +`EROFS` before HostFs touches the host: +creating, linking, renaming, or removing an entry; writing, truncating, or +opening a file for writing; and changing attributes or extended attributes. +Moving an entry into or out of a writable path changes a read-only directory, +so it also fails with `EROFS`. A hard link to a read-only file from inside a +writable path would make the file writable, so it fails with `EXDEV`, as a +link across mounts does. An object with several names is writable only if +every name that the guest has used for it is writable. + +OpenVMM canonicalizes each policy path relative to its export and rejects +paths that do not exist, paths outside the roots, a root itself, duplicates, +redundant or contradictory combinations, symlink/reparse components, and +nested-mount crossings before opening the device. HostFs also requires every +traverse-only path to be a directory when it attaches the share. + +```admonish warning +The policy applies to names inside the export. A host hard link or bind +mount that makes a read-only object reachable inside a writable path also +makes that object writable, and one that makes part of a denied path +reachable elsewhere exposes that part. HostFs pins the objects of denied +paths and traverse-only directories, so it rejects those objects themselves +at any other name, but not their contents. Do not place such aliases inside +an export. +``` + +A read-write attachment lets the guest create symbolic links, so ordinary +build tools (package managers, virtual environments, and language +toolchains) work in a live share. HostFs stores each target byte for byte and +never follows a link while resolving a host path; the guest kernel resolves +links in its own namespace. A target that names an absolute host path, a +location above the export, or a denied path therefore cannot reach host data +outside the policy. Opening a link object fails with `ELOOP`, and on Linux +changing its mode fails with `EOPNOTSUPP`. On Windows, HostFs always creates +WSL-style links, which Windows path resolution never follows, so Windows tools +see guest-created links as inaccessible files. Host software that later reads +the exported directory must treat guest-created links as untrusted. + +### Host identity of guest operations + +`--mount-owner` selects the host identity that performs the guest's +operations: + +- `vmm` (the default): every operation runs as the OpenVMM process, which + therefore owns every file and directory that the guest creates. The guest + enforces permissions against the ownership and mode bits that HostFs + reports. +- `caller`: each operation runs as the UID and GID that the guest kernel + reports for its caller, so a workload's files are owned by its own numeric + identity on the host, and the host also enforces permissions for that + identity. Guest UID 0 and GID 0 are squashed to the owner and group of the + export root, so the guest can create neither root-owned nor setuid-root + host files. OpenVMM therefore refuses an export root owned by UID 0 or + GID 0. + +```bash +openvmm --machine microvm \ + --mount /mnt/share,path/to/share,rw \ + --mount-owner caller \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +In `caller` mode, the request queue worker switches only its own thread's +credentials for one request at a time: it sets the filesystem UID and GID +with `setfsuid` and `setfsgid`, drops every supplementary group other than +that GID, and clears the effective capabilities, then restores all of them +before it handles anything else. Session negotiation, `FORGET`, and handle +release do not access host files and run unchanged. Assuming an identity +other than OpenVMM's own, or dropping OpenVMM's other supplementary groups, +needs `CAP_SETUID` and `CAP_SETGID`, for example as ambient capabilities of +an unprivileged OpenVMM process. Without them, only a caller whose UID and +GID match OpenVMM's filesystem UID and GID can run, and only if OpenVMM has +no other supplementary groups. A request that cannot run as its caller, +because a credential switch fails or the capabilities are missing, fails +with `EPERM`; HostFs never falls back to OpenVMM's identity, groups, or +capabilities. `caller` mode requires a Linux host. Windows has no +per-request POSIX identity to switch to, so OpenVMM rejects +`--mount-owner caller` there. + +```admonish warning +The guest kernel reports each caller's identity, and guest root may assume +any identity inside the guest. Guest root and a compromised guest kernel can +therefore act as any nonzero host UID and GID inside the export, including +leaving setuid files owned by it. Export only trees that every such identity +may modify, and keep the export on a host filesystem mounted `nosuid` when +host users might execute guest-created files. Grant OpenVMM only +`CAP_SETUID` and `CAP_SETGID`: these capabilities let it assume any host +identity. +``` + +`SectionFs`, aggregate roots, alternate tags, PCI transport, DAX, and extra +queues are not part of the microVM profile. + +## Snapshot attachments + +A microVM snapshot stores FUSE negotiation, namespace identifiers, lookup +counts, reopenable handles, directory cookies, and virtqueue progress. It +does not copy the host directory or serialize native file descriptors and +Windows handles. + +An open directory continues from its bounded captured entry snapshot, so +later host additions do not appear midway through that enumeration. New +lookups and newly opened directories still observe the live host tree. + +Restoring a snapshot captured with an active attachment requires a fresh +`--mount` argument for every captured share, in the same order, with the exact +same denied, allowed, and writable paths, canonical host path, guest target, +mode, and `--mount-owner` mode. Identity validation remains independent: before any vCPU +starts, OpenVMM pins the supplied root and validates its saved root and object +identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable +objects fail restore. A saved symbolic link is revalidated as the link itself, +without being followed, and an alias whose ancestor has become a link fails +restore. A handle that the guest held open for writing must still be on a +writable path. + +The device state of an attachment with allowed or writable paths records its +complete access policy, and a restore must supply the same one. OpenVMM +releases that predate these paths reject that state rather than restore the +share without them. + +`--mount-owner caller` applies only to guest requests. Capture and restore +still revalidate saved names and reopen saved handles as the OpenVMM process. +Unless OpenVMM may bypass file permissions, as root may, capture therefore +fails while the guest holds a name inside a directory that OpenVMM cannot +search. Restore fails when OpenVMM cannot reopen a saved handle with its saved +access, such as a handle open for writing on a file that a guest caller owns. +OpenVMM releases that predate `--mount-owner` reject the device state of a +`caller` attachment rather than restore it as `vmm`. + +A snapshot captured without `--mount` records the first slot as dormant. It +may restore without an attachment, or bind one new `--mount` attachment to +that slot; the second slot did not exist at capture, so it cannot be added on +restore. Because execution resumes after the cold-boot mount hook, the guest +must mount the newly attached backend explicitly: + +```bash +mkdir -p /mnt/share +mount -t virtiofs microvm /mnt/share +``` + +Snapshots created before the dormant-slot capability cannot add a restore-time +attachment and fail with a compatibility error. + +```admonish warning +An ordinary host directory is live external state. Host changes after capture +can be visible after restore or make identity validation fail. Restoring the +same read-write VM snapshot does not roll the host directory back. +Quiesce external host writers when deterministic replay is required. +``` + +The snapshot contract uses the `live-revalidate` policy. Immutable filesystem +generations and private writable clones are not currently exposed. + +Snapshot destinations, restore directories, and explicit guest-memory backing +files must be outside the exported host tree. OpenVMM rejects configurations +that would expose guest RAM or snapshot files through virtio-fs. + +## Security model + +Guest FUSE requests, paths, and saved aliases are untrusted. HostFs rejects +absolute and parent-relative aliases, does not follow symbolic links or +Windows reparse points while resolving guest or saved objects, and enforces +read-only mode before invoking a host mutation. + +On Linux, every host operation opens the parent directory of its path with +`openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)`, or with one +`O_NOFOLLOW` open per component where `openat2` is unavailable, and applies +the operation to the final component without following it. Extended +attributes and file system statistics use that pinned directory rather than +an absolute host path. Neither a guest-created link nor a concurrent rename +can therefore redirect an operation outside the export. On Windows, the guest +can only create WSL-style links, which are never followed, and HostFs rejects +any ancestor that is a symbolic link or reparse point before each operation. + +Denied paths are enforced in the server namespace rather than by guest mount +layout. Lookup and mutation operations reject denied prefixes, directory +enumeration omits their names, and denied root object identities reject +hard-link, junction, and bind-mount aliases. A traverse-only directory's object +is accepted only at its own path, and only as a directory, so neither a host +rename nor another name for it exposes its hidden entries. A guest-created +link cannot reach a denied path because the guest resolves it and every +resulting host lookup applies the same policy. Writable paths are enforced the +same way: every mutation checks the names that it changes and the object that +it modifies against the policy before the host operation. Mounting the same +virtio-fs tag at another guest path, or remounting it read-write, does not +change the policy, and each share's policy applies only to requests for its +own tag. + +```admonish warning +On Windows, the check for host-created NT symbolic links and junctions in +ancestor components is not handle-relative. Do not let an untrusted host +process create reparse points or replace directories inside a Windows export +while a VM runs. On every host, quiesce external namespace mutation during +capture and restore. +``` + +Host filesystem behavior differs where Windows cannot represent a POSIX +operation. Unsupported operations return a Linux error rather than reporting +false success. + +## Code references + +- Device implementation: + `vm/devices/virtio/virtiofs/` +- Share access policy: + `vm/devices/virtio/virtiofs/src/microvm/policy.rs` +- Per-thread filesystem credentials: + `vm/devices/support/fs/lxutil/src/unix/credentials.rs` +- FUSE session implementation: + `vm/devices/support/fs/fuse/` +- Resource contract: + `vm/devices/virtio/virtio_resources/src/lib.rs` and + `vm/devices/virtio/virtio_resources/src/fs/microvm.rs` +- microVM composition and restore attachment validation: + `openvmm/openvmm_entry/src/microvm/filesystem.rs` and + `openvmm/openvmm_entry/src/ttrpc/microvm.rs` +- Snapshot manifest and microVM filesystem contract: + `openvmm/openvmm_helpers/src/snapshot.rs` and + `openvmm/openvmm_helpers/src/snapshot/microvm.rs` +- [`virtiofs` rustdoc](https://openvmm.dev/rustdoc/linux/virtiofs/index.html) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index d56a1b71e83..10dd4b33c86 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -19,6 +19,499 @@ describes the source definitions. `MAJOR.MINOR.PATCH`. On Windows, the executable's `VERSIONINFO` uses the product version as `MAJOR.MINOR.PATCH.0`. * `--processors `: The number of processors. Defaults to 1. +* `--machine `: Select the guest-visible machine contract. The + default is `standard`. `microvm` selects the ACPI-free x86-64 Linux direct + microVM, which + runs on KVM, MSHV, or WHP with exactly 1, 2, 4, or 8 vCPUs. On + Linux, auto-detection prefers MSHV when `/dev/mshv` is available and falls + back to KVM: + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --hypervisor mshv \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --processors 8 --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz + ``` + + The kernel must be an uncompressed ELF64 image. The profile owns the base command line + (`earlycon=xe9 console=hvc0 reboot=t panic=-1`) and switches the primary + console to `hvc1` when `--virtio-console` is present. It appends + `nr_cpus=` from the validated processor topology. It reserves a 1-GiB + MMIO gap from 3 to 4 GiB and exposes only PIC/IOAPIC, PIT, a CMOS RTC + anchored to UTC, + the microVM portb console, lifecycle ports, and the optional fixed virtio + devices described below. User arguments cannot override `earlycon=`, + `console=`, `virtio_mmio.device=`, `virtnet_*=`, or `virtfs_*=`. + + One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains + dormant when `--mount` is omitted; `--mount` binds HostFs to it, and a + second `--mount` adds a second slot at MMIO `0xd0008000`, IRQ 13, unless + image slots claim them; + one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, + IRQ 7 as the boot/log console (`hvc1`); and `--microvm-sandbox-block` + exposes fixed distro, runtime, custom, and scratch slots starting at MMIO + `0xd0003000`. The profile also reserves MMIO `0xd0007000`, IRQ 3 for the + dedicated control console (`hvc2`), selected by the host-owned + `nvx_control_tty=hvc2` token. The reserved slot and resource identity do not + expose a live control endpoint. Both consoles + remain virtio-console devices from the guest's perspective. Ordinary + `--virtio-blk` is rejected. All use split rings. Firmware, ACPI, SMBIOS, PCI, + VMBus, UARTs, graphics, isolation, nested virtualization, and other devices + are rejected. Linux discovers contiguous APIC IDs and the IOAPIC from Intel + MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command + line starts at `0x20000`, and no ACPI or SMBIOS data is exposed. Host-driven + save/restore, pulse-save/restore, and worker restart remain unavailable. + + `microvm` may also expose a dedicated control virtio-console at MMIO + `0xd0007000`, IRQ 3. It requires the boot virtio-console, preserves + `console=hvc1`, and publishes `nvx_control_tty=hvc2`. The profile fixes + boot-before-control discovery order and rejects user overrides that could + change it. + + `--microvm-image-slots` declares four permanent, read-only image-slot + transports named `image0` through `image3` in microVM ABI 2. Their MMIO + windows are `0xd0008000` through `0xd000b000`, their edge-triggered IRQs are + 1, 13, 14, and 15, and their shared interrupt-status words are at offsets + `0x20` through `0x2c` in the existing shared-status page. The first slot + uses the second virtio-fs slot's window and status word, and the second slot + its IRQ 13, so a machine with image slots accepts at most one `--mount`. The + `microvm_image_slots=4` discovery token records the fixed capacity. + `--microvm-image-slot-boot-count ` selects the active prefix and + defaults to one. Active slots are empty virtio-blk devices with 512-byte + logical blocks; inactive slots report virtio device ID 0. A machine that + omits `--microvm-image-slots` keeps the original device topology without + reserving image-slot transports. + + Image slots require `--microvm-host-control listen=` and + `--microvm-control-auth-stdin`. The host-control service uses the same + capability and local peer identity as the control console. See + [Host-control Protocol](./host_control_protocol.md). + + `microvm` uses one socket and one die, + with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. + Guest-requested snapshot capture and new-process restore are available for + blockless and fixed-block machines on Linux/KVM, Linux/MSHV, and Windows/WHP, + except for the RAM-backed overlay layout. + + A non-empty `--microvm-sandbox-block` topology must end with the writable + `scratch` block unless the command line carries exactly one + `nvx_overlay_upper=ramfs` token. That RAM-backed overlay layout, for a guest + that keeps its overlay upper in RAM, requires exactly one read-only `distro` + block and no other block, and it rejects `--snapshot-destination` and + `--restore-snapshot`. Any other `nvx_overlay_upper` token, including a bare + or repeated one, is rejected. + + ```admonish warning title="microVM migration" + The canonical `microvm` spelling now selects the contract formerly exposed + as `microvm-v2`; the `microvm-v2` selector and the former ABI-v1 behavior are + removed. Image slots extend ABI 2 in place; snapshots with or without them + use ABI 2 and the same boot-layout version. Development snapshots from + before this extension are not guaranteed to remain compatible. + ``` +* `--net `: With `--machine microvm`, attach one virtio-net NIC + at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes + `/1` through `/30` are accepted. The first usable subnet address becomes + the gateway; network, broadcast, and gateway addresses cannot be assigned + to the guest. Guest and gateway MAC addresses are derived as + `52:54:00:::` from their IPv4 addresses. Networking + requires the only supported capability profile, `--network-profile portable`; + omitting it rejects the command before OpenVMM opens host resources. + + ```bash + openvmm --machine microvm --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz \ + --net 10.0.0.2/24 --network-profile portable + ``` + + `portable` uses an in-process Consomme endpoint on Linux/KVM, Linux/MSHV, + and Windows/WHP. It needs no TAP, root access, driver, or host network + configuration. `--net-tap` is incompatible and is rejected before any + endpoint or host resource is created. The gateway provides DNS over UDP and + TCP, ICMP echo, and outbound TCP/UDP through ordinary host sockets. Consomme + rejects IPv4 fragments deterministically; policy filtering remains before + host socket creation. Its per-connection TCP buffers start at 16 KiB and + are bounded at 4 MiB; UDP bindings expire after five minutes; and at most + 256 DNS requests are pending at once. At most 128 TCP, 256 UDP, and 16 ICMP + guest flows are active at once; excess flows are deterministically rejected + before a host socket is created. + + `--allow-host `, `--block-host `, and + `--allow-endpoint ` are repeatable, mutually exclusive + egress modes. Filtering runs before host socket creation. Active policy + fails closed for malformed packets, non-IPv4 traffic, and IPv4 options. + Exact endpoint mode also rejects UDP, ICMP, VLAN, fragments, and every TCP + destination not listed. Endpoint addresses must be usable unicast identities; + unspecified, current-network, loopback, link-local, multicast, reserved, + guest-self, subnet-network, and subnet-broadcast addresses are rejected before + host resources are opened. For each endpoint, ARP may resolve the endpoint + itself when it is on-link, or the gateway otherwise. Duplicate endpoint + addresses share one canonical next hop. This layer-2 permission does not relax + the independent destination, TCP, or port check. No implicit DNS exception is + added. + + `--network-egress ` and `--network-ingress ` map + directional network default actions onto the portable profile. Egress defaults + to `allow`; ingress defaults to `deny`, preserving the profile's existing + behavior when neither option is present. Egress `deny` without allow rules + blocks every guest-originated frame before Consomme opens a host socket. + Responses belonging to a guest-initiated flow remain permitted when ingress + is denied; unsolicited connections toward the guest remain unavailable. + + The portable profile cannot truthfully provide unrestricted inbound + connectivity, so `--network-ingress allow` is rejected before VM resources + are opened. Egress `allow` may accompany `--block-host`; egress `deny` may + accompany `--allow-host` or `--allow-endpoint`. Contradictory default and + rule combinations are rejected at the same validation boundary. + + `--network-egress-allow ` and `--network-egress-deny ` provide + the generic L3/L4 policy form. A rule is `IPv4[/PREFIX]`, which matches + every IPv4 protocol; `IPv4[/PREFIX]:tcp` or `IPv4[/PREFIX]:udp`, which match + every destination port of one protocol; `IPv4[/PREFIX]:icmp`, which matches + every ICMP message; `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`, + which match one TCP or UDP destination port; or + `IPv4[/PREFIX]:tcp:FIRST-LAST` / `IPv4[/PREFIX]:udp:FIRST-LAST`, which match + the inclusive range of TCP or UDP destination ports from `FIRST` through + `LAST`. Ports are `1` through `65535`, and a range whose `LAST` is below its + `FIRST` is rejected. ICMP rules take no port. These flags require an + explicit `--network-egress` default, accept at most 256 rules in each list, + and cannot be mixed with the legacy `--allow-host`, `--block-host`, or + `--allow-endpoint` forms. Deny rules are evaluated before allow rules. + Address-only rules apply to every IPv4 protocol, and protocol rules apply to + every fragment of their protocol; policies with a port-specific rule reject + fragmented IPv4 traffic because later fragments do not carry a verifiable + transport header. Parsing, canonicalization, and contradictory option checks + complete before VM resources are opened. + + `--host-loopback ` controls host-local access. The portable + profile cannot provide generic bidirectional host-loopback connectivity: + explicit `allow` without `--host-loopback-forward` is rejected before VM + resources are opened. With deliberate forwards, `allow` preserves + guest-to-host access subject to egress policy and publishes only the named + host-to-guest ports. Omitting the option preserves the existing + guest-gateway-to-host-loopback mapping without publishing guest ports. + `deny` blocks general gateway and host-local destinations and rejects every + `--host-loopback-forward`. + + `--network-proxy ` preserves one exact proxy endpoint when + host loopback is denied. The guest-visible address must equal the derived + gateway, and only that TCP port is translated to host loopback. UDP on the + same port is not exempt, even with ordinary egress allowed. The exception is + included in the snapshot policy digest. + + `--host-loopback-forward ` binds one localhost + port and forwards it into the guest. It requires explicit + `--host-loopback allow`; duplicate bindings and more than 64 forwards are + rejected before resources are opened. Live forwards are process-local + attachments and are rejected for snapshot capture or restore. + Explicit forwarding is port publishing, not support for a generic + bidirectional host-loopback allow policy. + + Networked snapshots record the `portable` profile, drain accepted TX and + endpoint-ready RX at the capture boundary, rewind unused guest RX + descriptors, and recreate a fresh Consomme endpoint generation on restore. + Restore of a networked snapshot requires `--network-profile portable` and + the same active egress policy rules. The policy digest binds the saved static + identity and its derived ARP next hops, which are reconstructed before vCPUs + start. Native sockets and NAT flow tables are not serialized. The capture + protocol does not retain pre-capture endpoint completions; restored guest + software must establish new host-side flows. +* `--mount `: With `--machine microvm`, attach + one no-DAX HostFs device at MMIO `0xd0001000`, IRQ 6, with tag `microvm`. + Repeat it once to attach a second device at MMIO `0xd0008000`, IRQ 13, with + tag `microvm1`, unless `--microvm-image-slots` claims that window and IRQ; + each share has its own guest target, access mode, and + denied paths. The default mode is read-only; `rw` must be explicit. The + guest target must be an absolute, non-root Linux path without dot, parent, + empty, whitespace, backslash, or `=` components. Guest targets and host + directories of different shares must not equal or contain one another; on + Linux, a bind mount or nested mount that reaches the files of another share + counts as containing it. + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz \ + --mount /mnt/share,path/to/share,ro + openvmm --machine microvm --hypervisor kvm \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz \ + --mount /workspace,path/to/workspace,rw \ + --mount /opt/hostedtoolcache,path/to/toolcache,ro + ``` + + The device has one high-priority queue, one request queue, direct-I/O file + behavior, zero entry and attribute cache lifetimes, and no shared-memory + window. `--mount` conflicts with `--virtio-fs` and + `--virtio-fs-shmem`; those standard-machine options cannot select the + microVM filesystem profile. A read-write attachment accepts guest-created + symbolic links, stores their targets exactly, and never follows them on the + host; a read-only attachment rejects them with `EROFS`. On Windows, the + links are WSL-style reparse points. + + `--mount-deny ` is repeatable and hides an existing file or + directory inside an exported root. Paths are canonicalized to + host-relative policy entries before resources are opened. With two shares, + each path must be absolute and applies to the share whose root contains it. + The complete root, + paths outside the roots, duplicates, overlaps, symlink/reparse components, + and nested-mount crossings are rejected. The virtio-fs server blocks the + denied subtree and its root object identity, so `..`, a second mount of the + same device, hard-link aliases, symlinks, junctions, and bind-mount aliases + cannot re-expose it. + + `--mount-allow ` is repeatable and exposes an existing file or + directory inside a `--mount-deny` path again. The hidden directories on the + way to it become traverse-only: the guest can look them up and list the + entries that lead to allowed paths, but sees nothing else in them and cannot + modify them. The nearest denied or allowed path that contains an allowed + path must be a denied path; a denied path may nest inside an allowed path. + + `--mount-write ` is repeatable and limits the guest's writes in a + read-write share to existing files or directories and everything below + them. The rest of the share is read-only, and the guest's modifications + there fail with `EROFS`; a hard link to a read-only file from a writable + path fails with `EXDEV`. Writable paths must not overlap or be hidden by a + denied path, and a read-only share rejects them. `--mount-allow` and + `--mount-write` paths follow the same canonicalization and share + attribution rules as `--mount-deny`. + See [virtio-fs](../../devices/virtio/virtio-fs.md#access-policy). + + `--mount-owner ` selects the host identity of the guest's + operations. `vmm`, the default, runs them all as OpenVMM. `caller` runs + each one as its guest caller's UID and GID, without supplementary groups or + capabilities, and squashes guest UID 0 and GID 0 to the owner of the export + root, which must not be root. `caller` requires a Linux host. It also + requires `CAP_SETUID` and `CAP_SETGID` unless every caller has OpenVMM's + own UID and GID and OpenVMM has no other supplementary groups; an + operation that cannot run as its caller fails with `EPERM`. The mode applies + to every `--mount`. + See [virtio-fs](../../devices/virtio/virtio-fs.md#host-identity-of-guest-operations). + + Filesystem snapshots contain guest-visible FUSE and queue state, not host + directory contents or native handles. An active snapshot requires + `--mount` again for every captured share, in capture order, with the exact + canonical host path, guest target, access mode, denied, allowed, and + writable paths, and `--mount-owner` mode; the live roots and every saved + object identity are + also revalidated before vCPUs start. A snapshot captured without `--mount` + may remain dormant or bind one new attachment to the first slot. The resumed + guest must then explicitly run `mount -t virtiofs microvm ` + because its cold-boot mount hook has already completed. + See [virtio-fs](../../devices/virtio/virtio-fs.md). +* `--snapshot-destination `: Publish a microVM snapshot when the guest + writes to PMIO port `0x605`. The destination must not exist and its parent + must already be a directory. OpenVMM automatically creates file-backed RAM + in that parent when no memory backing file was supplied, delivers the portb + console output that the guest wrote before its request, quiesces the VM, + writes and flushes a sibling staging directory, and atomically renames it to + `DIR`. After a successful commit, the source VM terminates without executing + the instruction after the snapshot `out`. + + `--snapshot-quiesce-timeout-ms ` sets the bounded quiesce + timeout and defaults to 5000. The same bound applies to delivering portb + output before quiescing; output still undelivered when it expires, or + accepted while no portb peer is connected, is saved in the snapshot and + written by the restored VM. A request with no configured destination is + ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, + KVM, MSHV, or WHP, and shared file-backed RAM. Sandbox block media must + be cached regular raw files with nonzero 512-byte-aligned geometry. An + attached virtio console saves accepted but undelivered input and the offset + of a partially forwarded guest transmit descriptor. An attached microVM + virtio-net device saves its static identity, queue progress, drained packet + ownership, endpoint generation, and policy requirement. + The fixed microVM virtio-fs slot saves either an explicit dormant state or, + when attached, its negotiated FUSE policy, namespace and handle identifiers, + aliases, and directory cookies. The host tree remains external live state. + + `--memory-capacity ` opts the snapshot into restore-time memory + expansion. `SIZE` is an immutable 128-MiB-aligned upper bound, must be at + least the base `--memory` size, and reserves the complete canonical GPA + aperture without adding it to the initial Linux direct e820 RAM map or + `memory.bin`. + + ```bash + openvmm --machine microvm --hypervisor kvm --memory 128M \ + --kernel vmlinux --initrd initramfs.cpio.gz \ + --snapshot-destination snapshot + ``` + + `/sbin/nvx-snapshot` requests a paired capture by default. When scratch is + mounted, it freezes the workload cgroup with a bounded wait, syncs, freezes + the scratch filesystem, and asks OpenVMM to drain queues and atomically + publish `scratch.img`. `/sbin/nvx-snapshot --fresh-scratch` is for a + pre-mount boundary and records that restore must supply a fresh scratch. +* `--microvm-workload-identity `: Add a fixed non-root numeric + workload identity to the host-owned microVM command line. UID and GID zero + are rejected. The guest workload supervisor must resolve both values in the + workload root before launch and fail closed when either identity is + unavailable. Workload requests cannot replace this identity. Snapshot + restore takes the captured identity from the authoritative command line and + rejects an override. +* `--microvm-lifecycle `: Select the host-owned workload + lifecycle written into the initial microVM command line. `one-shot` starts + one workload and expects the guest to terminate the VM when it completes. + `managed` keeps the guest supervisor resident for multiple sequential + workload requests and requires a fixed workload identity plus a live, + authenticated `--microvm-control-console`. Snapshot restore takes the + captured lifecycle and rejects an override. +* `--microvm-report `: Atomically create one bounded local JSON outcome + report after the microVM controller and worker mesh finish teardown. The + report contains a schema version, opaque instance ID, backend category, + workload operation/category and numeric status, network-policy + applied/rejected state with rule counts, and explicit resource-release + booleans. Worker join failures produce a `teardown-failure` outcome and a + nonzero process status. + + The destination must not exist and its parent must be a plain directory. + Reports never contain commands, environment values, paths, network + destinations, proxy details, workload output, credentials, or free-form + errors. OpenVMM writes the file only to the requested local destination and + does not upload it. +* `--restore-snapshot `: Restore a microVM from a committed snapshot. + The manifest supplies the authoritative RAM size, topology, ABI, + fixed device inventory, effective kernel command line, source backend, time + contract, and CPU profile. Kernel, initrd, command-line, ordinary + `--memory`, processor, device, and topology overrides are not accepted; + expansion-capable snapshots use only `--restore-memory`. Repeat the + snapshot's exact `--processors` count; a mismatch is rejected before any VP + starts. Restore requires the same backend kind as capture. + + When the snapshot contains a virtio console, its attachment policy comes + from the manifest. OpenVMM recreates listeners, reconnects required clients, + or requires an inherited replacement before creating the partition. Restore + fails before any vCPU starts when a required attachment cannot be rebuilt. + A listener peer may connect after restore; guest transmit descriptors remain + pending while no peer is connected. + + When the snapshot contains active virtio-fs attachments, restore requires + a fresh `--mount` for each, in capture order. Each argument must reproduce + the manifest's exact canonical host path, guest target, and `ro`/`rw` mode + while also supplying a live root with the same saved identity. A snapshot + advertising the dormant slot may instead accept one new attachment; + snapshots without that capability reject additive attachment. + + When the snapshot contains virtio-net, restore also requires + `--network-profile portable`; the snapshot's profile and canonical egress + policy must match the supplied portable configuration. + + For sandbox-block snapshots, restore repeats each read-only + `--microvm-sandbox-block` argument. Its role, access, geometry, and SHA-256 + must match the manifest. A paired snapshot supplies scratch internally from + a verified process-private copy of `scratch.img`; passing another scratch is + rejected. A fresh-scratch snapshot instead requires a writable scratch + argument with matching geometry. + + `--restore-memory ` selects the total RAM for this launch. It requires + an expansion-capable snapshot and a 128-MiB-aligned value from the exact + captured base through the immutable capacity. Base RAM remains a private + copy-on-write mapping of `memory.bin`; selected expansion ranges use fresh + zeroed private backing. Expansion implies the post-restore repair gate. + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --restore-snapshot snapshot + ``` +* `--restore-ready-path `: Connect to an existing Unix domain socket on + Linux or a `//./pipe/...` named pipe on Windows and write exactly + `OPENVMM_RESTORE_READY_V1\n` once all restored state, required attachments, + and execution-owned workers are ready. Ungated restores flush the event + before releasing the restored vCPU. Gated microVM restores flush it after the + guest acknowledges post-restore repair and external input is re-enabled, + while the restored vCPU remains stopped. + It is valid only with `--restore-snapshot` and is process-local; it is not + saved in the snapshot. A connection, write, or flush failure aborts startup + and stops the VM. The peer must accept and read the event while startup is + in progress; Windows flush completion waits for the named-pipe peer to + consume the complete frame. +* `--restore-entropy`: Accepted for compatibility; it has no effect. Every + microVM restore makes restore packet version 4 available on the private + portb restore channel. The packet carries 64 bytes of fresh entropy, the + online-VP and image-slot targets, the memory-expansion ranges, the downtime, + and the TSC rate deviation; the guest must consume it and explicitly reseed + its RNG. + [Snapshots](../../../user_guide/openvmm/snapshots.md) documents its format, + the portb status bits, and the time sample. + Every microVM portb device also reports generation-ID support in status bit + 5. Writing `0xa6` to the status port and reading 16 bytes from the data port + returns an opaque ID that is stable for that VM process and may be selected + repeatedly. OpenVMM creates it before vCPU entry and does not serialize it. + On restore, it is the first 16 bytes of the restore packet's fresh entropy, + allowing the guest repair path to update clone identity without additional + port I/O. +* `--restore-processors `: For an opt-in microVM snapshot, bring the + contiguous VP prefix `0..COUNT-1` online before restore readiness. The + snapshot's manifest VP count remains immutable capacity and must still match + `--processors`. The target must be 1, 2, 4, or 8 and satisfy + `boot-online <= target <= capacity`. This option sets the restore packet's + online-VP target and implies the post-restore gate. Snapshots without + activation metadata reject it. An + explicit MSHV restore instantiates and binds only the requested prefix while + validating the full saved VP inventory; that reduced-prefix process cannot + be saved again. MSHV restores without this option, and KVM and WHP restores, + instantiate the full VP capacity. +* `--restore-image-slots `: For a microVM snapshot with image slots, + activate the contiguous slot prefix `image0` through `image{COUNT-1}` before + restore readiness. `COUNT` must satisfy `boot-active <= COUNT <= 4`. This + option sets the restore packet's image-slot target and implies the + post-restore gate. Snapshots without image slots reject it. +* `--restore-gate-timeout-ms `: Bound microVM guest repair and + gate acknowledgement after restore. The default is 60000 milliseconds. +* `--cpu-profile `: Select the microVM's pinned CPU profile, `auto` + (the default) for the profile of the host's CPU generation, or `host` to + derive a development profile from this host's backend fingerprint. Where no + pinned profile serves an Intel or AMD host's CPU, `auto` falls back to that + host profile and logs a warning at the default log level at every such cold + boot. The warning starts with `NVX-CPU-PROFILE-FALLBACK:`, names the CPU and + the host profile with its digest, says what that cold boot's fingerprint + took, and asks for the CPU's fingerprint + (`--cpu-fingerprint`) so that a pinned profile can serve it. A CPU of + another vendor that no pinned profile serves (`E_PROFILE_HOST_UNKNOWN`), or + a profile of another generation (`E_CPU_GENERATION`), fails before the + partition is created. On an Intel or AMD host, `auto`'s error names + `--cpu-profile host` when the backend does not support the pinned profile + of the host's generation (`E_PROFILE_UNSUPPORTED`), and says that `auto` + fell back when the host cannot boot the host profile instead. A + restore uses the snapshot's profile and rejects any + other; `host` also names a host profile that the snapshot recorded. See + [Time and CPU compatibility](../../../user_guide/openvmm/snapshots.md#time-and-cpu-compatibility). +* `--x-time-abi-verify`: Build the microVM partition and run the time ABI + checks (CPU profile support, effective CPUID, rates) without starting the + guest, print one `NVX-TIME-ABI-VERIFY:` line, and exit with status 0 or 1. + Host qualification uses it; it cannot be combined with snapshot capture or + restore. +* `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose + `platform`, `workload-start`, or `instance-checkpoint`. The first two are + reusable clone policies; instance checkpoints use single-use resume policy. +* `--snapshot-block-identity `: Select `sha256` (the default) or + `generation`. SHA-256 computes and verifies every bound block. Generation + mode requires `--snapshot-generation-id <32-HEX-DIGITS>` and records that + caller-authenticated immutable storage generation instead of scanning block + bytes. The caller must keep the generation bound to the exact immutable + artifacts supplied at restore. +* `--snapshot-scratch-restore-mode `: Record `private-copy`, + `copy-on-write`, or `direct-claimed` for paired scratch. Private copy is the + default and may fall back to sparse copying. Copy-on-write is available only + on Linux, is valid only for workload-start clones, and fails if the + destination filesystem cannot reflink. Direct-claimed is valid only for + single-use instance checkpoints and requires the snapshot filesystem to + support exact-file hard links; it does not fall back to copying. + +A committed snapshot contains `manifest.bin`, `state.bin`, `memory.bin`, and +optionally the manifest-declared `scratch.img`. Restore rejects unknown files, +symlinks, malformed or oversized data, length or applicable scratch-digest +mismatches, and incompatible machine contracts before starting a vCPU. +`memory.bin` uses a private writable copy-on-write mapping. Paired scratch uses +the materialization policy recorded at capture; clone-policy snapshots never +attach the source artifact writable, while direct attachment requires an +atomic single-use resume claim. + +Versions 3 through 6 do not embed or validate checksums for `state.bin` or `memory.bin`; +legacy version 2 checksum fields are accepted without re-hashing their +payloads. This format does not detect same-length payload changes, +authenticate, or encrypt a snapshot. Treat all three artifacts as sensitive +guest state and protect the directory with host access controls. * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a comma-separated key/value list: @@ -88,6 +581,60 @@ describes the source definitions. --hypervisor whp:user_mode_apic,no_enlightenments --hypervisor kvm ``` +* `--cpu-fingerprint `: Write a CPU fingerprint of the host for the + backend selected by `--hypervisor` (or the auto-detected one) to `PATH`, or + to standard output for `-`, and exit without starting a VM. Backend + parameters are rejected. + + The fingerprint records the guest CPU surface that the backend supports on + this host: every CPUID leaf and subleaf, the XSAVE features and layout, the + feature MSRs, and the processor feature banks. It also records the time + capabilities (TSC and LAPIC timer rates, invariant TSC, TSC-deadline, + `TSC_ADJUST`, TSC offset control and scaling, time freezing, and MSR + interception) and the host CPU, OS, and hypervisor identity. KVM reports its + surface through `KVM_GET_SUPPORTED_CPUID` and its MSR-based features; MSHV + and WHP report the CPUID of a transient probe partition that is destroyed + before OpenVMM exits. No guest runs. + + The output is deterministic, sorted JSON (schema + `openvmm-cpu-fingerprint/v1`). CPUID fields that depend on the executing + processor or on the virtual processor state, such as APIC IDs and the + enabled XSAVE size, are normalized. `surface_digest` is the SHA-256 of the + guest CPU surface, so hosts that offer guests the same CPU share it, and + `digest` covers the whole document. + + OpenVMM then checks the fingerprint against the pinned CPU profile of the + host's generation (the pinned profile that `--cpu-profile auto` selects; + the check never falls back to a host profile) and prints one line to + standard error, for example: + + ```text + NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp profile=intel.icelake-sp.v1 profile_digest=sha256:… surface_digest=sha256:… host_invariant_tsc=yes + ``` + + If no pinned profile serves the host, or the backend does not support every + feature, limit, XSAVE layout, and MSR value of the profile, the line has + `status=fail`, `code`, and `detail`, and OpenVMM exits with status 1 and the + same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). On MSHV and + WHP, which present the hypervisor's own guest view at every CPUID entry that + the profile does not list, a partition that reads non-zero at such an entry + fails with `E_CPU_UNLISTED`, so that no such entry carries host data; the + identity range `0x40000000` to `0x4fffffff` and the topology leaves `0xb` + and `0x1f` are exempt. Both read VP 0 of a second probe partition + configured from the profile, as a cold boot configures its partition, at + the host's CPUID entries outside the profile, rather than the + fingerprint's probe partition, which enables every feature the backend + offers, such as the XSAVE components of CET, which no profile enables. KVM + installs the profile as the guest's whole CPUID table and answers every + other entry from it (zero, or the architecture's out-of-range and topology + results), so the check does not apply. The fingerprint is still written, so + hosts of new generations can be fingerprinted to derive their profiles: the + warning of `--cpu-profile auto`'s fallback asks for it. + + ```bash + openvmm --hypervisor kvm --cpu-fingerprint host.json + openvmm --hypervisor whp --cpu-fingerprint - + ``` * `--isolation `: Enable a confidential or isolated VM mode. Supported modes include `vbs` and, for `x86_64` guests on KVM or MSHV, `snp`. @@ -305,8 +852,96 @@ Serial devices can be configured to appear as different devices inside the guest dropped bytes with its own retransmission. Debugger mode is chosen independently per COM port, so one port can talk to WinDbg while another behaves normally. -* `--virtio-console `: Expose a virtio console device (appears as - `/dev/hvc0` inside the guest). +* `--virtio-console `: Expose a virtio console device. It normally + appears as `/dev/hvc0`. Under `--machine microvm`, it occupies fixed MMIO + `0xd0002000`, IRQ 7, and is selected as `/dev/hvc1`; the raw portb path + remains available as `hvc0` for early output and recovery. + + A microVM accepts these explicit attachment policies: + + * `listen=PATH` or `listen=tcp:IP:PORT`: save the canonical endpoint and + recreate the optional listener on restore. Unix sockets must be beside the + snapshot directory. Windows pipes use the + `//./pipe/openvmm-microvm-` namespace. TCP ports must be nonzero. + TCP addresses must be loopback addresses. A restore caller can resupply + `--virtio-console listen=` with `--restore-snapshot` to + replace the saved listener identity while preserving the attachment + contract. Omitting the option recreates the saved listener endpoint. + * `connect=PATH` or `connect=tcp:IP:PORT`: require a client connection before + vCPUs start. Cold boot and restore use a five-second timeout. The + restore command must explicitly resupply the matching client attachment. + * `console`: require the restore caller to supply the same inherited terminal + attachment. Portb recovery output moves to stderr while the terminal is + attached to `hvc1`. + * `none`: keep the device present and explicitly discard guest TX while + disconnected. + + A generic byte stream guarantees no replay up to OpenVMM's backend write + boundary; it cannot prove that the remote application consumed bytes without + its own acknowledgment protocol. + +* `--microvm-control-console `: With `--machine microvm`, expose a + second independent single-port virtio console at fixed MMIO `0xd0007000`, IRQ + 3. `--virtio-console` is required on a fresh boot and remains the only kernel + console. The control device normally appears as the profile-owned + `nvx_control_tty=hvc2`. + + On Linux, the only live backend is `listen=PATH`, and PATH is always an + AF_UNIX socket. Its parent must already be an owned, non-symlink directory + with mode `0700`; OpenVMM exclusively binds the socket, sets and verifies + mode `0600`, and verifies `SO_PEERCRED` before accepting the protocol + attachment. + + On Windows, `listen=//./pipe/openvmm-microvm-` creates one byte-mode + named pipe with a protected DACL granting access only to LocalSystem and the + OpenVMM process user. OpenVMM obtains the connecting process ID from the pipe, + resolves its token user SID, and requires it to match the OpenVMM process + user before capability authentication. TCP, client-connect, terminal, file, + stdout/stderr, and inherited control backends are rejected on every platform. + + A live endpoint also requires the hidden launcher option + `--microvm-control-auth-stdin`. The launcher must attach a prepared readable + one-way pipe to standard input, containing exactly 32 random capability + bytes, and close every writer before starting OpenVMM. OpenVMM safely + duplicates stdin into an owned file, performs one bounded nonblocking read + through EOF, and closes the duplicate. An all-zero capability is rejected. + Standard input remains at EOF and is reserved for authentication: the stdin + REPL is disabled, the boot console must use a socket or `none`, and portb + recovery output goes to stderr. This option cannot be combined with the + management RPC server, console relay, or `--paused`. Capability + bytes must never appear in arguments, environment variables, endpoint names, + logs, snapshots, or attachment identities. The first host record must prove + that capability. Peer identity is checked first. Authentication must complete + within `--microvm-control-auth-timeout-ms` (default 5000, range 1 to 60000). + A stalled or failed authentication attempt closes the connection without a + protocol Error record and without changing the broker epoch. + + The outer protocol uses byte-counted guest receive credits so host DATA is + backpressured before the guest's bounded ingress storage is exhausted. See + [Control-session Protocol](./control_session_protocol.md). + + `none` does not consume stdin and rejects `--microvm-control-auth-stdin`. + OpenVMM generates an unreachable random capability so disconnected process + tests remain supported. + + The numeric `--microvm-control-auth-handle` interface is not supported. + Launchers must explicitly select the stdin contract; there is no fallback + to an unauthenticated endpoint. + + Boot and control endpoints must be distinct. Snapshot capture records only + the separate `console:microvm-control0` endpoint and broker-authenticated + reconnect policy. It never records a capability or UID/SID. Restore validates + the saved endpoint contract, requires a fresh launcher-provided capability + for a live endpoint, and generates a fresh VMM instance ID; saved credentials + and stale capabilities are never reused. Restore callers resupply + `--microvm-control-console listen=` together with + `--microvm-control-auth-stdin`; the endpoint may change, but the saved stable + attachment ID, attachment kind, backend kind, reconnect policy, required + flag, length, and timeout remain exact. + + Restore snapshots containing this device through the CLI. The OpenVMM + management RPC does not expose control-console restore attachments and + rejects these snapshots explicitly. The `BACKEND` argument is the same for all serial devices: @@ -317,8 +952,10 @@ The `BACKEND` argument is the same for all serial devices: up to listen on the given path. Serial input and output is relayed to this pipe/socket. * `listen=tcp:IP:PORT`: As with `listen=PATH`, but listen for TCP - connections on the given IP address and port. Typically IP will be - 127.0.0.1, to restrict connections to the current host. + connections on the given IP address and port. A microVM requires a + loopback IP such as `127.0.0.1` or `::1`. + * `connect=PATH`: Connect to an existing named pipe or Unix socket. + * `connect=tcp:IP:PORT`: Connect to an existing TCP listener. ## Guest power events @@ -347,6 +984,17 @@ status code as `exit:` (0-255); a bare `exit` uses 0: A bare `exit` exits with status 0; `exit:` exits with that code instead, so a supervisor can tell the exit reasons apart. +For microVMs, a process-status shutdown through port `0x604`, or a power event +configured to exit, drains previously accepted portb console output before the +process exits. This includes the host stdout/stderr relay, not just the device's +transmit buffer. Draining is bounded to five seconds; an output error or timeout +is reported as a failure rather than a successful exit with truncated output. +In RPC-server mode, a drain failure also fails pending `WaitVm` requests and +terminates the server with an error instead of leaving it uninitialized. +A guest-requested snapshot capture instead flushes accepted portb output to its +endpoint before quiescing, without closing it because the capture can still roll +back; see `--snapshot-destination`. + * `--crash-dump-path `: when the guest triple-faults, write a WinDbg-compatible `.vmrs` dump of the VM's processor state and guest memory to `PATH` before the `--guest-crash-action` is applied (see diff --git a/Guide/src/reference/openvmm/management/control_session_protocol.md b/Guide/src/reference/openvmm/management/control_session_protocol.md new file mode 100644 index 00000000000..289b182158a --- /dev/null +++ b/Guide/src/reference/openvmm/management/control_session_protocol.md @@ -0,0 +1,86 @@ +# Control-session protocol + +The microVM control console uses an aligned record stream between the guest, +OpenVMM's broker, and the authenticated host endpoint. The protocol is frozen +at version 1. + +## Header + +Every record has a 44-byte little-endian header: + +| Offset | Size | Field | +| --- | ---: | --- | +| 0 | 4 | ASCII magic `NVXS` | +| 4 | 2 | protocol version | +| 6 | 1 | record type | +| 7 | 1 | flags, currently zero | +| 8 | 16 | broker instance ID | +| 24 | 8 | epoch | +| 32 | 8 | direction-local sequence | +| 40 | 4 | payload length | + +`GUEST_ATTACH` and `HOST_ATTACH` are bootstrap records and use zero instance, +epoch, and sequence fields. Post-bootstrap records use the current instance +and epoch. + +| Value | Record | Payload | +| ---: | --- | --- | +| 1 | `GUEST_ATTACH` | empty | +| 2 | `HOST_ATTACH` | exactly 32 capability bytes | +| 3 | `RESET` | empty | +| 4 | `ACK` | four-byte little-endian initial receive window | +| 5 | `DATA` | 1 through 65,536 opaque bytes | +| 6 | `WAIT` | empty | +| 7 | `READY` | empty | +| 8 | `ERROR` | four-byte little-endian error code | +| 9 | `CREDIT` | four-byte little-endian receive-credit increment | + +The sole valid `ERROR` code is `1` (`Authentication`). ACK and CREDIT carry +receive-credit values: + +* ACK contains one four-byte little-endian initial receive window. +* CREDIT contains one four-byte little-endian increment. +* Credit counts DATA payload bytes, not record headers. +* The initial window is from 65,536 through 4,194,314 bytes. +* A CREDIT increment is nonzero and cannot make available credit exceed the + initial window. + +The guest sends ACK only after the replacement consumer and its bounded ingress +state are ready. ACK, CREDIT, and guest DATA share the ordered guest-to-broker +sequence. CREDIT is generation-scoped by the instance and epoch fields and is +not forwarded to the host. + +The broker reserves credit for an entire DATA payload before enqueueing any +part of that record toward the guest. A record that has started physical +transmission remains prepaid and finishes to preserve stream alignment. +Unstarted DATA from a retired epoch is discarded. + +When credit is exhausted, one complete host record may remain in the broker's +bounded pending slot. This is ordinary backpressure, not a protocol failure. +Transport disconnect polling, RESET generation, and control-record output do +not require DATA credit. + +On an active host disconnect, the broker advances the epoch, clears usable +credit, drops pending and unstarted DATA, finishes any physically started +record, and then emits RESET. The replacement epoch receives fresh credit only +from its ACK. Device reset and snapshot restore follow the same no-credit-reuse +rule. Broker saved-state schema 2 contains no usable receive credit; a restored +instance obtains a fresh window from its new ACK. + +Malformed lengths or credit values, wrong-state records, wrong instance or +epoch values, and duplicate or out-of-order sequences fail closed. Existing +peer-identity and capability authentication happen before host DATA is +accepted. + +## Buffer accounting + +The advertised credit window bounds bytes charged to guest ingress, including +bytes in transport, parsing, or the guest's pending ingress buffer until the +guest releases them. Implementations may account a fixed inner duplex +separately; the NVX guest uses a 128-KiB inner duplex. Credit must be returned +only when charged bytes leave the bounded ingress buffer, not merely when the +outer record is read. + +OpenVMM separately bounds each output queue to 16 queued records and 1 MiB, +plus one current record. Its host input uses one bounded parser, one pending +record, and a 4-KiB transport slab. diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index c5c3da8a18c..71f2a23a3c1 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -117,4 +117,118 @@ cannot exceed the physical SMMU's width with acceleration. SMMU configuration cannot be changed at runtime. See [the CLI reference](cli.md) and [Arm SMMUv3](../../emulated/iommu/smmuv3.md) for platform requirements. +## microVM snapshots + +`CreateVMRequest.microvm_snapshot` exposes the microVM capture and +restore flow over both transports: + +* `destination_path` configures guest-requested capture. Supply a microVM + configuration, including `DirectBoot` kernel/initrd files, memory, and the + processor count. The path must not exist. `quiesce_timeout_ms` defaults to + five seconds when zero. Before quiescing, the source delivers the serial + port 0 (portb) output the guest wrote before its request, within the same + bound, so the endpoint receives it before the source terminates. + `memory_capacity_bytes` optionally reserves an immutable, 128-MiB-aligned + RAM capacity while keeping the configured base memory as the exact + `memory.bin` payload and initial Linux direct e820 map. +* `restore_path` selects manifest-authoritative restore. `config` may be + absent, or may contain only the matching microVM profile, an optional exact + processor-count assertion, serial port 0 host + attachment, matching `DevicesConfig.virtio_console` and + `DevicesConfig.virtiofs_config` attachments, and guest power actions. + Guest-visible boot, memory, processor topology, other device, NUMA, and PCIe + fields are rejected. A saved listener is reconstructed from the manifest; a saved + client requires the matching path configuration. +* `restore_entropy` is accepted for compatibility and is valid only with + `restore_path`; it has no effect, because every restore delivers restore + packet version 4 with fresh entropy. Every microVM process also receives a + fresh, non-serialized 16-byte generation ID through the fixed portb selector. + On restore, the ID is the first 16 bytes of the restore packet's entropy. +* `restore_processor_count` requests restore-time activation of the contiguous + VP prefix `0..count-1`. Zero requests no activation. A nonzero value is + valid only for a snapshot that advertises processor activation, implies the + post-restore gate, and must satisfy the snapshot's boot-online and immutable + capacity bounds. `ProcessorConfig.processor_count`, when present, remains an + exact capacity assertion. On MSHV, a nonzero value instantiates and binds + only that VP prefix, as for `--restore-processors`; MSHV restores without a + target, and all KVM and WHP restores, instantiate the full VP capacity. +* `restore_memory_bytes` selects a 128-MiB-aligned total RAM target between the + snapshot base and immutable capacity. Zero selects the base. Expansion uses + fresh private zeroed backing, implies the post-restore repair gate, and is + rejected for snapshots without a RAM capacity contract. A nonzero value sets + the memory-target flag of restore packet version 4, with the expansion + ranges (none at the base size); zero leaves the flag clear. +* `restore_image_slot_count` requests restore-time activation of the + contiguous image-slot prefix, as for `--restore-image-slots`. Zero preserves + the captured boot prefix. A nonzero value is valid only for an image-slot + snapshot, sets the restore packet's image-slot target, and implies the + post-restore gate. +* `restore_gate_timeout_ms` bounds gated guest repair. Zero selects the + 60-second default; a nonzero value is valid only with `restore_path`. +* `restore_ready_path` names an existing Unix domain socket on Linux or a + `//./pipe/...` named pipe on Windows. `ResumeVM` writes and flushes exactly + `OPENVMM_RESTORE_READY_V1\n` after all fatal restore startup work completes. + For a gated restore, this occurs after guest repair succeeds and host input + is re-enabled, while the restored vCPU remains stopped. Signaling failure makes `ResumeVM` fail and tears down the + managed VM. The peer must accept and read concurrently with `ResumeVM`; + Windows flush completion waits until the complete frame has been consumed. + +On cold boot, `DevicesConfig.virtio_console` may configure one microVM Unix +socket or named-pipe endpoint. Listener mode recreates the path on restore. +Client mode is required and uses a five-second connection timeout +before vCPUs start. The device uses MMIO `0xd0002000`, IRQ 7, and selects +`hvc1`; its canonical path and policy become the stable restore attachment. + +`DevicesConfig.virtiofs_config` may bind one HostFs attachment to the fixed +microVM slot. Set `tag` to `microvm`, supply `root_path`, and set +`guest_mount_target` to an absolute Linux path. `read_write=false` selects the +default read-only policy. The management API cannot set an access policy, so a +cold-booted attachment has no denied, allowed, or writable paths. Restoring an +active attachment requires the exact same canonical root path, tag, guest +target, and access mode, and the live root must retain its saved identity; +the snapshot's denied, allowed, and writable paths apply again. A snapshot +captured with the slot dormant may +omit the attachment or supply a new one; after `ResumeVM`, the guest explicitly +mounts tag `microvm`. The fixed device uses MMIO `0xd0001000`, IRQ 6, one +request queue, and no DAX window. The management API binds only the first +microVM virtio-fs slot: the command line's second `--mount` slot is not +exposed, and restoring a snapshot that captured two shares fails. + +The fork retains `VirtioFs.guest_mount_target` at protobuf field 3 and +`read_write` at field 4 for existing microVM clients. Standard-machine +`read_only` uses field 5, rather than upstream's conflicting field 3; clients +using that flag must generate bindings from this fork's `vmservice.proto`. +Standard-machine attachments reject the microVM mount fields, and microVM +attachments reject `read_only=true` before opening device resources. Use +`read_write` to select a microVM attachment's access mode. + +Capture and restore paths are mutually exclusive. A successful capture halts +the managed source VM at the committed boundary and terminates the OpenVMM +source process; clients observe the transport closing. Restore creates a private +copy-on-write RAM view and leaves the VM paused until `ResumeVM`. +The readiness endpoint is a process-local orchestration attachment and is not +part of saved state. Each successful restore publishes one event; validation, +attachment, or worker-start failure publishes none. + +`VMConfig.MICROVM` is numeric value 2 and accepts exactly 1, 2, 4, or 8 +processors. Numeric value 1 is reserved and rejected before host resources are +opened. Clients must regenerate bindings because `machine_profile` moved from +field 15 to field 17 when upstream assigned field 15 to `iommufds`; the +colliding legacy encoding is rejected. Clients that used the former +`MICROVM_V2` source name must also update their bindings. RPC construction is +currently blockless; role-bearing sandbox blocks remain CLI-only. Snapshot ABI +and boot layout remain value 2, which identifies the Linux-direct MP-table +layout. Value 1 snapshots are unsupported. + +`VMConfig.pvh_boot` and protobuf field 14 are reserved, upstream `iommufds` +retains field 15, `crash_dump_path` uses field 16, and `machine_profile` uses +field 17. Upstream's `disable_vmbus` and `disable_hv` use fields 20 and 21 +instead of upstream's fields 16 and 17, while the `igvm` boot configuration and +`isolation_config` keep upstream's fields 18 and 19. Generate bindings from this +fork for the combined schema. + +The API has the same KVM/MSHV/WHP backend, no-block device, artifact integrity, +and security restrictions documented under [`--snapshot-destination`]. + [`vmservice.proto`]: https://github.com/microsoft/openvmm/blob/main/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +[`--snapshot-destination`]: ./cli.md diff --git a/Guide/src/reference/openvmm/management/host_control_protocol.md b/Guide/src/reference/openvmm/management/host_control_protocol.md new file mode 100644 index 00000000000..84bbf6daf74 --- /dev/null +++ b/Guide/src/reference/openvmm/management/host_control_protocol.md @@ -0,0 +1,39 @@ +# Host-control Protocol + +The microVM host-control service is a host-only, reconnectable endpoint for +versioned runtime operations. Linux uses an owner-only Unix socket in an +owner-only directory. Windows uses a named pipe restricted to the local +system and the OpenVMM process owner. OpenVMM also verifies the connected +peer's UID or SID. + +The endpoint is selected with `--microvm-host-control listen=`. +`--microvm-control-auth-stdin` supplies one exact, nonzero 32-byte capability +through a prepared one-way pipe. The same capability authenticates the guest +control console when both endpoints are enabled. It is never accepted through +argv or the environment. + +After connecting, the client writes the raw 32-byte capability. Authentication +must complete within the configured control-auth timeout. Requests and +responses then use a little-endian `u32` byte length followed by UTF-8 JSON. +Frames in both directions are limited to 64 KiB; a response that would exceed +the limit is replaced by a `response_too_large` error. A connection closes +after 60 seconds without a new request or while a request frame is incomplete. +The management RPC never logs the capability. +Every request contains `version: 1`, a caller-selected `request_id`, and an +`operation`. Responses repeat the protocol version and request ID and contain +either `status: "ok"` or a stable error code and message. + +Protocol version 1 defines: + +- `query_image_slots`: returns all four slots as `inactive`, `empty`, or + `bound`, including the recorded identity and capacity. +- `bind_image_slot`: supplies `slot`, `path`, and `identity`. OpenVMM opens the + path itself as a read-only, non-symlink regular file. The active empty slot + accepts exactly one 512-byte-aligned image, updates capacity, and raises a + virtio configuration-change interrupt. Repeating the same identity is + idempotent. A different identity, inactive slot, invalid geometry, or + lifecycle transition fails explicitly. + +There is no unbind, eject, rebind, activation, device-add, or device-remove +operation. Media remains open until the VM exits. Snapshot capture and other +lifecycle transitions exclude binding. diff --git a/Guide/src/user_guide/openvmm/run.md b/Guide/src/user_guide/openvmm/run.md index f7841d07631..dfa3d97fd4a 100644 --- a/Guide/src/user_guide/openvmm/run.md +++ b/Guide/src/user_guide/openvmm/run.md @@ -36,6 +36,77 @@ To run these examples using a pre-compiled copy of OpenVMM, swap `cargo run --` with `/path/to/openvmm`. ``` +### microVM sandbox block devices + +`--machine microvm` is the only microVM profile. It assigns up to three +read-only lower layers and one writable scratch device to fixed virtio-mmio +locations: + +| Role | Access | MMIO address | IRQ | +| --- | --- | ---: | ---: | +| `distro` | read-only | `0xd0003000` | 4 | +| `runtime` | read-only | `0xd0004000` | 12 | +| `custom` | read-only | `0xd0005000` | 9 | +| `scratch` | writable | `0xd0006000` | 11 | + +Use `--microvm-sandbox-block ROLE:DISK`, in the order shown. Lower-layer +roles require the normal disk `,ro` option and a non-empty topology must end +with `scratch`, except for the RAM-backed overlay layout described below; +ordinary `--virtio-blk` is intentionally rejected. +For example: + +```shell +openvmm --machine microvm --kernel vmlinux --initrd initramfs.cpio.gz \ + --microvm-sandbox-block distro:file:distro.erofs,ro \ + --microvm-sandbox-block runtime:file:runtime.erofs,ro \ + --microvm-sandbox-block custom:file:custom.erofs,ro \ + --microvm-sandbox-block scratch:file:scratch.img +``` + +Capture and restore support cached regular raw files. Capture records +the role, access mode, exact geometry, and SHA-256 of every read-only layer. A +normal snapshot request pairs the writable scratch as `scratch.img`; restore +accepts the read-only layer arguments again and creates a private scratch copy +from that artifact. A pre-mount request may select fresh-scratch policy instead, +in which case restore requires a new writable scratch file of matching size. + +A guest that keeps its overlay upper in RAM can boot from one read-only image +without scratch. Pass exactly one `nvx_overlay_upper=ramfs` token on the kernel +command line and exactly one read-only `distro` block: + +```shell +openvmm --machine microvm --kernel vmlinux --initrd initramfs.cpio.gz \ + --cmdline "nvx_overlay_upper=ramfs" \ + --microvm-sandbox-block distro:file:distro.erofs,ro +``` + +OpenVMM rejects any other `nvx_overlay_upper` token, including a bare or +repeated one, and any other block in this layout. The layout supports neither +`--snapshot-destination` nor `--restore-snapshot`. + +### microVM deterministic SMP + +`--machine microvm --processors N` selects the microVM machine. +`N` must be exactly `1`, `2`, `4`, or `8`. The guest topology is independent +of the host: one socket, one die, `N` cores, one thread per core, no SMT or +NUMA, xAPIC mode, and contiguous APIC IDs `0..N-1`; APIC ID 0 is the BSP. +Custom socket, SMT, APIC, x2APIC, and NUMA options are rejected. + +The microVM uses fixed virtio device slots and sandbox block roles. Its +persisted ABI and boot layout remain value 2. The +MP floating pointer begins at `0x0`, the MP configuration table at `0x400`, +the boot GDT at `0x1000`, and the Linux zero page at `0x2000`. No ACPI MADT or +SMBIOS data is exposed. + +Snapshots record the ABI version, processor count, full topology, APIC IDs, +and boot-layout version. Restore requires an exact match before any VP starts. +For example: + +```shell +openvmm --machine microvm --processors 8 \ + --kernel vmlinux --initrd initramfs.cpio.gz +``` + ~~~admonish tip title="UEFI firmware required when running outside cargo" When running via `cargo run`, environment variables in `.cargo/config.toml` automatically point OpenVMM to the `mu_msvm` UEFI firmware (`MSVM.fd`) diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 65fbe83d8fd..6d045be37bb 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -5,32 +5,58 @@ the complete state of a running VM and resume it later. ## Overview -A snapshot captures three pieces of state: +A snapshot captures three required pieces of state and may pair writable +microVM scratch: - **Guest RAM** — the full contents of guest memory - **Device state** — the saved state of all emulated devices - **Manifest** — metadata describing the snapshot (architecture, memory size, VP count, page size, etc.) +- **Scratch** — the exact microVM writable image when capture occurs after mount -These are stored as three files in a snapshot directory: +These are stored as three required files and one optional paired file: | File | Contents | |-----------------|---------------------------------------------| | `manifest.bin` | Protobuf-encoded snapshot metadata | | `state.bin` | Serialized device state | | `memory.bin` | Memory backing file | +| `scratch.img` | Paired microVM scratch, when declared | + +MicroVM manifests also record the NVX time ABI contract: the declared TSC and +LAPIC rates, the capture anchor (VP 0's TSC paired with host UTC and host +monotonic time), the capture host's identity, and the CPU profile with the +effective CPUID that the guest observes. Every snapshot, microVM or standard +machine, is manifest version 6, and restore accepts only version 6 +(`E_SNAPSHOT_VERSION`); recapture snapshots taken by earlier versions. See +[Time and CPU compatibility](#time-and-cpu-compatibility). ## Prerequisites -Snapshots require **file-backed guest memory**. Pass `file=` in the -`--memory` option when launching the VM so that guest RAM is written to a -file on disk rather than held in anonymous memory. +Host-driven snapshots require **file-backed guest memory**. Pass `file=` +in the `--memory` option when launching a standard VM. A microVM launched with +`--snapshot-destination` automatically creates temporary file-backed RAM in +the destination's parent directory when no backing file was supplied. Capture +with sandbox blocks additionally requires +`--snapshot-tier platform|workload-start|instance-checkpoint`. Platform and +workload-start snapshots are reusable clones; instance checkpoints are +single-use resumes. + +Sandbox-block capture defaults to whole-file SHA-256 identity and private +scratch copying. Storage owners may opt into +`--snapshot-block-identity generation --snapshot-generation-id <32-HEX-DIGITS>` +when an authenticated immutable generation already binds the supplied files. +This avoids capture- and restore-time block scans; it does not authenticate +bytes by itself. `--snapshot-scratch-restore-mode` records `private-copy`, +reflink-only `copy-on-write` for workload-start clones, or `direct-claimed` +for instance checkpoints. ```admonish warning -The memory backing file and the snapshot directory must be on the **same -filesystem**. OpenVMM creates a hard link from the backing file to -`memory.bin` inside the snapshot directory, which does not work across -filesystem boundaries. +Automatically allocated microVM RAM and the snapshot destination are on the +same filesystem so OpenVMM can promote the exact RAM file by hard link. A +filesystem without hard-link support falls back to copying. Explicit +user-supplied memory is always copied into a uniquely named sibling staging +directory. OpenVMM atomically renames the completed directory into place. ``` ## Saving a snapshot @@ -52,15 +78,50 @@ specifying the output directory: save-snapshot path/to/snapshot-dir ``` -OpenVMM writes `manifest.bin`, `state.bin`, and a hard link to `memory.bin` -into the specified directory. +OpenVMM writes and flushes `manifest.bin`, `state.bin`, and `memory.bin` in a +sibling staging directory. Host-driven saves and user-supplied microVM backing +use an independent memory copy. Automatic microVM backing uses its exact RAM +file when the filesystem supports hard links. The destination must not already +exist. Publishing the completed directory is the commit point. + +On Windows, large mapped-RAM flushes use up to eight concurrent, disjoint, +page-aligned ranges to reduce sensitivity to fragmented dirty-page writeback. +Small ranges are flushed inline. Capture waits for every range, including +when a flush fails; an error prevents publication. The subsequent file and +directory durability barriers are unchanged. +Worker startup has a cost on fast storage; this policy targets writeback +stalls rather than guaranteeing lower capture latency on every host. + +The Windows-only `sparse_mmap` test `profile_fragmented_file_flush` reproduces +the fragmented writeback workload with alternating dirty 4-KiB pages in a +128-MiB file. It compares serial and bounded-parallel flushing, including the +subsequent file sync, and verifies the persisted bytes. Run it on an otherwise +idle host: + +```text +cargo nextest run --profile agent --release -p sparse_mmap --run-ignored only -E "test(profile_fragmented_file_flush)" --success-output immediate +``` + +Timing output is diagnostic, not a portable assertion or proof that an +external storage-throttling condition has been eliminated. ```admonish warning -After saving, the VM remains **paused** and resume is blocked. Resuming -would mutate guest RAM through `memory.bin`, corrupting the snapshot. -Use `shutdown` to exit OpenVMM after saving. +After a host-driven save, the VM remains **paused**. Guest-requested microVM +capture instead terminates the source process after publication commits. +If automatic-RAM publication fails after creating its staging link, OpenVMM +removes the complete staging directory before resuming; if cleanup cannot be +proved, it terminates the source instead. ``` +While a guest-requested snapshot boundary is held, VM-worker management RPCs +that can change VM state are rejected rather than queued. This includes memory +writes, pause/resume, reset, interrupt injection, hotplug, and state dumps. +Snapshot lifecycle RPCs and memory reads remain available. Mutating RPCs with +an error result report that the boundary is active; pause, clear-halt, and NMI +requests report a reply-channel error because their result types cannot carry +an application error. Retry a rejected operation after the boundary is +released; ordinary paused VMs are not subject to this restriction. + ## Restoring a snapshot To restore, pass the snapshot directory with `--restore-snapshot`: @@ -75,9 +136,133 @@ cargo run -- \ --restore-snapshot path/to/snapshot-dir ``` -`--restore-snapshot` automatically opens `memory.bin` from the snapshot -directory, so `file=...` should not be specified in `--memory` (the two -options are mutually exclusive). +`--restore-snapshot` verifies and opens `memory.bin` from the snapshot +directory, so `file=...` should not be specified in `--memory` (the two options +are mutually exclusive). Guest writes use a private copy-on-write mapping and +do not modify the snapshot artifact. + +Saved virtio-console listener attachments may be rebound to fresh restore-time +listener paths. The replacement must retain the saved stable attachment ID, +attachment kind, backend kind, reconnect policy, required flag, length, and +timeout; only the listener endpoint identity may change. Restore callers use +`--virtio-console listen=` and, for an authenticated control +listener, `--microvm-control-console listen=` with +`--microvm-control-auth-stdin`. OpenVMM validates an approved replacement +without reopening or canonicalizing the captured listener path, so the source +generation's private directory need not survive until restore. Client, +inherited-provider, and disconnected attachments retain their stricter saved +identity requirements. + +MicroVM orchestrators can add `--restore-ready-path `. OpenVMM connects +to an existing Unix domain socket on Linux or named pipe on Windows and writes +`OPENVMM_RESTORE_READY_V1\n` after restore validation, attachment resolution, +and state-unit startup. Ungated restores publish it before releasing a restored +vCPU. Gated microVM restores publish it after the guest acknowledges repair and +host input is re-enabled, while the restored vCPU remains stopped. The event +is single-use and is not serialized. +Failure to write and flush it stops the started units and fails restore without +releasing gated input. The peer must accept and read while resume is in +progress; on Windows, flush completion waits until the named-pipe peer consumes +the complete frame. + +For a tiered microVM restore, OpenVMM starts device workers with network and +control input gated. The guest performs post-restore repair and writes the +existing snapshot port (`0x605`) to acknowledge completion. OpenVMM stops at +that exact post-write boundary, completes the deferred write while the vCPU is +stopped, and then releases device input before resuming the vCPU. The acknowledgement is bounded by +`--restore-gate-timeout-ms` (60000 milliseconds by default). Platform manifests +leave read-only layer identities unbound, while later tiers require exact image +identities. An instance-checkpoint restore attempt atomically creates +`resume.claim`; subsequent restores are rejected. The claim is committed after +artifact and configuration validation but before worker construction, so the +restore attempt remains consumed if later worker startup fails. + +The no-ACPI microVM portb contract exposes a process-local 16-byte generation +ID. Status bit 5 advertises the feature; writing `0xa6` to status port `0xea` +and reading 16 bytes from data port `0xe9` returns the ID. It is repeatable +within one process and is never restored from snapshot state. A restore derives +the ID from the first 16 bytes of the restore packet's fresh entropy, so the +guest can reject an unchanged clone identity, reseed Linux, refresh runtime +identifiers, and acknowledge the input gate without another PMIO transfer. + +Every microVM restore exposes restore packet version 4. Status bit 1 +advertises it, and writing `0xa5` to the status port selects it for reading +from the data port. Its 32-byte little-endian header holds: + +- the magic `OVR` and version 4; +- flags: bit 0, the downtime came from host UTC; bit 1, a memory target; + bit 2, the guest must acknowledge the restore through port `0x605`; bit 3, + a test hook is active; bit 4, an image-slot target; +- the online-VP target (0 for none), the memory-range count, and the active + image-slot target (0 for none; flag bit 4 is set exactly when it is + nonzero); +- the generation counter (`u32`), which is 0 at cold boot and one more than + the snapshot's at each restore; +- the TSC rate deviation (`i32`, ppm scaled by 2^16); +- the downtime in nanoseconds (`u64`); and +- host UTC in nanoseconds since the Unix epoch (`u64`), latched when the guest + first selects the packet. + +The `(u64 GPA start, u64 length)` memory ranges and 64 bytes of fresh entropy +follow the header. Status bits 2, 3, and 4 report an online-VP target, a +memory target, and one or more memory ranges, so a zero-range target can skip +post-restore repair. + +Status bit 6 advertises the time sample. Writing `0xa7` to the status port +latches a 16-byte sample: version 1, flags (bit 3: a test hook is active), two +reserved zero bytes, the generation counter (`u32`), and host UTC in +nanoseconds (`u64`). The guest reads it from window port `0xeb`, so samples +never interleave with console input on the data port. The selected packet and +the window can be read one, two, or four bytes at a time. + +A microVM template may opt into restore-time vCPU activation by booting with an +explicit canonical `maxcpus=1`, `2`, `4`, or `8` value below or equal to its +configured VP capacity. The snapshot records that boot-online count while its +topology, APIC IDs, and saved VP inventory remain fixed at capacity. +`--restore-processors ` requests the contiguous online prefix +`0..COUNT-1` and must satisfy `boot-online <= COUNT <= capacity`. The guest +onlines and verifies that prefix before acknowledging the restore gate. +On MSHV, an explicit target instantiates and binds only that VP prefix; saving +such a reduced-prefix runtime is unsupported. MSHV restores without an explicit +target, and all KVM and WHP restores, instantiate the full VP capacity. +A microVM restore sets the TSC of every instantiated VP in one synchronized +operation before any VP runs: each TSC continues from the capture anchor, +advanced by the host downtime at the declared rate. KVM sets one common TSC +offset; MSHV and WHP freeze partition time, write the same TSC to every VP, +and resume partition time. Every backend reads each VP's TSC back and rejects +a restore whose values differ (`E_TSC_SYNC_READBACK`), and one-shot LAPIC +timers advance by the same downtime. The guest TSC is never scaled, and +RDTSC is never trapped or emulated. +Snapshots without the explicit capture-time `maxcpus` opt-in reject a restore +target. This is not a post-readiness hotplug API and cannot add VPs absent +from the saved topology. + +MicroVM ABI 2 snapshots with image slots record a fixed capacity of four and +the active cold-boot prefix. Capture is accepted only while every active slot is +empty; a bound slot rejects capture before guest execution is quiesced. +`--restore-image-slots ` may activate a larger contiguous prefix before +restore readiness and must satisfy +`boot-active <= COUNT <= 4`. The restore packet carries the target in header +byte 7 and sets flag bit 4; no status bit advertises it. An +explicit target always holds the restore gate, so the packet also sets flag +bit 2: the guest verifies the active prefix and acknowledges, even when no new +slot becomes active. +Restored slots always begin empty, and no slot can be activated after +readiness. Snapshots without image slots record zero capacity and boot-active +count, and their restore packet leaves header byte 7 zero. + +```admonish warning +Snapshots do not contain or validate embedded checksums for +`state.bin` or `memory.bin`. Restore still requires regular files, bounded +manifest and state decoding, exact artifact lengths, and a compatible machine +contract, but same-length payload changes are not detected. Paired `scratch.img` always +has an exact length and uses either the default SHA-256 identity or an explicit +caller-authenticated storage generation. Generation mode avoids reading the +file solely for verification and therefore relies on the storage owner to keep +that generation immutable and correctly bound. Protect snapshot directories +with host access controls. Integrity or authentication for export and +transport must be supplied outside the default snapshot format. +``` ```admonish note The `--memory` and `--processors` values must match the values recorded in @@ -85,45 +270,161 @@ the snapshot manifest. If they do not match, OpenVMM will report a validation error and refuse to start. ``` +## Time and CPU compatibility + +Every microVM uses the NVX time ABI v1. The guest sees a minimal Hyper-V +identity: CPUID leaves `0x40000000` through `0x40000005`, explicit zero leaves +`0x40000006` through `0x4000000f` and `0x40000080` through `0x40000082`, and +identity MSRs that return the declared TSC rate (`0x40000022`) and LAPIC rate +(`0x40000023`). Linux takes both rates from these MSRs, so a cold-boot kernel +command line must not set `tsc_early_khz=` or `lapic_timer_hz=` +(`E_CMDLINE_CLOCK_TOKEN`). The TSC is invariant, and the LAPIC timer runs in +one-shot mode at a fixed rate: 1 GHz on KVM and 200 MHz on MSHV and WHP. +TSC-deadline mode, `IA32_TSC_ADJUST`, and kvmclock are not exposed. Under an +AMD CPU profile, AMD's configuration MSRs read fixed values on every backend: +HWCR (`0xc0010015`) only `TscFreqSel`, so Linux takes the TSC to count at the +P0 frequency, and DE_CFG (`0xc0011029`) only `LFENCE` serialization. Writing +the value read back changes nothing; any other write raises #GP. + +The guest CPU is a pinned CPU profile: one per CPU generation, the same on +every backend. OpenVMM pins `intel.skylake-sp.v1` (Xeon Scalable, first +generation: family 6, model 85, steppings 0 to 4), `intel.icelake-sp.v1` +(Xeon Scalable, third generation: 6/106), `intel.emeraldrapids.v1` (Xeon +Scalable, fifth generation: 6/207), `intel.alderlake.v1` (Core, twelfth +generation: 6/151 and 6/154), `amd.milan.v1` (AMD EPYC, third generation: +25/1), `amd.genoa.v1` (AMD EPYC, fourth generation: 25/17), and +`amd.turin.v1` (AMD EPYC, fifth generation: 26/2). `--cpu-profile ` +selects one; the default, `auto`, selects the profile of the host's +generation. An Intel or AMD host without one boots on a host profile +instead: `auto` falls back to the profile that `--cpu-profile host` +selects, for which OpenVMM fingerprints the backend on this host, as +`--cpu-fingerprint` does, and applies the pinned profiles' derivation policy +to it. Every cold boot that falls back logs a warning at the default log +level that starts with `NVX-CPU-PROFILE-FALLBACK:`, names the CPU and the +host profile with its digest, says what that cold boot's fingerprint took, +and asks for the CPU's fingerprint so that a +pinned profile can serve it. A host of another vendor without a profile +fails (`E_PROFILE_HOST_UNKNOWN`). A host profile, +`intel.host.v1` or `amd.host.v1`, serves only the host's CPU model and +stepping, is not pinned, can change with the host's microcode, firmware, +or hypervisor, and costs a fingerprint of the backend at every cold boot. +Every boot checks that the backend +supports the profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its +effective CPUID (`E_CPU_SURFACE`); where `auto` fell back, such a failure +says so. On an Intel or AMD host, `auto`'s error names `--cpu-profile host` +where the backend does not support the pinned profile of the host's +generation, because a host profile derives from what the backend supports. +A snapshot records the profile and the +effective CPUID. Restore requires the same profile, pinned in this OpenVMM with +the same digest, or, for a host profile, the snapshot's own copy of it, which +must match the recorded digest; a host of the profile's generation; and an +identical effective CPUID. +OpenVMM computes the effective CPUID, including its own leaves and the bits +it owns, such as the cache topology, so an OpenVMM build that computes it +differently rejects earlier snapshots with `E_CPU_SURFACE`. + +Restore also requires: + +- the backend that captured the snapshot; +- a native TSC rate within 250 ppm of the snapshot's + (`E_TSC_RATE_TOLERANCE`), and the identical LAPIC rate + (`E_LAPIC_RATE_MISMATCH`); and +- a downtime between 0 and 30 days (`E_DOWNTIME_NEGATIVE`, + `E_DOWNTIME_EXCESSIVE`). + +The declared rates never change across a restore; the restore packet reports +the rate deviation so the guest can compensate. The downtime is host monotonic +time when capture and restore run on the same host boot, and host UTC +otherwise. Capture and restore reject an armed periodic or TSC-deadline LAPIC +timer and a periodically counting PIT channel 0 (`E_LAPIC_PERIODIC`, +`E_LAPIC_TSC_DEADLINE`, `E_PIT_ACTIVE`). + +A time ABI error leads its message with a stable code in brackets, for +example `[E_TSC_RATE_TOLERANCE]`, and OpenVMM exits with status 1. For host +qualification, `--x-time-abi-verify` builds the partition, runs these checks +without starting the guest, prints one `NVX-TIME-ABI-VERIFY:` line, and exits +with status 0 or 1. + ## Device configuration on restore -The snapshot only stores device *state*, not device *configuration*. All -device flags (e.g. `--disk`, `--nic`, `--serial`, `--virtio-blk`, etc.) -must be specified on the restore command line exactly as they were when -the snapshot was saved — they are not read from the snapshot. +For standard-machine snapshots, device flags must still be supplied on restore +and must reproduce the saved machine. For microVM snapshots, the manifest is +authoritative for RAM, topology, ABI, fixed devices, placement, +features, interrupts, and the effective Linux direct command line. Restore-time +guest-visible overrides are rejected. + +Every snapshot records a complete state-unit inventory. Each emulated device +saves state under a unique name (for example `"pit"`, `"vmbus"`, or `"ide"`), +and restore requires the saved and current inventories to match exactly. A +microVM manifest additionally records and validates the exact device inventory +and order. + +For a phase-3 virtio console, the manifest also records its stable attachment +ID, canonical endpoint identity, reconnect policy, requiredness, and timeout. +Native socket, pipe, terminal, and file handles are never serialized. Restore +recreates listeners, reconnects required clients, or requires an inherited +replacement before starting the partition. Accepted host input and a partial +guest transmit offset live in the device-private virtio payload, preserving +their order across a new-process restore. Host input is gated before the vCPU +snapshot boundary and resumed only if capture rolls back. + +If establishing the snapshot boundary returns an error after vCPU stopping +begins, OpenVMM keeps host input gated and stops and tears down the VM instead +of attempting a live rollback. This applies to both capture and the +post-restore acknowledgement boundary. Teardown still depends on the affected +backends responding; it does not provide a bounded shutdown deadline. + +For microVM virtio-fs, the manifest always records the fixed, +guest-discoverable first slot. A dormant slot has no host attachment or +filesystem policy and carries explicit dormant device-private state. An active +slot also records the stable attachment ID, exact canonical host path, pinned +root identity, guest mount target, access mode, denied, allowed, and writable +paths, ownership mode, no-DAX queue +policy, and `live-revalidate` restore mode. A second slot is recorded only +when a second share was attached, with the same fields for that share. +Its device-private payload records FUSE negotiation, namespace IDs and aliases, +lookup counts, reopenable handles, bounded directory-entry snapshots and +cookies, and queue progress. Native file descriptors and Windows handles are +never serialized. + +Restoring active slots requires a fresh +`--mount ` attachment for each, in capture +order, with the same canonical host path, target, and mode, the same +`--mount-deny`, `--mount-allow`, and `--mount-write` paths, and the same +`--mount-owner` mode. OpenVMM independently validates the roots and every +saved object identity before starting a vCPU. A dormant-slot snapshot may +restore without an attachment or bind one new attachment to the first slot. +For a new attachment, the resumed guest explicitly mounts tag `microvm`; the +cold-boot mount hook does not run again. -The snapshot manifest validates that `--memory`, `--processors`, -architecture, and page size match the values recorded at save time. However, -it does **not** record the list of CLI device flags. Instead, device -configuration compatibility is enforced at the state-unit level: each -emulated device saves its state under a unique name (e.g. `"pit"`, -`"vmbus"`, `"ide"`), and restore matches saved-state entries to the -currently instantiated devices by name. +```admonish warning +The host directory is external live state, not snapshot content. Host +mutations after capture can change restored reads or make restore fail. A +read-write restore also changes the shared host directory, and restoring the +same VM snapshot again does not roll those changes back. +``` The rules are: | Scenario | Result | |---|---| | Device set matches exactly | Restore succeeds | +| Dormant microVM virtio-fs slot becomes attached | **Restore succeeds** — the only additive transition | | Snapshot contains a device not in current config | **Restore fails** — unknown unit name | -| Current config has a device not in snapshot | Restore succeeds — device starts in its default/initial state | +| Current config has a device not in snapshot | **Restore fails** — inventory mismatch | In practice this means: - You must pass the **same device flags** on restore as you did on save. Removing a device that was present at save time will cause restore to fail. -- Adding a *new* device that was not present at save time is technically - allowed — the new device will start in its power-on default state. - This is not tested and the device may not be functional, since the - guest OS will not have enumerated or initialised it during boot. - The supported path is to use the same device flags on save and restore. +- Adding a new device that was not present at save time fails inventory + validation rather than starting an unenumerated device in its default state. ```admonish warning -There is no single error message that tells you "your device configuration -changed". Instead you will see errors like `restore failed: unknown unit -name` when saved-state entries cannot be matched. If you see this, compare -your restore command line with the one used at save time. +Inventory errors identify the saved and current state-unit lists. Compare the +restore configuration with the capture configuration when restoring a standard +machine. ``` ## Device save/restore support @@ -153,9 +454,10 @@ OpenVMM snapshots: | VMBus Keyboard / Mouse / Video | VMBus | Yes | | Guest Emulation Log | VMBus | Yes | | virtio-blk | Virtio (PCI/MMIO) | Yes | -| virtio-net | Virtio (PCI/MMIO) | Yes | +| virtio-net | Virtio (PCI/MMIO) | microVM only | | virtio-pmem | Virtio (PCI/MMIO) | Yes | | virtio-rng | Virtio (PCI/MMIO) | Yes | +| virtio-console | Virtio (PCI/MMIO) | Yes | | NVMe | PCI | **No** | | VGA | PCI | **No** (`todo!()`) | | GDMA (MANA network) | PCI | **No** (`todo!()`) | @@ -163,8 +465,8 @@ OpenVMM snapshots: | Assigned PCI (pass-through) | PCI | **No** | | Relayed vPCI | PCI | **No** | | PCAT BIOS firmware | Chipset (ISA) | **No** (see limitations) | -| virtio-9p, virtiofs | Virtio (PCI/MMIO) | **No** | -| virtio-console | Virtio (PCI/MMIO) | **No** | +| virtio-fs | Virtio (MMIO) | microVM HostFs only | +| virtio-9p | Virtio (PCI/MMIO) | **No** | | Guest Crash Device | VMBus | **No** | | Guest Emulation Device (GED) | VMBus | **No** | | VMBus serial (host) | VMBus | **No** | @@ -180,13 +482,20 @@ immediately with a clear error if any active device does not support it. - Snapshots are **not portable** across architectures (e.g., you cannot restore an x86_64 snapshot on aarch64) -- After restoring, `memory.bin` in the snapshot directory becomes the live - guest RAM backing file and will be modified as the VM runs. To restore - from the same snapshot multiple times, copy the snapshot directory before - each restore. +- Restores use private copy-on-write RAM, so a clone-policy snapshot can be + restored repeatedly without copying it or modifying `memory.bin`. + Instance-checkpoint snapshots permit one restore attempt. - VMs using VPCI or PCIe devices do not currently support save/restore - OpenHCL-based VMs do not currently support this snapshot mechanism - VMs using PCAT firmware do not support save/restore -- `--memory` and `--processors` must be specified on restore and match the - snapshot manifest values. A future version may read these from the snapshot - automatically. +- Standard-machine restore still requires matching `--memory` and + `--processors`. MicroVM restore reads them authoritatively from the manifest + and rejects overrides. Persisted microVM ABI and boot-layout value 2 are + supported. +- MicroVM restore requires the backend, CPU generation, and CPU profile of + the capture, and an OpenVMM build that computes the same effective CPUID. + A host profile's snapshot, including one of a cold boot whose `auto` fell + back to a host profile, restores only on a host of the capture host's CPU + model and stepping whose backend supports the profile. + Snapshots from manifest versions before 6, or from a build that computes a + different effective CPUID, must be recaptured. diff --git a/flowey/flowey_hvlite/Cargo.toml b/flowey/flowey_hvlite/Cargo.toml index 5ca34093828..28f27725bf4 100644 --- a/flowey/flowey_hvlite/Cargo.toml +++ b/flowey/flowey_hvlite/Cargo.toml @@ -24,6 +24,9 @@ serde = { workspace = true, features = ["std"] } serde_json.workspace = true target-lexicon = { workspace = true, features = ["serde_support"] } +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/flowey/flowey_hvlite/src/pipelines/build_docs.rs b/flowey/flowey_hvlite/src/pipelines/build_docs.rs index 8fd4ff0ebbe..98ed28db501 100644 --- a/flowey/flowey_hvlite/src/pipelines/build_docs.rs +++ b/flowey/flowey_hvlite/src/pipelines/build_docs.rs @@ -177,6 +177,7 @@ impl IntoPipeline for BuildDocsCli { let job = pipeline .new_job(FlowPlatform::Linux(FlowPlatformLinuxDistro::Ubuntu), FlowArch::X86_64, "publish openvmm.dev") .gh_set_pool(crate::pipelines_shared::gh_pools::linux_x64_gh()) + .gh_dangerous_override_if("github.repository == 'microsoft/openvmm'") .gh_set_concurrency_group(GhConcurrencyGroup { name: "publish-openvmm.dev".into(), cancel_in_progress: false, diff --git a/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs b/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs index 21f1033bc57..575a5ff8a8a 100644 --- a/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs +++ b/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs @@ -57,6 +57,37 @@ enum PipelineConfig { PrRelease, } +trait CheckinGatesJobExt { + fn gh_set_pool_with_fork_gate(self, pool: GhRunner, config: PipelineConfig) -> Self; +} + +impl CheckinGatesJobExt for PipelineJob<'_> { + fn gh_set_pool_with_fork_gate(self, pool: GhRunner, config: PipelineConfig) -> Self { + let is_upstream_only = + matches!(pool, GhRunner::SelfHosted(_) | GhRunner::RunnerGroup { .. }); + let job = self.gh_set_pool(pool); + + let condition = match (config, is_upstream_only) { + (PipelineConfig::Pr, true) => Some( + "github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false", + ), + (PipelineConfig::Ci, true) => Some("github.repository == 'microsoft/openvmm'"), + (PipelineConfig::PrRelease, true) => Some( + "github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", + ), + (PipelineConfig::PrRelease, false) => Some( + "contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", + ), + _ => None, + }; + + match condition { + Some(condition) => job.gh_dangerous_override_if(condition), + None => job, + } + } +} + /// A unified pipeline defining all checkin gates required to land a commit in /// the OpenVMM repo. #[derive(clap::Args)] @@ -163,8 +194,7 @@ impl IntoPipeline for CheckinGatesCli { )?; pipeline.inject_all_jobs_with(move |job| { - let mut job = job - .dep_on(&cfg_common_params) + job.dep_on(&cfg_common_params) .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init) .dep_on( |_| flowey_lib_hvlite::_jobs::cfg_hvlite_reposource::Params { @@ -178,16 +208,7 @@ impl IntoPipeline for CheckinGatesCli { .gh_grant_permissions::([( GhPermission::IdToken, GhPermissionValue::Write, - )]); - - // For the release pipeline, only run if the "release-ci-required" label is present and PR is not draft - if matches!(config, PipelineConfig::PrRelease) { - job = job.gh_dangerous_override_if( - "contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", - ); - } - - job + )]) }); let openhcl_musl_target = |arch: CommonArch| -> Triple { @@ -237,7 +258,8 @@ impl IntoPipeline for CheckinGatesCli { // Quick check gate // - // Combined fmt + clippy on one self-hosted linux machine. + // Combined fmt + clippy on one GitHub-hosted linux machine so that it + // can bootstrap the remaining fork-capable jobs. // Catches the most common failures quickly before fanning out expensive jobs. let quick_check_job = if matches!(config, PipelineConfig::Pr | PipelineConfig::PrRelease) { let job = pipeline @@ -246,7 +268,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "quick check [fmt, clippy x64-linux]", ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) // 1. xtask fmt (linux) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { @@ -276,7 +298,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "xtask fmt (windows)", ) - .gh_set_pool(gh_pools::windows_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::windows_x64_gh(), config) .ado_set_pool(ado_pools::default_windows()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { target: CommonTriple::X86_64_WINDOWS_MSVC, @@ -295,7 +317,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "xtask fmt (linux)", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { target: CommonTriple::X86_64_LINUX_GNU, @@ -320,7 +342,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "cargo hack (linux)", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_cargo_hack::Request { profile: CommonProfile::from_release(release), @@ -369,7 +391,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build artifacts (shared VMM tests) [windows]", ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()); for (arch, pub_pipette_windows) in shared_win_pipette_artifacts { shared_win_job = shared_win_job.publish(pub_pipette_windows, |pipette| { @@ -477,7 +499,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build artifacts (shared VMM tests) [linux]", ) - .gh_set_pool(gh_pools::linux_intel_v6_1es()) + .gh_set_pool_with_fork_gate(gh_pools::linux_intel_v6_1es(), config) .ado_set_pool(ado_pools::default_linux()); for ( arch, @@ -637,7 +659,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (not for VMM tests) [{arch_tag}-windows]"), ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()) .publish(pub_hypestv, |hypestv| { flowey_lib_hvlite::build_hypestv::Request { @@ -713,7 +735,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (for VMM tests) [{arch_tag}-windows]"), ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()) .publish(pub_openvmm, |openvmm| { flowey_lib_hvlite::build_openvmm::Request { @@ -896,7 +918,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (for VMM tests) [{arch_tag}-linux]"), ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::default_linux(), config) .ado_set_pool(ado_pools::default_linux()) .publish(pub_openvmm, |openvmm| { flowey_lib_hvlite::build_openvmm::Request { @@ -1153,7 +1175,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, build_openhcl_job_tag(arch_tag, mi_secure), ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::default_linux(), config) .ado_set_pool(ado_pools::default_linux()) .dep_on(|ctx| { let publish_baseline_artifact = pub_openhcl_baseline @@ -1208,7 +1230,10 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("verify openhcl binary size [{}]", arch_tag), ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) + .gh_dangerous_override_if( + "github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false", + ) .side_effect(|done| { flowey_lib_hvlite::_jobs::check_openvmm_hcl_size::Request { target: CommonTriple::Common { @@ -1366,7 +1391,7 @@ impl IntoPipeline for CheckinGatesCli { let mut clippy_unit_test_job = pipeline .new_job(platform, arch, job_name) - .gh_set_pool(gh_pool); + .gh_set_pool_with_fork_gate(gh_pool, config); if let Some(pool) = ado_pool { clippy_unit_test_job = clippy_unit_test_job.ado_set_pool(pool); @@ -1794,7 +1819,7 @@ impl IntoPipeline for CheckinGatesCli { let mut vmm_tests_run_job = pipeline .new_job(platform, arch, format!("run vmm-tests [{label}]")) - .gh_set_pool(gh_pool); + .gh_set_pool_with_fork_gate(gh_pool, config); if let Some(pool) = ado_pool { vmm_tests_run_job = vmm_tests_run_job.ado_set_pool(pool); @@ -1918,7 +1943,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("run vmm-perf [{label}]"), ) - .gh_set_pool(pool) + .gh_set_pool_with_fork_gate(pool, config) .with_timeout_in_minutes(120) .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init) .dep_on( @@ -1949,7 +1974,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "test flowey local backend", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .side_effect(|done| { flowey_lib_hvlite::_jobs::test_local_flowey_build_igvm::Request { base_recipe: OpenhclIgvmRecipe::X64, @@ -1969,7 +1994,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build openvmm [distribution config, x64-linux-gnu]", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| { flowey_lib_hvlite::_jobs::check_distro_build_from_checkout::Request { done } @@ -2005,7 +2030,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "openvmm checkin gates", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) // always run this job, regardless whether or not any previous jobs failed .gh_dangerous_override_if("always() && github.event.pull_request.draft == false") .gh_dangerous_global_env_var("ANY_JOBS_FAILED", "${{ contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'failure') }}") @@ -2033,7 +2058,7 @@ impl IntoPipeline for CheckinGatesCli { GhPermission::Contents, GhPermissionValue::Write, )]) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .dep_on( |ctx| flowey_lib_hvlite::_jobs::publish_vmgstool_gh_release::Request { vmgstools: vmgstools @@ -2054,3 +2079,25 @@ impl IntoPipeline for CheckinGatesCli { Ok(pipeline) } } + +#[cfg(test)] +mod tests { + use test_with_tracing::test; + + #[test] + fn release_pr_jobs_keep_the_release_label_gate() { + let workflow = include_str!("../../../../.github/workflows/openvmm-pr-release.yaml"); + let conditions: Vec<_> = workflow + .lines() + .filter(|line| line.starts_with(" if: ")) + .collect(); + + assert!(!conditions.is_empty()); + for condition in conditions { + assert!( + condition.contains("release-ci-required"), + "release PR job is missing the release label gate: {condition}" + ); + } + } +} diff --git a/flowey/flowey_lib_common/Cargo.toml b/flowey/flowey_lib_common/Cargo.toml index 36619527901..97c8d8c1be9 100644 --- a/flowey/flowey_lib_common/Cargo.toml +++ b/flowey/flowey_lib_common/Cargo.toml @@ -25,5 +25,8 @@ xshell.workspace = true [target.'cfg(unix)'.dependencies] rlimit.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs b/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs index 87ddcb6a38c..28663674991 100644 --- a/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs +++ b/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs @@ -239,7 +239,7 @@ impl FlowNode for Node { // Make a converted path relative/portable if requested. let make_portable_path = |path: PathBuf| -> anyhow::Result { let path = if portable { - if windows_target { + if windows_via_wsl2 { let working_dir_trimmed = working_dir_win.as_ref().unwrap().trim_end_matches('\\'); let path_win = path.display().to_string(); diff --git a/flowey/flowey_lib_common/src/install_cargo_nextest.rs b/flowey/flowey_lib_common/src/install_cargo_nextest.rs index b8fef4b067a..6855ef8f32f 100644 --- a/flowey/flowey_lib_common/src/install_cargo_nextest.rs +++ b/flowey/flowey_lib_common/src/install_cargo_nextest.rs @@ -4,6 +4,8 @@ //! Install `cargo-nextest`. use flowey::node::prelude::*; +use std::io; +use std::io::Read; flowey_request! { pub struct Request(pub WriteVar); @@ -52,12 +54,11 @@ impl FlowNode for Node { let nextest_path = rt.read(nextest_path); let cargo_home = rt.read(cargo_home); - // copy to cargo home bin folder so that nextest - // is accessible via `cargo nextest`` - fs_err::copy( + install_nextest( &nextest_path, - cargo_home.join("bin").join(&cargo_nextest_bin), - )?; + &cargo_home.join("bin").join(&cargo_nextest_bin), + ) + .context("failed to install cargo-nextest")?; Ok(()) } @@ -66,3 +67,124 @@ impl FlowNode for Node { Ok(()) } } + +fn install_nextest(source: &Path, destination: &Path) -> io::Result<()> { + if files_match(source, destination)? { + let permissions = fs_err::metadata(source)?.permissions(); + if fs_err::metadata(destination)?.permissions() != permissions { + fs_err::set_permissions(destination, permissions)?; + } + } else { + fs_err::copy(source, destination)?; + } + Ok(()) +} + +fn files_match(source: &Path, destination: &Path) -> io::Result { + let mut source = fs_err::File::open(source)?; + let mut destination = match fs_err::File::open(destination) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut remaining = source.metadata()?.len(); + if destination.metadata()?.len() != remaining { + return Ok(false); + } + + // Do not overwrite an identical executable: it may be running on Windows. + let mut source_bytes = [0; 16 * 1024]; + let mut destination_bytes = [0; 16 * 1024]; + while remaining != 0 { + let len = remaining.min(source_bytes.len() as u64) as usize; + source.read_exact(&mut source_bytes[..len])?; + destination.read_exact(&mut destination_bytes[..len])?; + if source_bytes[..len] != destination_bytes[..len] { + return Ok(false); + } + remaining -= len as u64; + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn installs_missing_and_changed_binaries() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download"); + let destination = dir.path().join("installed"); + let mut contents = vec![0x5a; 32 * 1024 + 1]; + fs_err::write(&source, &contents).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), contents); + + contents[32 * 1024] = 0xa5; + fs_err::write(&source, &contents).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), contents); + + fs_err::write(&source, b"shorter").unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), b"shorter"); + } + + #[test] + fn missing_source_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let destination = dir.path().join("installed"); + fs_err::write(&destination, b"installed").unwrap(); + assert_eq!( + install_nextest(&dir.path().join("missing"), &destination) + .unwrap_err() + .kind(), + io::ErrorKind::NotFound + ); + assert_eq!(fs_err::read(destination).unwrap(), b"installed"); + } + + #[cfg(windows)] + #[test] + fn identical_binary_is_not_overwritten_while_in_use() { + use fs_err::os::windows::fs::OpenOptionsExt; + + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download.exe"); + let destination = dir.path().join("installed.exe"); + fs_err::write(&source, b"nextest").unwrap(); + install_nextest(&source, &destination).unwrap(); + + let _in_use = fs_err::OpenOptions::new() + .read(true) + .share_mode(1) // FILE_SHARE_READ: deny overwrites as a running image does. + .open(&destination) + .unwrap(); + install_nextest(&source, &destination).unwrap(); + + fs_err::write(&source, b"updated").unwrap(); + assert!(install_nextest(&source, &destination).is_err()); + assert_eq!(fs_err::read(&destination).unwrap(), b"nextest"); + } + + #[cfg(unix)] + #[test] + fn identical_binary_recovers_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download"); + let destination = dir.path().join("installed"); + fs_err::write(&source, b"nextest").unwrap(); + fs_err::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap(); + install_nextest(&source, &destination).unwrap(); + fs_err::set_permissions(&destination, std::fs::Permissions::from_mode(0o644)).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!( + fs_err::metadata(destination).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } +} diff --git a/flowey/flowey_lib_hvlite/Cargo.toml b/flowey/flowey_lib_hvlite/Cargo.toml index c915de38204..2127d8c6a94 100644 --- a/flowey/flowey_lib_hvlite/Cargo.toml +++ b/flowey/flowey_lib_hvlite/Cargo.toml @@ -30,6 +30,7 @@ which.workspace = true [dev-dependencies] tempfile.workspace = true +test_with_tracing.workspace = true [target.'cfg(windows)'.dependencies] crossterm = { workspace = true, features = ["windows"] } diff --git a/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs b/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs index cd640b9e1c7..f94f645148e 100644 --- a/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs +++ b/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs @@ -124,17 +124,16 @@ impl SimpleFlowNode for Node { fs_err::create_dir(&test_log_dir)?; env.insert("TEST_OUTPUT_PATH".into(), converted_log_dir); - if temp_dir.exists() { - fs_err::remove_dir_all(&temp_dir)?; - }; - fs_err::create_dir(&temp_dir)?; - - if matches!(rt.platform().kind(), FlowPlatformKind::Windows) || windows_via_wsl2 { - env.insert("TEMP".into(), converted_temp_dir.clone()); - env.insert("TMP".into(), converted_temp_dir.clone()); - env.insert("SystemTemp".into(), converted_temp_dir); - } else { - env.insert("TMPDIR".into(), converted_temp_dir); + let temp_env_vars = + temp_env_vars(rt.backend(), rt.platform().kind(), windows_via_wsl2); + if !temp_env_vars.is_empty() { + if temp_dir.exists() { + fs_err::remove_dir_all(&temp_dir)?; + }; + fs_err::create_dir(&temp_dir)?; + for name in temp_env_vars { + env.insert((*name).into(), converted_temp_dir.clone()); + } } if let Some(disk_image_dir) = converted_disk_image_dir { @@ -170,3 +169,50 @@ impl SimpleFlowNode for Node { Ok(()) } } + +fn temp_env_vars( + backend: FlowBackend, + platform: FlowPlatformKind, + windows_via_wsl2: bool, +) -> &'static [&'static str] { + // Native local runs must not inherit the checkout's potentially long path: + // temporary Unix socket names have a much smaller limit than file paths. + // CI still needs its scratch disk, and Windows-via-WSL needs Windows paths. + if matches!(backend, FlowBackend::Local) && !windows_via_wsl2 { + &[] + } else if matches!(platform, FlowPlatformKind::Windows) || windows_via_wsl2 { + &["TEMP", "TMP", "SystemTemp"] + } else { + &["TMPDIR"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn native_local_runs_preserve_caller_temp_directories() { + for platform in [FlowPlatformKind::Windows, FlowPlatformKind::Unix] { + assert!(temp_env_vars(FlowBackend::Local, platform, false).is_empty()); + } + } + + #[test] + fn ci_and_cross_windows_runs_keep_content_disk_temp_directories() { + let windows = &["TEMP", "TMP", "SystemTemp"]; + assert_eq!( + temp_env_vars(FlowBackend::Ado, FlowPlatformKind::Windows, false), + windows + ); + assert_eq!( + temp_env_vars(FlowBackend::Local, FlowPlatformKind::Unix, true), + windows + ); + assert_eq!( + temp_env_vars(FlowBackend::Ado, FlowPlatformKind::Unix, false), + &["TMPDIR"] + ); + } +} diff --git a/openhcl/underhill_core/src/dispatch/mod.rs b/openhcl/underhill_core/src/dispatch/mod.rs index 5196d2e4d3a..e144f66ced0 100644 --- a/openhcl/underhill_core/src/dispatch/mod.rs +++ b/openhcl/underhill_core/src/dispatch/mod.rs @@ -840,7 +840,14 @@ impl LoadedVm { } async fn start(&mut self, correlation_id: Option) { - self.state_units.start().await; + if let Err(error) = self.state_units.start().await { + tracing::error!( + CVM_ALLOWED, + error = error.as_ref() as &dyn std::error::Error, + "VM state units failed to start" + ); + return; + } // Log the boot/blackout time. let reference_time = ReferenceTime::new(self.partition.reference_time()); @@ -1067,7 +1074,7 @@ impl LoadedVm { ) .await?; - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; self.emuplat_servicing.netvsp_state.push(save_state); Ok(()) diff --git a/openhcl/underhill_core/src/loader/mod.rs b/openhcl/underhill_core/src/loader/mod.rs index c04b2f1eb79..d711b7e5b48 100644 --- a/openhcl/underhill_core/src/loader/mod.rs +++ b/openhcl/underhill_core/src/loader/mod.rs @@ -279,6 +279,7 @@ fn load_linux(params: LoadLinuxParams<'_>) -> Result { with_psp: platform_config.general.psp_enabled, pm_base: chipset_resources::pm::DEFAULT_PM_PIO_BASE, acpi_irq: chipset_resources::pm::DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, }; @@ -505,6 +506,7 @@ pub fn write_uefi_config( with_psp: platform_config.general.psp_enabled, pm_base: chipset_resources::pm::DEFAULT_PM_PIO_BASE, acpi_irq: chipset_resources::pm::DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, #[cfg(guest_arch = "aarch64")] diff --git a/openhcl/underhill_core/src/vmbus_relay_unit.rs b/openhcl/underhill_core/src/vmbus_relay_unit.rs index bce72bc9b17..21a172e45d9 100644 --- a/openhcl/underhill_core/src/vmbus_relay_unit.rs +++ b/openhcl/underhill_core/src/vmbus_relay_unit.rs @@ -43,8 +43,9 @@ impl VmbusRelayHandle { struct VmbusRelayUnit(HostVmbusTransport); impl StateUnit for &'_ VmbusRelayUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 712555de8a9..c3f0052c6ab 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -2329,6 +2329,7 @@ async fn new_underhill_vm( let vmtime_source = vmtime_keeper.builder().build(tp.driver(0)).await.unwrap(); let vmtime = state_units .add("vmtime") + .advances_time() .spawn(&tp, |recv| { let mut vmtime = vmtime_keeper; async move { @@ -2734,6 +2735,7 @@ async fn new_underhill_vm( with_psp: dps.general.psp_enabled, pm_base: DEFAULT_PM_PIO_BASE, acpi_irq: DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, }; @@ -3044,6 +3046,7 @@ async fn new_underhill_vm( time_source: PlatformResource.into_resource(), century_reg_idx: 0x32, initial_cmos: None, + mode: dev::GenericCmosRtcMode::Standard, }); if dps.general.tpm_enabled { @@ -3911,6 +3914,7 @@ async fn new_underhill_vm( WrappedPartition(partition.clone()), PartitionUnitParams { processor_topology: &processor_topology, + active_vp_count: None, halt_vps, halt_request_recv, client_notify_send: halt_notify_send, @@ -4164,7 +4168,7 @@ async fn halt_task( while let Ok(reason) = halt_notify_recv.recv().await { let halt_request = match reason { - HaltReason::PowerOff => HaltRequest::PowerOff, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } => HaltRequest::PowerOff, // The paravisor's own watchdog raises Reset, so Watchdog never reaches // this arm today; it is folded into Reset to stay exhaustive. Honoring a // configurable paravisor watchdog action would mean raising Watchdog here. diff --git a/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs b/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs index e083fd78a58..aa48cbc5f9b 100644 --- a/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs +++ b/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs @@ -1634,6 +1634,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, HypervisorBackedX86> { self.set_register_state(value) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } diff --git a/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs b/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs index e7b86a3ebdc..7b3c12a1dce 100755 --- a/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs +++ b/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs @@ -2927,6 +2927,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, SnpBacked> { Err(vp_state::Error::Unimplemented("tsc")) } + fn tsc_deadline(&mut self) -> Result { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + + fn set_tsc_deadline(&mut self, _value: &vp::TscDeadline) -> Result<(), Self::Error> { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + fn tsc_aux(&mut self) -> Result { let vmsa = self.vp.runner.vmsa(self.vtl); Ok(vp::TscAux { diff --git a/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs b/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs index ff271123714..0584040c5ab 100644 --- a/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs +++ b/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs @@ -4192,6 +4192,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, TdxBacked> { Err(vp_state::Error::Unimplemented("tsc")) } + fn tsc_deadline(&mut self) -> Result { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + + fn set_tsc_deadline(&mut self, _value: &vp::TscDeadline) -> Result<(), Self::Error> { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + fn tsc_aux(&mut self) -> Result { Ok(vp::TscAux { value: self.vp.backing.vtls[self.vtl].private_regs.msr_tsc_aux, diff --git a/openvmm/hypervisor_resources/Cargo.toml b/openvmm/hypervisor_resources/Cargo.toml index 332c036db2f..aaad8f491b2 100644 --- a/openvmm/hypervisor_resources/Cargo.toml +++ b/openvmm/hypervisor_resources/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true +cpu_profile.workspace = true linkme.workspace = true mesh.workspace = true vm_resource.workspace = true diff --git a/openvmm/hypervisor_resources/src/lib.rs b/openvmm/hypervisor_resources/src/lib.rs index 9714cd033c5..ac326c1c5b8 100644 --- a/openvmm/hypervisor_resources/src/lib.rs +++ b/openvmm/hypervisor_resources/src/lib.rs @@ -117,6 +117,49 @@ pub trait HypervisorProbe: Send + Sync + 'static { /// passed as `(key, "true")`. Backends should return an error for /// unrecognized keys. fn new_resource(&self, params: &[(&str, &str)]) -> anyhow::Result>; + + /// Returns the guest CPU surface that this backend supports on this host, + /// for a host CPU fingerprint. + /// + /// This needs no guest. A backend may create a transient probe partition + /// but must not leave any state behind. `params` are the backend + /// parameters, as for [`new_resource`](Self::new_resource); backends + /// reject the ones that do not apply to fingerprinting. + /// + /// The default implementation reports that the backend does not support + /// fingerprinting. + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + let _ = params; + anyhow::bail!( + "the {} hypervisor backend does not support CPU fingerprinting", + self.name() + ) + } + + /// Returns what VP 0 of a transient probe partition configured from + /// `profile`, as a time ABI cold boot configures its partition, reads at + /// every entry of the host's CPUID outside the profile's tables + /// (`cpu_profile::unlisted_cpuid_candidates`), for the + /// `--cpu-fingerprint` check that those entries read zero + /// (`E_CPU_UNLISTED`). `params` are as for + /// [`cpu_fingerprint`](Self::cpu_fingerprint), and like it, this needs no + /// guest and leaves no state behind. + /// + /// `None` means that the backend has no such probe, so the check reads + /// the fingerprint's own probe partition instead, which enables every + /// feature the backend offers. The default implementation returns + /// `None`. + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + let _ = (params, profile); + Ok(None) + } } /// Private module for linkme infrastructure. diff --git a/openvmm/membacking/Cargo.toml b/openvmm/membacking/Cargo.toml index 7e78eb001eb..590f20c3b90 100644 --- a/openvmm/membacking/Cargo.toml +++ b/openvmm/membacking/Cargo.toml @@ -33,5 +33,8 @@ windows-sys = { workspace = true, features = [ "Win32_System_Memory", ]} +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/openvmm/membacking/src/lib.rs b/openvmm/membacking/src/lib.rs index 8aa86856293..c0aa05aefc2 100644 --- a/openvmm/membacking/src/lib.rs +++ b/openvmm/membacking/src/lib.rs @@ -88,6 +88,7 @@ mod sys { /// On Unix, this is an empty (uninhabitable) enum. pub type RemoteProcess = sys::RemoteProcess; +pub use mapping_manager::FileMappingMode; pub use mapping_manager::Mappable; pub use memory_manager::DeviceMemoryMapper; pub use memory_manager::GuestMemoryBuilder; diff --git a/openvmm/membacking/src/mapping_manager/manager.rs b/openvmm/membacking/src/mapping_manager/manager.rs index dfd509c923e..eb92376d944 100644 --- a/openvmm/membacking/src/mapping_manager/manager.rs +++ b/openvmm/membacking/src/mapping_manager/manager.rs @@ -61,6 +61,7 @@ impl MappingManager { max_addr: u64, minimum_va_alignment: Option, supports_memory_fault_resolution: bool, + track_memory_faults: bool, ) -> Result<(Self, Arc), VaMapperError> { let this = Self::new_bare(spawn, max_addr, minimum_va_alignment); // Create the primary mapper as part of construction. Being first, it is @@ -72,6 +73,7 @@ impl MappingManager { true, MapperRole::Primary { supports_memory_fault_resolution, + track_memory_faults, }, ) .await?; @@ -302,7 +304,7 @@ fn inspect_mappings(mappings: &Vec) -> impl '_ + Inspect { req.respond() .field("writable", mapping.params.writable) .field("mapping_type", mapping.params.mapping_type) - .field("backed_by_fd", mapping.params.backing.mappable().is_some()) + .field("backed_by_fd", mapping.params.backing.is_file_backed()) .hex("file_offset", mapping.params.backing.file_offset()); }), ); @@ -315,6 +317,15 @@ struct Mapping { active_mappers: Vec, } +/// Controls whether writes to a file-backed mapping update the backing object. +#[derive(Debug, Copy, Clone, Eq, PartialEq, MeshPayload)] +pub enum FileMappingMode { + /// Writes are visible through the backing object and all of its mappings. + Shared, + /// Writes are private to this mapper and do not modify the backing object. + CopyOnWrite, +} + /// How a guest memory mapping is backed by host memory. #[derive(Debug, MeshPayload, Clone)] pub enum MappingBacking { @@ -328,6 +339,17 @@ pub enum MappingBacking { /// The file offset into `mappable`. file_offset: u64, }, + /// Backed by a file whose writable pages are private to this mapper. + /// + /// Although this retains a mappable handle internally, it cannot be + /// exported to DMA targets or additional VA mappers because each mapping + /// would otherwise receive an independent private copy. + CopyOnWriteFile { + /// The OS object to map. + mappable: Mappable, + /// The file offset into `mappable`. + file_offset: u64, + }, /// Backed by private anonymous memory committed directly by the mapping /// manager. There is no backing fd, so the memory cannot be shared with /// other processes or programmed into DMA targets that require an fd; it is @@ -354,17 +376,34 @@ impl MappingBacking { pub fn mappable(&self) -> Option<&Mappable> { match self { MappingBacking::File { mappable, .. } => Some(mappable), - MappingBacking::Private => None, + MappingBacking::CopyOnWriteFile { .. } | MappingBacking::Private => None, } } /// Returns the offset within the backing object, or 0 if there is none. pub fn file_offset(&self) -> u64 { match self { - MappingBacking::File { file_offset, .. } => *file_offset, + MappingBacking::File { file_offset, .. } + | MappingBacking::CopyOnWriteFile { file_offset, .. } => *file_offset, MappingBacking::Private => 0, } } + + /// Returns whether the backing has process-private write state. + pub fn is_private(&self) -> bool { + matches!( + self, + MappingBacking::CopyOnWriteFile { .. } | MappingBacking::Private + ) + } + + /// Returns whether the backing uses a file or section internally. + pub fn is_file_backed(&self) -> bool { + matches!( + self, + MappingBacking::File { .. } | MappingBacking::CopyOnWriteFile { .. } + ) + } } /// Host-memory policy for a mapping, applied to the mapped VA range after the @@ -710,7 +749,7 @@ impl MappingManagerTask { // Private memory is anonymous and lives in a single mapper's address // space, so it requires at most one mapper. Reject adding private RAM // (e.g. via hotplug) while more than one mapper is present. - if matches!(params.backing, MappingBacking::Private) && self.mappers.mappers.len() > 1 { + if params.backing.is_private() && self.mappers.mappers.len() > 1 { anyhow::bail!("cannot add private memory while multiple mappers are present"); } @@ -765,9 +804,7 @@ impl MappingManagerTask { /// Returns true if any current mapping is backed by private memory. fn has_private_mapping(&self) -> bool { - self.mappings - .iter() - .any(|m| matches!(m.params.backing, MappingBacking::Private)) + self.mappings.iter().any(|m| m.params.backing.is_private()) } fn get_dma_target_mappings(&self) -> Vec { @@ -1490,6 +1527,7 @@ mod tests { true, // eager MapperRole::Primary { supports_memory_fault_resolution: false, + track_memory_faults: false, }, ); let (mapper, _) = futures::join!(mapper_future, async { @@ -1530,6 +1568,7 @@ mod tests { true, // eager MapperRole::Primary { supports_memory_fault_resolution: false, + track_memory_faults: false, }, ); let (mapper, mapper_req_send) = futures::join!(mapper_future, async { diff --git a/openvmm/membacking/src/mapping_manager/mod.rs b/openvmm/membacking/src/mapping_manager/mod.rs index 0f6ff059099..261f0fe02d4 100644 --- a/openvmm/membacking/src/mapping_manager/mod.rs +++ b/openvmm/membacking/src/mapping_manager/mod.rs @@ -8,6 +8,7 @@ mod mappable; mod object_cache; mod va_mapper; +pub use manager::FileMappingMode; pub use manager::MappingBacking; pub use manager::MappingManager; pub use manager::MappingManagerClient; diff --git a/openvmm/membacking/src/mapping_manager/va_mapper.rs b/openvmm/membacking/src/mapping_manager/va_mapper.rs index b969bfe8efc..ecad769daab 100644 --- a/openvmm/membacking/src/mapping_manager/va_mapper.rs +++ b/openvmm/membacking/src/mapping_manager/va_mapper.rs @@ -87,6 +87,8 @@ use windows_sys::Win32::System::Memory::PAGE_READONLY; #[cfg(windows)] use windows_sys::Win32::System::Memory::PAGE_READWRITE; #[cfg(windows)] +use windows_sys::Win32::System::Memory::PAGE_WRITECOPY; +#[cfg(windows)] use windows_sys::Win32::System::Memory::SECTION_MAP_READ; #[cfg(windows)] use windows_sys::Win32::System::Memory::SECTION_MAP_WRITE; @@ -118,6 +120,8 @@ pub(crate) enum MapperRole { /// enabled when this is set. Carried on the `Primary` variant because it /// is meaningless for a secondary mapper. supports_memory_fault_resolution: bool, + /// Whether to retain fault counters for diagnostics. + track_memory_faults: bool, }, /// Any other mapper; plain read-write 4 KB pages. Secondary, @@ -131,8 +135,8 @@ struct MappingProps { /// Backed by private anonymous memory (committed up front) rather than a /// shared file/section mapping. private: bool, - /// General per-mapping fault counters, always present. See [`FaultStats`]. - stats: FaultStats, + /// Optional per-mapping fault counters. See [`FaultStats`]. + stats: Option, /// Soft-large-page (Windows THP) state, or `None` when the scheme does not /// apply (non-primary/device mappers, read-only or non-THP ranges, and every /// non-Windows host). See the [`soft_lp`] module. @@ -141,16 +145,28 @@ struct MappingProps { /// Per-mapping fault counters, exposed via `Inspect`. /// -/// Recorded for every mapping regardless of host OS, role, or backing, so -/// general fault accounting is available even on mappings that never use soft -/// large pages. Kept per mapping — one set of counters per backing, and thus -/// per NUMA node — so they scale for large multi-NUMA-node VMs rather than -/// contending on a single global counter. The counters are plain atomics -/// (`SharedCounter`), bumped in place under the mapping-index read lock. +/// When diagnostics request accounting, counters are recorded for every mapping +/// regardless of host OS or backing, including mappings that never use soft +/// large pages. They remain absent on the default path. Counters are kept per +/// mapping — one set per backing, and thus per NUMA node — so they scale for +/// large multi-NUMA-node VMs rather than contending on a single global counter. +/// The counters are plain atomics (`SharedCounter`), bumped in place under the +/// mapping-index read lock. #[derive(Debug, Default, Inspect)] struct FaultStats { /// Guest memory faults resolved for this mapping. guest_faults: SharedCounter, + /// Bytes returned to the backend while resolving guest memory faults. + populated_bytes: SharedCounter, +} + +/// Aggregate fault counters for all mappings owned by a VA mapper. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct MemoryFaultCounters { + /// Guest memory faults resolved through this mapper. + pub guest_faults: u64, + /// Bytes returned to the backend while resolving those faults. + pub populated_bytes: u64, } /// A virtual address space mapper for guest memory. @@ -173,6 +189,23 @@ impl std::fmt::Debug for VaMapper { } } +impl VaMapper { + pub(crate) fn fault_counters(&self) -> MemoryFaultCounters { + self.inner.mappings.read().iter().fold( + MemoryFaultCounters::default(), + |mut total, (_, props)| { + if let Some(stats) = &props.stats { + total.guest_faults = total.guest_faults.wrapping_add(stats.guest_faults.get()); + total.populated_bytes = total + .populated_bytes + .wrapping_add(stats.populated_bytes.get()); + } + total + }, + ) + } +} + impl Drop for VaMapper { fn drop(&mut self) { // Do not join the mapper thread here. The mapping manager must process @@ -189,10 +222,10 @@ impl Drop for VaMapper { impl Inspect for VaMapper { /// Contributes each mapping's counters to the shared `mappings` node, keyed /// by GPA range, so the stats sit alongside the mapping they describe (the - /// mapping-manager entry with the same range merges with this one). Every - /// mapping contributes a `faults` child (general fault accounting); only - /// soft-large-page mappings (the primary mapper's writable THP ranges on - /// Windows) additionally contribute a `soft_large_pages` child. + /// mapping-manager entry with the same range merges with this one). Mappings + /// contribute a `faults` child when accounting is enabled; soft-large-page + /// mappings (the primary mapper's writable THP ranges on Windows) + /// additionally contribute a `soft_large_pages` child. fn inspect(&self, req: inspect::Request<'_>) { req.respond().field( "mappings", @@ -205,7 +238,9 @@ impl Inspect for VaMapper { &range.to_string(), inspect::adhoc(|req| { let mut resp = req.respond(); - resp.field("faults", &props.stats); + if let Some(stats) = &props.stats { + resp.field("faults", stats); + } if let Some(sl) = &props.soft_lp { resp.field("soft_large_pages", sl); } @@ -248,6 +283,8 @@ struct MapperInner { /// wedge on the first guest write; the primary mapper only enables them when /// this is set. supports_memory_fault_resolution: bool, + /// Whether mappings retain guest-memory fault counters for diagnostics. + track_memory_faults: bool, req_send: mesh::Sender, /// Maintains a weak reference to avoid a reference cycle with the partition. host_access: OnceLock, @@ -354,7 +391,8 @@ impl MapperTask { // `soft_lp` module. They also depend on the partition delivering write // faults to raise deferred-protect windows, so don't even build one when // the partition can't resolve faults. - let soft_lp = if self.inner.supports_memory_fault_resolution { + let copy_on_write = matches!(params.backing, MappingBacking::CopyOnWriteFile { .. }); + let soft_lp = if self.inner.supports_memory_fault_resolution && !copy_on_write { SoftLp::new( params.range, ¶ms.policy, @@ -375,9 +413,16 @@ impl MapperTask { mappable, file_offset, } => { - self.map_file(¶ms, mappable, *file_offset, deferred_protect)?; + self.map_file(¶ms, mappable, *file_offset, deferred_protect, false)?; false } + MappingBacking::CopyOnWriteFile { + mappable, + file_offset, + } => { + self.map_file(¶ms, mappable, *file_offset, false, true)?; + true + } MappingBacking::Private => { self.map_private(¶ms, deferred_protect)?; true @@ -387,7 +432,7 @@ impl MapperTask { params.range, MappingProps { private, - stats: FaultStats::default(), + stats: self.inner.track_memory_faults.then(FaultStats::default), soft_lp, }, ); @@ -402,6 +447,7 @@ impl MapperTask { mappable: &super::mappable::Mappable, file_offset: u64, deferred_protect: bool, + copy_on_write: bool, ) -> Result<(), MappingError> { let &MappingParams { range, @@ -423,18 +469,29 @@ impl MapperTask { // fails with ERROR_INVALID_PARAMETER). Deferred protect implies // `writable`. #[cfg(windows)] - let (protect, access) = ( - if writable { - PAGE_READWRITE - } else { - PAGE_READONLY - }, - if writable { - SECTION_MAP_READ | SECTION_MAP_WRITE - } else { - SECTION_MAP_READ - }, - ); + let (protect, access) = if copy_on_write { + ( + if writable { + PAGE_WRITECOPY + } else { + PAGE_READONLY + }, + SECTION_MAP_READ, + ) + } else { + ( + if writable { + PAGE_READWRITE + } else { + PAGE_READONLY + }, + if writable { + SECTION_MAP_READ | SECTION_MAP_WRITE + } else { + SECTION_MAP_READ + }, + ) + }; // `deferred_protect` is only consulted on Windows below; keep it live on // other targets so the shared parameter doesn't warn. let _ = deferred_protect; @@ -451,13 +508,23 @@ impl MapperTask { ) } _ => { - self.inner.mapping.map_file( - range.start() as usize, - range.len() as usize, - mappable, - file_offset, - writable, - ) + if copy_on_write { + self.inner.mapping.map_file_copy_on_write( + range.start() as usize, + range.len() as usize, + mappable, + file_offset, + writable, + ) + } else { + self.inner.mapping.map_file( + range.start() as usize, + range.len() as usize, + mappable, + file_offset, + writable, + ) + } } }; @@ -732,11 +799,12 @@ impl VaMapper { // Soft large pages apply only to the primary mapper, and only when the // partition resolves faults; `supports_memory_fault_resolution` rides on // the `Primary` variant. - let (primary, supports_memory_fault_resolution) = match role { + let (primary, supports_memory_fault_resolution, track_memory_faults) = match role { MapperRole::Primary { supports_memory_fault_resolution, - } => (true, supports_memory_fault_resolution), - MapperRole::Secondary => (false, false), + track_memory_faults, + } => (true, supports_memory_fault_resolution, track_memory_faults), + MapperRole::Secondary => (false, false, false), }; let mapping = match &remote_process { None => SparseMapping::new_with_minimum_alignment( @@ -769,6 +837,7 @@ impl VaMapper { eager: AtomicBool::new(eager), primary, supports_memory_fault_resolution, + track_memory_faults, req_send, host_access: OnceLock::new(), }); @@ -841,6 +910,26 @@ impl VaMapper { self.inner.mapping.len() } + /// Flushes every active shared file mapping in this mapper. + pub fn flush_shared_file_mappings(&self) -> std::io::Result<()> { + let ranges: Vec = self + .inner + .mappings + .read() + .iter() + .filter_map(|(range, properties)| { + (!properties.private) + .then(|| MemoryRange::new(*range.start()..range.end().saturating_add(1))) + }) + .collect(); + for range in ranges { + self.inner + .mapping + .flush(range.start() as usize, range.len() as usize)?; + } + Ok(()) + } + /// Returns true if this mapper receives mappings eagerly. pub fn is_eager(&self) -> bool { self.inner.eager.load(Ordering::Relaxed) @@ -1010,19 +1099,22 @@ impl ResolveMemoryFault for VaMapper { UnexpectedPageFault, )); } - props.stats.guest_faults.increment(); - // Soft large pages (Windows) raise the covering 2 MB window on the first // write and may resolve to the whole window; every other mapping (and // every non-Windows host) resolves to the single faulting page. - match &props.soft_lp { + let populated = match &props.soft_lp { Some(sl) => sl .resolve(&self.inner.mapping, fault, write, start, end) .map_err(|err| { GuestMemoryBackingError::new(GuestMemoryErrorKind::Other, fault.start(), err) }), None => Ok(fault), + }?; + if let Some(stats) = &props.stats { + stats.guest_faults.increment(); + stats.populated_bytes.add(populated.len()); } + Ok(populated) } } diff --git a/openvmm/membacking/src/memory_manager/mod.rs b/openvmm/membacking/src/memory_manager/mod.rs index bc2f97bc8e6..83f235e7669 100644 --- a/openvmm/membacking/src/memory_manager/mod.rs +++ b/openvmm/membacking/src/memory_manager/mod.rs @@ -8,6 +8,7 @@ mod device_memory; pub use device_memory::DeviceMemoryMapper; use crate::RemoteProcess; +use crate::mapping_manager::FileMappingMode; use crate::mapping_manager::Mappable; use crate::mapping_manager::MappingBacking; use crate::mapping_manager::MappingManager; @@ -62,11 +63,22 @@ pub struct GuestMemoryManager { supports_memory_fault_resolution: bool, } +/// Aggregate guest-physical fault counters for a memory manager. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MemoryFaultCounters { + /// Guest memory faults resolved through the VMM. + pub guest_faults: u64, + /// Bytes returned to the backend while resolving those faults. + pub populated_bytes: u64, +} + /// A single RAM backing allocation — one memfd or anonymous region. #[derive(Debug)] struct RamBacking { /// The file-backed memory handle. `None` for private (anonymous) backings. mappable: Option, + /// How writes to a file-backed mapping are handled. + file_mapping_mode: FileMappingMode, /// GPA ranges covered by this backing. ranges: Vec, /// Prefetch pages at build time. @@ -169,6 +181,9 @@ pub enum MemoryBuildError { /// Hugepages are incompatible with x86 legacy RAM splitting. #[error("hugepages are incompatible with x86 legacy RAM splitting")] HugepagesWithLegacy, + /// Copy-on-write mappings require an existing file backing. + #[error("copy-on-write file mapping requires an existing memory backing")] + CopyOnWriteWithoutExistingBacking, /// Invalid hugepage size. #[error("hugepage size {0} must be a power of two and at least the host page size")] InvalidHugepageSize(MemorySize), @@ -212,6 +227,7 @@ pub struct RamBackingRequest { hugepages: bool, hugepage_size: Option, existing_mappable: Option, + file_mapping_mode: FileMappingMode, host_numa_node: Option, } @@ -229,6 +245,7 @@ impl RamBackingRequest { hugepages: false, hugepage_size: None, existing_mappable: None, + file_mapping_mode: FileMappingMode::Shared, host_numa_node: None, } } @@ -272,6 +289,12 @@ impl RamBackingRequest { self } + /// Controls whether writes update an existing file backing. + pub fn file_mapping_mode(mut self, mode: FileMappingMode) -> Self { + self.file_mapping_mode = mode; + self + } + /// Bind this backing's memory to a specific host NUMA node /// (Linux: `mbind(MPOL_BIND)`, Windows: `CreateFileMappingNuma` for /// large-page sections and `MemExtendedParameterNumaNode` otherwise). @@ -348,6 +371,7 @@ pub struct GuestMemoryBuilder { pin_mappings: bool, x86_legacy_support: bool, supports_memory_fault_resolution: bool, + track_memory_faults: bool, backing_requests: Vec, } @@ -359,6 +383,7 @@ impl GuestMemoryBuilder { pin_mappings: false, x86_legacy_support: false, supports_memory_fault_resolution: false, + track_memory_faults: false, backing_requests: Vec::new(), } } @@ -404,6 +429,12 @@ impl GuestMemoryBuilder { self } + /// Enables guest-memory fault accounting for diagnostics. + pub fn track_memory_faults(mut self, enable: bool) -> Self { + self.track_memory_faults = enable; + self + } + /// Adds a RAM backing request. Call once per backing (one per NUMA node, /// or once for a non-NUMA VM). pub fn add_backing(mut self, request: RamBackingRequest) -> Self { @@ -428,6 +459,11 @@ impl GuestMemoryBuilder { if req.private_memory && req.existing_mappable.is_some() { return Err(MemoryBuildError::PrivateMemoryWithExistingBacking); } + if req.file_mapping_mode == FileMappingMode::CopyOnWrite + && req.existing_mappable.is_none() + { + return Err(MemoryBuildError::CopyOnWriteWithoutExistingBacking); + } if req.host_numa_node.is_some() && cfg!(not(any(target_os = "linux", target_os = "windows"))) { @@ -485,6 +521,7 @@ impl GuestMemoryBuilder { if req.private_memory { backings.push(RamBacking { mappable: None, + file_mapping_mode: FileMappingMode::Shared, ranges: req.ranges, prefetch: req.prefetch, transparent_hugepages: req.transparent_hugepages, @@ -538,6 +575,7 @@ impl GuestMemoryBuilder { backings.push(RamBacking { mappable: Some(mappable), + file_mapping_mode: req.file_mapping_mode, ranges: req.ranges, // On Windows, hugepage (SEC_LARGE_PAGES) backing only yields 2 MB // SLAT entries when the SLAT is populated in >= 512-page batches; @@ -575,6 +613,7 @@ impl GuestMemoryBuilder { max_addr, max_hugepage_size, self.supports_memory_fault_resolution, + self.track_memory_faults, ) .await .map_err(MemoryBuildError::VaMapper)?; @@ -624,12 +663,18 @@ impl GuestMemoryBuilder { // region-driven DMA machinery (mapped by host VA). Without // this, an assigned device DMAing to private RAM would take // IOMMU faults (silent DMA failure). - let backing_kind = match &backing.mappable { - Some(mappable) => MappingBacking::File { + let backing_kind = match (&backing.mappable, backing.file_mapping_mode) { + (Some(mappable), FileMappingMode::Shared) => MappingBacking::File { mappable: mappable.clone(), file_offset, }, - None => MappingBacking::Private, + (Some(mappable), FileMappingMode::CopyOnWrite) => { + MappingBacking::CopyOnWriteFile { + mappable: mappable.clone(), + file_offset, + } + } + (None, _) => MappingBacking::Private, }; region .add_mapping( @@ -688,18 +733,43 @@ impl GuestMemoryBuilder { #[derive(Debug, MeshPayload)] pub struct SharedMemoryBacking { guest_ram: Mappable, + file_mapping_mode: FileMappingMode, } impl SharedMemoryBacking { /// Create a SharedMemoryBacking from a mappable handle/fd. pub fn from_mappable(guest_ram: Mappable) -> Self { - Self { guest_ram } + Self { + guest_ram, + file_mapping_mode: FileMappingMode::Shared, + } + } + + /// Create a SharedMemoryBacking with an explicit file mapping mode. + pub fn from_mappable_with_mode( + guest_ram: Mappable, + file_mapping_mode: FileMappingMode, + ) -> Self { + Self { + guest_ram, + file_mapping_mode, + } } /// Returns the mappable, consuming this backing. pub fn into_mappable(self) -> Mappable { self.guest_ram } + + /// Returns whether writes to this backing are private to the current VM. + pub fn is_copy_on_write(&self) -> bool { + self.file_mapping_mode == FileMappingMode::CopyOnWrite + } + + /// Returns the mappable and mapping mode, consuming this backing. + pub fn into_parts(self) -> (Mappable, FileMappingMode) { + (self.guest_ram, self.file_mapping_mode) + } } /// A mesh-serializable object for providing access to guest memory. @@ -737,6 +807,20 @@ impl GuestMemoryManager { } } + /// Flushes guest writes from the primary shared file mappings. + pub fn flush_shared_file_backing(&self) -> io::Result<()> { + self.va_mapper.flush_shared_file_mappings() + } + + /// Returns aggregate guest-physical fault and population counters. + pub fn fault_counters(&self) -> MemoryFaultCounters { + let counters = self.va_mapper.fault_counters(); + MemoryFaultCounters { + guest_faults: counters.guest_faults, + populated_bytes: counters.populated_bytes, + } + } + /// Returns a resolver that prepares guest-memory backing on demand in /// response to partition memory-access faults (soft large pages, lazy /// commit). @@ -783,8 +867,12 @@ impl GuestMemoryManager { if self.guest_ram.len() != 1 { return None; } + if self.guest_ram[0].file_mapping_mode != FileMappingMode::Shared { + return None; + } Some(SharedMemoryBacking { guest_ram: self.guest_ram[0].mappable.clone()?, + file_mapping_mode: FileMappingMode::Shared, }) } @@ -927,6 +1015,9 @@ mod tests { use super::*; use pal_async::async_test; use std::error::Error as _; + use std::io::Read; + use std::io::Seek; + use std::io::Write; /// Build a GuestMemoryManager with the given backing range groups, /// and return a GuestMemory handle for read/write testing. @@ -967,6 +1058,70 @@ mod tests { )); } + #[async_test] + async fn test_copy_on_write_existing_backing_is_private() { + let size = SparseMapping::page_size() as u64; + let original = vec![0x5a_u8; size as usize]; + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&original).unwrap(); + file.sync_all().unwrap(); + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let backing = RamBackingRequest::new(vec![MemoryRange::new(0..size)]) + .existing_mappable(mappable.into()) + .file_mapping_mode(FileMappingMode::CopyOnWrite); + let manager = GuestMemoryBuilder::new() + .add_backing(backing) + .build(size) + .await + .unwrap(); + + assert!(manager.shared_memory_backing().is_none()); + let memory = manager.client().guest_memory().await.unwrap(); + memory.write_at(0, &[0xa5]).unwrap(); + assert_eq!(memory.read_plain::(0).unwrap(), 0xa5); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + } + + #[async_test] + async fn test_snapshot_cow_and_fresh_private_expansion_are_independent() { + let page = SparseMapping::page_size() as u64; + let original = vec![0x5a_u8; page as usize]; + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&original).unwrap(); + file.sync_all().unwrap(); + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&file, false).unwrap(); + + let manager = GuestMemoryBuilder::new() + .add_backing( + RamBackingRequest::new(vec![MemoryRange::new(0..page)]) + .existing_mappable(mappable.into()) + .file_mapping_mode(FileMappingMode::CopyOnWrite), + ) + .add_backing( + RamBackingRequest::new(vec![MemoryRange::new(page..2 * page)]).private_memory(true), + ) + .build(2 * page) + .await + .unwrap(); + let memory = manager.client().guest_memory().await.unwrap(); + + assert_eq!(memory.read_plain::(0).unwrap(), 0x5a); + assert_eq!(memory.read_plain::(page).unwrap(), 0); + memory.write_at(0, &[0xa5]).unwrap(); + memory.write_at(page, &[0xcc]).unwrap(); + assert_eq!(memory.read_plain::(0).unwrap(), 0xa5); + assert_eq!(memory.read_plain::(page).unwrap(), 0xcc); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + } + #[test] fn test_validate_hugepage_size() { let page_size = SparseMapping::page_size() as u64; diff --git a/openvmm/openvmm_core/Cargo.toml b/openvmm/openvmm_core/Cargo.toml index d3a08b5c2e3..b60c3e7df2f 100644 --- a/openvmm/openvmm_core/Cargo.toml +++ b/openvmm/openvmm_core/Cargo.toml @@ -10,6 +10,8 @@ rust-version.workspace = true gdb = ["vmm_core/gdb"] [dev-dependencies] +chipset.workspace = true +inspect = { workspace = true, features = ["initiate"] } test_with_tracing.workspace = true [dependencies] @@ -43,6 +45,7 @@ igvm = { workspace = true, features = ["corim"] } igvm_defs.workspace = true loader.workspace = true virt.workspace = true +cpu_profile.workspace = true vm_loader.workspace = true vmgs.workspace = true vmgs_broker.workspace = true diff --git a/openvmm/openvmm_core/src/partition.rs b/openvmm/openvmm_core/src/partition.rs index e289fdacd85..304c2071b73 100644 --- a/openvmm/openvmm_core/src/partition.rs +++ b/openvmm/openvmm_core/src/partition.rs @@ -11,6 +11,8 @@ //! //! If this ends up not being true, then this layer should probably be removed. +pub mod microvm; + use anyhow::Context as _; use async_trait::async_trait; use guestmem::DoorbellRegistration; @@ -54,7 +56,12 @@ use vmm_core::partition_unit::VmPartition; use vmm_core::partition_unit::VpRunner; /// A base partition, with methods needed at rutnime along with methods to initialize the vm. -pub trait HvlitePartition: Inspect + Send + Sync + RequestYield { +pub trait HvlitePartition: + Inspect + Send + Sync + RequestYield + microvm::MicrovmPartition +{ + /// Completes backend partition initialization after guest memory is attached. + fn finalize_memory(&self) -> anyhow::Result<()>; + /// Gets a line set target to trigger local APIC LINTs. /// /// The line number is the VP index times 2, plus the LINT number (0 or 1). @@ -187,6 +194,11 @@ impl HvlitePartition for T where T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, { + fn finalize_memory(&self) -> anyhow::Result<()> { + Partition::finalize_memory(self)?; + Ok(()) + } + #[cfg(guest_arch = "x86_64")] fn into_lint_target(self: Arc, vtl: Vtl) -> Arc { Arc::new(vmm_core::emuplat::apic::ApicLintLineTarget::new(self, vtl)) diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs new file mode 100644 index 00000000000..05052f16d31 --- /dev/null +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Hypervisor partition operations used by the microVM profile. + +use super::ArchPartition; +use super::BasicPartitionStateAccess; +use virt::Partition; +use virt::PartitionAccessState; +use virt::PartitionMemoryMapper; + +/// Backend partition operations required by the microVM lifecycle. +pub trait MicrovmPartition: Send + Sync { + /// Returns the NVX time ABI primitives, if the partition was built with a + /// time ABI configuration. + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend>; +} + +impl MicrovmPartition for T +where + T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, +{ + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + Partition::time_abi(self) + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 4299a26ab46..029513f037e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -6,9 +6,13 @@ mod dump; mod ecam_config_access; mod intel_vtd_wiring; mod ioapic_iommu_wiring; +mod microvm; mod pcie_topology; mod pcie_wiring; +mod restore; mod smmu_wiring; +mod snapshot_rpc; +mod time_abi; use crate::emuplat; use crate::partition::BindHvliteVp; @@ -56,7 +60,6 @@ use membacking::SharedMemoryBacking; use memory_range::MemoryRange; use mesh::MeshPayload; use mesh::error::RemoteError; -use mesh::payload::Protobuf; use mesh::payload::message::ProtobufMessage; use mesh_worker::Worker; use mesh_worker::WorkerId; @@ -83,15 +86,17 @@ use openvmm_defs::config::Vtl2BaseAddressType; use openvmm_defs::config::Vtl2Config; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::config::X86TopologyConfig; +use openvmm_defs::microvm::MachineProfile; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; +use openvmm_defs::worker::SavedState; use openvmm_defs::worker::VM_WORKER; use openvmm_defs::worker::VmWorkerParameters; use openvmm_pcat_locator::RomFileLocation; use pal_async::DefaultDriver; use pal_async::DefaultPool; +use pal_async::driver::SpawnDriver; use pal_async::local::block_with_io; -use pal_async::task::Spawn; use pal_async::task::Task; use pal_async::timer::PolledTimer; use pci_core::PciInterruptPin; @@ -100,7 +105,6 @@ use pcie::switch::GenericPcieSwitch; use scsi_core::ResolveScsiDeviceHandleParams; use scsidisk::atapi_scsi::AtapiScsiDisk; use serial_16550_resources::ComPort; -use state_unit::SavedStateUnit; use state_unit::SpawnedUnit; use state_unit::StateUnits; use std::fs::File; @@ -131,6 +135,7 @@ use vm_topology::memory::MemoryLayout; use vm_topology::pcie::PcieHostBridge; use vm_topology::pcie::PcieHostBridgeCxlInfo; use vm_topology::processor::ProcessorTopology; +#[cfg(guest_arch = "aarch64")] use vm_topology::processor::TopologyBuilder; use vm_topology::processor::aarch64::Aarch64Topology; use vm_topology::processor::aarch64::GicVersion; @@ -139,7 +144,6 @@ use vmbus_channel::channel::VmbusDevice; use vmbus_server::HvsockRelayChannel; use vmbus_server::VmbusServer; use vmbus_server::hvsock::HvsockRelay; -use vmcore::save_restore::SavedStateRoot; use vmcore::vm_task::VmTaskDriverSource; use vmcore::vm_task::thread::ThreadDriverBackend; use vmcore::vmtime::VmTime; @@ -206,6 +210,8 @@ pub fn new_device_thread() -> (JoinHandle<()>, DefaultDriver) { impl Manifest { fn from_config(config: Config) -> Self { Self { + machine_profile: config.machine_profile, + microvm: config.microvm.into(), load_mode: config.load_mode, floppy_disks: config.floppy_disks, ide_disks: config.ide_disks, @@ -284,13 +290,8 @@ pub struct Manifest { chipset_capabilities: VmChipsetCapabilities, layout: vmm_core_defs::LayoutConfig, rtc_delta_milliseconds: i64, -} - -#[derive(Protobuf, SavedStateRoot)] -#[mesh(package = "openvmm")] -pub struct SavedState { - #[mesh(1)] - pub units: Vec, + machine_profile: MachineProfile, + microvm: microvm::MicrovmManifest, } async fn open_simple_disk( @@ -331,6 +332,8 @@ pub struct VmWorker { vm: LoadedVm, rpc: mesh::Receiver, device_thread: JoinHandle<()>, + /// Restored snapshot handles, dropped when `run` returns after the device thread exits. + _snapshot_restore_guards: Option, } impl Worker for VmWorker { @@ -338,38 +341,81 @@ impl Worker for VmWorker { type State = RestartState; const ID: WorkerId = VM_WORKER; - fn new(parameters: Self::Parameters) -> anyhow::Result { + fn new(mut parameters: Self::Parameters) -> anyhow::Result { + let worker_construct = openvmm_defs::profile::ProfileSpan::start(); + let restore_params = restore::RestoreParameters::take(&mut parameters)?; + let microvm_params = microvm::MicrovmParameters::take(&mut parameters)?; let (device_thread, device_driver) = new_device_thread(); - let manifest = Manifest::from_config(parameters.cfg); + let mut manifest = Manifest::from_config(parameters.cfg); + manifest.microvm.hypervisor_id = parameters.hypervisor.id().to_owned(); let hypervisor = block_on(ResourceResolver::new().resolve(parameters.hypervisor, ())) .context("failed to resolve hypervisor backend")?; + // A snapshot of a host CPU profile carries the profile's only copy, + // from which the partition takes its profile. + #[cfg(guest_arch = "x86_64")] + if let Some(input) = &restore_params.state.time_abi { + manifest.microvm.restored_host_profile = + time_abi::restored_host_profile(&input.cpu_profile); + } + let shared_memory = parameters .shared_memory - .map(|fd| SharedMemoryBacking::from_mappable(fd.into())); + .map(|fd| restore_params.shared_memory_backing(fd)); + + // A cold boot whose CPU profile this host cannot boot names the host + // profile where one could boot instead, or says that `auto` fell back + // to one. + #[cfg(guest_arch = "x86_64")] + let hint = { + let requested = manifest + .microvm + .time_abi + .as_ref() + .map(|time_abi| (time_abi.cpu_profile.clone(), time_abi.hooks.clone())); + let restoring = parameters.saved_state.is_some(); + move |err| { + time_abi::hint_cold_boot( + err, + requested + .as_ref() + .map(|(cpu_profile, hooks)| (cpu_profile.as_str(), hooks)), + restoring, + ) + } + }; + #[cfg(not(guest_arch = "x86_64"))] + let hint = |err: anyhow::Error| err; let vm = block_on(InitializedVm::new( VmTaskDriverSource::new(ThreadDriverBackend::new(device_driver)), hypervisor.0, manifest, shared_memory, - ))?; + )) + .map_err(&hint)?; let saved_state = parameters .saved_state .map(|m| m.parse()) .transpose() .context("failed to decode saved state")?; + microvm_params.check_cold_boot(&vm, saved_state.is_some())?; - let vm = block_with_io(|_| vm.load(saved_state, parameters.notify))?; + let mut vm = + block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state)) + .map_err(&hint)?; + vm.snapshot_boundary = microvm_params.snapshot_boundary; LOADED_VM.store(&vm); + worker_construct.complete_milestone("startup", "worker_construct", Default::default()); Ok(Self { vm, rpc: parameters.rpc, device_thread, + _snapshot_restore_guards: restore_params.guards, }) } @@ -395,17 +441,20 @@ impl Worker for VmWorker { shared_memory, ))?; pal_async::local::block_on(async { - let mut vm = vm.load(Some(saved_state), notify).await?; + let mut vm = vm + .load(Some(saved_state), notify, Default::default()) + .await?; LOADED_VM.store(&vm); if running { - vm.resume().await; + vm.resume().await?; } Ok(Self { vm, rpc, device_thread, + _snapshot_restore_guards: None, }) }) } @@ -476,6 +525,9 @@ pub(crate) struct InitializedVm { processor_topology: ProcessorTopology, igvm_file: Option, driver_source: VmTaskDriverSource, + /// The time ABI of the partition, when it uses the time ABI. + #[cfg(guest_arch = "x86_64")] + time_abi_partition: Option, } trait ExtractTopologyConfig { @@ -508,7 +560,10 @@ struct X86TopologyResult { } #[cfg(guest_arch = "x86_64")] -fn build_x86_topology(config: &ProcessorTopologyConfig) -> anyhow::Result { +fn build_x86_topology( + config: &ProcessorTopologyConfig, + machine_profile: MachineProfile, +) -> anyhow::Result { use vm_topology::processor::x86::X2ApicState; let arch = match &config.arch { @@ -516,7 +571,7 @@ fn build_x86_topology(config: &ProcessorTopologyConfig) -> anyhow::Result arch.clone(), _ => anyhow::bail!("invalid architecture config"), }; - let mut builder = TopologyBuilder::from_host_topology()?; + let mut builder = microvm::x86_topology_builder(machine_profile)?; builder.apic_id_offset(arch.apic_id_offset); if let Some(smt) = config.enable_smt { builder.smt_enabled(smt); @@ -878,6 +933,8 @@ pub(crate) struct LoadedVm { state_units: StateUnits, inner: LoadedVmInner, running: bool, + snapshot_restore: restore::SnapshotRestore, + snapshot_boundary: microvm::SnapshotBoundary, } struct DynamicVpciDeviceEntry { @@ -894,6 +951,9 @@ struct LoadedVmInner { partition: Arc, chipset_devices: ChipsetDevices, _vmtime: SpawnedUnit, + /// The time ABI state, when the partition uses the time ABI. + #[cfg(guest_arch = "x86_64")] + time_abi: Option, memory_manager: GuestMemoryManager, gm: GuestMemory, vtl0_hvsock_relay: Option, @@ -927,6 +987,7 @@ struct LoadedVmInner { pci_legacy_interrupts: Vec<((u8, Option), u32)>, firmware_event_send: Option>, + machine_profile: MachineProfile, load_mode: LoadMode, igvm_file: Option, next_igvm_file: Option, @@ -1194,7 +1255,7 @@ impl InitializedVm { }; #[cfg(not(guest_arch = "aarch64"))] let mut processor_topology = { - let result = build_x86_topology(&cfg.processor_topology)?; + let result = build_x86_topology(&cfg.processor_topology, cfg.machine_profile)?; result.processor_topology }; @@ -1231,7 +1292,37 @@ impl InitializedVm { #[cfg(guest_arch = "aarch64")] let device_assignment_msi_iova_range = resolve_device_assignment_msi_iova_range(platform_info.device_assignment_msi_iova); + let lazy_memory_registration = + microvm::uses_lazy_memory_registration(&cfg, shared_memory.as_ref()); + let user_mode_memory_faults = !lazy_memory_registration; + #[cfg(guest_arch = "x86_64")] + let (time_abi_partition, time_abi_config) = match &cfg.microvm.time_abi { + Some(parameters) if cfg.machine_profile == MachineProfile::Microvm => { + let profile = time_abi::select_cpu_profile( + ¶meters.cpu_profile, + &cfg.microvm.hypervisor_id, + cfg.microvm.restored_host_profile.as_ref(), + ¶meters.hooks, + )?; + let effective_cpuid = Arc::new(virt::time_abi::cpuid::effective_cpuid( + &profile, + &processor_topology, + )?); + let (msrs, config) = time_abi::partition_config(&profile, &effective_cpuid); + ( + Some(time_abi::PartitionTimeAbi { + msrs, + profile, + effective_cpuid, + }), + Some(config), + ) + } + _ => (None, None), + }; + + let partition_prototype = openvmm_defs::profile::ProfileSpan::start(); let proto = hypervisor .new_partition(virt::ProtoPartitionConfig { processor_topology: &processor_topology, @@ -1241,8 +1332,13 @@ impl InitializedVm { nested_virt: cfg.hypervisor.nested_virt, #[cfg(guest_arch = "aarch64")] device_assignment_msi_iova_range, + user_mode_memory_faults, + lazy_memory_registration, + #[cfg(guest_arch = "x86_64")] + time_abi: time_abi_config, }) .context("failed to create the prototype partition")?; + partition_prototype.complete("startup", "partition_prototype", Default::default()); let physical_address_size = proto.max_physical_address_size(); @@ -1282,6 +1378,7 @@ impl InitializedVm { .iter() .filter(|(bus, _)| matches!(bus, VirtioBus::Mmio)) .count(); + let virtio_mmio_count = microvm::virtio_mmio_count(cfg.machine_profile, virtio_mmio_count); // On aarch64 Linux direct boot, start RAM at 1 GiB to avoid the low GPA // region (128 MiB–129 MiB) that iommufd reserves for the host MSI @@ -1312,6 +1409,7 @@ impl InitializedVm { }; let resolved_layout = resolve_memory_layout(MemoryLayoutInput { node_mem_sizes: &node_mem_sizes, + memory_capacity: cfg.microvm.memory_capacity, layout: cfg.layout.clone(), pcie_root_complexes: &cfg.pcie_root_complexes, virtio_mmio_count, @@ -1323,6 +1421,10 @@ impl InitializedVm { }) .context("invalid memory configuration")?; let mem_layout = resolved_layout.memory_layout; + let guest_memory_counters = openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(mem_layout.ram().iter().map(|range| range.range.len()).sum()), + ..Default::default() + }; let resolved_pcie_root_complex_ranges = resolved_layout.pcie_root_complex_ranges; let virtio_mmio_region = resolved_layout.virtio_mmio_region; let chipset_mmio = resolved_layout.chipset_mmio; @@ -1431,7 +1533,7 @@ impl InitializedVm { "shared memory restore not supported with {nodes_with_ranges} memory nodes" ); } - Some(smb.into_mappable()) + Some(smb.into_parts()) } else { None }; @@ -1440,9 +1542,17 @@ impl InitializedVm { memory_builder = memory_builder .vtl0_alias_map(vtl0_alias_map) .supports_memory_fault_resolution(supports_memory_fault_resolution) + .track_memory_faults(openvmm_defs::profile::enabled()) .x86_legacy_support( matches!(cfg.load_mode, LoadMode::Pcat { .. }) || cfg.chipset.with_hyperv_vga, ); + memory_builder = microvm::add_snapshot_restore_backing( + memory_builder, + &cfg, + &mut ranges_by_node, + nodes_with_ranges, + &mut existing_mappable, + )?; for (vnode, ranges) in ranges_by_node.into_iter().enumerate() { if ranges.is_empty() { @@ -1468,8 +1578,10 @@ impl InitializedVm { if mem.hugepages { backing = backing.hugepages(mem.hugepage_size); } - if let Some(mappable) = existing_mappable.take() { - backing = backing.existing_mappable(mappable); + if let Some((mappable, file_mapping_mode)) = existing_mappable.take() { + backing = backing + .existing_mappable(mappable) + .file_mapping_mode(file_mapping_mode); } memory_builder = memory_builder.add_backing(backing); @@ -1499,10 +1611,12 @@ impl InitializedVm { .end_of_layout() .max(mem_layout.vtl2_range().map_or(0, |r| r.end())); + let cow_map_view = openvmm_defs::profile::ProfileSpan::start(); let mut memory_manager = memory_builder .build(max_addr) .await .context("failed to build guest memory")?; + cow_map_view.complete("startup", "cow_map_view", guest_memory_counters); let gm = memory_manager .client() @@ -1522,6 +1636,7 @@ impl InitializedVm { )); } + let partition_build = openvmm_defs::profile::ProfileSpan::start(); let (partition, vps) = proto .build(virt::PartitionConfig { mem_layout: &mem_layout, @@ -1532,11 +1647,13 @@ impl InitializedVm { .then(|| memory_manager.memory_fault_resolver()), }) .context("failed to create the partition")?; + partition_build.complete("startup", "partition_build", Default::default()); let vps = vps.into_iter().map(|vp| Box::new(vp) as _).collect(); let partition = Arc::new(partition); + let gpa_registration = openvmm_defs::profile::ProfileSpan::start(); memory_manager .attach_partition( Vtl::Vtl0, @@ -1558,6 +1675,22 @@ impl InitializedVm { .await .context("failed to attach memory to VTL2")?; } + gpa_registration.complete("startup", "gpa_registration", guest_memory_counters); + + let partition_finalize = openvmm_defs::profile::ProfileSpan::start(); + let finalize_result = { + let _span = tracing::info_span!("post-memory partition finalization").entered(); + let started = std::time::Instant::now(); + let result = partition.finalize_memory(); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "post-memory partition finalization completed" + ); + result + }; + finalize_result.context("failed to finalize partition memory")?; + partition_finalize.complete("startup", "partition_finalize", Default::default()); Ok(Self { partition, @@ -1576,6 +1709,8 @@ impl InitializedVm { processor_topology, igvm_file, driver_source, + #[cfg(guest_arch = "x86_64")] + time_abi_partition, }) } @@ -1587,12 +1722,13 @@ impl InitializedVm { self, saved_state: Option, client_notify_send: mesh::Sender, + snapshot_restore: restore::SnapshotRestore, ) -> Result { use vmotherboard::options::dev; let Self { partition, - vps, + mut vps, vmtime_keeper, vmtime_source, memory_manager, @@ -1607,8 +1743,16 @@ impl InitializedVm { processor_topology, igvm_file, driver_source, + #[cfg(guest_arch = "x86_64")] + time_abi_partition, } = self; + let instantiated_vp_count = snapshot_restore.select_instantiated_vps( + &mut vps, + processor_topology.vp_count(), + saved_state.is_some(), + )?; + let mut resolver = ResourceResolver::new(); // Expose the partition reference time source, if available. @@ -1773,6 +1917,7 @@ impl InitializedVm { with_psp: cfg.chipset.with_generic_psp, pm_base: PM_BASE, acpi_irq: SYSTEM_IRQ_ACPI, + level_triggered_irqs: &[], iommu: None, }, }; @@ -1822,6 +1967,7 @@ impl InitializedVm { let vmtime = state_units .add("vmtime") + .advances_time() .spawn(driver_source.simple(), { |recv| { let mut vmtime = vmtime_keeper; @@ -1833,6 +1979,91 @@ impl InitializedVm { }) .unwrap(); + #[cfg(guest_arch = "x86_64")] + let time_abi_state = match time_abi_partition { + Some(time_abi::PartitionTimeAbi { + msrs, + profile, + effective_cpuid, + }) => { + let parameters = cfg + .microvm + .time_abi + .as_ref() + .context("time ABI parameters are missing")?; + let hypervisor = &cfg.microvm.hypervisor_id; + let restore = snapshot_restore.time_abi.as_ref(); + anyhow::ensure!( + saved_state.is_some() == restore.is_some(), + "a time ABI restore requires both the saved state and the time ABI restore inputs" + ); + let checks_started = std::time::Instant::now(); + if let Some(input) = restore { + anyhow::ensure!( + input.cpu_profile.id == profile.id(), + "the partition's CPU profile '{}' is not the snapshot's '{}'", + profile.id(), + input.cpu_profile.id + ); + time_abi::check_recorded_cpuid(&effective_cpuid, &input.cpu_profile)?; + } + let recorded_done = std::time::Instant::now(); + time_abi::check_presented_cpuid( + partition.as_ref(), + hypervisor, + &profile, + &effective_cpuid, + )?; + let presented_done = std::time::Instant::now(); + time_abi::check_profile_support(partition.as_ref(), hypervisor, &profile)?; + let support_done = std::time::Instant::now(); + tracing::info!( + cpu_profile = profile.id(), + recorded_cpuid_us = (recorded_done - checks_started).as_micros() as u64, + presented_cpuid_us = (presented_done - recorded_done).as_micros() as u64, + profile_support_us = (support_done - presented_done).as_micros() as u64, + "time ABI CPU checks passed" + ); + let report = time_abi::declare_rates( + partition.as_ref(), + &msrs, + hypervisor, + profile.id().to_owned(), + restore.map(|input| virt::time_abi::DeclaredRates { + tsc_hz: input.contract.tsc_frequency_hz, + apic_hz: input.contract.apic_frequency_hz, + }), + ¶meters.hooks, + )?; + let unit = state_units + .add(time_abi::TIME_ABI_UNIT) + .spawn(driver_source.simple(), { + let msrs = msrs.clone(); + let report = report.clone(); + move |recv| { + state_unit::run_unit(time_abi::TimeAbiUnit { msrs, report }, recv) + } + }) + .unwrap(); + Some(time_abi::TimeAbiState { + _unit: unit, + msrs, + profile, + effective_cpuid, + report, + generation: parameters.generation, + hooks: parameters.hooks.clone(), + }) + } + None => { + anyhow::ensure!( + snapshot_restore.time_abi.is_none(), + "time ABI restore inputs require a time ABI partition" + ); + None + } + }; + let mut input_distributor = InputDistributor::new(cfg.input); resolver.add_async_resolver::( input_distributor.client().clone(), @@ -1950,6 +2181,7 @@ impl InitializedVm { .into_resource(), century_reg_idx: 0x32, // TODO: automatically sync with FADT initial_cmos: initial_rtc_cmos, + mode: dev::GenericCmosRtcMode::Standard, } }); @@ -2114,7 +2346,7 @@ impl InitializedVm { .with_device_handles(cfg.chipset_devices) .with_pci_device_handles(cfg.pci_chipset_devices) .with_isa_dma_handle(cfg.isa_dma_controller) - .with_trace_unknown_pio(true) // todo: add CLI param? + .with_trace_unknown_pio(microvm::trace_unknown_pio(cfg.machine_profile)) .build(&driver_source, &state_units, &resolver) .await?; @@ -3025,6 +3257,8 @@ impl InitializedVm { // allocation indexed by the order of VirtioBus::Mmio devices. let mut pci_device_number = 10; let mut virtio_mmio_index = 0; + let mut microvm_virtio_slots = + microvm::VirtioMmioSlots::new(&cfg.microvm.sandbox_blocks, chipset_mmio); // Avoid an ISA interrupt to avoid conflicts and to avoid needing to // configure the line as level-triggered in the MADT (necessary for @@ -3049,6 +3283,16 @@ impl InitializedVm { ) .await?; match bus { + VirtioBus::Mmio if cfg.machine_profile == MachineProfile::Microvm => { + microvm_virtio_slots.add_device( + &chipset_builder, + &driver_source, + &gm, + &partition, + &id, + device, + )?; + } VirtioBus::Mmio => { let mmio_start = virtio_mmio_region.start() + virtio_mmio_index as u64 * 0x1000; virtio_mmio_index += 1; @@ -3121,6 +3365,7 @@ impl InitializedVm { partition.clone().into_vm_partition(), PartitionUnitParams { processor_topology: &processor_topology, + active_vp_count: Some(instantiated_vp_count), halt_vps, halt_request_recv, client_notify_send: halt_send, @@ -3135,6 +3380,12 @@ impl InitializedVm { .context("failed to create partition unit")?; // Start the VP backing threads. + assert_eq!( + vps.len(), + vp_runners.len(), + "every instantiated VP needs exactly one binder and one runner" + ); + let vp_thread_bind = openvmm_defs::profile::ProfileSpan::start(); try_join_all(vps.into_iter().zip(vp_runners).enumerate().map( |(vp_index, (mut vp, runner))| { let partition = partition.clone(); @@ -3142,17 +3393,22 @@ impl InitializedVm { let (send, recv) = mesh::oneshot(); thread::Builder::new() .name(format!("vp-{}", vp_index)) - .spawn(move || match vp.bind() { - Ok(mut vp) => { - send.send(Ok(())); - block_on_vp( - partition, - VpIndex::new(vp_index as u32), - vp.run(runner, &chipset), - ) - } - Err(err) => { - send.send(Err(err)); + .spawn(move || { + let vp_bind = openvmm_defs::profile::ProfileSpan::start(); + let bound = vp.bind(); + complete_vp_bind_profile(vp_bind, vp_index); + match bound { + Ok(mut vp) => { + send.send(Ok(())); + block_on_vp( + partition, + VpIndex::new(vp_index as u32), + vp.run(runner, &chipset), + ) + } + Err(err) => { + send.send(Err(err)); + } } }) .unwrap(); @@ -3165,10 +3421,13 @@ impl InitializedVm { }, )) .await?; + vp_thread_bind.complete("startup", "vp_thread_bind", Default::default()); let mut this = LoadedVm { state_units, running: false, + snapshot_restore, + snapshot_boundary: Default::default(), inner: LoadedVmInner { driver_source, resolver, @@ -3176,6 +3435,8 @@ impl InitializedVm { partition, chipset_devices: devices, _vmtime: vmtime, + #[cfg(guest_arch = "x86_64")] + time_abi: time_abi_state, memory_manager, gm, vtl0_hvsock_relay, @@ -3197,6 +3458,7 @@ impl InitializedVm { chipset_cfg: cfg.chipset, chipset_capabilities: cfg.chipset_capabilities, firmware_event_send: cfg.firmware_event_send, + machine_profile: cfg.machine_profile, load_mode: cfg.load_mode, virtio_mmio_region, virtio_mmio_irq, @@ -3225,9 +3487,11 @@ impl InitializedVm { }; if let Some(saved_state) = saved_state { + let saved_state_restore = this.begin_snapshot_restore(&saved_state).await?; this.restore(saved_state) .await .context("loadedvm restore failed")?; + this.finish_snapshot_restore(saved_state_restore).await?; } else { // Assign PCI bus numbers/BARs before building firmware so that the // ACPI tables (specifically the SRAT generic-initiator entries) can @@ -3241,6 +3505,19 @@ impl InitializedVm { } } +/// Emits the non-exclusive profile milestone of binding VP `vp_index`. +fn complete_vp_bind_profile(span: openvmm_defs::profile::ProfileSpan, vp_index: usize) { + if vp_index == 0 { + span.complete_milestone("startup", "vp_bind_bsp", Default::default()); + } else if openvmm_defs::profile::enabled() { + span.complete_milestone( + "startup", + &format!("vp_bind_ap_{vp_index}"), + Default::default(), + ); + } +} + impl LoadedVmInner { fn slit_info(&self) -> Option { let num_nodes = self.numa_cfg.nodes.len(); @@ -3301,6 +3578,7 @@ impl LoadedVmInner { with_pit: self.chipset_capabilities.with_pit, pm_base: PM_BASE, acpi_irq: SYSTEM_IRQ_ACPI, + level_triggered_irqs: microvm::level_triggered_irqs(self.machine_profile), iommu: match &self.iommu_devices { IommuDevices::AmdVi(devices) => { Some(vmm_core::acpi_builder::X86IommuAcpiConfig::AmdVi( @@ -3350,6 +3628,16 @@ impl LoadedVmInner { } = match &self.load_mode { LoadMode::None => return Ok(()), #[cfg(guest_arch = "x86_64")] + &LoadMode::Linux { + ref kernel, + ref initrd, + ref cmdline, + isolation, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + ref smbios, + .. + } => microvm::load_linux_x86_mptable(self, kernel, initrd, cmdline, isolation, smbios)?, + #[cfg(guest_arch = "x86_64")] &LoadMode::Linux { ref kernel, ref initrd, @@ -3364,6 +3652,7 @@ impl LoadedVmInner { anyhow::bail!("device tree boot mode is not supported on x86_64"); } openvmm_defs::config::LinuxDirectBootMode::Acpi => {} + openvmm_defs::config::LinuxDirectBootMode::MpTable => unreachable!(), } let isolation = match (isolation, self.hypervisor_cfg.with_isolation) { ( @@ -3666,13 +3955,13 @@ impl LoadedVmInner { } impl LoadedVm { - async fn resume(&mut self) -> bool { + async fn resume(&mut self) -> anyhow::Result { if self.running { - return false; + return Ok(false); } - self.state_units.start().await; + self.start_state_units().await?; self.running = true; - true + Ok(true) } async fn pause(&mut self) -> bool { @@ -3711,7 +4000,10 @@ impl LoadedVm { let stop_guard = self.inner.partition_unit.temporarily_stop_vps().await; // Start state units so device config space is accessible. - self.state_units.start().await; + self.state_units + .start() + .await + .context("failed to start devices for PCI resource assignment")?; let result = ecam_config_access::assign_pci_resources_for_root_complexes( &self.inner.chipset, @@ -3729,7 +4021,7 @@ impl LoadedVm { pub async fn run( mut self, - driver: &impl Spawn, + driver: &impl SpawnDriver, mut rpc_recv: mesh::Receiver, mut worker_rpc: mesh::Receiver>, ) { @@ -3737,6 +4029,8 @@ impl LoadedVm { WorkerRpc(Result, mesh::RecvError>), VmRpc(Result), Halt(Result), + SnapshotBoundary(microvm::SnapshotBoundaryEvent), + Restore(restore::RestoreEvent), } // Start a task to handle state unit inspections by filtering the worker @@ -3764,7 +4058,21 @@ impl LoadedVm { let a = rpc_recv.recv().map(Event::VmRpc); let b = worker_rpc.recv().map(Event::WorkerRpc); let c = self.inner.halt_recv.recv().map(Event::Halt); - (a, b, c).race().await + let d = self.snapshot_boundary.recv().map(Event::SnapshotBoundary); + let e = self.snapshot_restore.next_event(driver).map(Event::Restore); + (a, b, c, d, e).race().await + }; + + let event = match event { + Event::VmRpc(Ok(message)) => match self.filter_vm_rpc(message) { + Some(message) => Event::VmRpc(Ok(message)), + None => continue, + }, + Event::WorkerRpc(Ok(message)) => match self.filter_worker_rpc(message) { + Some(message) => Event::WorkerRpc(Ok(message)), + None => continue, + }, + event => event, }; match event { @@ -3797,7 +4105,12 @@ impl LoadedVm { } Err(err) => { if stopped { - self.state_units.start().await; + if let Err(start_error) = self.state_units.start().await { + rpc.complete(Err(RemoteError::new(start_error.context( + "worker restart failed and the VM could not resume", + )))); + continue; + } } rpc.complete(Err(RemoteError::new(err))); } @@ -3822,12 +4135,28 @@ impl LoadedVm { rpc.handle(async |()| self.inner.partition_unit.clear_halt().await) .await } - VmRpc::Resume(rpc) => rpc.handle(async |()| self.resume().await).await, + VmRpc::Resume(rpc) => { + rpc.handle_failable(async |()| self.resume().await).await + } VmRpc::Pause(rpc) => rpc.handle(async |()| self.pause().await).await, VmRpc::Save(rpc) => { rpc.handle_failable(async |()| self.save().await.map(ProtobufMessage::new)) .await } + VmRpc::QuiesceForSnapshot(rpc) => { + rpc.handle(async |timeout| self.quiesce_for_snapshot(timeout).await) + .await; + } + VmRpc::ResumeAfterFailedSnapshot(rpc) => { + rpc.handle_failable(async |timeout| { + self.resume_after_failed_snapshot(timeout).await + }) + .await; + } + VmRpc::ReleaseSnapshotBoundary(rpc) => { + rpc.handle_failable(async |()| self.release_snapshot_boundary().await) + .await + } VmRpc::Nmi(rpc) => rpc.handle_sync(|vpindex| { if vpindex < self.inner.processor_topology.vp_count() { // Send an NMI MSI to the processor. We could raise @@ -3870,7 +4199,7 @@ impl LoadedVm { ) .await?; self.inner.vmbus_devices.push(device); - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; anyhow::Ok(()) }) .await @@ -3899,7 +4228,7 @@ impl LoadedVm { self.save_reset_restore().await?; if paused { - self.resume().await; + self.resume().await?; } Ok(()) }) @@ -4016,7 +4345,7 @@ impl LoadedVm { // MSI. The guest may begin probing config space // immediately after receiving the interrupt, so // the device must be ready first. - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; // Now attach the device and notify the guest. if let Err(e) = rc.lock().hotplug_add_device( @@ -4178,6 +4507,18 @@ impl LoadedVm { Event::Halt(Ok(reason)) => { self.inner.client_notify_send.send(reason); } + Event::SnapshotBoundary(event) => { + if !self.handle_snapshot_boundary(event).await { + break; + } + } + Event::Restore(restore::RestoreEvent::GateExpired) => { + self.handle_restore_gate_timeout().await; + break; + } + Event::Restore(restore::RestoreEvent::PacketSelected) => { + self.snapshot_restore.restore_packet_selected(); + } } } @@ -4280,11 +4621,13 @@ impl LoadedVm { async fn save(&mut self) -> anyhow::Result { Ok(SavedState { units: self.state_units.save().await?, + inventory: self.state_units.inventory(), }) } /// Restore state on the VM. async fn restore(&mut self, state: SavedState) -> anyhow::Result<()> { + restore::validate_inventory(&self.state_units, &state)?; self.state_units.restore(state.units).await?; Ok(()) } @@ -4312,6 +4655,8 @@ impl LoadedVm { } let manifest = Manifest { + machine_profile: self.inner.machine_profile, + microvm: Default::default(), // TODO load_mode: self.inner.load_mode, floppy_disks: vec![], // TODO ide_disks: vec![], // TODO @@ -4379,7 +4724,7 @@ impl LoadedVm { } if resume { - self.resume().await; + self.resume().await?; } Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/dump.rs b/openvmm/openvmm_core/src/worker/dispatch/dump.rs index ec47b71126d..a0108c686da 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/dump.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/dump.rs @@ -19,7 +19,9 @@ impl LoadedVm { let was_running = self.pause().await; let result = self.dump_state_inner(file).await; if was_running { - self.resume().await; + self.resume() + .await + .context("failed to resume VM after dump")?; } result } diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs new file mode 100644 index 00000000000..8add589a93a --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -0,0 +1,774 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Worker construction and lifecycle support for the microVM profile. + +use super::InitializedVm; +use super::LoadedVm; +use super::LoadedVmInner; +use super::Manifest; +use super::RestartState; +use super::snapshot_rpc; +use crate::partition::HvlitePartition; +use crate::worker::memory_layout::ChipsetMmioRanges; +use anyhow::Context; +use chipset_device_resources::IRQ_LINE_SET; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; +use guestmem::GuestMemory; +use hvdef::Vtl; +use membacking::FileMappingMode; +use membacking::GuestMemoryBuilder; +use membacking::Mappable; +use membacking::SharedMemoryBacking; +use memory_range::MemoryRange; +use mesh::MeshPayload; +use mesh::error::RemoteError; +use mesh::rpc::Rpc; +use mesh_worker::WorkerRpc; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmConfig; +use openvmm_defs::rpc::PulseSaveRestoreError; +use openvmm_defs::rpc::VmRpc; +use openvmm_defs::worker::VmWorkerParameters; +use std::sync::Arc; +use std::time::Duration; +use virtio::VirtioMmioDevice; +use virtio::VirtioMmioInterruptMode; +use virtio::resolve::ResolvedVirtioDevice; +use vm_loader::InitialLoad; +use vmcore::vm_task::VmTaskDriverSource; +use vmm_core::partition_unit::StopGuard; + +/// The microVM part of the worker [`Manifest`]: the subset of +/// [`MicrovmConfig`] that the worker consumes after validating the +/// configuration. +#[derive(MeshPayload, Default)] +pub(super) struct MicrovmManifest { + pub(super) sandbox_blocks: Vec, + pub(super) memory_capacity: Option, + pub(super) snapshot_memory_ranges: Vec, + pub(super) restore_memory_ranges: Vec, + /// The NVX time ABI parameters, when the time ABI is selected. + pub(super) time_abi: Option, + /// The CPU profile record of the snapshot that the worker restores, set + /// by the worker when the profile is a host profile: the snapshot carries + /// the profile's only copy, from which the partition takes its profile. + pub(super) restored_host_profile: Option, + /// The hypervisor backend ID (`kvm`, `mshv`, or `whp`), set by the + /// worker. + pub(super) hypervisor_id: String, +} + +impl From for MicrovmManifest { + fn from(config: MicrovmConfig) -> Self { + let MicrovmConfig { + network: _, + filesystems: _, + sandbox_blocks, + image_slots: _, + filesystem_bootstrap: _, + memory_capacity, + snapshot_memory_ranges, + restore_memory_ranges, + time_abi, + } = config; + Self { + sandbox_blocks, + memory_capacity, + snapshot_memory_ranges, + restore_memory_ranges, + time_abi, + restored_host_profile: None, + hypervisor_id: String::new(), + } + } +} + +/// MicroVM inputs taken from the [`VmWorkerParameters`]. +pub(super) struct MicrovmParameters { + /// Snapshot boundary channels handed to the loaded VM. + pub(super) snapshot_boundary: SnapshotBoundary, +} + +impl MicrovmParameters { + /// Takes the microVM inputs out of the worker parameters and validates the + /// machine configuration for the selected hypervisor. + pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { + let snapshot_boundary = SnapshotBoundary { + requests: parameters.snapshot_boundary_requests.take(), + ready: parameters.snapshot_ready.take(), + ..Default::default() + }; + openvmm_defs::microvm::validate_machine_config( + ¶meters.cfg, + Some(parameters.hypervisor.id()), + )?; + Ok(Self { snapshot_boundary }) + } + + /// Checks the kernel command line of a microVM cold boot, before the VM + /// is loaded: the time ABI declares both clock rates, so the command line + /// must not set them (`E_CMDLINE_CLOCK_TOKEN`). + pub(super) fn check_cold_boot( + &self, + vm: &InitializedVm, + restored_from_snapshot: bool, + ) -> anyhow::Result<()> { + check_cold_boot_command_line(&vm.cfg, restored_from_snapshot) + } +} + +/// A guest snapshot-boundary request, or the closure of the request channel. +pub(super) type SnapshotBoundaryEvent = Result; + +/// Guest-requested snapshot boundary state of a [`LoadedVm`]. +#[derive(Default)] +pub(super) struct SnapshotBoundary { + /// Deferred snapshot PMIO requests awaiting an exact post-OUT boundary. + requests: Option>, + /// Notifies the controller after the worker establishes a boundary. + ready: Option>, + /// Holds the vCPUs stopped while a boundary is active. + stop_guard: Option, + /// Completes the guest's snapshot transaction when the boundary is released. + transaction_complete: Option>, + /// Input-gate timeout of the active boundary. + input_gate_timeout: Option, +} + +impl SnapshotBoundary { + /// Receives the next boundary request. Never completes when there is no + /// request channel. + pub(super) async fn recv(&mut self) -> SnapshotBoundaryEvent { + match self.requests.as_mut() { + Some(requests) => requests.recv().await, + None => std::future::pending().await, + } + } +} + +#[cfg(guest_arch = "x86_64")] +pub(super) fn load_linux_x86_mptable( + vm: &LoadedVmInner, + kernel: &std::fs::File, + initrd: &Option, + cmdline: &str, + isolation: openvmm_defs::config::LinuxIsolationConfig, + smbios: &openvmm_defs::config::SmbiosConfig, +) -> anyhow::Result> { + anyhow::ensure!( + vm.machine_profile == MachineProfile::Microvm, + "Linux MP-table boot mode requires the microVM profile" + ); + anyhow::ensure!( + isolation == openvmm_defs::config::LinuxIsolationConfig::None + && vm.hypervisor_cfg.with_isolation.is_none(), + "microVM MP-table boot does not support isolation" + ); + let apic_ids = vm + .processor_topology + .vps_arch() + .map(|vp| vp.apic_id) + .collect::>(); + let reserved_memory_ranges = [MemoryRange::new( + openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + ..openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + + openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + )]; + let kernel_config = crate::worker::vm_loaders::linux::KernelConfig { + kernel, + initrd, + cmdline, + mem_layout: &vm.mem_layout, + isolation: crate::worker::vm_loaders::linux::KernelIsolationConfig::None, + smbios, + }; + Ok(crate::worker::vm_loaders::microvm::load_linux_x86_mptable( + &kernel_config, + &vm.gm, + &apic_ids, + &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, + &reserved_memory_ranges, + )?) +} + +impl LoadedVm { + pub(super) async fn establish_snapshot_boundary( + &mut self, + request: MicrovmSnapshotBoundaryRequest, + ) -> bool { + let Some(request) = snapshot_rpc::filter_boundary_request(request) else { + return true; + }; + if self.snapshot_boundary.stop_guard.is_some() { + tracelimit::warn_ratelimited!("dropping duplicate microVM snapshot boundary request"); + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + } + let Some(snapshot_ready) = self.snapshot_boundary.ready.clone() else { + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + }; + + if self.snapshot_restore.input_gated { + // A boundary under the armed post-restore input gate is the + // guest's acknowledgement of the restore. + self.snapshot_restore.restore_acknowledged(); + } + if !self.snapshot_restore.input_gated { + let input_gate = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = self + .state_units + .quiesce_input_for_save(request.input_gate_timeout) + .await + { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to gate host input before snapshot boundary" + ); + if let Err(resume_error) = self + .state_units + .resume_input_after_save(request.input_gate_timeout) + .await + { + tracelimit::error_ratelimited!( + error = resume_error.as_ref() as &dyn std::error::Error, + "host-input gate rollback is uncertain; terminating VM worker" + ); + request.transaction_complete.complete(()); + return false; + } + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + } + input_gate.complete("capture", "input_gate", Default::default()); + } + + let vp_stop_at_io_boundary = openvmm_defs::profile::ProfileSpan::start(); + match self + .inner + .partition_unit + .temporarily_stop_vps_at_io_boundary(request.release_write, request.write_completed) + .await + { + Ok(stop_guard) => { + vp_stop_at_io_boundary.complete( + "capture", + "vp_stop_at_io_boundary", + Default::default(), + ); + self.snapshot_boundary.stop_guard = Some(stop_guard); + self.snapshot_boundary.transaction_complete = Some(request.transaction_complete); + self.snapshot_boundary.input_gate_timeout = Some(request.input_gate_timeout); + snapshot_ready.send(request.scratch_policy); + true + } + Err(error) => { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to establish snapshot PMIO boundary; terminating VM worker" + ); + request.transaction_complete.complete(()); + false + } + } + } + + pub(super) async fn release_snapshot_boundary(&mut self) -> anyhow::Result<()> { + let input_gate_timeout = self + .snapshot_boundary + .input_gate_timeout + .context("snapshot boundary is missing its input-gate timeout")?; + self.state_units + .resume_input_after_save(input_gate_timeout) + .await?; + let transaction_complete = self + .snapshot_boundary + .transaction_complete + .take() + .context("no active microVM snapshot boundary")?; + let stop_guard = self + .snapshot_boundary + .stop_guard + .take() + .context("snapshot boundary is missing its vCPU stop guard")?; + let restore_gate_profile = self.snapshot_restore.gate_profile.take(); + self.snapshot_boundary.input_gate_timeout = None; + self.snapshot_restore.gate_timeout = None; + self.snapshot_restore.gate_deadline = None; + self.snapshot_restore.input_gated = false; + transaction_complete.complete(()); + drop(stop_guard); + if let Some(profile) = restore_gate_profile { + profile.complete_milestone("restore", "guest_repair_gate", Default::default()); + } + Ok(()) + } + + pub(super) async fn quiesce_for_snapshot( + &mut self, + timeout: Duration, + ) -> Result + { + use mesh::payload::message::ProtobufMessage; + + if self.inner.machine_profile != MachineProfile::Microvm { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::Rejected( + RemoteError::new(anyhow::anyhow!( + "guest-requested snapshot quiesce requires the microVM profile" + )), + )); + } + if !self.running { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::Rejected( + RemoteError::new(anyhow::anyhow!("VM is already stopped")), + )); + } + + let quiesce = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = self.state_units.quiesce_for_save(timeout).await { + return Err(if error.has_uncertain_state() { + openvmm_defs::rpc::SnapshotQuiesceError::Uncertain(RemoteError::new(error)) + } else { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + }); + } + quiesce.complete("capture", "quiesce", Default::default()); + self.running = false; + + // Time ABI capture steps 1 and 2: the LAPIC timers, then the capture + // anchor and records. The PIT checks itself when saved. + let time = self.capture_time_abi().await.map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; + + let save_state = openvmm_defs::profile::ProfileSpan::start(); + let saved_state = self.save().await.map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; + save_state.complete("capture", "save_state", Default::default()); + let mapped_memory_flush = openvmm_defs::profile::ProfileSpan::start(); + self.inner + .memory_manager + .flush_shared_file_backing() + .context("failed to flush mapped guest RAM") + .map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; + mapped_memory_flush.complete("capture", "mapped_memory_flush", Default::default()); + let effective_command_line = match &self.inner.load_mode { + openvmm_defs::config::LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => cmdline.clone(), + _ => { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe( + RemoteError::new(anyhow::anyhow!( + "microVM snapshot has no effective command line" + )), + )); + } + }; + Ok(openvmm_defs::rpc::SnapshotSaveResponse { + state_unit_names: saved_state.inventory.clone(), + saved_state: ProtobufMessage::new(saved_state), + effective_command_line, + time, + }) + } + + /// Takes the time ABI records of a capture: checks the LAPIC timers, then + /// takes the capture anchor and records (capture steps 1 to 3 of the + /// specification). + #[cfg(guest_arch = "x86_64")] + async fn capture_time_abi(&mut self) -> anyhow::Result { + let state = self + .inner + .time_abi + .as_ref() + .context("a microVM snapshot requires the time ABI")?; + self.inner.partition_unit.check_one_shot_timers().await?; + super::time_abi::capture_records(self.inner.partition.as_ref(), state) + } + + #[cfg(not(guest_arch = "x86_64"))] + async fn capture_time_abi(&mut self) -> anyhow::Result { + anyhow::bail!("microVM snapshots require an x86-64 guest") + } + + /// Resumes the VM after a rollback-safe snapshot failure and releases the + /// snapshot boundary. + pub(super) async fn resume_after_failed_snapshot( + &mut self, + timeout: Duration, + ) -> anyhow::Result<()> { + self.state_units.resume_after_failed_save(timeout).await?; + self.running = true; + self.release_snapshot_boundary().await?; + Ok(()) + } + + /// Handles an event of the snapshot boundary request channel. Returns + /// `false` when the VM worker must stop. + pub(super) async fn handle_snapshot_boundary(&mut self, event: SnapshotBoundaryEvent) -> bool { + match event { + Ok(request) => { + if !self.establish_snapshot_boundary(request).await { + if self.running { + self.state_units.stop().await; + self.running = false; + } + return false; + } + } + Err(_) => { + self.snapshot_boundary.requests = None; + } + } + true + } + + /// Applies the snapshot-boundary and microVM restrictions to a management + /// RPC. Rejected RPCs are completed here; the others are returned for + /// dispatch. + pub(super) fn filter_vm_rpc(&self, message: VmRpc) -> Option { + let message = snapshot_rpc::filter(message, self.snapshot_boundary.stop_guard.is_some())?; + if self.inner.machine_profile != MachineProfile::Microvm { + return Some(message); + } + match message { + VmRpc::Save(rpc) => { + rpc.handle_failable_sync(|()| anyhow::bail!("save is unavailable for microVM")); + None + } + VmRpc::PulseSaveRestore(rpc) => { + rpc.complete(Err(PulseSaveRestoreError::UnsupportedMachineProfile)); + None + } + message => Some(message), + } + } + + /// Rejects worker RPCs that the microVM profile does not support. Rejected + /// RPCs are completed here; the others are returned for dispatch. + pub(super) fn filter_worker_rpc( + &self, + message: WorkerRpc, + ) -> Option> { + match message { + WorkerRpc::Restart(rpc) if self.inner.machine_profile == MachineProfile::Microvm => { + rpc.complete(Err(RemoteError::new(anyhow::anyhow!( + "worker restart is unavailable for microVM" + )))); + None + } + message => Some(message), + } + } +} + +fn virtio_mmio_config( + id: &str, + sandbox_blocks: &[openvmm_defs::microvm::MicrovmSandboxBlockConfig], + sandbox_block_index: &mut usize, + filesystem_index: &mut usize, + image_slot_index: &mut usize, + chipset_mmio: ChipsetMmioRanges, +) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { + let (start, irq) = match id { + "virtio-net" => ( + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + openvmm_defs::microvm::microvm_virtio_net_irq(None)?, + ), + // The configuration orders virtio-fs devices by slot. + "virtiofs" => { + let slot = openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .get(*filesystem_index) + .context("microVM has more virtio-fs devices than fixed slots")?; + *filesystem_index += 1; + (slot.mmio_base, slot.irq) + } + "virtio-console" => ( + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, + ), + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID => ( + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + ), + "virtio-blk" => { + let block = sandbox_blocks + .get(*sandbox_block_index) + .context("microVM virtio-blk device has no sandbox role")?; + *sandbox_block_index += 1; + (block.role.mmio_base(), block.role.irq()) + } + "virtio-blk-image-slot" => { + let index = *image_slot_index; + let base = *openvmm_defs::microvm::MICROVM_IMAGE_SLOT_MMIO_BASES + .get(index) + .context("microVM has too many image-slot devices")?; + let irq = openvmm_defs::microvm::MICROVM_IMAGE_SLOT_IRQS[index]; + *image_slot_index += 1; + (base, irq) + } + _ => anyhow::bail!("unsupported microVM virtio device '{id}' reached worker construction"), + }; + let len = openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN; + anyhow::ensure!( + start >= chipset_mmio.low.start() + && start + .checked_add(len) + .is_some_and(|end| end <= chipset_mmio.low.end()), + "microVM virtio slot for '{id}' is outside the fixed low-MMIO aperture" + ); + let disabled_features = match id { + "virtio-net" => !openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES, + "virtiofs" => !openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES, + "virtio-blk" => { + let block = sandbox_blocks + .iter() + .find(|block| block.role.mmio_base() == start) + .context("microVM block slot has no role")?; + !openvmm_defs::microvm::microvm_sandbox_block_features(block.role) + } + "virtio-blk-image-slot" => !openvmm_defs::microvm::microvm_image_slot_features(), + _ => 1 << 34, + }; + let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { + status_gpa: openvmm_defs::microvm::microvm_virtio_status_gpa(start) + .context("microVM slot has no shared-status word")?, + }; + Ok((start, len, irq, disabled_features, interrupt_mode)) +} + +/// Assigns the fixed virtio-mmio slots of the microVM profile to devices, in +/// device order. +pub(super) struct VirtioMmioSlots<'a> { + sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], + sandbox_block_index: usize, + filesystem_index: usize, + image_slot_index: usize, + chipset_mmio: ChipsetMmioRanges, +} + +impl<'a> VirtioMmioSlots<'a> { + pub(super) fn new( + sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], + chipset_mmio: ChipsetMmioRanges, + ) -> Self { + Self { + sandbox_blocks, + sandbox_block_index: 0, + filesystem_index: 0, + image_slot_index: 0, + chipset_mmio, + } + } + + /// Adds a virtio-mmio device at its fixed microVM slot. + pub(super) fn add_device( + &mut self, + chipset_builder: &vmotherboard::ChipsetBuilder<'_>, + driver_source: &VmTaskDriverSource, + gm: &GuestMemory, + partition: &Arc, + id: &str, + device: ResolvedVirtioDevice, + ) -> anyhow::Result<()> { + let (mmio_start, mmio_len, irq, disabled_features, interrupt_mode) = virtio_mmio_config( + id, + self.sandbox_blocks, + &mut self.sandbox_block_index, + &mut self.filesystem_index, + &mut self.image_slot_index, + self.chipset_mmio, + )?; + let id = format!("{id}-{mmio_start}"); + let gm = gm.clone(); + chipset_builder.arc_mutex_device(id).try_add(|services| { + VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( + device.0, + &driver_source.simple(), + gm, + services.new_line(IRQ_LINE_SET, "interrupt", irq), + partition.clone().into_doorbell_registration(Vtl::Vtl0), + mmio_start, + mmio_len, + disabled_features, + interrupt_mode, + ) + })?; + Ok(()) + } +} + +/// Adds the split guest RAM backings of a microVM snapshot restore. The +/// backed RAM ranges are removed from `ranges_by_node`, so the generic +/// per-node backings skip them. +pub(super) fn add_snapshot_restore_backing( + mut memory_builder: GuestMemoryBuilder, + cfg: &Manifest, + ranges_by_node: &mut [Vec], + nodes_with_ranges: usize, + existing_mappable: &mut Option<(Mappable, FileMappingMode)>, +) -> anyhow::Result { + if cfg.microvm.snapshot_memory_ranges.is_empty() { + return Ok(memory_builder); + } + + anyhow::ensure!( + cfg.machine_profile == MachineProfile::Microvm && nodes_with_ranges == 1, + "snapshot RAM range restore requires a single-node microVM" + ); + let active_ranges = ranges_by_node + .iter_mut() + .find(|ranges| !ranges.is_empty()) + .expect("nodes_with_ranges is one"); + let mut restored_ranges = cfg.microvm.snapshot_memory_ranges.clone(); + restored_ranges.extend_from_slice(&cfg.microvm.restore_memory_ranges); + anyhow::ensure!( + coalesce_adjacent_ranges(&restored_ranges)? == *active_ranges, + "snapshot base and expansion ranges do not match the selected RAM layout" + ); + + let (mappable, file_mapping_mode) = existing_mappable + .take() + .context("snapshot RAM ranges require an existing memory backing")?; + let mem = cfg.numa.nodes[0] + .mem + .as_ref() + .context("snapshot RAM ranges require node 0 memory configuration")?; + let base_backing = + membacking::RamBackingRequest::new(cfg.microvm.snapshot_memory_ranges.clone()) + .prefetch(mem.prefetch_memory) + .transparent_hugepages(mem.transparent_hugepages) + .host_numa_node(mem.host_numa_node) + .existing_mappable(mappable) + .file_mapping_mode(file_mapping_mode); + memory_builder = memory_builder.add_backing(base_backing); + + if !cfg.microvm.restore_memory_ranges.is_empty() { + let expansion_backing = + membacking::RamBackingRequest::new(cfg.microvm.restore_memory_ranges.clone()) + .prefetch(mem.prefetch_memory) + .private_memory(true) + .transparent_hugepages(mem.transparent_hugepages) + .host_numa_node(mem.host_numa_node); + memory_builder = memory_builder.add_backing(expansion_backing); + } + active_ranges.clear(); + Ok(memory_builder) +} + +fn coalesce_adjacent_ranges(ranges: &[MemoryRange]) -> anyhow::Result> { + let mut coalesced: Vec = Vec::with_capacity(ranges.len()); + for &range in ranges { + anyhow::ensure!(!range.is_empty(), "snapshot RAM range is empty"); + if let Some(previous) = coalesced.last_mut() { + anyhow::ensure!( + range.start() >= previous.end(), + "snapshot RAM ranges overlap or are out of order" + ); + if range.start() == previous.end() { + *previous = MemoryRange::new(previous.start()..range.end()); + continue; + } + } + coalesced.push(range); + } + Ok(coalesced) +} + +pub(super) fn uses_lazy_memory_registration( + cfg: &Manifest, + shared_memory: Option<&SharedMemoryBacking>, +) -> bool { + #[cfg(all(windows, feature = "virt_whp"))] + let has_vpci_resources = !cfg.vpci_resources.is_empty(); + #[cfg(not(all(windows, feature = "virt_whp")))] + let has_vpci_resources = false; + let prefetch_memory = cfg + .numa + .nodes + .iter() + .any(|node| node.mem.as_ref().is_some_and(|mem| mem.prefetch_memory)); + + cfg!(all(windows, feature = "virt_whp", guest_arch = "x86_64")) + && cfg.machine_profile == MachineProfile::Microvm + && cfg.hypervisor.with_vtl2.is_none() + && cfg.microvm.restore_memory_ranges.is_empty() + && !has_vpci_resources + && !prefetch_memory + && shared_memory.is_some_and(SharedMemoryBacking::is_copy_on_write) +} + +/// Returns the number of virtio-mmio slots that the memory layout allocates. +/// MicroVM devices use fixed slots in the low MMIO aperture instead. +pub(super) fn virtio_mmio_count( + machine_profile: MachineProfile, + virtio_mmio_count: usize, +) -> usize { + if machine_profile == MachineProfile::Microvm { + 0 + } else { + virtio_mmio_count + } +} + +pub(super) fn level_triggered_irqs(machine_profile: MachineProfile) -> &'static [u32] { + match machine_profile { + MachineProfile::Microvm => &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, + MachineProfile::Standard => &[], + } +} + +pub(super) fn trace_unknown_pio(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Standard +} + +#[cfg(guest_arch = "x86_64")] +fn check_cold_boot_command_line( + cfg: &Manifest, + restored_from_snapshot: bool, +) -> anyhow::Result<()> { + if restored_from_snapshot || cfg.machine_profile != MachineProfile::Microvm { + return Ok(()); + } + + let openvmm_defs::config::LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } = &cfg.load_mode + else { + anyhow::bail!("microVM has no supported cold-boot command line"); + }; + virt::time_abi::check_command_line_clock_tokens(cmdline)?; + Ok(()) +} + +#[cfg(not(guest_arch = "x86_64"))] +fn check_cold_boot_command_line( + _cfg: &Manifest, + _restored_from_snapshot: bool, +) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(guest_arch = "x86_64")] +pub(super) fn x86_topology_builder( + machine_profile: MachineProfile, +) -> anyhow::Result> +{ + if machine_profile == MachineProfile::Microvm { + Ok(vm_topology::processor::TopologyBuilder::new_x86()) + } else { + Ok(vm_topology::processor::TopologyBuilder::from_host_topology()?) + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs new file mode 100644 index 00000000000..9d92f856154 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -0,0 +1,565 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore support for the VM worker: the restore inputs taken from +//! the worker parameters, the restore state kept by [`LoadedVm`], the VP +//! prefix instantiated by an explicit restore-time activation target, the +//! steps around applying the saved state, and the post-restore start sequence +//! (input gate, readiness event, and VP release). + +use super::LoadedVm; +use anyhow::Context; +use futures::FutureExt; +use futures_concurrency::future::Race; +use hypervisor_resources::HypervisorKind; +use hypervisor_resources::MshvHandle; +use membacking::FileMappingMode; +use membacking::SharedMemoryBacking; +use openvmm_defs::profile::ProfileSpan; +use openvmm_defs::worker::RESTORE_READY_EVENT_V1; +use openvmm_defs::worker::SavedState; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_defs::worker::VmWorkerParameters; +use pal_async::driver::Driver; +use pal_async::timer::Instant; +use pal_async::timer::PolledTimer; +use state_unit::StateUnits; +use std::fs::File; +use std::future::Future; +use std::io::Write as _; +use std::time::Duration; +use vm_resource::ResourceId; +use vmm_core::partition_unit::StopGuard; + +/// Snapshot-restore inputs taken from the [`VmWorkerParameters`]. +pub(super) struct RestoreParameters { + /// Restore state handed to the loaded VM. + pub(super) state: SnapshotRestore, + /// Mapping mode of the file-backed guest RAM handle. + file_mapping_mode: FileMappingMode, + /// Snapshot generation handles that must outlive the restored VM. + pub(super) guards: Option, +} + +impl RestoreParameters { + /// Takes the restore inputs out of the worker parameters. + pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { + let guards = parameters.snapshot_restore_guards.take(); + let ready_sink = parameters.restore_ready_sink.take(); + let gate_timeout = parameters.restore_gate_timeout.take(); + let restore_vp_count = parameters.restore_vp_count.take(); + let vp_prefix = restore_vp_prefix(parameters.hypervisor.id(), restore_vp_count); + tracing::debug!( + ?restore_vp_count, + ?vp_prefix, + "received restore-time VP activation target" + ); + let time_abi = parameters.restore_time.take(); + let file_mapping_mode = if parameters.shared_memory_copy_on_write { + FileMappingMode::CopyOnWrite + } else { + FileMappingMode::Shared + }; + Ok(Self { + state: SnapshotRestore { + time_abi, + vp_prefix, + ready_sink, + gate_timeout, + ..Default::default() + }, + file_mapping_mode, + guards, + }) + } + + /// Wraps the file-backed guest RAM handle with the restore's mapping mode. + pub(super) fn shared_memory_backing(&self, fd: SharedMemoryFd) -> SharedMemoryBacking { + SharedMemoryBacking::from_mappable_with_mode(fd.into(), self.file_mapping_mode) + } +} + +/// Returns the VP prefix that a restore instantiates, or `None` for the full +/// VP capacity. +/// +/// Only MSHV instantiates an explicit restore-time activation target: it +/// creates application processors lazily while binding them, so discarding +/// the suffix binders avoids creating VPs that the restore will not run. +/// Other backends create their VPs with the partition and keep the full +/// capacity. +fn restore_vp_prefix(hypervisor: &str, restore_vp_count: Option) -> Option { + restore_vp_count.filter(|_| hypervisor == >::ID) +} + +/// Discards the binders after an explicit VP prefix and returns the number +/// of VPs to instantiate. The topology and saved VP inventory keep the full +/// capacity. +fn select_instantiated_vps( + vps: &mut Vec, + vp_capacity: u32, + vp_prefix: Option, + restoring: bool, +) -> anyhow::Result { + anyhow::ensure!( + vps.len() == vp_capacity as usize, + "backend returned {} VP binders for topology capacity {vp_capacity}", + vps.len() + ); + let Some(vp_prefix) = vp_prefix else { + return Ok(vp_capacity); + }; + anyhow::ensure!(restoring, "a restore VP prefix requires saved state"); + anyhow::ensure!( + (1..=vp_capacity).contains(&vp_prefix), + "restore VP prefix {vp_prefix} is outside topology capacity 1..={vp_capacity}" + ); + vps.truncate(vp_prefix as usize); + Ok(vp_prefix) +} + +/// Snapshot-restore state of a [`LoadedVm`]. +#[derive(Default)] +pub(super) struct SnapshotRestore { + /// The time ABI inputs of a restore, validated by the controller. + pub(super) time_abi: Option, + /// VP prefix instantiated for an explicit MSHV restore-time activation + /// target. + vp_prefix: Option, + /// Whether the VM was loaded from saved state. + restored_from_snapshot: bool, + /// Holds the VPs stopped after a restore until the VM first starts. + start_guard: Option, + /// Single-use sink for the restore readiness event. + ready_sink: Option, + /// Timeout for the post-restore input gate, when required. + pub(super) gate_timeout: Option, + /// Deadline for the guest to acknowledge the post-restore input gate. + pub(super) gate_deadline: Option, + /// Profile span covering the post-restore input gate. + pub(super) gate_profile: Option, + /// Whether host input is gated by the post-restore input gate. + pub(super) input_gated: bool, + /// With profiling enabled, notified when the guest first selects the + /// time ABI restore packet. + packet_selected: Option>, + /// Profile span from the release of the restored VPs to the guest's + /// first selection of the restore packet. + resume_profile: Option, + /// Profile span from the guest's first selection of the restore packet to + /// its acknowledgement of a gated restore. + repair_profile: Option, +} + +/// A post-restore event handled by the VM worker's run loop. +pub(super) enum RestoreEvent { + /// The armed post-restore input gate expired. + GateExpired, + /// The guest first selected the time ABI restore packet. + PacketSelected, +} + +impl SnapshotRestore { + /// Discards the binders after the restore VP prefix, if any, before + /// binding creates their VPs. Returns the number of VPs to instantiate. + pub(super) fn select_instantiated_vps( + &self, + vps: &mut Vec, + vp_capacity: u32, + restoring: bool, + ) -> anyhow::Result { + select_instantiated_vps(vps, vp_capacity, self.vp_prefix, restoring) + } + + /// Returns a future that completes at the next post-restore event: the + /// expiry of the armed input gate, or the guest's first selection of the + /// restore packet. It never completes while neither is pending. + pub(super) fn next_event( + &mut self, + driver: &impl Driver, + ) -> impl Future { + let deadline = self.gate_deadline; + let packet_selected = &mut self.packet_selected; + async move { + let gate_expired = async { + match deadline { + Some(deadline) => { + PolledTimer::new(driver).sleep_until(deadline).await; + } + None => std::future::pending().await, + } + RestoreEvent::GateExpired + }; + let selected = async { + if let Some(selected) = packet_selected.as_mut() { + let result = selected.await; + *packet_selected = None; + if result.is_ok() { + return RestoreEvent::PacketSelected; + } + } + std::future::pending().await + }; + (gate_expired, selected).race().await + } + } + + /// Ends the `restore/guest_resume` profile phase at the guest's first + /// selection of the restore packet, and starts the `restore/guest_repair` + /// phase of a gated restore. + pub(super) fn restore_packet_selected(&mut self) { + if let Some(resume) = self.resume_profile.take() { + resume.complete("restore", "guest_resume", Default::default()); + if self.gate_profile.is_some() { + self.repair_profile = Some(ProfileSpan::start()); + } + } + } + + /// Ends the `restore/guest_repair` profile phase when the guest + /// acknowledges a gated restore. + pub(super) fn restore_acknowledged(&mut self) { + // The guest selects the packet before it acknowledges the restore, so + // handle a selection that the run loop has not handled yet first. + if let Some(result) = self + .packet_selected + .as_mut() + .and_then(|selected| selected.now_or_never()) + { + self.packet_selected = None; + if result.is_ok() { + self.restore_packet_selected(); + } + } + if let Some(repair) = self.repair_profile.take() { + repair.complete("restore", "guest_repair", Default::default()); + } + } +} + +/// Checks that the saved state of a time ABI restore holds the partition +/// state, which the restore clock sets and advances. +#[cfg(guest_arch = "x86_64")] +fn validate_snapshot_restore_partition_presence( + saved_state: &SavedState, + time_abi_restore: bool, +) -> anyhow::Result<()> { + anyhow::ensure!( + !time_abi_restore + || saved_state + .units + .iter() + .any(|unit| unit.name == "partition"), + "a time ABI restore requires partition state" + ); + Ok(()) +} + +/// Validates the state-unit inventory of a saved-state envelope. Saved state +/// without an inventory is restored without the check. +pub(super) fn validate_inventory( + state_units: &StateUnits, + state: &SavedState, +) -> anyhow::Result<()> { + if !state.inventory.is_empty() { + state_units.validate_inventory(&state.inventory)?; + } + Ok(()) +} + +impl LoadedVm { + /// Prepares to restore `saved_state` into the loaded VM: checks that a + /// time ABI restore has partition state, and starts the profile span of + /// the restore. A time ABI restore drops the VPs' saved TSC values, which + /// its synchronized TSC set supersedes. + pub(super) async fn begin_snapshot_restore( + &mut self, + #[cfg_attr(not(guest_arch = "x86_64"), expect(unused_variables))] saved_state: &SavedState, + ) -> anyhow::Result { + self.snapshot_restore.restored_from_snapshot = true; + #[cfg(guest_arch = "x86_64")] + { + let time_abi_restore = self.snapshot_restore.time_abi.is_some(); + validate_snapshot_restore_partition_presence(saved_state, time_abi_restore)?; + if time_abi_restore { + self.inner.partition_unit.omit_saved_tsc().await; + } + } + Ok(ProfileSpan::start()) + } + + /// Completes a restore after the saved state is applied: sets and + /// advances the guest clocks of a time ABI restore, and holds the VPs + /// stopped until the VM first starts. + pub(super) async fn finish_snapshot_restore( + &mut self, + saved_state_restore: ProfileSpan, + ) -> anyhow::Result<()> { + saved_state_restore.complete("restore", "saved_state_restore", Default::default()); + #[cfg(guest_arch = "x86_64")] + if let Some(mut input) = self.snapshot_restore.time_abi.take() { + self.snapshot_restore.packet_selected = input.packet_selected.take(); + let clock_restore = ProfileSpan::start(); + self.time_abi_restore(input).await?; + clock_restore.complete("restore", "time_abi_clock", Default::default()); + } + self.snapshot_restore.start_guard = + Some(self.inner.partition_unit.temporarily_stop_vps().await); + Ok(()) + } + + /// Starts the state units for [`LoadedVm::resume`], sequencing the + /// post-restore input gate, the restore readiness event, and the release + /// of the restored VPs around the start. + pub(super) async fn start_state_units(&mut self) -> anyhow::Result<()> { + if let Some(timeout) = self.snapshot_restore.gate_timeout { + self.state_units + .quiesce_input_for_save(timeout) + .await + .context("failed to establish post-restore input gate")?; + self.snapshot_restore.input_gated = true; + } + let device_start = ProfileSpan::start(); + self.state_units + .start() + .await + .context("VM state units failed to start")?; + if self.snapshot_restore.restored_from_snapshot && openvmm_defs::profile::enabled() { + let faults = self.inner.memory_manager.fault_counters(); + device_start.complete( + "restore", + "device_start", + openvmm_defs::profile::ProfileCounters { + gpa_faults: Some(faults.guest_faults), + populated_bytes: Some(faults.populated_bytes), + ..Default::default() + }, + ); + } + if let Some(mut sink) = self.snapshot_restore.ready_sink.take() { + let signal_result = sink.write_all(RESTORE_READY_EVENT_V1).and_then(|()| { + #[cfg(windows)] + { + sink.sync_all() + } + #[cfg(not(windows))] + { + sink.flush() + } + }); + if let Err(error) = signal_result { + self.state_units.stop().await; + return Err(error).context("failed to publish restore readiness event"); + } + } + if let Some(timeout) = self.snapshot_restore.gate_timeout { + self.snapshot_restore.gate_deadline = Some(Instant::now().saturating_add(timeout)); + self.snapshot_restore.gate_profile = Some(ProfileSpan::start()); + } + if self.snapshot_restore.packet_selected.is_some() { + self.snapshot_restore.resume_profile = Some(ProfileSpan::start()); + } + self.snapshot_restore.start_guard.take(); + Ok(()) + } + + /// Stops the VM after the guest failed to acknowledge the post-restore + /// input gate in time. + pub(super) async fn handle_restore_gate_timeout(&mut self) { + tracing::error!("post-restore input gate acknowledgement timed out"); + if self.running { + self.state_units.stop().await; + self.running = false; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn binders() -> Vec { + (0..8).collect() + } + + #[cfg(guest_arch = "x86_64")] + fn saved_state(unit_names: &[&str]) -> SavedState { + use state_unit::SavedStateUnit; + use vmcore::save_restore::NoSavedState; + use vmcore::save_restore::SavedStateBlob; + + SavedState { + units: unit_names + .iter() + .map(|name| SavedStateUnit { + name: (*name).to_owned(), + state: SavedStateBlob::new(NoSavedState), + }) + .collect(), + inventory: Vec::new(), + } + } + + #[cfg(guest_arch = "x86_64")] + #[test] + fn time_abi_restore_requires_partition_state() { + validate_snapshot_restore_partition_presence(&saved_state(&[]), false).unwrap(); + validate_snapshot_restore_partition_presence(&saved_state(&["partition"]), true).unwrap(); + let error = validate_snapshot_restore_partition_presence(&saved_state(&["other"]), true) + .unwrap_err(); + assert_eq!( + error.to_string(), + "a time ABI restore requires partition state" + ); + + // The payload must hold the unit; the inventory alone does not. + let mut inventory_only = saved_state(&[]); + inventory_only.inventory.push("partition".to_owned()); + validate_snapshot_restore_partition_presence(&inventory_only, true).unwrap_err(); + } + + #[test] + fn limits_only_explicit_mshv_restores() { + assert_eq!(restore_vp_prefix("mshv", Some(2)), Some(2)); + assert_eq!(restore_vp_prefix("mshv", None), None); + for hypervisor in ["kvm", "whp", "hvf"] { + assert_eq!(restore_vp_prefix(hypervisor, Some(2)), None); + assert_eq!(restore_vp_prefix(hypervisor, None), None); + } + } + + #[test] + fn restore_vp_prefix_discards_suffix_binders() { + for prefix in [1, 2, 4, 8] { + let mut vps = binders(); + assert_eq!( + select_instantiated_vps(&mut vps, 8, Some(prefix), true).unwrap(), + prefix + ); + assert_eq!(vps, (0..prefix).collect::>()); + } + } + + #[test] + fn untargeted_loads_keep_every_binder() { + for restoring in [false, true] { + let mut vps = binders(); + assert_eq!( + select_instantiated_vps(&mut vps, 8, None, restoring).unwrap(), + 8 + ); + assert_eq!(vps, binders()); + } + } + + #[test] + fn rejects_invalid_restore_vp_prefix() { + for prefix in [0, 9] { + let mut vps = binders(); + let error = select_instantiated_vps(&mut vps, 8, Some(prefix), true).unwrap_err(); + assert_eq!( + error.to_string(), + format!("restore VP prefix {prefix} is outside topology capacity 1..=8") + ); + assert_eq!(vps, binders()); + } + + let mut vps = binders(); + let error = select_instantiated_vps(&mut vps, 8, Some(2), false).unwrap_err(); + assert_eq!( + error.to_string(), + "a restore VP prefix requires saved state" + ); + assert_eq!(vps, binders()); + } + + #[test] + fn rejects_a_partial_backend_binder_set() { + let mut vps = (0..4).collect::>(); + let error = select_instantiated_vps(&mut vps, 8, None, true).unwrap_err(); + assert_eq!( + error.to_string(), + "backend returned 4 VP binders for topology capacity 8" + ); + } + + #[test] + fn restore_events_report_the_first_packet_selection() { + pal_async::DefaultPool::run_with(async |driver| { + let (send, recv) = mesh::oneshot(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + ..Default::default() + }; + assert!(restore.next_event(&driver).now_or_never().is_none()); + send.send(()); + assert!(matches!( + restore.next_event(&driver).await, + RestoreEvent::PacketSelected + )); + assert!(restore.packet_selected.is_none()); + assert!(restore.next_event(&driver).now_or_never().is_none()); + }); + } + + #[test] + fn restore_events_ignore_a_dropped_packet_notifier() { + pal_async::DefaultPool::run_with(async |driver| { + let (send, recv) = mesh::oneshot::<()>(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + ..Default::default() + }; + drop(send); + assert!(restore.next_event(&driver).now_or_never().is_none()); + assert!(restore.packet_selected.is_none()); + }); + } + + #[test] + fn restore_events_report_the_gate_expiry() { + pal_async::DefaultPool::run_with(async |driver| { + let mut restore = SnapshotRestore { + gate_deadline: Some(Instant::now()), + ..Default::default() + }; + assert!(matches!( + restore.next_event(&driver).await, + RestoreEvent::GateExpired + )); + }); + } + + #[test] + fn packet_selection_starts_the_repair_phase_of_a_gated_restore() { + for gated in [false, true] { + let mut restore = SnapshotRestore { + resume_profile: Some(ProfileSpan::start()), + gate_profile: gated.then(ProfileSpan::start), + ..Default::default() + }; + restore.restore_packet_selected(); + assert!(restore.resume_profile.is_none()); + assert_eq!(restore.repair_profile.is_some(), gated); + restore.restore_acknowledged(); + assert!(restore.repair_profile.is_none()); + } + } + + #[test] + fn acknowledgement_handles_an_unhandled_packet_selection_first() { + let (send, recv) = mesh::oneshot(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + resume_profile: Some(ProfileSpan::start()), + gate_profile: Some(ProfileSpan::start()), + ..Default::default() + }; + send.send(()); + restore.restore_acknowledged(); + assert!(restore.packet_selected.is_none()); + assert!(restore.resume_profile.is_none()); + assert!(restore.repair_profile.is_none()); + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs b/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs new file mode 100644 index 00000000000..8bf211c05c0 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Filter guest snapshot requests and management mutations at the snapshot boundary. + +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use mesh::error::RemoteError; +use openvmm_defs::rpc::PulseSaveRestoreError; +use openvmm_defs::rpc::VmRpc; + +#[derive(Debug, thiserror::Error)] +#[error("VM mutation is unavailable while a microVM snapshot boundary is active")] +struct SnapshotBoundaryActive; + +pub(super) fn filter_boundary_request( + request: MicrovmSnapshotBoundaryRequest, +) -> Option { + // Stop/reset drops the release receiver but leaves the request queued. + // The worker serializes reset with boundary establishment, so checking + // before gating input or stopping VPs prevents capture of a reset VM. + if request.release_write.is_closed() { + tracelimit::warn_ratelimited!("dropping cancelled microVM snapshot boundary request"); + return None; + } + Some(request) +} + +pub(super) fn filter(message: VmRpc, boundary_active: bool) -> Option { + if !boundary_active { + return Some(message); + } + + let operation = format!("{message:?}"); + match message { + message @ (VmRpc::QuiesceForSnapshot(_) + | VmRpc::ResumeAfterFailedSnapshot(_) + | VmRpc::ReleaseSnapshotBoundary(_) + | VmRpc::ReadMemory(_)) => return Some(message), + VmRpc::Save(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::Resume(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::Reset(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddVmbusDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::ConnectHvsock(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::StartReloadIgvm(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::CompleteReloadIgvm(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::WriteMemory(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::UpdateCliParams(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddPcieDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::RemovePcieDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddVpciDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::RemoveVpciDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::DumpState(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::PulseSaveRestore(rpc) => rpc.complete(Err(PulseSaveRestoreError::Other( + RemoteError::new(SnapshotBoundaryActive), + ))), + VmRpc::Pause(rpc) | VmRpc::ClearHalt(rpc) => drop(rpc), + VmRpc::Nmi(rpc) => drop(rpc), + } + tracelimit::warn_ratelimited!( + rpc = operation, + "rejected management RPC during microVM snapshot boundary" + ); + None +} + +#[cfg(test)] +mod tests { + use super::filter; + use super::filter_boundary_request; + use chipset::microvm::MicrovmSnapshotRequest; + use chipset_device::io::IoError; + use chipset_device::io::IoResult; + use chipset_device::pio::PortIoIntercept; + use chipset_device::poll_device::PollDevice; + use futures::executor::block_on; + use mesh::rpc::Rpc; + use mesh::rpc::RpcSend; + use openvmm_defs::rpc::VmRpc; + use std::task::Context; + use std::task::Poll; + use std::task::Waker; + use std::time::Duration; + use test_with_tracing::test; + use vmcore::device_state::ChangeDeviceState; + + const SNAPSHOT_PORT: u16 = 0x605; + + #[test] + fn snapshot_boundary_drops_requests_cancelled_by_stop_or_reset() { + for reset in [false, true] { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + device.start(); + let IoResult::Defer(mut cancelled_write) = device.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + + if reset { + block_on(device.reset()); + } else { + block_on(device.stop()); + } + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + cancelled_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::InvalidRegister)) + )); + + device.start(); + let IoResult::Defer(mut next_write) = device.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("next snapshot write was not deferred"); + }; + // Cancellation leaves the old request ahead of the new one in the queue. + let old_request = requests.try_recv().unwrap(); + assert!(old_request.release_write.is_closed()); + assert!(filter_boundary_request(old_request).is_none()); + + let request = filter_boundary_request(requests.try_recv().unwrap()) + .expect("a cancelled predecessor must not discard the next request"); + request.release_write.send(()); + device.poll_device(&mut cx); + assert!(matches!( + next_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + block_on(request.write_completed).unwrap(); + request.transaction_complete.complete(()); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn snapshot_boundary_accepts_live_request() { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + device.start(); + let IoResult::Defer(mut write) = device.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + let request = filter_boundary_request(requests.try_recv().unwrap()) + .expect("live snapshot request was discarded"); + request.release_write.send(()); + let mut cx = Context::from_waker(Waker::noop()); + device.poll_device(&mut cx); + assert!(matches!(write.poll_write(&mut cx), Poll::Ready(Ok(())))); + block_on(request.write_completed).unwrap(); + request.transaction_complete.complete(()); + } + + #[test] + fn snapshot_boundary_drops_request_from_dropped_device() { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + assert!(matches!( + device.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + drop(device); + assert!(filter_boundary_request(requests.try_recv().unwrap()).is_none()); + } + + #[test] + fn mutation_outside_snapshot_reaches_dispatch() { + for message in [ + VmRpc::WriteMemory(Rpc::detached((0, vec![1]))), + VmRpc::Reset(Rpc::detached(())), + VmRpc::Resume(Rpc::detached(())), + VmRpc::Pause(Rpc::detached(())), + ] { + assert!(filter(message, false).is_some()); + } + } + + #[test] + fn snapshot_control_and_reads_reach_dispatch() { + for message in [ + VmRpc::QuiesceForSnapshot(Rpc::detached(Duration::from_secs(1))), + VmRpc::ResumeAfterFailedSnapshot(Rpc::detached(Duration::from_secs(1))), + VmRpc::ReleaseSnapshotBoundary(Rpc::detached(())), + VmRpc::ReadMemory(Rpc::detached((0, 1))), + ] { + assert!(filter(message, true).is_some()); + } + } + + #[test] + fn snapshot_mutations_fail_without_waiting_for_release() { + let (send, mut recv) = mesh::channel(); + let write = send.call(VmRpc::WriteMemory, (0, vec![1])); + let reset = send.call(VmRpc::Reset, ()); + let resume = send.call(VmRpc::Resume, ()); + let remove = send.call(VmRpc::RemovePcieDevice, "port".to_owned()); + + for _ in 0..4 { + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + } + for result in [block_on(write), block_on(reset), block_on(remove)] { + let error = result.unwrap().unwrap_err(); + assert!(error.to_string().contains("snapshot boundary is active")); + } + assert!(block_on(resume).unwrap().is_err()); + + let release = send.call(VmRpc::ReleaseSnapshotBoundary, ()); + let Some(VmRpc::ReleaseSnapshotBoundary(rpc)) = filter(recv.try_recv().unwrap(), true) + else { + panic!("snapshot release was blocked"); + }; + rpc.complete(Ok(())); + block_on(release).unwrap().unwrap(); + } + + #[test] + fn infallible_mutations_report_channel_error() { + let (send, mut recv) = mesh::channel(); + let pause = send.call(VmRpc::Pause, ()); + let clear_halt = send.call(VmRpc::ClearHalt, ()); + let nmi = send.call(VmRpc::Nmi, 0); + for _ in 0..3 { + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + } + assert!(block_on(pause).is_err()); + assert!(block_on(clear_halt).is_err()); + assert!(block_on(nmi).is_err()); + } + + #[test] + fn mutation_can_be_retried_after_boundary_release() { + let (send, mut recv) = mesh::channel(); + let first = send.call(VmRpc::WriteMemory, (0, vec![1])); + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + assert!(block_on(first).unwrap().is_err()); + + let retry = send.call(VmRpc::WriteMemory, (0, vec![2])); + let Some(VmRpc::WriteMemory(rpc)) = filter(recv.try_recv().unwrap(), false) else { + panic!("write was blocked after boundary release"); + }; + rpc.complete(Ok(())); + block_on(retry).unwrap().unwrap(); + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs new file mode 100644 index 00000000000..201b832caa9 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -0,0 +1,2015 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI v1 in the VM worker, which every microVM uses: the partition +//! configuration, the backend preflight and rate policy, the `time-abi` state +//! unit, the capture records, and the restore clock. + +#![cfg(guest_arch = "x86_64")] + +use super::LoadedVm; +use crate::partition::HvlitePartition; +use anyhow::Context as _; +use chipset_resources::microvm_time::RestoreTimeRecord; +use cpu_profile::CpuProfile; +use cpu_profile::EffectiveCpuid; +use cpu_profile::HostCpuSignature; +use cpu_profile::PartitionProfile; +use cpu_profile::ProfileError; +use cpu_profile::ProfileErrorCode; +use cpu_profile::fingerprint::CpuFingerprint; +use futures_concurrency::future::Join; +use inspect::Inspect; +use inspect::InspectMut; +use openvmm_defs::time_abi::EffectiveCpuidEntry; +use openvmm_defs::time_abi::RestoreTimeInput; +use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; +use openvmm_defs::time_abi::TimeCapture; +use openvmm_defs::time_abi::decode_effective_cpuid; +use openvmm_defs::time_abi::encode_effective_cpuid; +use state_unit::SpawnedUnit; +use state_unit::StateUnit; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::time_abi::DeclaredRates; +use virt::time_abi::DowntimeSource; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::RateCheck; +use virt::time_abi::TIME_ABI_VERSION; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TimeAbiTestHooks; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::surface::SupportedCpuSurface; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// The name of the time ABI state unit. +pub(super) const TIME_ABI_UNIT: &str = "time-abi"; + +/// The most differences an `E_CPU_SURFACE` read-back failure names. +const MAX_REPORTED_CPUID_DIFFERENCES: usize = 16; + +/// The time ABI state of a loaded VM. +pub(super) struct TimeAbiState { + /// The `time-abi` state unit. + pub _unit: SpawnedUnit, + /// The identity MSR handler, holding the declared rates. + pub msrs: Arc, + /// The CPU profile. + pub profile: PartitionProfile, + /// The effective CPUID of the partition. + pub effective_cpuid: Arc, + /// The preflight report. + pub report: TimeAbiReport, + /// The generation counter of this VM process. + pub generation: u32, + /// The active test hooks. + pub hooks: TimeAbiTestHooks, +} + +/// The time ABI of a new partition, set up before the partition is created. +pub(super) struct PartitionTimeAbi { + /// The identity MSR handler. + pub msrs: Arc, + /// The CPU profile. + pub profile: PartitionProfile, + /// The effective CPUID of the partition. + pub effective_cpuid: Arc, +} + +/// Returns the identity MSR handler and the time ABI configuration of a new +/// partition with the CPU profile `profile` and its effective CPUID. +pub(super) fn partition_config( + profile: &PartitionProfile, + effective_cpuid: &EffectiveCpuid, +) -> (Arc, TimeAbiConfig) { + let msrs = Arc::new(TimeAbiMsrs::new()); + let config = TimeAbiConfig { + cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(effective_cpuid)), + msrs: msrs.clone(), + cpu_profile: profile.clone(), + }; + (msrs, config) +} + +/// Selects the CPU profile of a new partition before it is created. +/// +/// `requested` is `--cpu-profile` on cold boot (`auto`, `host`, or an ID), +/// or the profile a snapshot recorded on restore. An ID selects a pinned +/// profile whose generation contains the host CPU (`E_PROFILE_UNKNOWN`, +/// `E_CPU_GENERATION`), and `auto` the pinned profile of the host CPU's +/// generation. On a CPU that no pinned profile serves, `auto` falls back to +/// the host profile that `host` selects where host profiles serve the CPU, +/// and warns ([`fall_back`]), and fails elsewhere +/// (`E_PROFILE_HOST_UNKNOWN`). The `host-cpu-unknown` test hook in `hooks` +/// makes `auto` treat any CPU so. `host` derives a host profile from the +/// `hypervisor` backend's fingerprint of this host. A host profile's ID +/// selects the profile of `restored`, the record of the snapshot being +/// restored, which carries the profile's only copy +/// ([`restored_host_profile`]). +pub(super) fn select_cpu_profile( + requested: &str, + hypervisor: &str, + restored: Option<&SnapshotCpuProfile>, + hooks: &TimeAbiTestHooks, +) -> anyhow::Result { + let host = host_cpu()?; + if requested == cpu_profile::HOST { + let started = std::time::Instant::now(); + let fingerprint = host_fingerprint(hypervisor)?; + let profile = host_partition_profile(&fingerprint, &host)?; + tracing::warn!( + "{}", + host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &profile, + started.elapsed(), + !cpu_profile::pinned_profile_serves(&host), + ) + ); + return Ok(profile); + } + match select_on_host(requested, &host, restored, hooks)? { + Selection::Profile(profile) => Ok(profile), + Selection::HostFallback => fall_back(&host, || host_fingerprint(hypervisor)), + } +} + +/// What [`select_on_host`] selects. +enum Selection { + /// A pinned profile, or the host profile of the snapshot being restored. + Profile(PartitionProfile), + /// `auto`'s fallback to a host profile, which [`fall_back`] derives. + HostFallback, +} + +/// Selects the CPU profile `requested` on the host CPU `host`, as +/// [`select_cpu_profile`] does for any request but `host`. +fn select_on_host( + requested: &str, + host: &HostCpuSignature, + restored: Option<&SnapshotCpuProfile>, + hooks: &TimeAbiTestHooks, +) -> Result { + if cpu_profile::is_host_profile_id(requested) { + let record = restored + .filter(|record| record.id == requested) + .ok_or_else(|| { + ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!( + "CPU profile {requested:?} is a host profile, which only a snapshot of it \ + carries" + ), + ) + })?; + let profile = PartitionProfile::host(cpu_profile::host_profile_for_restore( + &record.id, + &record.sha256, + &record.profile, + )?)?; + cpu_profile::check_generation(&profile, host)?; + return Ok(Selection::Profile(profile)); + } + if requested == cpu_profile::AUTO && falls_back(host, hooks) { + return Ok(Selection::HostFallback); + } + // Where `auto` still fails with `E_PROFILE_HOST_UNKNOWN` on a CPU that + // host profiles serve, pinned profiles of more than one generation serve + // it, a catalog defect, which `--cpu-profile host` avoids. + cpu_profile::select(requested, host) + .map(Selection::Profile) + .map_err(|mut err| { + if requested == cpu_profile::AUTO + && err.code == ProfileErrorCode::ProfileHostUnknown + && cpu_profile::supports_host_profiles(host) + { + err.message.push_str("; "); + err.message.push_str(HOST_PROFILE_HINT); + } + err + }) +} + +/// Returns whether a cold boot with `--cpu-profile auto` falls back to a host +/// profile on the host CPU `host`: no pinned profile serves the CPU, or the +/// `host-cpu-unknown` test hook in `hooks` treats it so, and host profiles +/// serve it. +fn falls_back(host: &HostCpuSignature, hooks: &TimeAbiTestHooks) -> bool { + (hooks.host_cpu_unknown || !cpu_profile::pinned_profile_serves(host)) + && cpu_profile::supports_host_profiles(host) +} + +/// Falls back from `--cpu-profile auto` to a host profile on the host CPU +/// `host`: derives the profile from this host's fingerprint, which +/// `fingerprint` takes, as `--cpu-profile host` does, and warns at the +/// default log level with [`FALLBACK_MARKER`] ([`host_profile_warning`]), +/// naming what the fingerprint and the derivation cost. +/// +/// A failure, the fingerprint's included, says that `auto` fell back +/// ([`FellBack`]), and keeps its code. +fn fall_back( + host: &HostCpuSignature, + fingerprint: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + let started = std::time::Instant::now(); + fingerprint() + .and_then(|fingerprint| { + let profile = host_partition_profile(&fingerprint, host)?; + tracing::warn!( + "{}", + host_profile_warning( + HostProfileOrigin::Fallback, + &fingerprint, + &profile, + started.elapsed(), + true, + ) + ); + Ok(profile) + }) + .map_err(|err| err.context(FellBack::new(host))) +} + +/// The context of a failed cold boot whose `--cpu-profile auto` fell back to +/// a host profile ([`fall_back`]), so that its error says so. +#[derive(Debug)] +struct FellBack { + /// The host CPU, as [`describe_signature`] names it. + cpu: String, +} + +impl FellBack { + fn new(host: &HostCpuSignature) -> Self { + Self { + cpu: describe_signature(host), + } + } +} + +impl std::fmt::Display for FellBack { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "--cpu-profile auto fell back to a host CPU profile, because no built-in CPU profile \ + serves this host's CPU, {}, but this host cannot boot one", + self.cpu + ) + } +} + +/// The marker that leads the warning of every cold boot whose +/// `--cpu-profile auto` falls back to a host profile ([`fall_back`]), which +/// NVX's tools and log scrapers match. +const FALLBACK_MARKER: &str = "NVX-CPU-PROFILE-FALLBACK:"; + +/// NVX's issue form that requests a built-in CPU profile for a CPU. +const CPU_PROFILE_REQUEST_URL: &str = + "https://github.com/microsoft/nvx/issues/new?template=cpu-profile.yml"; + +/// How a cold boot came to use a host profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HostProfileOrigin { + /// `--cpu-profile auto` fell back to it. + Fallback, + /// `--cpu-profile host` selected it. + Requested, +} + +/// Returns the warning of a cold boot on `profile`, a host profile that +/// `origin` selected from `fingerprint`, this host's fingerprint: the host +/// CPU, the profile and its digest, and the limits of a host profile, +/// including `cost`, what this cold boot's fingerprint and derivation took. +/// With `invite`, for a CPU that no pinned profile serves, it also asks the +/// user to request a built-in profile for the CPU with the CPU's +/// fingerprint, through NVX's issue form, which the link prefills with the +/// CPU. +fn host_profile_warning( + origin: HostProfileOrigin, + fingerprint: &CpuFingerprint, + profile: &PartitionProfile, + cost: Duration, + invite: bool, +) -> String { + let cpu = describe_cpu(&fingerprint.host.cpu); + let backend = &fingerprint.backend.name; + let selected = format!( + "{} (sha256:{}), a profile derived from this host's {backend} backend for development", + profile.id(), + hex(&profile.record().digest) + ); + let mut warning = match origin { + HostProfileOrigin::Fallback => format!( + "{FALLBACK_MARKER} no built-in CPU profile serves this host's CPU, {cpu}, so \ + --cpu-profile auto fell back to {selected}." + ), + HostProfileOrigin::Requested if invite => format!( + "--cpu-profile host selected {selected}; no built-in CPU profile serves this \ + host's CPU, {cpu}." + ), + HostProfileOrigin::Requested => format!("--cpu-profile host selected {selected}."), + }; + warning.push_str(&format!( + "\nIt is not pinned: a microcode, firmware, hypervisor, or OS update can change it. \ + Each cold boot fingerprints the backend first, which took {:.1} ms here, and its \ + snapshots restore only on hosts of the same CPU model and stepping whose hypervisor \ + supports it.", + cost.as_secs_f64() * 1000.0 + )); + if invite { + warning.push_str(&format!( + "\nHelp NVX add a built-in profile for this CPU: run\n \ + openvmm --hypervisor {backend} --cpu-fingerprint fingerprint.json\n\ + and attach fingerprint.json to a CPU profile request:\n {}", + cpu_profile_request_url(&fingerprint.host.cpu) + )); + } + warning +} + +/// Returns the link to NVX's CPU profile request form for the host CPU `cpu`, +/// which prefills its title and CPU fields. +fn cpu_profile_request_url(cpu: &cpu_profile::host::HostCpu) -> String { + let mut url = format!( + "{CPU_PROFILE_REQUEST_URL}&title={}&signature={}", + encode_query_value(&format!("CPU profile: {}", describe_cpu(cpu))), + encode_query_value(&cpu_signature( + &cpu.vendor, + cpu.family, + cpu.model, + cpu.stepping + )) + ); + if !cpu.brand.is_empty() { + url.push_str("&cpu="); + url.push_str(&encode_query_value(&cpu.brand)); + } + url +} + +/// Percent-encodes `value` for a URL query: every byte but ASCII letters, +/// digits, `-`, `.`, `_`, and `~`. +fn encode_query_value(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +/// Returns `bytes` in lowercase hexadecimal. +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Names a CPU's signature as NVX does: its vendor string and its display +/// family, model, and stepping, such as `GenuineIntel 6/173/1`. +fn cpu_signature(vendor: &str, family: u32, model: u32, stepping: u32) -> String { + format!("{vendor} {family}/{model}/{stepping}") +} + +/// Names a host CPU as NVX does: its signature ([`cpu_signature`]) and its +/// brand string, if any, such as +/// `GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C)`. +fn describe_cpu(cpu: &cpu_profile::host::HostCpu) -> String { + let signature = cpu_signature(&cpu.vendor, cpu.family, cpu.model, cpu.stepping); + if cpu.brand.is_empty() { + signature + } else { + format!("{signature} ({})", cpu.brand) + } +} + +/// Names the host CPU `host` by its signature ([`cpu_signature`]), without +/// the brand string, which a signature lacks. +fn describe_signature(host: &HostCpuSignature) -> String { + cpu_signature( + &host.vendor_str(), + host.family(), + host.model(), + host.stepping(), + ) +} + +/// What a failed cold boot with `--cpu-profile auto` suggests where host +/// profiles serve the host CPU and a host profile could boot. +const HOST_PROFILE_HINT: &str = + "--cpu-profile host boots on a profile derived from this host, for development"; + +/// Returns whether `code` is a CPU profile failure of a cold boot, after the +/// selection: the backend lacks the profile, or does not present it. +fn is_profile_failure(code: TimeAbiCode) -> bool { + matches!( + code, + TimeAbiCode::ProfileUnsupported + | TimeAbiCode::ProfileTimeBits + | TimeAbiCode::CpuSurface + | TimeAbiCode::CpuUnlisted + ) +} + +/// Explains `err`, the failure of a VM worker that cold booted with +/// `--cpu-profile requested` and the test hooks `hooks` on the host CPU +/// `host`, where its CPU profile failed. +/// +/// Where `auto` fell back to a host profile ([`falls_back`]), a CPU profile +/// failure says so ([`FellBack`]), unless the fallback itself already did. +/// Elsewhere, `auto` names `--cpu-profile host` where a host profile could +/// boot instead: `auto` selected a built-in profile that this host's backend +/// does not support (`E_PROFILE_UNSUPPORTED`), and host profiles serve the +/// CPU's vendor. A host profile derives from what the backend supports. An +/// explicit profile ID and `host` get no hint. +/// +/// The hint is a context of `err`, which keeps the backend's error chain: +/// the code, where the backend found the shortfall, and what it lacks. +fn hint_host_profile( + err: anyhow::Error, + requested: &str, + host: &HostCpuSignature, + hooks: &TimeAbiTestHooks, +) -> anyhow::Error { + if requested != cpu_profile::AUTO { + return err; + } + let code = leading_code(&err); + if falls_back(host, hooks) { + if err.downcast_ref::().is_some() || !code.is_some_and(is_profile_failure) { + return err; + } + return err.context(FellBack::new(host)); + } + if !cpu_profile::supports_host_profiles(host) || code != Some(TimeAbiCode::ProfileUnsupported) { + return err; + } + err.context(format!( + "this host cannot boot the built-in CPU profile that --cpu-profile auto selected; \ + {HOST_PROFILE_HINT}" + )) +} + +/// Returns `err`, the failure of a VM worker, with the hint of +/// [`hint_host_profile`] if the worker cold booted a time ABI microVM with +/// the `--cpu-profile` and test hooks of `requested` on this host. A restore +/// (`restoring`), which takes the snapshot's profile, gets none. +pub(super) fn hint_cold_boot( + err: anyhow::Error, + requested: Option<(&str, &TimeAbiTestHooks)>, + restoring: bool, +) -> anyhow::Error { + match (requested, restoring, host_cpu()) { + (Some((requested, hooks)), false, Ok(host)) => { + hint_host_profile(err, requested, &host, hooks) + } + _ => err, + } +} + +/// Returns the time ABI code that OpenVMM's exit message gives `err`: the +/// first bracketed code in its chain. +fn leading_code(err: &anyhow::Error) -> Option { + let chain = format!("{err:#}"); + chain.match_indices("[E_").find_map(|(start, _)| { + let code = &chain[start + 1..]; + TimeAbiCode::from_name(&code[..code.find(']')?]) + }) +} + +/// Fingerprints this host and the `hypervisor` backend, as +/// `--cpu-fingerprint` does, for a host profile. +fn host_fingerprint(hypervisor: &str) -> anyhow::Result { + let probe = hypervisor_resources::probe_by_name(hypervisor) + .with_context(|| format!("the {hypervisor} backend cannot be fingerprinted"))?; + let backend = probe + .cpu_fingerprint(&[]) + .with_context(|| format!("failed to fingerprint the {hypervisor} backend"))?; + let host = cpu_profile::host::HostIdentity::collect().context("failed to identify the host")?; + Ok(CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "openvmm".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + }, + host, + backend, + )) +} + +/// Derives the host profile of `fingerprint`, this host's fingerprint, under +/// the profiles' derivation policy, and checks that the host CPU `host` is in +/// its generation. +fn host_partition_profile( + fingerprint: &CpuFingerprint, + host: &HostCpuSignature, +) -> anyhow::Result { + let profile = PartitionProfile::host(cpu_profile::derive_host_profile(fingerprint)?)?; + cpu_profile::check_generation(&profile, host)?; + Ok(profile) +} + +/// Returns the CPU profile record of a snapshot that the worker restores, if +/// its profile is a host profile: the snapshot carries the profile's only +/// copy, from which [`select_cpu_profile`] takes the partition's profile. The +/// controller's restore preflight has checked the record; the selection +/// decodes it again, because the VM worker can run in another process. +pub(super) fn restored_host_profile(record: &SnapshotCpuProfile) -> Option { + cpu_profile::is_host_profile_id(&record.id).then(|| record.clone()) +} + +/// Returns the vendor and signature of the host CPU (`E_CPU_GENERATION` if +/// the host is not x86-64). +fn host_cpu() -> Result { + // The host CPU is identified with the host's CPUID instruction. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Ok(HostCpuSignature::current()) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + Err(TimeAbiError::new( + TimeAbiCode::CpuGeneration, + "the host CPU cannot be identified on this architecture", + )) + } +} + +/// Returns the partition's time ABI backend (`E_TSC_SYNC_UNSUPPORTED` if the +/// backend does not implement the time ABI). +fn backend<'a>( + partition: &'a dyn HvlitePartition, + hypervisor: &str, +) -> Result<&'a dyn TimeAbiBackend, TimeAbiError> { + partition.time_abi().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("the {hypervisor} backend does not implement the time ABI"), + ) + }) +} + +/// What the time ABI preflight established for a VM process, exposed through +/// inspect at `time-abi` together with the declared rates. +#[derive(Debug, Clone, Inspect)] +pub(super) struct TimeAbiReport { + /// The backend. + pub hypervisor: String, + /// The CPU profile. + pub cpu_profile: String, + /// The identity MSR route. + #[inspect(debug)] + pub msr_route: IdentityMsrRoute, + /// The synchronized TSC set method. + #[inspect(debug)] + pub sync: TscSyncMethod, + /// The backend's native TSC rate `F_d`, after the rate test hook. + pub native_tsc_hz: u64, + /// On restore, the accepted rate check against the snapshot's rate. + #[inspect(debug)] + pub rate_check: Option, +} + +/// Runs the backend preflight and the rate policy, and declares the rates +/// to the guest: restore steps 8 and 9 of the specification when `saved` +/// holds the snapshot's rates, or their cold-boot equivalent. +pub(super) fn declare_rates( + partition: &dyn HvlitePartition, + msrs: &TimeAbiMsrs, + hypervisor: &str, + cpu_profile: String, + saved: Option, + hooks: &TimeAbiTestHooks, +) -> anyhow::Result { + let backend = backend(partition, hypervisor)?; + let preflight = backend.preflight()?; + let rates = virt::time_abi::negotiate_rates(backend, hypervisor, saved, hooks)?; + msrs.declare(rates.declared)?; + tracing::info!( + hypervisor, + cpu_profile, + msr_route = ?preflight.msr_route, + sync = ?preflight.sync, + native_tsc_hz = rates.native_tsc_hz, + tsc_hz = rates.declared.tsc_hz, + apic_hz = rates.declared.apic_hz, + deviation_ppb = ?rates.check.map(|check| check.deviation_ppb), + "time ABI rates declared" + ); + Ok(TimeAbiReport { + hypervisor: hypervisor.to_owned(), + cpu_profile, + msr_route: preflight.msr_route, + sync: preflight.sync, + native_tsc_hz: rates.native_tsc_hz, + rate_check: rates.check, + }) +} + +/// Compares the effective CPUID recomputed for this partition with the one +/// the snapshot recorded: restore step 8 of the specification +/// (`E_CPU_SURFACE`). It compares the binary records, about a microsecond of +/// work, and decodes the snapshot's only to name the differences. +pub(super) fn check_recorded_cpuid( + effective_cpuid: &EffectiveCpuid, + record: &SnapshotCpuProfile, +) -> anyhow::Result<()> { + if encode_effective_cpuid(record_entries(effective_cpuid)) == record.effective_cpuid { + return Ok(()); + } + let recorded = decode_effective_cpuid(&record.effective_cpuid).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("the snapshot's effective CPUID record is malformed: {err}"), + ) + })?; + let differences = recorded_cpuid_differences(effective_cpuid, &recorded); + let count = differences.len(); + let shown = &differences[..count.min(MAX_REPORTED_CPUID_DIFFERENCES)]; + Err(TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "the effective CPUID of CPU profile {} differs from the snapshot's in {count} entries: {}", + record.id, + shown.join("; ") + ), + ) + .into()) +} + +/// Returns the entries of the snapshot record of `effective`, in its order. +fn record_entries(effective: &EffectiveCpuid) -> impl Iterator + '_ { + effective.results().map(|result| EffectiveCpuidEntry { + function: result.function, + index: result.index, + result: result.result, + mask: result.mask, + }) +} + +/// Lists the entries in which this partition's effective CPUID and a +/// snapshot's `recorded` one differ. +fn recorded_cpuid_differences( + effective: &EffectiveCpuid, + recorded: &[EffectiveCpuidEntry], +) -> Vec { + let key = |entry: &EffectiveCpuidEntry| (entry.function, entry.index); + let name = |(function, index): (u32, Option)| match index { + Some(index) => format!("CPUID {function:#x}.{index}"), + None => format!("CPUID {function:#x}"), + }; + let ours: BTreeMap<_, _> = record_entries(effective) + .map(|entry| (key(&entry), entry)) + .collect(); + let theirs: BTreeMap<_, _> = recorded.iter().map(|entry| (key(entry), *entry)).collect(); + let mut differences = Vec::new(); + for (&entry_key, entry) in &ours { + match theirs.get(&entry_key) { + None => differences.push(format!("{} is not in the snapshot", name(entry_key))), + Some(theirs) if theirs != entry => differences.push(format!( + "{} is {:#x?} under mask {:#x?}, and {:#x?} under mask {:#x?} in the snapshot", + name(entry_key), + entry.result, + entry.mask, + theirs.result, + theirs.mask + )), + Some(_) => {} + } + } + for &entry_key in theirs + .keys() + .filter(|entry_key| !ours.contains_key(entry_key)) + { + differences.push(format!("{} is only in the snapshot", name(entry_key))); + } + if differences.is_empty() { + differences.push("the snapshot lists the same entries in another order".to_owned()); + } + differences +} + +/// Checks that VP 0 observes the effective CPUID, on cold boot and restore +/// before any VP runs (`E_CPU_SURFACE`): the backend's report, looked up as +/// the guest's CPUID instruction finds it, equals every governed leaf under +/// its masks, and a leaf without a subleaf is read at subleaf 0. +/// +/// The report of a pass-through backend also holds VP 0's view at the host's +/// entries outside the profile's tables +/// (`cpu_profile::unlisted_cpuid_candidates`); each must read zero, so that +/// no reserved entry exposes a host feature (`E_CPU_UNLISTED`). A table +/// backend reports none, and its guests read zero there by construction. +pub(super) fn check_presented_cpuid( + partition: &dyn HvlitePartition, + hypervisor: &str, + profile: &CpuProfile, + effective_cpuid: &EffectiveCpuid, +) -> anyhow::Result<()> { + let leaves = backend(partition, hypervisor)?.effective_cpuid()?; + check_presented(profile, effective_cpuid, leaves) +} + +/// Checks a backend's report of VP 0's CPUID, as [`check_presented_cpuid`] +/// describes. +fn check_presented( + profile: &CpuProfile, + effective_cpuid: &EffectiveCpuid, + leaves: Vec, +) -> anyhow::Result<()> { + let presented = CpuidLeafSet::new(leaves); + let differences = cpuid_differences(&presented, effective_cpuid); + if differences.is_empty() { + let entries: Vec<_> = presented + .leaves() + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries)?; + return Ok(()); + } + let count = differences.len(); + let shown = &differences[..count.min(MAX_REPORTED_CPUID_DIFFERENCES)]; + Err(TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "VP 0 does not observe the effective CPUID of CPU profile {} in {count} results: {}", + profile.id(), + shown.join("; ") + ), + ) + .into()) +} + +/// Lists every result of `effective` that `presented` does not match under +/// the result's masks. +fn cpuid_differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { + const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + effective + .results() + .filter_map(|expected| { + let subleaf = expected.index.unwrap_or(0); + let actual = presented.result(expected.function, subleaf, &[0; 4]); + let registers: Vec = (0..4) + .filter(|®ister| { + (actual[register] ^ expected.result[register]) & expected.mask[register] != 0 + }) + .map(|register| { + format!( + "{} is {:#010x}, not {:#010x} under mask {:#010x}", + REGISTERS[register], + actual[register], + expected.result[register], + expected.mask[register] + ) + }) + .collect(); + (!registers.is_empty()).then(|| { + let leaf = match expected.index { + Some(index) => format!("CPUID {:#x}.{index}", expected.function), + None => format!("CPUID {:#x}", expected.function), + }; + format!("{leaf} {}", registers.join(", ")) + }) + }) + .collect() +} + +/// Checks that the backend supports the CPU profile, from the CPU surface it +/// reports (`E_PROFILE_UNSUPPORTED`), at every cold boot and restore. A +/// backend that reports no surface fails the check. +pub(super) fn check_profile_support( + partition: &dyn HvlitePartition, + hypervisor: &str, + profile: &CpuProfile, +) -> anyhow::Result<()> { + let surface = backend(partition, hypervisor)? + .supported_cpu_surface()? + .ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "the {hypervisor} backend reports no CPU surface to verify CPU profile {} against", + profile.id() + ), + ) + })?; + cpu_profile::verify_support(profile, &host_cpu_surface(&surface, hypervisor))?; + Ok(()) +} + +/// Converts a backend's CPU surface to the profile crate's form. KVM's is a +/// table; MSHV and WHP build theirs from the host's CPUID, which does not show +/// what a guest reads outside the profile's tables. +fn host_cpu_surface( + surface: &SupportedCpuSurface, + hypervisor: &str, +) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + presentation: match hypervisor { + "kvm" => cpu_profile::CpuidPresentation::Table, + _ => cpu_profile::CpuidPresentation::PassThroughHostView, + }, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } +} + +/// Takes the time ABI records of a capture: capture steps 2 and 3 of the +/// specification, the capture anchor and host identities, then the declared +/// rates, the CPU profile record, and the generation counter. The caller has +/// stopped every VP and checked the LAPIC timers (step 1). +pub(super) fn capture_records( + partition: &dyn HvlitePartition, + state: &TimeAbiState, +) -> anyhow::Result { + let backend = backend(partition, &state.report.hypervisor)?; + let rates = state + .msrs + .declared() + .context("the time ABI rates are not declared")?; + let anchor = backend.capture_anchor()?; + let identity = virt::time_abi::host::host_identity()?; + let record = state.profile.record(); + tracing::info!( + tsc = anchor.tsc, + utc_ns = anchor.sample.utc_ns, + pairing_ns = anchor.pairing_ns, + generation = state.generation, + "time ABI capture anchor" + ); + Ok(TimeCapture { + time: SnapshotTimeContract { + time_abi_version: TIME_ABI_VERSION, + tsc_frequency_hz: rates.tsc_hz, + tsc_tolerance_ppm: virt::time_abi::rate::TSC_TOLERANCE_PPM, + apic_frequency_hz: rates.apic_hz, + capture_tsc: anchor.tsc, + capture_utc_ns: anchor.sample.utc_ns, + capture_monotonic_ns: anchor.sample.monotonic_ns, + host_clock: identity.clock.as_str().to_owned(), + host_id: identity.host_id.to_vec(), + host_boot_id: identity.boot_id.to_vec(), + capture_generation: state.generation, + }, + cpu_profile: SnapshotCpuProfile { + id: record.id.to_owned(), + sha256: record.digest.to_vec(), + profile: record.encoding.to_vec(), + effective_cpuid: encode_effective_cpuid(record_entries(&state.effective_cpuid)), + capture_cpu_signature: virt::time_abi::surface::host_cpu_signature().unwrap_or(0), + }, + }) +} + +impl LoadedVm { + /// Restores the guest clocks: restore steps 12 to 16 of the + /// specification, after the saved state is restored and before any + /// restored VP runs. + /// + /// It performs the synchronized TSC set at the downtime selected at the + /// restore anchor, then concurrently advances and sets every LAPIC and + /// advances VM time and the RTC, and seals the time fields of the restore + /// packet. The LAPIC advance rejects a periodic or TSC-deadline timer, so + /// restore needs no separate timer check, and the PIT checks itself when + /// restored. + pub(super) async fn time_abi_restore(&mut self, input: RestoreTimeInput) -> anyhow::Result<()> { + let state = self + .inner + .time_abi + .as_ref() + .context("a time ABI restore requires a time ABI partition")?; + let hypervisor = state.report.hypervisor.clone(); + let hooks = state.hooks.clone(); + let rate_deviation = state + .report + .rate_check + .map_or(0, |check| check.rate_deviation); + let capture = input.contract.capture_record()?; + let tsc_hz = input.contract.tsc_frequency_hz; + let apic_hz = input.contract.apic_frequency_hz; + + let started = std::time::Instant::now(); + let partition = self.inner.partition.clone(); + let mut downtime = None; + let set = + backend(partition.as_ref(), &hypervisor)?.set_synchronized_tsc(&mut |sample| { + let selected = virt::time_abi::downtime::select_downtime( + &capture, + &input.destination, + sample, + &hooks, + )?; + let target = + virt::time_abi::downtime::tsc_target(capture.tsc, selected.nanos, tsc_hz)?; + downtime = Some(selected); + Ok(target) + })?; + let downtime = + downtime.context("the backend set the TSC without selecting the downtime")?; + let tsc_set = started.elapsed(); + if let Some(step_ns) = downtime.host_wall_clock_step_ns { + tracing::warn!( + step_ns, + "host wall clock was stepped since capture; the downtime uses host monotonic time" + ); + } + tracing::info!( + downtime_ns = downtime.nanos, + source = ?downtime.source, + target = set.target, + method = ?set.method, + vps = set.readback.len(), + "time ABI synchronized TSC set" + ); + + // Steps 14 and 15 touch disjoint state, since no backend's LAPIC + // reads VM time under the time ABI, so they run concurrently. Both + // run to completion, so a failure never cancels a state unit + // operation midway. + let downtime_ns = downtime.nanos; + let partition_unit = &mut self.inner.partition_unit; + let state_units = &mut self.state_units; + let lapic_advance = async move { + let result = partition_unit + .advance_lapic_timers(downtime_ns, apic_hz) + .await; + (result, started.elapsed()) + }; + let vm_time_advance = async move { + let result = state_units + .advance_time(Duration::from_nanos(downtime_ns)) + .await; + (result, started.elapsed()) + }; + let ((lapic_result, lapic_advanced), (vm_time_result, vm_time_advanced)) = + (lapic_advance, vm_time_advance).join().await; + lapic_result?; + vm_time_result.context("failed to advance restored VM time")?; + let finished = started.elapsed(); + // Per-phase durations attribute the restore.time_abi_clock profile + // phase. The LAPIC and VM time phases overlap; each is measured from + // the end of the TSC set. + tracing::info!( + tsc_set_us = tsc_set.as_micros() as u64, + lapic_us = (lapic_advanced - tsc_set).as_micros() as u64, + vm_time_us = (vm_time_advanced - tsc_set).as_micros() as u64, + total_us = finished.as_micros() as u64, + "time ABI restore clock" + ); + + input.restore_record.send(RestoreTimeRecord { + downtime_ns: downtime.nanos, + downtime_utc: downtime.source == DowntimeSource::Utc, + rate_deviation, + test_hooks: hooks.active(), + }); + Ok(()) + } +} + +/// The `time-abi` state unit. It saves and restores +/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`, which is partition-wide guest state, +/// and inspects the declared rates and the preflight report. +pub(super) struct TimeAbiUnit { + pub msrs: Arc, + pub report: TimeAbiReport, +} + +impl InspectMut for TimeAbiUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond().merge(&*self.msrs).merge(&self.report); + } +} + +impl StateUnit for TimeAbiUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + self.msrs.reset(); + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(Some(SavedStateBlob::new(self.msrs.save()))) + } + + async fn restore(&mut self, state: SavedStateBlob) -> Result<(), RestoreError> { + self.msrs.restore(state.parse()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openvmm_defs::time_abi::EFFECTIVE_CPUID_ENTRY_BYTES; + use virt::time_abi::cpuid::effective_cpuid; + use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use vm_topology::processor::ProcessorTopology; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::VpIndex; + use vm_topology::processor::x86::X2ApicState; + + const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + + /// The signature of the test profile's CPU, an Ice Lake-SP Xeon. + const TEST_SIGNATURE: u32 = 0x0006_06a6; + + /// The signature of a Granite Rapids Xeon, family 6 model 173 stepping 1, + /// which no pinned profile serves. + const GRANITE_RAPIDS: u32 = 0x000a_06d1; + + /// The brand string of the Granite Rapids test CPU. + const GRANITE_RAPIDS_BRAND: &str = "Intel(R) Xeon(R) 6973P-C"; + + /// No test hook. + const NO_HOOKS: TimeAbiTestHooks = TimeAbiTestHooks { + force_utc_downtime: false, + boot_id_mismatch: false, + dest_rate_offset_ppm: 0, + downtime_add_s: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + host_cpu_unknown: false, + }; + + /// The `host-cpu-unknown` test hook. + const HOST_CPU_UNKNOWN: TimeAbiTestHooks = TimeAbiTestHooks { + host_cpu_unknown: true, + ..NO_HOOKS + }; + + fn test_profile() -> &'static CpuProfile { + cpu_profile::pinned(TEST_PROFILE).unwrap() + } + + /// Returns the profile of `selection`, which must not be a fallback. + fn selected( + selection: Result, + ) -> Result { + selection.map(|selection| match selection { + Selection::Profile(profile) => profile, + Selection::HostFallback => panic!("the selection fell back to a host profile"), + }) + } + + /// Returns whether `selection` is `auto`'s fallback to a host profile. + fn is_fallback(selection: &Result) -> bool { + matches!(selection, Ok(Selection::HostFallback)) + } + + /// Returns the partition profile of the pinned `profile`. + fn partition_profile(profile: &CpuProfile) -> PartitionProfile { + PartitionProfile::pinned(profile.id()).unwrap() + } + + fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// Returns the MSR handler, the configuration, and the effective CPUID of + /// a partition with `vp_count` VPs and the test profile. + fn test_config(vp_count: u32) -> (Arc, TimeAbiConfig, EffectiveCpuid) { + let profile = test_profile(); + let effective = + effective_cpuid(profile, &topology(vp_count, X2ApicState::Supported)).unwrap(); + let (msrs, config) = partition_config(&partition_profile(profile), &effective); + (msrs, config, effective) + } + + fn test_unit(msrs: Arc) -> TimeAbiUnit { + TimeAbiUnit { + msrs, + report: TimeAbiReport { + hypervisor: "kvm".to_owned(), + cpu_profile: TEST_PROFILE.to_owned(), + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::CommonOffset, + native_tsc_hz: 2_100_000_000, + rate_check: None, + }, + } + } + + fn code(result: anyhow::Result) -> String { + let message = format!("{:#}", result.unwrap_err()); + message + .strip_prefix('[') + .and_then(|rest| rest.split_once(']')) + .map_or(message.clone(), |(code, _)| code.to_owned()) + } + + #[test] + fn unit_saves_restores_and_resets_the_invariant_control() { + futures::executor::block_on(async { + let (msrs, config, _) = test_config(4); + assert!(Arc::ptr_eq(&msrs, &config.msrs)); + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + let mut unit = test_unit(msrs.clone()); + let saved = unit.save().await.unwrap().unwrap(); + + let (restored, _, _) = test_config(4); + let mut restored_unit = test_unit(restored.clone()); + restored_unit.restore(saved).await.unwrap(); + assert_eq!(restored.tsc_invariant_control(), 1); + + restored_unit.reset().await.unwrap(); + assert_eq!(restored.tsc_invariant_control(), 0); + }); + } + + #[test] + fn unit_inspects_the_report() { + let (msrs, _, _) = test_config(1); + let mut unit = test_unit(msrs); + let mut inspection = inspect::inspect("", &mut unit); + futures::executor::block_on(inspection.resolve()); + let inspect::Node::Dir(entries) = inspection.results() else { + panic!("the unit inspects as a directory"); + }; + let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect(); + for name in [ + "tsc_invariant_control", + "hypervisor", + "cpu_profile", + "msr_route", + "sync", + "native_tsc_hz", + ] { + assert!(names.contains(&name), "{names:?}"); + } + } + + /// Every pinned profile completes, for every topology, into a partition + /// CPUID that carries the whole effective CPUID, the identity, and the + /// time bits, with only the per-VP bits left to the backend. + #[test] + fn partition_cpuid_is_the_complete_profile() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); + let (_, config) = partition_config(&partition_profile(profile), &effective); + assert_eq!(config.cpu_profile.id(), profile.id()); + + let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut cpuid, vp_count).unwrap(); + virt::time_abi::identity::check_time_bits(&mut cpuid, true).unwrap(); + assert!( + cpuid_differences(&config.cpuid, &effective).is_empty(), + "{} at {vp_count} VPs", + profile.id() + ); + let x2apic_bit = effective.lookup(1, 0)[2] & (1 << 21) != 0; + assert_eq!(x2apic_bit, x2apic == X2ApicState::Supported); + + for leaf in config.cpuid.leaves() { + let per_vp = virt::x86::topology::per_vp_cpuid_bits(leaf.function); + for (mask, bits) in leaf.mask.iter().zip(per_vp) { + assert_eq!(mask & bits, 0, "{leaf:?}"); + } + } + } + } + } + } + + /// A pass-through backend's report may hold VP 0's view at the host's + /// entries outside the profile: zero there passes, and anything else is + /// `E_CPU_UNLISTED`. A report of the table alone passes, for every pinned + /// profile and topology. + #[test] + fn presented_cpuid_rejects_non_zero_unlisted_entries() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8, 64] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); + let (_, config) = partition_config(&partition_profile(profile), &effective); + check_presented(profile, &effective, config.cpuid.leaves().to_vec()) + .unwrap_or_else(|err| { + panic!("{} with {vp_count} VPs, {x2apic:?}: {err:#}", profile.id()) + }); + } + } + } + + let (_, config, effective) = test_config(2); + let profile = test_profile(); + let table = config.cpuid.leaves().to_vec(); + + // Leaf 0x1D is above Ice Lake-SP's maximum basic leaf (0x1B). + let mut zero = table.clone(); + zero.push(CpuidLeaf::new(0x1d, [0; 4])); + check_presented(profile, &effective, zero).unwrap(); + + let mut leaked = table.clone(); + leaked.push(CpuidLeaf::new(0x1d, [1, 0, 0, 0])); + assert_eq!( + code(check_presented(profile, &effective, leaked.clone())), + "E_CPU_UNLISTED" + ); + + // A governed difference is reported first. + leaked.retain(|leaf| leaf.function != 0x8000_0002); + assert_eq!( + code(check_presented(profile, &effective, leaked)), + "E_CPU_SURFACE" + ); + } + + /// The effective CPUID ends leaf 0Bh with Intel's terminating subleaf, + /// which carries VP 0's x2APIC ID; the backend sets each VP's own. No + /// pinned profile reaches leaf 1Fh. + #[test] + fn effective_cpuid_terminates_the_extended_topology() { + for profile in cpu_profile::pinned_profiles() { + assert!(profile.lookup(0, 0)[0] < 0x1f, "{}", profile.id()); + for vp_count in [1, 2, 8] { + let effective = + effective_cpuid(profile, &topology(vp_count, X2ApicState::Supported)).unwrap(); + let subleaves = |function| { + effective + .results() + .filter(|result| result.function == function) + .map(|result| (result.index, result.result, result.mask)) + .collect::>() + }; + let leaf_b = subleaves(0xb); + assert_eq!(leaf_b.len(), 3, "{}: {leaf_b:x?}", profile.id()); + assert_eq!(leaf_b[2], (Some(2), [0, 0, 2, 0], [!0; 4])); + assert!(subleaves(0x1f).is_empty()); + + let (_, config) = partition_config(&partition_profile(profile), &effective); + let terminator = config + .cpuid + .leaves() + .iter() + .find(|leaf| leaf.function == 0xb && leaf.index == Some(2)) + .unwrap(); + assert_eq!(terminator.mask, [!0, !0, !0, 0]); + } + } + } + + #[test] + fn presented_cpuid_must_match_under_the_masks() { + let (_, config, effective) = test_config(2); + let presented = config.cpuid.leaves().to_vec(); + assert!(cpuid_differences(&CpuidLeafSet::new(presented.clone()), &effective).is_empty()); + + // A feature bit the profile sets (AVX2) that VP 0 does not see. + let mut changed = presented.clone(); + changed.push( + CpuidLeaf::new(7, [0; 4]) + .indexed(0) + .masked([0, 1 << 5, 0, 0]), + ); + let differences = cpuid_differences(&CpuidLeafSet::new(changed), &effective); + assert_eq!(differences.len(), 1, "{differences:?}"); + assert!( + differences[0].starts_with("CPUID 0x7.0 EBX is"), + "{differences:?}" + ); + + // Runtime-owned bits, such as OSXSAVE, are not part of the profile. + let mut running = presented.clone(); + running.push(CpuidLeaf::new(1, [0, 0, 1 << 27, 0]).masked([0, 0, 1 << 27, 0])); + assert!(cpuid_differences(&CpuidLeafSet::new(running), &effective).is_empty()); + + // A leaf VP 0 does not report reads as zeros. + let without_brand: Vec<_> = presented + .iter() + .copied() + .filter(|leaf| leaf.function != 0x8000_0002) + .collect(); + let differences = cpuid_differences(&CpuidLeafSet::new(without_brand), &effective); + assert_eq!(differences.len(), 1, "{differences:?}"); + assert!(differences[0].starts_with("CPUID 0x80000002 ")); + } + + #[test] + fn recorded_cpuid_must_match() { + let (_, _, effective) = test_config(2); + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + let record = SnapshotCpuProfile { + id: TEST_PROFILE.to_owned(), + sha256: pinned.digest.to_vec(), + profile: pinned.encoding.to_vec(), + effective_cpuid: encode_effective_cpuid(record_entries(&effective)), + capture_cpu_signature: 0, + }; + check_recorded_cpuid(&effective, &record).unwrap(); + + // Another topology changes the topology leaves. + let (_, _, other) = test_config(4); + let message = format!("{:#}", check_recorded_cpuid(&other, &record).unwrap_err()); + assert!(message.starts_with("[E_CPU_SURFACE]"), "{message}"); + assert!(message.contains("CPUID 0xb.1"), "{message}"); + + // One flipped bit is named. + let mut flipped = record.clone(); + flipped.effective_cpuid[3 * 4] ^= 1; + let message = format!( + "{:#}", + check_recorded_cpuid(&effective, &flipped).unwrap_err() + ); + assert!(message.starts_with("[E_CPU_SURFACE]"), "{message}"); + assert!(message.contains("CPUID 0x0 is"), "{message}"); + + // A missing entry, and the same entries in another order. + let mut truncated = record.clone(); + truncated + .effective_cpuid + .truncate(record.effective_cpuid.len() - EFFECTIVE_CPUID_ENTRY_BYTES); + assert_eq!( + code(check_recorded_cpuid(&effective, &truncated)), + "E_CPU_SURFACE" + ); + let mut reordered = record.clone(); + let (first, second) = reordered + .effective_cpuid + .split_at_mut(EFFECTIVE_CPUID_ENTRY_BYTES); + first.swap_with_slice(&mut second[..EFFECTIVE_CPUID_ENTRY_BYTES]); + assert_eq!( + code(check_recorded_cpuid(&effective, &reordered)), + "E_CPU_SURFACE" + ); + + let mut malformed = record.clone(); + malformed.effective_cpuid.pop(); + assert_eq!( + code(check_recorded_cpuid(&effective, &malformed)), + "E_MANIFEST_TIME" + ); + } + + /// Capture copies the pinned profile's precomputed record, so restore + /// never encodes or hashes a profile. + #[test] + fn pinned_records_are_the_profiles() { + for profile in cpu_profile::pinned_profiles() { + let pinned = cpu_profile::pinned_record(profile.id()).unwrap(); + assert_eq!(pinned.id, profile.id()); + assert_eq!(pinned.encoding, profile.encode().as_slice()); + assert_eq!(pinned.digest, profile.digest()); + } + } + + #[test] + fn cpu_profile_selection() { + for requested in [ + "interim.host.kvm.v1", + "intel.cascadelake.v1", + "intel.host.v1", + ] { + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert_eq!( + code(select_cpu_profile(requested, "kvm", None, hooks)), + "E_PROFILE_UNKNOWN" + ); + } + } + + // `auto` selects the pinned profile of the host's generation, if any, + // and otherwise falls back to a host profile where one can serve the + // host. The `nosuch` backend, which cannot be fingerprinted, stops a + // fallback at its first step, which says that `auto` fell back. + let host = host_cpu().unwrap(); + let fell_back = |result: anyhow::Result| { + assert_eq!( + format!("{:#}", result.unwrap_err()), + format!( + "{}: the nosuch backend cannot be fingerprinted", + FellBack::new(&host) + ) + ); + }; + let auto = select_cpu_profile(cpu_profile::AUTO, "nosuch", None, &NO_HOOKS); + if cpu_profile::pinned_profile_serves(&host) { + let profile = auto.unwrap(); + assert!(!profile.is_host()); + cpu_profile::check_generation(&profile, &host).unwrap(); + } else if cpu_profile::supports_host_profiles(&host) { + fell_back(auto); + } else { + assert_eq!(code(auto), "E_PROFILE_HOST_UNKNOWN"); + } + // The `host-cpu-unknown` hook makes `auto` fall back on any CPU that + // host profiles serve. + let hooked = select_cpu_profile(cpu_profile::AUTO, "nosuch", None, &HOST_CPU_UNKNOWN); + if cpu_profile::supports_host_profiles(&host) { + fell_back(hooked); + } else { + assert_eq!(code(hooked), "E_PROFILE_HOST_UNKNOWN"); + } + + // A pinned ID selects its profile only in its generation, whatever + // the hooks. + for profile in cpu_profile::pinned_profiles() { + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let selected = select_cpu_profile(profile.id(), "kvm", None, hooks); + if cpu_profile::check_generation(profile, &host).is_ok() { + assert_eq!(selected.unwrap().id(), profile.id()); + } else { + assert_eq!(code(selected), "E_CPU_GENERATION"); + } + } + } + } + + /// `auto` falls back to a host profile only where one can boot: on Intel + /// and AMD CPUs that no pinned profile serves, or that the + /// `host-cpu-unknown` test hook treats so. Another vendor's CPU still + /// fails, without naming `--cpu-profile host`, and an explicit profile + /// never falls back. + #[test] + fn auto_falls_back_to_host_profiles_only_where_they_serve() { + let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); + let granite_rapids = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let zen3 = HostCpuSignature::new(*b"AuthenticAMD", 0x00a2_0f12); + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let milan = HostCpuSignature::new(*b"AuthenticAMD", 0x00a0_0f11); + let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); + let select = |requested, host, hooks| select_on_host(requested, host, None, hooks); + + for host in [&tiger_lake, &granite_rapids, &zen3] { + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert!(falls_back(host, hooks), "{host}"); + assert!(is_fallback(&select(cpu_profile::AUTO, host, hooks))); + } + } + for (host, id) in [(&icelake, TEST_PROFILE), (&milan, "amd.milan.v1")] { + assert!(!falls_back(host, &NO_HOOKS), "{host}"); + assert_eq!( + selected(select(cpu_profile::AUTO, host, &NO_HOOKS)) + .unwrap() + .id(), + id + ); + assert!(falls_back(host, &HOST_CPU_UNKNOWN), "{host}"); + assert!(is_fallback(&select( + cpu_profile::AUTO, + host, + &HOST_CPU_UNKNOWN + ))); + } + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert!(!falls_back(&hygon, hooks)); + let error = selected(select(cpu_profile::AUTO, &hygon, hooks)).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown, "{error}"); + assert!(!error.message.contains("--cpu-profile host"), "{error}"); + + let error = selected(select(TEST_PROFILE, &tiger_lake, hooks)).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuGeneration, "{error}"); + assert!(!error.message.contains("--cpu-profile host"), "{error}"); + assert_eq!( + selected(select(TEST_PROFILE, &icelake, hooks)) + .unwrap() + .id(), + TEST_PROFILE + ); + } + } + + /// `auto`'s fallback derives the host profile that `--cpu-profile host` + /// derives, checks its generation, and warns with the stable marker, the + /// host CPU, the profile and its digest, the limits of a host profile, + /// and an invitation to request a built-in profile through NVX's issue + /// form, which the link prefills with the CPU. + #[test] + fn auto_falls_back_to_a_host_profile_and_warns() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fingerprint = test_fingerprint("kvm", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let profile = fall_back(&host, || Ok(fingerprint.clone())).unwrap(); + assert!(profile.is_host()); + assert_eq!(profile.id(), "intel.host.v1"); + assert_eq!( + *profile, + cpu_profile::derive_host_profile(&fingerprint).unwrap() + ); + cpu_profile::check_generation(&profile, &host).unwrap(); + + let digest = profile.digest_string(); + let warning = host_profile_warning( + HostProfileOrigin::Fallback, + &fingerprint, + &profile, + Duration::from_micros(12_345), + true, + ); + let lines: Vec<_> = warning.lines().collect(); + assert_eq!( + lines[0], + format!( + "NVX-CPU-PROFILE-FALLBACK: no built-in CPU profile serves this host's CPU, \ + GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C), so --cpu-profile auto fell \ + back to intel.host.v1 ({digest}), a profile derived from this host's kvm \ + backend for development." + ) + ); + assert_eq!( + lines[1], + "It is not pinned: a microcode, firmware, hypervisor, or OS update can change it. \ + Each cold boot fingerprints the backend first, which took 12.3 ms here, and its \ + snapshots restore only on hosts of the same CPU model and stepping whose \ + hypervisor supports it." + ); + assert_eq!( + lines[2..], + [ + "Help NVX add a built-in profile for this CPU: run", + " openvmm --hypervisor kvm --cpu-fingerprint fingerprint.json", + "and attach fingerprint.json to a CPU profile request:", + " https://github.com/microsoft/nvx/issues/new?template=cpu-profile.yml\ + &title=CPU%20profile%3A%20GenuineIntel%206%2F173%2F1%20%28Intel%28R%29%20Xeon\ + %28R%29%206973P-C%29&signature=GenuineIntel%206%2F173%2F1\ + &cpu=Intel%28R%29%20Xeon%28R%29%206973P-C", + ] + ); + } + + /// An explicit `--cpu-profile host` warns without the fallback's marker, + /// and invites a request for a built-in profile only on a CPU that no + /// pinned profile serves. + #[test] + fn explicit_host_profiles_invite_requests_only_where_no_profile_serves() { + let fingerprint = test_fingerprint("whp", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let profile = host_partition_profile(&fingerprint, &host).unwrap(); + let digest = profile.digest_string(); + let invited = host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &profile, + Duration::from_micros(4_960), + true, + ); + assert!( + invited.starts_with(&format!( + "--cpu-profile host selected intel.host.v1 ({digest}), a profile derived from \ + this host's whp backend for development; no built-in CPU profile serves this \ + host's CPU, GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C).\nIt is not pinned" + )), + "{invited}" + ); + assert!(!invited.contains(FALLBACK_MARKER), "{invited}"); + assert!( + invited.contains("\n openvmm --hypervisor whp --cpu-fingerprint fingerprint.json\n"), + "{invited}" + ); + assert!( + invited.contains(&format!("\n {CPU_PROFILE_REQUEST_URL}&title=")), + "{invited}" + ); + + let (served, _) = test_host_profile("whp"); + let served = PartitionProfile::host(served).unwrap(); + let fingerprint = test_fingerprint("whp", TEST_SIGNATURE, ""); + let warning = host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &served, + Duration::from_micros(4_960), + false, + ); + assert_eq!( + warning.lines().collect::>(), + [ + format!( + "--cpu-profile host selected intel.host.v1 ({}), a profile derived from \ + this host's whp backend for development.", + served.digest_string() + ) + .as_str(), + "It is not pinned: a microcode, firmware, hypervisor, or OS update can change \ + it. Each cold boot fingerprints the backend first, which took 5.0 ms here, and \ + its snapshots restore only on hosts of the same CPU model and stepping whose \ + hypervisor supports it.", + ] + ); + } + + #[test] + fn cpu_profile_requests_prefill_the_cpu() { + assert_eq!( + encode_query_value("az-AZ_09.~ /(R)&=?#%+\u{e9}"), + "az-AZ_09.~%20%2F%28R%29%26%3D%3F%23%25%2B%C3%A9" + ); + // A CPU without a brand string prefills only its signature. + let fingerprint = test_fingerprint("mshv", GRANITE_RAPIDS, ""); + assert_eq!(describe_cpu(&fingerprint.host.cpu), "GenuineIntel 6/173/1"); + assert_eq!( + cpu_profile_request_url(&fingerprint.host.cpu), + format!( + "{CPU_PROFILE_REQUEST_URL}&title=CPU%20profile%3A%20GenuineIntel%206%2F173%2F1\ + &signature=GenuineIntel%206%2F173%2F1" + ) + ); + assert_eq!( + describe_signature(&HostCpuSignature::new(*b"AuthenticAMD", 0x00a6_0f12)), + "AuthenticAMD 25/97/2" + ); + } + + /// A fallback that cannot boot says that `auto` fell back, keeps the + /// failure's code, and names no `--cpu-profile host`, which it already + /// tried: a failed fingerprint or derivation, such as a backend that + /// lacks a CPU feature that the time ABI requires (`E_PROFILE_UNSUPPORTED`), + /// and a backend that cannot boot the derived profile. + #[test] + fn a_fallback_that_cannot_boot_says_that_auto_fell_back() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fell_back = FellBack::new(&host).to_string(); + assert_eq!( + fell_back, + "--cpu-profile auto fell back to a host CPU profile, because no built-in CPU \ + profile serves this host's CPU, GenuineIntel 6/173/1, but this host cannot boot one" + ); + + let no_rdtscp = + test_fingerprint_with("whp", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND, |cpuid| { + for entry in cpuid.iter_mut() { + if entry.key() == (0x8000_0001, None) { + let mut registers = entry.registers(); + registers[3] &= !(1 << 27); + *entry = cpu_profile::cpuid::CpuidEntry::new(0x8000_0001, None, registers); + } + } + }); + let err = fall_back(&host, || Ok(no_rdtscp)).unwrap_err(); + let message = format!("{err:#}"); + assert!( + message.starts_with(&format!("{fell_back}: [E_PROFILE_UNSUPPORTED] ")), + "{message}" + ); + assert!(message.contains("RDTSCP"), "{message}"); + assert_eq!(leading_code(&err), Some(TimeAbiCode::ProfileUnsupported)); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + + let err = fall_back(&host, || { + Err(anyhow::anyhow!("failed to fingerprint the kvm backend")) + }) + .unwrap_err(); + assert_eq!( + format!("{err:#}"), + format!("{fell_back}: failed to fingerprint the kvm backend") + ); + + // The cold boot's hint does not repeat what the fallback said. + let wrapped = err.context("failed to create the partition"); + let hinted = format!( + "{:#}", + hint_host_profile(wrapped, cpu_profile::AUTO, &host, &NO_HOOKS) + ); + assert_eq!(hinted.matches(&fell_back).count(), 1, "{hinted}"); + + // A backend that cannot boot the profile that `auto` fell back to. + let unsupported = || { + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + "the kvm backend does not support CPU profile intel.host.v1", + )) + .context("failed to create the prototype partition") + }; + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + for (host, hooks) in [(&host, &NO_HOOKS), (&icelake, &HOST_CPU_UNKNOWN)] { + let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host, hooks); + let message = format!("{err:#}"); + assert_eq!( + message, + format!("{}: {:#}", FellBack::new(host), unsupported()) + ); + assert_eq!(leading_code(&err), Some(TimeAbiCode::ProfileUnsupported)); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + } + } + + /// A cold boot with `auto` names `--cpu-profile host` where the backend + /// does not support the built-in profile that `auto` selected, on Intel + /// and AMD CPUs only, and says that `auto` fell back where it fell back + /// to a host profile. The hint keeps the backend's error chain. + #[test] + fn auto_names_host_profiles_where_the_backend_lacks_the_profile() { + let unsupported = || { + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + "the kvm backend does not support CPU profile amd.milan.v1", + )) + .context("failed to create the prototype partition") + }; + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let milan = HostCpuSignature::new(*b"AuthenticAMD", 0x00a0_0f11); + let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); + let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); + for host in [&icelake, &milan] { + let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host, &NO_HOOKS); + let message = format!("{err:#}"); + assert!(message.starts_with("this host cannot boot the built-in CPU profile that --cpu-profile auto selected; --cpu-profile host boots"), "{message}"); + assert!( + message.ends_with(&format!("{:#}", unsupported())), + "{message}" + ); + assert_eq!( + leading_code(&err), + Some(TimeAbiCode::ProfileUnsupported), + "{message}" + ); + } + // Where `auto` fell back, every CPU profile failure says so instead. + for code in [ + TimeAbiCode::ProfileUnsupported, + TimeAbiCode::ProfileTimeBits, + TimeAbiCode::CpuSurface, + TimeAbiCode::CpuUnlisted, + ] { + let err = anyhow::Error::new(TimeAbiError::new(code, "intel.host.v1 failed")); + let message = format!( + "{:#}", + hint_host_profile(err, cpu_profile::AUTO, &tiger_lake, &NO_HOOKS) + ); + assert!( + message.starts_with(&FellBack::new(&tiger_lake).to_string()), + "{message}" + ); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + } + // Another vendor's CPU, an explicit profile, `host`, and other + // failures get no hint, after a fallback too. + for (requested, host, err) in [ + (cpu_profile::AUTO, &hygon, unsupported()), + (TEST_PROFILE, &icelake, unsupported()), + (cpu_profile::HOST, &milan, unsupported()), + (cpu_profile::HOST, &tiger_lake, unsupported()), + ( + cpu_profile::AUTO, + &milan, + anyhow::Error::new(TimeAbiError::new(TimeAbiCode::CpuSurface, "VP 0 differs")) + .context("failed to create the prototype partition"), + ), + ( + cpu_profile::AUTO, + &milan, + anyhow::anyhow!("no time ABI code"), + ), + ( + cpu_profile::AUTO, + &tiger_lake, + anyhow::anyhow!("no time ABI code"), + ), + ( + cpu_profile::AUTO, + &tiger_lake, + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "no synchronized TSC set", + )), + ), + ] { + let before = format!("{err:#}"); + let message = format!("{:#}", hint_host_profile(err, requested, host, &NO_HOOKS)); + assert_eq!(message, before); + } + // Neither does a restore, nor a VM without the time ABI. + for (requested, restoring) in [ + (Some((cpu_profile::AUTO, &NO_HOOKS)), true), + (Some((cpu_profile::AUTO, &HOST_CPU_UNKNOWN)), true), + (None, false), + ] { + let message = format!("{:#}", hint_cold_boot(unsupported(), requested, restoring)); + assert_eq!(message, format!("{:#}", unsupported())); + } + } + + #[test] + fn a_host_profile_needs_a_backend_fingerprint() { + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None, hooks).unwrap_err(); + assert_eq!( + format!("{error:#}"), + "the nosuch backend cannot be fingerprinted" + ); + } + } + + /// Returns the fingerprint of a `backend` host of the GenuineIntel CPU + /// with `signature` and `brand`, whose surface is the test profile's + /// after `edit` changes its CPUID. + fn test_fingerprint_with( + backend: &str, + signature: u32, + brand: &str, + edit: impl FnOnce(&mut Vec), + ) -> CpuFingerprint { + let mut cpuid: Vec<_> = test_profile() + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + let mut registers = entry.values(); + if (leaf, subleaf) == (1, None) { + registers[0] = signature; + } + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, registers) + }) + .collect(); + edit(&mut cpuid); + let host = HostCpuSignature::new(*b"GenuineIntel", signature); + let host = cpu_profile::host::HostIdentity { + cpu: cpu_profile::host::HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: cpu_profile::Hex32(signature), + family: host.family(), + model: host.model(), + stepping: host.stepping(), + brand: brand.to_owned(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: cpu_profile::host::HostOs { + kind: "windows".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + }; + CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + }, + host, + cpu_profile::fingerprint::BackendFingerprint::new(backend, "test", cpuid), + ) + } + + /// Returns the fingerprint of a `backend` host of the GenuineIntel CPU + /// with `signature` and `brand`, whose surface is the test profile's. + fn test_fingerprint(backend: &str, signature: u32, brand: &str) -> CpuFingerprint { + test_fingerprint_with(backend, signature, brand, |_| {}) + } + + /// Returns a snapshot's record of the host profile `profile`, captured + /// on a CPU with `signature`. + fn host_profile_record(profile: &CpuProfile, signature: u32) -> SnapshotCpuProfile { + SnapshotCpuProfile { + id: profile.id().to_owned(), + sha256: profile.digest().to_vec(), + profile: profile.encode(), + effective_cpuid: Vec::new(), + capture_cpu_signature: signature, + } + } + + /// Returns the host profile that `--cpu-profile host` derives on a + /// `backend` host of the test profile's CPU whose surface is the test + /// profile's, and a snapshot's record of it. + fn test_host_profile(backend: &str) -> (CpuProfile, SnapshotCpuProfile) { + let fingerprint = test_fingerprint(backend, TEST_SIGNATURE, ""); + let profile = cpu_profile::derive_host_profile(&fingerprint).unwrap(); + let record = host_profile_record(&profile, TEST_SIGNATURE); + (profile, record) + } + + #[test] + fn restore_takes_a_host_profile_from_the_snapshot() { + let (profile, record) = test_host_profile("whp"); + let host = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let select = |requested: &str, host, restored| { + selected(select_on_host(requested, host, restored, &NO_HOOKS)) + }; + assert_eq!(restored_host_profile(&record), Some(record.clone())); + let selected_profile = select(profile.id(), &host, Some(&record)).unwrap(); + assert!(selected_profile.is_host()); + assert_eq!(*selected_profile, profile); + assert_eq!( + selected_profile.record().encoding, + record.profile.as_slice() + ); + assert_eq!( + selected_profile.record().digest.as_slice(), + record.sha256.as_slice() + ); + + // Each VM takes its own snapshot's host profile, even one of the same + // ID, here derived from another backend's fingerprint. + let (other, other_record) = test_host_profile("kvm"); + assert_eq!(other.id(), profile.id()); + let reselected = select(other.id(), &host, Some(&other_record)).unwrap(); + assert_eq!(*reselected, other); + assert_ne!(reselected.record().digest, selected_profile.record().digest); + + let error_code = |result: Result| result.unwrap_err().code; + // Only the snapshot of a host profile carries it. + assert_eq!( + error_code(select(profile.id(), &host, None)), + ProfileErrorCode::ProfileUnknown + ); + let mut renamed = record.clone(); + renamed.id = "intel.host.v2".to_owned(); + assert_eq!( + error_code(select(profile.id(), &host, Some(&renamed))), + ProfileErrorCode::ProfileUnknown + ); + // A host profile serves only its own CPU model and stepping. + let elsewhere = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE + 1); + assert_eq!( + error_code(select(profile.id(), &elsewhere, Some(&record))), + ProfileErrorCode::CpuGeneration + ); + // A pinned profile's document under the host profile's ID. + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + let mut swapped = record.clone(); + swapped.profile = pinned.encoding.to_vec(); + swapped.sha256 = pinned.digest.to_vec(); + assert_eq!( + error_code(select(profile.id(), &host, Some(&swapped))), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's snapshot needs no record: its ID selects it. + let mut pinned_snapshot = swapped.clone(); + pinned_snapshot.id = TEST_PROFILE.to_owned(); + assert_eq!(restored_host_profile(&pinned_snapshot), None); + let pinned_profile = select(TEST_PROFILE, &host, None).unwrap(); + assert!(!pinned_profile.is_host()); + assert_eq!(pinned_profile.record(), pinned); + } + + /// A snapshot of a cold boot whose `auto` fell back records the host + /// profile, which a restore takes from the snapshot, as it takes any + /// host profile's, with any test hooks: a restore never falls back, and + /// serves only the capture host's CPU model and stepping. + #[test] + fn restore_takes_a_fallback_profile_from_the_snapshot() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fingerprint = test_fingerprint("kvm", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let fallback = fall_back(&host, || Ok(fingerprint)).unwrap(); + let record = host_profile_record(&fallback, GRANITE_RAPIDS); + assert_eq!(fallback.record().encoding, record.profile.as_slice()); + assert_eq!( + fallback.record().digest.as_slice(), + record.sha256.as_slice() + ); + assert_eq!(restored_host_profile(&record), Some(record.clone())); + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let restored = + selected(select_on_host(fallback.id(), &host, Some(&record), hooks)).unwrap(); + assert!(restored.is_host()); + assert_eq!(*restored, *fallback); + assert_eq!(restored.record(), fallback.record()); + + let stepping_2 = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS + 1); + assert_eq!( + selected(select_on_host( + fallback.id(), + &stepping_2, + Some(&record), + hooks + )) + .unwrap_err() + .code, + ProfileErrorCode::CpuGeneration + ); + } + } +} diff --git a/openvmm/openvmm_core/src/worker/memory_layout.rs b/openvmm/openvmm_core/src/worker/memory_layout.rs index 1db9e1a07a0..adb552ac062 100644 --- a/openvmm/openvmm_core/src/worker/memory_layout.rs +++ b/openvmm/openvmm_core/src/worker/memory_layout.rs @@ -16,6 +16,8 @@ //! VTL2 chipset MMIO. Callers express sizing intent; the resolver places //! everything and derives the effective MMIO gaps for [`MemoryLayout`]. +mod microvm; + use super::vm_loaders::igvm::Vtl2MemoryLayoutRequest; use anyhow::Context; use anyhow::bail; @@ -119,6 +121,10 @@ pub(super) struct MemoryLayoutInput<'a> { /// memory-less nodes (e.g. device-only NUMA nodes). The request /// order is the vnode assignment order. pub node_mem_sizes: &'a [u64], + /// Optional single-node RAM capacity used for restore-time expansion. + /// The layout reserves this full amount while publishing only + /// `node_mem_sizes[0]` as guest-visible RAM. + pub memory_capacity: Option, /// Chipset MMIO sizing from the manifest builder. pub layout: vmm_core_defs::LayoutConfig, /// PCIe root complex address-space intents. These are resolved by this @@ -156,6 +162,7 @@ pub(super) fn resolve_memory_layout( input: MemoryLayoutInput<'_>, ) -> anyhow::Result { validate_node_mem_sizes(input.node_mem_sizes)?; + microvm::validate_memory_capacity(input.node_mem_sizes, input.memory_capacity)?; let mut ram_ranges_by_node = vec![Vec::new(); input.node_mem_sizes.len()]; let mut pcie_root_complex_ranges = input @@ -380,6 +387,7 @@ pub(super) fn resolve_memory_layout( if ram_size == 0 { continue; } + let ram_size = microvm::layout_ram_size(vnode, ram_size, input.memory_capacity); let ram_alignment = if ram_size < GB { TWO_MB } else { GB }; builder.ram(format!("ram{vnode}"), ram_ranges, ram_size, ram_alignment); } @@ -480,6 +488,7 @@ pub(super) fn resolve_memory_layout( } } + let ram_ranges_by_node = microvm::active_ram_ranges(ram_ranges_by_node, input.node_mem_sizes); let ram = ram_ranges_by_node .into_iter() .enumerate() @@ -641,6 +650,7 @@ mod tests { ) -> MemoryLayoutInput<'_> { MemoryLayoutInput { node_mem_sizes, + memory_capacity: None, layout: DEFAULT_LAYOUT, pcie_root_complexes: &[], virtio_mmio_count: 0, diff --git a/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs new file mode 100644 index 00000000000..6e49143e41c --- /dev/null +++ b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM memory-capacity layout support. + +use memory_range::MemoryRange; + +pub(super) fn validate_memory_capacity( + node_mem_sizes: &[u64], + memory_capacity: Option, +) -> anyhow::Result<()> { + if let Some(memory_capacity) = memory_capacity { + anyhow::ensure!( + node_mem_sizes.len() == 1, + "RAM capacity requires a single NUMA node" + ); + anyhow::ensure!( + memory_capacity >= node_mem_sizes[0], + "RAM capacity {memory_capacity:#x} is below active RAM size {:#x}", + node_mem_sizes[0] + ); + anyhow::ensure!( + memory_capacity.is_multiple_of(super::PAGE_SIZE), + "RAM capacity {memory_capacity:#x} is not page-aligned" + ); + } + Ok(()) +} + +pub(super) fn layout_ram_size(vnode: usize, active_size: u64, memory_capacity: Option) -> u64 { + if vnode == 0 { + memory_capacity.unwrap_or(active_size) + } else { + active_size + } +} + +/// Trims each node's layout RAM to the guest-visible active RAM, excluding the +/// reserved restore-time capacity. +pub(super) fn active_ram_ranges( + ranges_by_node: Vec>, + node_mem_sizes: &[u64], +) -> Vec> { + ranges_by_node + .into_iter() + .zip(node_mem_sizes) + .map(|(ranges, &active_size)| { + memory_range_prefix(&ranges, active_size).expect("layout RAM must cover active RAM") + }) + .collect() +} + +fn memory_range_prefix(ranges: &[MemoryRange], size: u64) -> anyhow::Result> { + let mut remaining = size; + let mut prefix = Vec::new(); + for range in ranges { + if remaining == 0 { + break; + } + let length = range.len().min(remaining); + prefix.push(MemoryRange::new(range.start()..range.start() + length)); + remaining -= length; + } + anyhow::ensure!(remaining == 0, "RAM ranges do not cover requested prefix"); + Ok(prefix) +} + +#[cfg(test)] +mod tests { + use super::super::*; + use test_with_tracing::test; + + const MB: u64 = 1024 * 1024; + #[cfg(guest_arch = "x86_64")] + const DEFAULT_CHIPSET_LOW_MMIO_SIZE: u32 = 128 * 1024 * 1024; + #[cfg(guest_arch = "aarch64")] + const DEFAULT_CHIPSET_LOW_MMIO_SIZE: u32 = 512 * 1024 * 1024; + const DEFAULT_CHIPSET_HIGH_MMIO_SIZE: u64 = 512 * 1024 * 1024; + + const DEFAULT_LAYOUT: vmm_core_defs::LayoutConfig = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: DEFAULT_CHIPSET_LOW_MMIO_SIZE, + chipset_high_mmio_size: DEFAULT_CHIPSET_HIGH_MMIO_SIZE, + vtl2_chipset_mmio_size: 0, + }; + + fn input( + node_mem_sizes: &[u64], + vtl2_layout: Option, + ) -> MemoryLayoutInput<'_> { + MemoryLayoutInput { + node_mem_sizes, + memory_capacity: None, + layout: DEFAULT_LAYOUT, + pcie_root_complexes: &[], + virtio_mmio_count: 0, + pcie_ecam_below_4gb: false, + vtl2_layout, + ram_start_address: 0, + vtl2_framebuffer_size: 0, + physical_address_size: 46, + } + } + + fn resolve(input: MemoryLayoutInput<'_>) -> MemoryLayout { + resolve_memory_layout(input).unwrap().memory_layout + } + + #[test] + fn microvm_ram_ends_at_3gb_and_resumes_at_4gb() { + let mut config = input(&[4 * GB], None); + config.layout = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: GB as u32, + chipset_high_mmio_size: 0, + vtl2_chipset_mmio_size: 0, + }; + let layout = resolve(config); + assert_eq!( + layout + .ram() + .iter() + .map(|range| range.range) + .collect::>(), + [ + MemoryRange::new(0..3 * GB), + MemoryRange::new(4 * GB..5 * GB), + ] + ); + } + + #[test] + fn memory_capacity_reserves_aperture_without_publishing_it_as_ram() { + let mut config = input(&[512 * MB], None); + config.memory_capacity = Some(2 * GB); + config.layout = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: GB as u32, + chipset_high_mmio_size: GB, + vtl2_chipset_mmio_size: 0, + }; + let layout = config.layout.clone(); + + let resolved = resolve_memory_layout(config).unwrap(); + + assert_eq!( + resolved.memory_layout.ram(), + &[MemoryRangeWithNode { + range: MemoryRange::new(0..512 * MB), + vnode: 0, + }] + ); + let reserved_high_mmio_start = resolved.chipset_mmio.high.start(); + + let mut expanded = input(&[GB], None); + expanded.memory_capacity = Some(2 * GB); + expanded.layout = layout; + let expanded = resolve_memory_layout(expanded).unwrap(); + assert_eq!( + expanded.memory_layout.ram(), + &[MemoryRangeWithNode { + range: MemoryRange::new(0..GB), + vnode: 0, + }] + ); + assert_eq!(expanded.chipset_mmio.high.start(), reserved_high_mmio_start); + } +} diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs b/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs index 258c0a8341c..2bde66fd7e6 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs @@ -35,6 +35,10 @@ pub enum Error { Efi(#[source] guestmem::GuestMemoryError), #[error("failed to finalize SNP VMSA")] SnpVmsa(#[source] anyhow::Error), + #[error("Linux kernel command line contains an embedded NUL")] + CommandLineNul(#[source] std::ffi::NulError), + #[error("MP-table Linux direct boot does not support isolation")] + MpTableIsolation, } struct Aarch64EfiInfo { @@ -191,7 +195,7 @@ pub fn load_linux_x86( size: initrd_size, }); - let cmdline = CString::new(cfg.cmdline).unwrap(); + let cmdline = CString::new(cfg.cmdline).map_err(Error::CommandLineNul)?; let snp = match cfg.isolation { KernelIsolationConfig::None => None, KernelIsolationConfig::Snp(snp) => Some(snp), diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs new file mode 100644 index 00000000000..d850a721792 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM-specific Linux boot configuration. + +use super::linux::Error as LinuxError; +use super::linux::KernelConfig; +use super::linux::KernelIsolationConfig; +use guestmem::GuestMemory; +use loader::importer::X86Register; +use loader::linux::InitrdAddressType; +use loader::linux::InitrdConfig; +use memory_range::MemoryRange; +use std::ffi::CString; +use std::io::Seek; +use vm_loader::InitialLoad; +use vm_loader::Loader; + +#[cfg_attr(not(guest_arch = "x86_64"), expect(dead_code))] +pub fn load_linux_x86_mptable( + cfg: &KernelConfig<'_>, + gm: &GuestMemory, + apic_ids: &[u32], + level_triggered_irqs: &[u32], + reserved_memory_ranges: &[MemoryRange], +) -> Result, LinuxError> { + if !matches!(cfg.isolation, KernelIsolationConfig::None) { + return Err(LinuxError::MpTableIsolation); + } + + let mut kernel_file = cfg.kernel; + let (mut initrd_reader, initrd_size) = if let Some(mut initrd_file) = cfg.initrd.as_ref() { + initrd_file.rewind().map_err(LinuxError::InitRd)?; + let size = initrd_file + .seek(std::io::SeekFrom::End(0)) + .map_err(LinuxError::InitRd)?; + (Some(initrd_file), size) + } else { + (None, 0) + }; + let initrd_config = initrd_reader.as_mut().map(|reader| InitrdConfig { + initrd_address: InitrdAddressType::AfterKernel, + initrd: reader, + size: initrd_size, + }); + let cmdline = CString::new(cfg.cmdline).map_err(LinuxError::CommandLineNul)?; + let mut loader = Loader::new(gm.clone(), cfg.mem_layout, hvdef::Vtl::Vtl0); + loader::linux::microvm::load_x86_mptable( + &mut loader, + &mut kernel_file, + initrd_config, + &cmdline, + cfg.mem_layout, + &loader::mptable::MpTableConfig { + apic_ids, + level_triggered_irqs, + }, + reserved_memory_ranges, + ) + .map_err(LinuxError::Loader)?; + Ok(loader.initial_regs_and_page_imports()) +} diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs b/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs index dcb574a2dce..603e0e026ad 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs @@ -5,5 +5,6 @@ pub mod igvm; pub mod linux; +pub mod microvm; pub mod pcat; pub mod uefi; diff --git a/openvmm/openvmm_defs/Cargo.toml b/openvmm/openvmm_defs/Cargo.toml index ba75221295b..b135f2f55ad 100644 --- a/openvmm/openvmm_defs/Cargo.toml +++ b/openvmm/openvmm_defs/Cargo.toml @@ -7,13 +7,16 @@ edition.workspace = true rust-version.workspace = true [dependencies] +chipset_resources.workspace = true hypervisor_resources.workspace = true openvmm_pcat_locator.workspace = true # vmcore memory_range = { workspace = true, features = ["mesh"] } +state_unit.workspace = true vm_resource.workspace = true vmgs_resources.workspace = true +vmcore.workspace = true vmotherboard.workspace = true floppy_resources.workspace = true diff --git a/openvmm/openvmm_defs/src/config.rs b/openvmm/openvmm_defs/src/config.rs index d12c16d1a97..131d4641043 100644 --- a/openvmm/openvmm_defs/src/config.rs +++ b/openvmm/openvmm_defs/src/config.rs @@ -66,6 +66,10 @@ pub struct Config { pub layout: vmm_core_defs::LayoutConfig, // This is used for testing. TODO: resourcify, and also store this in VMGS. pub rtc_delta_milliseconds: i64, + /// The versioned guest-visible machine contract. + pub machine_profile: crate::microvm::MachineProfile, + /// Configuration specific to the microVM machine profile. + pub microvm: crate::microvm::MicrovmConfig, } pub const DEFAULT_GIC_DISTRIBUTOR_BASE: u64 = 0xFFFF_0000; @@ -121,6 +125,8 @@ pub enum LinuxDirectBootMode { /// an EFI system table so the kernel enters its ACPI code path. On x86, /// ACPI tables are always provided via the zero page. Acpi, + /// Intel MP 1.4 tables for ACPI-free x86-64 microVM discovery. + MpTable, } /// Isolation-specific settings for Linux direct boot. diff --git a/openvmm/openvmm_defs/src/lib.rs b/openvmm/openvmm_defs/src/lib.rs index 158f5cff881..9e71205aa56 100644 --- a/openvmm/openvmm_defs/src/lib.rs +++ b/openvmm/openvmm_defs/src/lib.rs @@ -8,5 +8,8 @@ pub mod config; pub mod entrypoint; +pub mod microvm; +pub mod profile; pub mod rpc; +pub mod time_abi; pub mod worker; diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs new file mode 100644 index 00000000000..acf13ae32f5 --- /dev/null +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -0,0 +1,2760 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM machine profile definitions. + +use crate::config::ArchTopologyConfig; +use crate::config::Config; +use crate::config::LinuxDirectBootMode; +use crate::config::LinuxIsolationConfig; +use crate::config::LoadMode; +use crate::config::SmbiosBiosOverrides; +use crate::config::SmbiosConfig; +use crate::config::SmbiosSystemOverrides; +use crate::config::VirtioBus; +use crate::config::X2ApicConfig; +use crate::config::X86TopologyConfig; +use guid::Guid; +use memory_range::MemoryRange; +use mesh::MeshPayload; +use net_backend_resources::mac_address::MacAddress; +use std::fmt::Write as _; +use vmotherboard::options::BaseChipsetManifest; + +/// The persisted microVM ABI version, including optional image slots. +pub const MICROVM_ABI_VERSION_2: u32 = 2; +/// Fixed image-slot capacity when enabled. +pub const MICROVM_IMAGE_SLOT_CAPACITY: u8 = 4; +/// Linux memory-block granularity used for microVM restore-time expansion. +pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = 128 * 1024 * 1024; + +/// Returns whether a processor count is valid for the microVM. +pub const fn microvm_processor_count_supported(processor_count: u32) -> bool { + matches!(processor_count, 1 | 2 | 4 | 8) +} + +/// Command line owned by the microVM profile. +pub const MICROVM_BASE_COMMAND_LINE: &str = "earlycon=xe9 console=hvc0 reboot=t panic=-1"; +/// Command line when the microVM virtio console is present. +pub const MICROVM_CONSOLE_COMMAND_LINE: &str = "earlycon=xe9 console=hvc1 reboot=t panic=-1"; +/// Maximum microVM command-line size, including its trailing NUL. +pub const MICROVM_COMMAND_LINE_MAX_SIZE: usize = 64 * 1024; +/// Fixed distro virtio-blk MMIO base. +pub const MICROVM_VIRTIO_BLK_MMIO_BASE: u64 = 0xd000_3000; +/// Reserved microVM virtio-net MMIO base. +pub const MICROVM_VIRTIO_NET_MMIO_BASE: u64 = 0xd000_0000; +/// Reserved MMIO base of the first microVM virtio-fs slot. +pub const MICROVM_VIRTIO_FS_MMIO_BASE: u64 = 0xd000_1000; +/// Reserved microVM virtio-console MMIO base. +pub const MICROVM_VIRTIO_CONSOLE_MMIO_BASE: u64 = 0xd000_2000; +/// Fixed microVM control virtio-console MMIO base. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE: u64 = 0xd000_7000; +/// Fixed MMIO base of the second microVM virtio-fs slot. +pub const MICROVM_VIRTIO_FS1_MMIO_BASE: u64 = 0xd000_8000; +/// Fixed microVM image-slot MMIO bases in slot order. +/// +/// The first image slot uses the second virtio-fs slot's window, so a VM with +/// image slots attaches at most one filesystem. +pub const MICROVM_IMAGE_SLOT_MMIO_BASES: [u64; MICROVM_IMAGE_SLOT_CAPACITY as usize] = [ + MICROVM_VIRTIO_FS1_MMIO_BASE, + 0xd000_9000, + 0xd000_a000, + 0xd000_b000, +]; +/// Fixed microVM virtio transport window length. +pub const MICROVM_VIRTIO_MMIO_LEN: u64 = 0x1000; +/// Fixed distro virtio-blk interrupt. +pub const MICROVM_VIRTIO_BLK_IRQ: u32 = 4; +/// Fixed virtio-blk interrupt for the runtime lower layer. +/// +/// IRQ 8 is exclusively owned by the microVM RTC. +pub const MICROVM_VIRTIO_RUNTIME_BLK_IRQ: u32 = 12; +/// Fixed virtio-blk interrupt for the custom lower layer. +pub const MICROVM_VIRTIO_CUSTOM_BLK_IRQ: u32 = 9; +/// Fixed virtio-blk interrupt for the writable scratch layer. +pub const MICROVM_VIRTIO_SCRATCH_BLK_IRQ: u32 = 11; +/// Fixed microVM virtio-console interrupt. +pub const MICROVM_VIRTIO_CONSOLE_IRQ: u32 = 7; +/// Fixed microVM control virtio-console interrupt. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ: u32 = 3; +/// Fixed interrupt of the first microVM virtio-fs slot. +pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; +/// Fixed interrupt of the second microVM virtio-fs slot. +/// +/// The MP table routes only ISA interrupts, and no other device of any +/// backend uses IRQ 13 except the second image slot, which never coexists +/// with this slot. +pub const MICROVM_VIRTIO_FS1_IRQ: u32 = 13; +/// Fixed microVM virtio-net interrupt on KVM. +pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; +/// Fixed microVM virtio-net interrupt on WHP. +pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; +/// Fixed image-slot interrupts in slot order. +/// +/// The second image slot uses the second virtio-fs slot's interrupt. +pub const MICROVM_IMAGE_SLOT_IRQS: [u32; MICROVM_IMAGE_SLOT_CAPACITY as usize] = + [1, MICROVM_VIRTIO_FS1_IRQ, 14, 15]; +/// Exact microVM virtio-net feature mask: MAC and virtio version 1. +pub const MICROVM_VIRTIO_NET_FEATURES: u64 = (1 << 5) | (1 << 32); +/// Exact microVM virtio-fs feature mask: indirect descriptors, event index, +/// virtio version 1, and access-platform. +pub const MICROVM_VIRTIO_FS_FEATURES: u64 = (1 << 28) | (1 << 29) | (1 << 32) | (1 << 33); +/// MicroVM client console reconnect timeout. +pub const MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS: u64 = 5_000; +/// Resource identity of the microVM control virtio-console device. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_ID: &str = "virtio-control-console"; +/// Host-owned kernel command-line token identifying the control tty. +pub const MICROVM_CONTROL_TTY_COMMAND_LINE: &str = "nvx_control_tty=hvc2"; +/// MicroVM virtio MMIO reservations in stable device order. +pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 9] = [ + MICROVM_VIRTIO_NET_MMIO_BASE, + MICROVM_VIRTIO_FS_MMIO_BASE, + MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + MICROVM_VIRTIO_BLK_MMIO_BASE, + 0xd000_4000, + 0xd000_5000, + 0xd000_6000, + MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + MICROVM_VIRTIO_FS1_MMIO_BASE, +]; + +/// A fixed microVM virtio-fs slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MicrovmFilesystemSlot { + /// Stable identity of the slot's device and host attachment. + pub stable_id: &'static str, + /// Guest-visible virtio-fs mount tag. + pub tag: &'static str, + /// Base of the slot's virtio-mmio transport window. + pub mmio_base: u64, + /// The slot's edge-triggered interrupt. + pub irq: u32, +} + +impl MicrovmFilesystemSlot { + /// Returns the `virtio_mmio.device=` token that discovers this slot. + pub fn discovery_token(&self) -> String { + format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + self.mmio_base, self.irq + ) + } +} + +/// The fixed microVM virtio-fs slots. Filesystems occupy them in attachment +/// order. A cold boot always exposes the first slot, dormant when nothing is +/// attached to it; a later slot exists only with a filesystem attached. +pub const MICROVM_FILESYSTEM_SLOTS: [MicrovmFilesystemSlot; 2] = [ + MicrovmFilesystemSlot { + stable_id: "fs:microvm0", + tag: "microvm", + mmio_base: MICROVM_VIRTIO_FS_MMIO_BASE, + irq: MICROVM_VIRTIO_FS_IRQ, + }, + MicrovmFilesystemSlot { + stable_id: "fs:microvm1", + tag: "microvm1", + mmio_base: MICROVM_VIRTIO_FS1_MMIO_BASE, + irq: MICROVM_VIRTIO_FS1_IRQ, + }, +]; +/// Fixed sandbox virtio-blk MMIO slots in layer order. +pub const MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES: [u64; 4] = [ + MICROVM_VIRTIO_BLK_MMIO_BASE, + 0xd000_4000, + 0xd000_5000, + 0xd000_6000, +]; +/// Guest-physical base of the shared virtio interrupt-status page. +pub const MICROVM_SHARED_STATUS_PAGE_GPA: u64 = 0x3_0000; +/// Size of the shared virtio interrupt-status page. +pub const MICROVM_SHARED_STATUS_PAGE_SIZE: u64 = 0x1000; +/// Shared-status offset for virtio-net. +pub const MICROVM_VIRTIO_NET_STATUS_OFFSET: u64 = 0x00; +/// Shared-status offset for the first virtio-fs slot. +pub const MICROVM_VIRTIO_FS_STATUS_OFFSET: u64 = 0x04; +/// Shared-status offset for virtio-console. +pub const MICROVM_VIRTIO_CONSOLE_STATUS_OFFSET: u64 = 0x08; +/// Shared-status offset for the distro virtio-blk slot. +pub const MICROVM_VIRTIO_DISTRO_BLK_STATUS_OFFSET: u64 = 0x0c; +/// Shared-status offset for the runtime virtio-blk slot. +pub const MICROVM_VIRTIO_RUNTIME_BLK_STATUS_OFFSET: u64 = 0x10; +/// Shared-status offset for the custom virtio-blk slot. +pub const MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET: u64 = 0x14; +/// Shared-status offset for the scratch virtio-blk slot. +pub const MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET: u64 = 0x18; +/// Shared-status offset for the dedicated control virtio-console. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET: u64 = 0x1c; +/// Shared-status offset for the second virtio-fs slot. +pub const MICROVM_VIRTIO_FS1_STATUS_OFFSET: u64 = 0x20; +/// Shared-status offsets for image slots in slot order. The first image slot +/// shares the second virtio-fs slot's window and word. +pub const MICROVM_IMAGE_SLOT_STATUS_OFFSETS: [u64; MICROVM_IMAGE_SLOT_CAPACITY as usize] = + [MICROVM_VIRTIO_FS1_STATUS_OFFSET, 0x24, 0x28, 0x2c]; + +/// Returns the shared interrupt-status word for a fixed virtio-mmio slot. +pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { + let offset = match mmio_base { + MICROVM_VIRTIO_NET_MMIO_BASE => MICROVM_VIRTIO_NET_STATUS_OFFSET, + MICROVM_VIRTIO_FS_MMIO_BASE => MICROVM_VIRTIO_FS_STATUS_OFFSET, + MICROVM_VIRTIO_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONSOLE_STATUS_OFFSET, + MICROVM_VIRTIO_BLK_MMIO_BASE => MICROVM_VIRTIO_DISTRO_BLK_STATUS_OFFSET, + 0xd000_4000 => MICROVM_VIRTIO_RUNTIME_BLK_STATUS_OFFSET, + 0xd000_5000 => MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET, + 0xd000_6000 => MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET, + MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET, + // Also the first image slot's window and word. + MICROVM_VIRTIO_FS1_MMIO_BASE => MICROVM_VIRTIO_FS1_STATUS_OFFSET, + 0xd000_9000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[1], + 0xd000_a000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[2], + 0xd000_b000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[3], + _ => return None, + }; + Some(MICROVM_SHARED_STATUS_PAGE_GPA + offset) +} +/// The microVM publishes no level-triggered virtio IRQs in its MP table. +pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; + +/// The stable role of a microVM sandbox block device. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum MicrovmSandboxBlockRole { + /// The lowest, widest-shared read-only layer. + Distro, + /// The read-only runtime layer above the distro layer. + Runtime, + /// The optional read-only customer layer above the runtime layer. + Custom, + /// The writable overlayfs upper and work directories. + Scratch, +} + +impl MicrovmSandboxBlockRole { + /// Returns the canonical manifest and CLI name of this role. + pub const fn as_str(self) -> &'static str { + match self { + Self::Distro => "distro", + Self::Runtime => "runtime", + Self::Custom => "custom", + Self::Scratch => "scratch", + } + } + + /// Returns the role's fixed virtio-mmio address. + pub const fn mmio_base(self) -> u64 { + MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[self.index()] + } + + /// Returns the role's fixed interrupt. + pub const fn irq(self) -> u32 { + match self { + Self::Distro => MICROVM_VIRTIO_BLK_IRQ, + Self::Runtime => MICROVM_VIRTIO_RUNTIME_BLK_IRQ, + Self::Custom => MICROVM_VIRTIO_CUSTOM_BLK_IRQ, + Self::Scratch => MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + } + } + + /// Returns whether the role must be read-only. + pub const fn is_read_only(self) -> bool { + !matches!(self, Self::Scratch) + } + + const fn index(self) -> usize { + match self { + Self::Distro => 0, + Self::Runtime => 1, + Self::Custom => 2, + Self::Scratch => 3, + } + } +} + +/// Returns the fixed virtio-blk feature mask for a sandbox role. +pub const fn microvm_sandbox_block_features(role: MicrovmSandboxBlockRole) -> u64 { + const RING_INDIRECT_DESC: u64 = 1 << 28; + const RING_EVENT_IDX: u64 = 1 << 29; + const VERSION_1: u64 = 1 << 32; + const ACCESS_PLATFORM: u64 = 1 << 33; + const BLK_SEG_MAX: u64 = 1 << 2; + const BLK_READ_ONLY: u64 = 1 << 5; + const BLK_SIZE: u64 = 1 << 6; + const BLK_FLUSH: u64 = 1 << 9; + const BLK_TOPOLOGY: u64 = 1 << 10; + + RING_INDIRECT_DESC + | RING_EVENT_IDX + | VERSION_1 + | ACCESS_PLATFORM + | BLK_SEG_MAX + | BLK_SIZE + | BLK_FLUSH + | BLK_TOPOLOGY + | if role.is_read_only() { + BLK_READ_ONLY + } else { + 0 + } +} + +/// Returns the fixed read-only feature mask of an active image slot. +pub const fn microvm_image_slot_features() -> u64 { + microvm_sandbox_block_features(MicrovmSandboxBlockRole::Distro) +} + +/// Returns the stable image-slot identity. +pub fn microvm_image_slot_name(index: u8) -> anyhow::Result { + anyhow::ensure!( + index < MICROVM_IMAGE_SLOT_CAPACITY, + "microVM image slot index {index} exceeds capacity {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + Ok(format!("image{index}")) +} + +/// Guest-visible activation contract for optional microVM image slots. +#[derive(MeshPayload, Debug, Clone, Copy, PartialEq, Eq)] +pub struct MicrovmImageSlotsConfig { + /// Number of slots active on the captured cold boot. + pub boot_count: u8, + /// Number of slots instantiated for this launch. + pub active_count: u8, +} + +impl MicrovmImageSlotsConfig { + /// Validates the boot and launch activation counts. + pub fn validate(self) -> anyhow::Result<()> { + anyhow::ensure!( + (1..=MICROVM_IMAGE_SLOT_CAPACITY).contains(&self.boot_count), + "microVM image-slot boot count must be between 1 and {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + anyhow::ensure!( + (self.boot_count..=MICROVM_IMAGE_SLOT_CAPACITY).contains(&self.active_count), + "microVM image-slot active count must be between the boot count and {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + Ok(()) + } +} + +/// The immutable role and access mode of a microVM sandbox block device. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub struct MicrovmSandboxBlockConfig { + /// The fixed guest-visible role and transport location. + pub role: MicrovmSandboxBlockRole, + /// Whether writes are rejected by the VMM. + pub read_only: bool, +} + +/// Static guest-visible network identity for the microVM NIC. +#[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] +pub struct MicrovmNetworkConfig { + /// Required cross-platform host-network implementation contract. + pub profile: MicrovmNetworkProfile, + pub guest_ipv4: std::net::Ipv4Addr, + pub prefix_length: u8, + pub derived_gateway_ipv4: std::net::Ipv4Addr, + pub guest_mac: MacAddress, + pub gateway_mac: MacAddress, +} + +/// Required host-network implementation contract for a microVM NIC. +/// +/// Profiles are explicit so snapshots never silently acquire different host +/// networking semantics on another supported hypervisor. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmNetworkProfile { + /// User-mode Consomme NAT on every supported host backend. + Portable, +} + +impl MicrovmNetworkProfile { + /// Returns the stable command-line and snapshot spelling of this profile. + pub const fn as_str(self) -> &'static str { + match self { + Self::Portable => "portable", + } + } +} + +/// Access policy for the microVM host filesystem. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemAccess { + /// Reject guest mutations before invoking host filesystem operations. + ReadOnly, + /// Permit the common cross-platform mutation contract. + ReadWrite, +} + +impl MicrovmFilesystemAccess { + /// Returns the command-line spelling of this policy. + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "ro", + Self::ReadWrite => "rw", + } + } + + /// Returns whether host filesystem mutations are allowed. + pub fn is_read_only(self) -> bool { + matches!(self, Self::ReadOnly) + } +} + +/// Host identity that performs the guest's operations on the microVM +/// filesystem. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemOwner { + /// Run every operation as the VMM process. + Vmm, + /// Run each operation as its guest caller's UID and GID, with UID 0 and + /// GID 0 squashed to the owner of the export root. Linux only. + Caller, +} + +impl MicrovmFilesystemOwner { + /// Returns the command-line and snapshot spelling of this mode. + pub fn as_str(self) -> &'static str { + match self { + Self::Vmm => "vmm", + Self::Caller => "caller", + } + } + + /// Returns whether operations run as their guest callers. + pub fn is_caller(self) -> bool { + matches!(self, Self::Caller) + } +} + +/// Guest-visible configuration for the microVM virtio-fs device. +#[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] +pub struct MicrovmFilesystemConfig { + /// Absolute guest path at which the initramfs mounts the filesystem. + pub guest_mount_target: String, + /// Snapshot-authoritative access policy. + pub access: MicrovmFilesystemAccess, + /// Canonical host-relative paths hidden by the virtio-fs server. + pub denied_paths: Vec, + /// Canonical host-relative paths inside denied paths that the virtio-fs + /// server exposes again. + pub allowed_paths: Vec, + /// Canonical host-relative paths that are the only parts of a read-write + /// filesystem that the guest can modify; none makes all of it writable. + pub writable_paths: Vec, + /// Snapshot-authoritative host identity of guest operations. + pub owner: MicrovmFilesystemOwner, +} + +/// A kind of path in the access policy of a microVM filesystem. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemPathKind { + /// A path hidden from the guest. + Denied, + /// A path inside a denied path that the guest can reach again. + Allowed, + /// One of the only paths that the guest can modify. + Writable, +} + +impl std::fmt::Display for MicrovmFilesystemPathKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Denied => "denied", + Self::Allowed => "allowed", + Self::Writable => "writable", + }) + } +} + +/// Returns whether the canonical relative path `inner` equals or is inside the +/// canonical relative path `outer`. +fn policy_path_contains(outer: &str, inner: &str) -> bool { + inner + .strip_prefix(outer) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/')) +} + +/// Returns whether the nearest denied or allowed path that contains `path` is +/// a denied path (`Some(true)`), an allowed path (`Some(false)`), or neither. +/// `path` itself counts only when `inclusive`. +fn nearest_policy_rule( + path: &str, + denied: &[String], + allowed: &[String], + inclusive: bool, +) -> Option { + denied + .iter() + .map(|rule| (rule, true)) + .chain(allowed.iter().map(|rule| (rule, false))) + .filter(|(rule, _)| (inclusive || *rule != path) && policy_path_contains(rule, path)) + .max_by_key(|(rule, _)| rule.len()) + .map(|(_, denied)| denied) +} + +/// Validates one list of canonical, host-relative policy paths in lexical +/// order, without regard to the filesystem's other policy paths. +pub fn validate_microvm_filesystem_policy_paths( + kind: MicrovmFilesystemPathKind, + paths: &[String], +) -> Result<(), InvalidMicrovmFilesystemConfig> { + if paths.len() > 128 { + return Err(InvalidMicrovmFilesystemConfig::TooManyPolicyPaths(kind)); + } + let total_bytes = paths + .iter() + .try_fold(0usize, |total, path| total.checked_add(path.len())) + .ok_or(InvalidMicrovmFilesystemConfig::PolicyPathsTooLarge(kind))?; + if total_bytes > 16 * 1024 { + return Err(InvalidMicrovmFilesystemConfig::PolicyPathsTooLarge(kind)); + } + for path in paths { + if path.is_empty() + || path.len() > 4096 + || path.starts_with('/') + || path.ends_with('/') + || path.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | ':') + }) + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(InvalidMicrovmFilesystemConfig::InvalidPolicyPath( + kind, + path.clone(), + )); + } + } + if paths.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths( + kind, + )); + } + Ok(()) +} + +impl MicrovmFilesystemConfig { + /// Validates and constructs the microVM filesystem configuration. + pub fn new( + guest_mount_target: String, + access: MicrovmFilesystemAccess, + ) -> Result { + if guest_mount_target.is_empty() + || !guest_mount_target.starts_with('/') + || guest_mount_target == "/" + || guest_mount_target.len() > 4096 + || guest_mount_target.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | '=') + }) + || guest_mount_target + .split('/') + .skip(1) + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(InvalidMicrovmFilesystemConfig::InvalidGuestTarget( + guest_mount_target, + )); + } + Ok(Self { + guest_mount_target, + access, + denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), + owner: MicrovmFilesystemOwner::Vmm, + }) + } + + /// Selects the host identity that performs the guest's operations. + pub fn with_owner(mut self, owner: MicrovmFilesystemOwner) -> Self { + self.owner = owner; + self + } + + /// Adds a canonical, non-overlapping denied-path policy. + pub fn with_denied_paths( + self, + denied_paths: Vec, + ) -> Result { + self.with_access_policy(denied_paths, Vec::new(), Vec::new()) + } + + /// Adds the complete access policy of the filesystem, as canonical + /// host-relative paths in lexical order. + /// + /// Denied paths hide subtrees, and allowed paths expose subtrees of denied + /// paths again. The nearest denied or allowed path that contains a denied + /// path must be an allowed path, if any, and the nearest one that contains + /// an allowed path must be a denied path. Writable paths, when present, are + /// the only parts of a read-write filesystem that the guest can modify; they + /// must not overlap or be inside a denied path that no allowed path exposes. + pub fn with_access_policy( + mut self, + denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, + ) -> Result { + validate_microvm_filesystem_policy_paths(MicrovmFilesystemPathKind::Denied, &denied_paths)?; + validate_microvm_filesystem_policy_paths( + MicrovmFilesystemPathKind::Allowed, + &allowed_paths, + )?; + validate_microvm_filesystem_policy_paths( + MicrovmFilesystemPathKind::Writable, + &writable_paths, + )?; + for path in &denied_paths { + if nearest_policy_rule(path, &denied_paths, &allowed_paths, false) == Some(true) { + return Err(InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths); + } + } + for path in &allowed_paths { + if denied_paths.contains(path) + || nearest_policy_rule(path, &denied_paths, &allowed_paths, false) != Some(true) + { + return Err(InvalidMicrovmFilesystemConfig::MisplacedAllowedPath( + path.clone(), + )); + } + } + if self.access.is_read_only() && !writable_paths.is_empty() { + return Err(InvalidMicrovmFilesystemConfig::ReadOnlyWritablePaths); + } + for (index, path) in writable_paths.iter().enumerate() { + if writable_paths + .iter() + .enumerate() + .any(|(other_index, other)| { + other_index != index && policy_path_contains(other, path) + }) + { + return Err(InvalidMicrovmFilesystemConfig::OverlappingWritablePaths); + } + if nearest_policy_rule(path, &denied_paths, &allowed_paths, true) == Some(true) { + return Err(InvalidMicrovmFilesystemConfig::HiddenWritablePath( + path.clone(), + )); + } + } + self.denied_paths = denied_paths; + self.allowed_paths = allowed_paths; + self.writable_paths = writable_paths; + Ok(self) + } + + /// Returns the pinned guest bootstrap command-line tokens of this + /// filesystem when it is attached to `slot`. + pub fn command_line_fragment(&self, slot: &MicrovmFilesystemSlot) -> String { + format!( + "virtfs_dir={} virtfs_tag={} virtfs_mode={}", + self.guest_mount_target, + slot.tag, + self.access.as_str() + ) + } +} + +/// Returns whether one guest mount target equals or contains the other. +fn microvm_guest_targets_overlap(left: &str, right: &str) -> bool { + let contains = |outer: &str, inner: &str| { + inner + .strip_prefix(outer) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/')) + }; + contains(left, right) || contains(right, left) +} + +/// Validates the filesystems attached to the fixed microVM virtio-fs slots, +/// in slot order: there are no more than the slots, and no guest mount target +/// equals or contains another, so no mount hides or shadows another. +pub fn validate_microvm_filesystems( + filesystems: &[MicrovmFilesystemConfig], +) -> Result<(), InvalidMicrovmFilesystemConfig> { + if filesystems.len() > MICROVM_FILESYSTEM_SLOTS.len() { + return Err(InvalidMicrovmFilesystemConfig::TooManyFilesystems); + } + for (index, filesystem) in filesystems.iter().enumerate() { + for other in &filesystems[..index] { + if microvm_guest_targets_overlap( + &other.guest_mount_target, + &filesystem.guest_mount_target, + ) { + return Err(InvalidMicrovmFilesystemConfig::OverlappingGuestTargets( + other.guest_mount_target.clone(), + filesystem.guest_mount_target.clone(), + )); + } + } + } + Ok(()) +} + +/// Error returned for an invalid microVM filesystem specification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum InvalidMicrovmFilesystemConfig { + /// The guest mount target is not a canonical absolute Linux path. + #[error( + "invalid guest mount target '{0}': expected an absolute non-root Linux path without empty, dot, parent, whitespace, backslash, or '=' components" + )] + InvalidGuestTarget(String), + /// A policy path was not a canonical relative path. + #[error( + "invalid {0} path '{1}': expected a relative path without empty, dot, parent, whitespace, backslash, or ':' components" + )] + InvalidPolicyPath(MicrovmFilesystemPathKind, String), + /// A policy-path list exceeded its count bound. + #[error("microVM filesystem permits at most 128 {0} paths")] + TooManyPolicyPaths(MicrovmFilesystemPathKind), + /// A policy-path list exceeded its aggregate byte bound. + #[error("microVM filesystem {0} paths exceed the 16-KiB aggregate limit")] + PolicyPathsTooLarge(MicrovmFilesystemPathKind), + /// A policy-path list was not unique and in canonical lexical order. + #[error("microVM filesystem {0} paths are not in canonical order")] + NonCanonicalPolicyPaths(MicrovmFilesystemPathKind), + /// A denied path was inside another denied path, with no allowed path + /// between them. + #[error("microVM filesystem denied paths overlap without an allowed path between them")] + OverlappingDeniedPaths, + /// An allowed path was not inside a denied path, or was inside another + /// allowed path with no denied path between them. + #[error( + "microVM filesystem allowed path '{0}' must be inside a denied path, with no other allowed path between them" + )] + MisplacedAllowedPath(String), + /// A read-only filesystem had writable paths. + #[error("a read-only microVM filesystem cannot have writable paths")] + ReadOnlyWritablePaths, + /// One writable path contained another. + #[error("microVM filesystem writable paths overlap")] + OverlappingWritablePaths, + /// A writable path was inside a denied path that no allowed path exposes. + #[error("microVM filesystem writable path '{0}' is hidden by a denied path")] + HiddenWritablePath(String), + /// More filesystems were attached than the microVM has virtio-fs slots. + #[error("microVM permits at most {} filesystems", MICROVM_FILESYSTEM_SLOTS.len())] + TooManyFilesystems, + /// One guest mount target equals or contains another. + #[error("microVM filesystem guest mount targets '{0}' and '{1}' overlap")] + OverlappingGuestTargets(String, String), +} + +impl MicrovmNetworkConfig { + /// Returns the subnet mask derived from `prefix_length`. + pub fn netmask(&self) -> std::net::Ipv4Addr { + std::net::Ipv4Addr::from(u32::MAX << (32 - self.prefix_length)) + } + + /// Returns the pinned NVX guest-bootstrap command-line tokens. + pub fn command_line_fragment(&self) -> String { + self.command_line_fragment_with_dns(false) + } + + /// Returns the pinned bootstrap tokens, optionally including gateway DNS. + pub fn command_line_fragment_with_dns(&self, gateway_dns: bool) -> String { + let dns = if gateway_dns { + format!(" virtnet_dns={}", self.derived_gateway_ipv4) + } else { + String::new() + }; + format!( + "virtnet_ip={} virtnet_mask={} virtnet_gw={}{}", + self.guest_ipv4, + self.netmask(), + self.derived_gateway_ipv4, + dns, + ) + } + + fn derive_mac(address: std::net::Ipv4Addr) -> MacAddress { + let [_, second, third, fourth] = address.octets(); + MacAddress::new([0x52, 0x54, 0x00, second, third, fourth]) + } +} + +/// Error returned for an invalid microVM static network specification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum InvalidMicrovmNetworkConfig { + #[error("expected /, for example 10.0.0.2/24")] + InvalidFormat, + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + #[error("invalid IPv4 prefix '{0}'")] + InvalidPrefix(String), + #[error("IPv4 prefix /{0} is outside the supported range /1 through /30")] + PrefixOutOfRange(u8), + #[error("guest IPv4 address {0} is the subnet network address")] + NetworkAddress(std::net::Ipv4Addr), + #[error("guest IPv4 address {0} is the subnet broadcast address")] + BroadcastAddress(std::net::Ipv4Addr), + #[error("guest IPv4 address {0} collides with the derived gateway")] + GatewayCollision(std::net::Ipv4Addr), +} + +impl std::str::FromStr for MicrovmNetworkConfig { + type Err = InvalidMicrovmNetworkConfig; + + fn from_str(spec: &str) -> Result { + let (address, prefix) = spec + .split_once('/') + .filter(|(_, prefix)| !prefix.contains('/')) + .ok_or(InvalidMicrovmNetworkConfig::InvalidFormat)?; + let guest_ipv4 = address + .parse::() + .map_err(|_| InvalidMicrovmNetworkConfig::InvalidAddress(address.to_owned()))?; + let prefix_length = prefix + .parse::() + .map_err(|_| InvalidMicrovmNetworkConfig::InvalidPrefix(prefix.to_owned()))?; + if !(1..=30).contains(&prefix_length) { + return Err(InvalidMicrovmNetworkConfig::PrefixOutOfRange(prefix_length)); + } + + let mask = u32::MAX << (32 - prefix_length); + let guest = u32::from(guest_ipv4); + let network = guest & mask; + let broadcast = network | !mask; + if guest == network { + return Err(InvalidMicrovmNetworkConfig::NetworkAddress(guest_ipv4)); + } + if guest == broadcast { + return Err(InvalidMicrovmNetworkConfig::BroadcastAddress(guest_ipv4)); + } + + let derived_gateway_ipv4 = std::net::Ipv4Addr::from(network + 1); + if guest_ipv4 == derived_gateway_ipv4 { + return Err(InvalidMicrovmNetworkConfig::GatewayCollision(guest_ipv4)); + } + + Ok(Self { + profile: MicrovmNetworkProfile::Portable, + guest_ipv4, + prefix_length, + derived_gateway_ipv4, + guest_mac: Self::derive_mac(guest_ipv4), + gateway_mac: Self::derive_mac(derived_gateway_ipv4), + }) + } +} + +/// Returns the pinned virtio-net IRQ for the selected microVM backend. +pub fn microvm_virtio_net_irq(hypervisor_id: Option<&str>) -> anyhow::Result { + match hypervisor_id { + Some("kvm" | "mshv") => Ok(MICROVM_VIRTIO_NET_KVM_IRQ), + Some("whp") => Ok(MICROVM_VIRTIO_NET_WHP_IRQ), + Some(other) => anyhow::bail!("microVM virtio-net does not support hypervisor '{other}'"), + None if cfg!(target_os = "linux") => Ok(MICROVM_VIRTIO_NET_KVM_IRQ), + None if cfg!(windows) => Ok(MICROVM_VIRTIO_NET_WHP_IRQ), + None => { + anyhow::bail!("microVM virtio-net requires an explicit KVM, MSHV, or WHP hypervisor") + } + } +} + +fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { + let bases = &MICROVM_VIRTIO_MMIO_BASES; + for (index, base) in bases.iter().copied().enumerate() { + let end = base + .checked_add(MICROVM_VIRTIO_MMIO_LEN) + .ok_or_else(|| anyhow::anyhow!("microVM virtio MMIO reservation overflows"))?; + anyhow::ensure!( + base >= 0xc000_0000 && end <= 0x1_0000_0000, + "microVM virtio MMIO reservation {index} is outside the fixed aperture" + ); + if let Some(next) = bases.get(index + 1) { + anyhow::ensure!(end <= *next, "microVM virtio MMIO reservations overlap"); + } + } + // The first image slot reuses the second virtio-fs slot's window; the + // others follow every fixed reservation. + anyhow::ensure!( + MICROVM_IMAGE_SLOT_MMIO_BASES[0] == MICROVM_VIRTIO_FS1_MMIO_BASE + && MICROVM_IMAGE_SLOT_STATUS_OFFSETS[0] == MICROVM_VIRTIO_FS1_STATUS_OFFSET, + "the first microVM image slot must reuse the second virtio-fs slot's window" + ); + let fixed_end = bases.iter().max().copied().unwrap_or_default() + MICROVM_VIRTIO_MMIO_LEN; + for (index, base) in MICROVM_IMAGE_SLOT_MMIO_BASES.iter().copied().enumerate() { + let end = base + .checked_add(MICROVM_VIRTIO_MMIO_LEN) + .ok_or_else(|| anyhow::anyhow!("microVM image-slot MMIO reservation overflows"))?; + anyhow::ensure!( + base >= 0xc000_0000 && end <= 0x1_0000_0000, + "microVM image-slot MMIO reservation {index} is outside the fixed aperture" + ); + anyhow::ensure!( + index == 0 || base >= fixed_end, + "microVM image-slot MMIO reservation {index} overlaps a fixed reservation" + ); + if let Some(next) = MICROVM_IMAGE_SLOT_MMIO_BASES.get(index + 1) { + anyhow::ensure!(end <= *next, "microVM image-slot MMIO reservations overlap"); + } + } + let mut previous_status_gpa = None; + for base in bases.iter().chain(&MICROVM_IMAGE_SLOT_MMIO_BASES[1..]) { + let status_gpa = microvm_virtio_status_gpa(*base).ok_or_else(|| { + anyhow::anyhow!("microVM virtio MMIO slot {base:#x} has no shared-status word") + })?; + anyhow::ensure!( + status_gpa.is_multiple_of(size_of::() as u64) + && status_gpa >= MICROVM_SHARED_STATUS_PAGE_GPA + && status_gpa + size_of::() as u64 + <= MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE, + "microVM shared-status word for slot {base:#x} is outside the reserved page" + ); + anyhow::ensure!( + previous_status_gpa.is_none_or(|previous| previous < status_gpa), + "microVM shared-status words are duplicated or out of order" + ); + previous_status_gpa = Some(status_gpa); + } + Ok(()) +} + +/// Returns whether the guest explicitly requests a RAM-backed overlay upper. +/// The opt-in permits a read-only distro block without a writable scratch block. +pub fn ramfs_overlay_requested(cmdline: &str) -> anyhow::Result { + // A bare key has no value, so it fails like any value other than ramfs. + let mut values = cmdline.split_ascii_whitespace().filter_map(|token| { + let (name, value) = token.split_once('=').unwrap_or((token, "")); + (name == "nvx_overlay_upper").then_some(value) + }); + match (values.next(), values.next()) { + (None, None) => Ok(false), + (Some("ramfs"), None) => Ok(true), + _ => anyhow::bail!("microVM nvx_overlay_upper must appear once and equal ramfs"), + } +} + +fn validate_microvm_sandbox_blocks( + blocks: &[MicrovmSandboxBlockConfig], + ramfs_overlay: bool, +) -> anyhow::Result<()> { + anyhow::ensure!( + blocks.len() <= MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES.len(), + "microVM permits at most three read-only layers and one writable scratch device" + ); + for (index, block) in blocks.iter().enumerate() { + anyhow::ensure!( + block.read_only == block.role.is_read_only(), + "microVM sandbox block role {:?} must be {}", + block.role, + if block.role.is_read_only() { + "read-only" + } else { + "writable" + } + ); + if let Some(previous) = index.checked_sub(1).and_then(|index| blocks.get(index)) { + anyhow::ensure!( + previous.role < block.role, + "microVM sandbox block roles must be unique and in fixed order" + ); + } + } + if ramfs_overlay { + anyhow::ensure!( + blocks.len() == 1 + && blocks[0].role == MicrovmSandboxBlockRole::Distro + && blocks[0].read_only, + "microVM RAM-backed overlay requires exactly one read-only distro block" + ); + } else if !blocks.is_empty() { + anyhow::ensure!( + blocks + .last() + .is_some_and(|block| block.role == MicrovmSandboxBlockRole::Scratch), + "microVM sandbox block topology requires a writable scratch device" + ); + } + Ok(()) +} + +/// Returns the number of virtio-fs slots present when the first slot is +/// reserved as `first_slot` and `attached` filesystems occupy the slots. +pub fn microvm_filesystem_slot_count(first_slot: bool, attached: usize) -> anyhow::Result { + anyhow::ensure!( + attached == 0 || first_slot, + "microVM filesystem policy requires the fixed virtio-fs slot" + ); + anyhow::ensure!( + attached <= MICROVM_FILESYSTEM_SLOTS.len(), + InvalidMicrovmFilesystemConfig::TooManyFilesystems + ); + Ok(if first_slot { attached.max(1) } else { 0 }) +} + +/// Appends sandbox virtio devices in fixed-address order. +/// +/// `filesystem_slot` reserves the first virtio-fs slot, and `filesystems` +/// occupy the virtio-fs slots in order. A virtio-fs slot after the first is +/// discovered only with a filesystem attached. `image_slots` adds the image +/// slots, which use the second virtio-fs slot's window and interrupt. +pub fn append_microvm_virtio_discovery( + cmdline: &mut String, + network: Option<(&MicrovmNetworkConfig, u32, bool)>, + filesystem_slot: bool, + filesystems: &[MicrovmFilesystemConfig], + has_console: bool, + has_control_console: bool, + blocks: &[MicrovmSandboxBlockConfig], + image_slots: Option, +) -> anyhow::Result<()> { + validate_microvm_sandbox_blocks(blocks, ramfs_overlay_requested(cmdline)?)?; + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } + anyhow::ensure!( + !cmdline.split_ascii_whitespace().any(|token| { + if has_control_console { + kernel_parameter_name_matches(token, "virtio_mmio.device") + } else { + token.starts_with("virtio_mmio.device=") + } + }), + "microVM command line already contains virtio-mmio discovery" + ); + let filesystem_slots = microvm_filesystem_slot_count(filesystem_slot, filesystems.len())?; + validate_microvm_filesystems(filesystems)?; + anyhow::ensure!( + image_slots.is_none() || filesystem_slots <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); + + use std::fmt::Write as _; + if let Some((_, irq, _)) = network { + anyhow::ensure!( + matches!(irq, MICROVM_VIRTIO_NET_KVM_IRQ | MICROVM_VIRTIO_NET_WHP_IRQ), + "microVM virtio-net IRQ {irq} is not part of the fixed machine contract" + ); + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" + )?; + } + if filesystem_slots > 0 { + write!( + cmdline, + " {}", + MICROVM_FILESYSTEM_SLOTS[0].discovery_token() + )?; + } + if has_console { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" + )?; + } + for block in blocks { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + block.role.mmio_base(), + block.role.irq() + )?; + } + if has_control_console { + anyhow::ensure!( + has_console, + "microVM control console requires the boot virtio-console" + ); + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ} {MICROVM_CONTROL_TTY_COMMAND_LINE}" + )?; + } + for slot in MICROVM_FILESYSTEM_SLOTS + .iter() + .take(filesystem_slots) + .skip(1) + { + write!(cmdline, " {}", slot.discovery_token())?; + } + if image_slots.is_some() { + for (index, base) in MICROVM_IMAGE_SLOT_MMIO_BASES.iter().enumerate() { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{base:#x}:{}", + MICROVM_IMAGE_SLOT_IRQS[index] + )?; + } + write!( + cmdline, + " microvm_image_slots={MICROVM_IMAGE_SLOT_CAPACITY}" + )?; + } + if let Some((network, _, gateway_dns)) = network { + write!( + cmdline, + " {}", + network.command_line_fragment_with_dns(gateway_dns) + )?; + } + for (filesystem, slot) in filesystems.iter().zip(&MICROVM_FILESYSTEM_SLOTS) { + write!(cmdline, " {}", filesystem.command_line_fragment(slot))?; + } + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit after device discovery" + ); + Ok(()) +} + +/// Validates the virtio-fs device inventory against the attached filesystems, +/// which occupy the first slots, and returns the number of virtio-fs devices. +/// Only the first slot may be present without a filesystem. +fn validate_microvm_filesystem_devices(config: &Config) -> anyhow::Result { + let filesystem_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtiofs") + .count(); + let filesystems = &config.microvm.filesystems; + anyhow::ensure!( + filesystem_count <= MICROVM_FILESYSTEM_SLOTS.len(), + "microVM permits at most {} virtio-fs devices", + MICROVM_FILESYSTEM_SLOTS.len() + ); + anyhow::ensure!( + filesystems.len() <= filesystem_count, + "microVM filesystem policy requires a virtio-fs device" + ); + anyhow::ensure!( + filesystem_count <= filesystems.len().max(1), + "microVM exposes a virtio-fs slot after the first only with a filesystem attached" + ); + anyhow::ensure!( + config.microvm.image_slots.is_none() || filesystem_count <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); + validate_microvm_filesystems(filesystems)?; + anyhow::ensure!( + !config.microvm.filesystem_bootstrap || !filesystems.is_empty(), + "microVM filesystem bootstrap requires an active filesystem policy" + ); + Ok(filesystem_count) +} + +fn validate_microvm_command_line( + config: &Config, + hypervisor_id: Option<&str>, +) -> anyhow::Result<()> { + let MachineProfile::Microvm = config.machine_profile else { + unreachable!("microVM command-line validation requires the microVM profile"); + }; + let cmdline = match &config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: LinuxDirectBootMode::MpTable, + .. + } => cmdline, + _ => anyhow::bail!("microVM requires Linux MP-table load mode"), + }; + anyhow::ensure!( + !cmdline.contains('\0'), + "microVM command line contains an embedded NUL" + ); + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM command line exceeds the 64-KiB ABI limit" + ); + + let tokens = cmdline.split_ascii_whitespace().collect::>(); + let has_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + let has_control_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == MICROVM_VIRTIO_CONTROL_CONSOLE_ID); + validate_microvm_control_console_command_line(&tokens, has_control_console)?; + let block_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtio-blk") + .count(); + let image_slot_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtio-blk-image-slot") + .count(); + let has_network = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-net"); + let filesystem_count = validate_microvm_filesystem_devices(config)?; + anyhow::ensure!( + has_network == config.microvm.network.is_some(), + "microVM virtio-net device and static network identity must be configured together" + ); + validate_microvm_sandbox_blocks( + &config.microvm.sandbox_blocks, + ramfs_overlay_requested(cmdline)?, + )?; + anyhow::ensure!( + block_count == config.microvm.sandbox_blocks.len(), + "microVM sandbox block roles do not match the virtio-blk device inventory" + ); + if let Some(image_slots) = config.microvm.image_slots { + image_slots.validate()?; + anyhow::ensure!( + image_slot_count == MICROVM_IMAGE_SLOT_CAPACITY as usize, + "microVM requires exactly {MICROVM_IMAGE_SLOT_CAPACITY} configured image-slot transports" + ); + } else { + anyhow::ensure!( + image_slot_count == 0, + "microVM does not permit image-slot transports without image-slot configuration" + ); + } + anyhow::ensure!( + !has_control_console || has_console, + "microVM control console requires the boot console" + ); + let base_tokens = if has_console { + MICROVM_CONSOLE_COMMAND_LINE + } else { + MICROVM_BASE_COMMAND_LINE + } + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.starts_with(&base_tokens), + "microVM command line does not begin with the ABI base tokens" + ); + for prefix in [ + "earlycon=", + "console=", + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtfs_dir=", + "virtfs_tag=", + "virtfs_mode=", + "microvm_image_slots=", + ] { + let count = if prefix == "virtio_mmio.device=" && has_control_console { + tokens + .iter() + .filter(|token| kernel_parameter_name_matches(token, "virtio_mmio.device")) + .count() + } else { + tokens + .iter() + .filter(|token| token.starts_with(prefix)) + .count() + }; + let expected = match prefix { + "virtio_mmio.device=" => config.virtio_devices.len(), + "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), + "virtfs_dir=" | "virtfs_tag=" | "virtfs_mode=" => { + if config.microvm.filesystem_bootstrap { + config.microvm.filesystems.len() + } else { + 0 + } + } + "microvm_image_slots=" => usize::from(config.microvm.image_slots.is_some()), + _ => 1, + }; + anyhow::ensure!( + count == expected, + "microVM command line has an invalid number of {prefix} tokens" + ); + } + let dns_tokens = tokens + .iter() + .filter(|token| token.starts_with("virtnet_dns=")) + .copied() + .collect::>(); + anyhow::ensure!( + dns_tokens.len() <= 1, + "microVM command line has an invalid number of virtnet_dns= tokens" + ); + if let Some(dns) = dns_tokens.first() { + let network = config + .microvm + .network + .as_ref() + .ok_or_else(|| anyhow::anyhow!("microVM DNS bootstrap requires virtio-net"))?; + anyhow::ensure!( + **dns == format!("virtnet_dns={}", network.derived_gateway_ipv4), + "microVM DNS bootstrap does not match the portable gateway" + ); + } + let mut expected_discovery = Vec::new(); + if has_network { + let irq = microvm_virtio_net_irq(hypervisor_id)?; + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" + )); + } + if filesystem_count > 0 { + expected_discovery.push(MICROVM_FILESYSTEM_SLOTS[0].discovery_token()); + } + if has_console { + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" + )); + } + expected_discovery.extend(config.microvm.sandbox_blocks.iter().map(|block| { + format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + block.role.mmio_base(), + block.role.irq() + ) + })); + if has_control_console { + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ}" + )); + expected_discovery.push(MICROVM_CONTROL_TTY_COMMAND_LINE.to_owned()); + } + expected_discovery.extend( + MICROVM_FILESYSTEM_SLOTS + .iter() + .take(filesystem_count) + .skip(1) + .map(MicrovmFilesystemSlot::discovery_token), + ); + if config.microvm.image_slots.is_some() { + expected_discovery.extend( + MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .zip(MICROVM_IMAGE_SLOT_IRQS) + .map(|(base, irq)| { + format!("virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{base:#x}:{irq}") + }), + ); + expected_discovery.push(format!("microvm_image_slots={MICROVM_IMAGE_SLOT_CAPACITY}")); + } + if let Some(network) = &config.microvm.network { + expected_discovery.extend( + network + .command_line_fragment_with_dns(!dns_tokens.is_empty()) + .split_ascii_whitespace() + .map(str::to_owned), + ); + } + if config.microvm.filesystem_bootstrap { + for (filesystem, slot) in config + .microvm + .filesystems + .iter() + .zip(&MICROVM_FILESYSTEM_SLOTS) + { + expected_discovery.extend( + filesystem + .command_line_fragment(slot) + .split_ascii_whitespace() + .map(str::to_owned), + ); + } + } + if !expected_discovery.is_empty() { + anyhow::ensure!( + tokens.ends_with( + &expected_discovery + .iter() + .map(String::as_str) + .collect::>() + ), + "microVM virtio discovery tokens are not in fixed-address order" + ); + } + Ok(()) +} + +/// Builds the microVM command line and rejects profile-owned user tokens. +pub fn build_microvm_command_line( + user_args: &[String], + has_console: bool, +) -> anyhow::Result { + build_microvm_command_line_inner(user_args, has_console, false) +} + +/// Builds the microVM command line with control-console tokens reserved. +pub fn build_microvm_control_command_line( + user_args: &[String], + has_console: bool, +) -> anyhow::Result { + build_microvm_command_line_inner(user_args, has_console, true) +} + +/// Appends the host-owned processor capacity to a microVM command line. +pub fn append_microvm_processor_limit( + cmdline: &mut String, + processor_count: u32, +) -> anyhow::Result<()> { + anyhow::ensure!( + microvm_processor_count_supported(processor_count), + "microVM does not support {processor_count} vCPUs" + ); + write!(cmdline, " nr_cpus={processor_count}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + +/// Appends the host-owned fixed workload identity to a microVM command line. +pub fn append_microvm_workload_identity( + cmdline: &mut String, + uid: u32, + gid: u32, +) -> anyhow::Result<()> { + anyhow::ensure!(uid != 0, "microVM workload UID must be nonzero"); + anyhow::ensure!(gid != 0, "microVM workload GID must be nonzero"); + write!(cmdline, " nvx_workload_uid={uid} nvx_workload_gid={gid}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + +/// Appends the host-owned workload lifecycle to a microVM command line. +pub fn append_microvm_lifecycle(cmdline: &mut String, managed: bool) -> anyhow::Result<()> { + let lifecycle = if managed { "managed" } else { "one-shot" }; + write!(cmdline, " nvx_lifecycle={lifecycle}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + +fn kernel_parameter_name_matches(token: &str, expected: &str) -> bool { + let Some((name, _)) = token.split_once('=') else { + return false; + }; + name.len() == expected.len() + && name + .bytes() + .zip(expected.bytes()) + .all(|(actual, expected)| actual == expected || (actual == b'-' && expected == b'_')) +} + +fn validate_microvm_control_console_command_line( + tokens: &[&str], + has_control_console: bool, +) -> anyhow::Result<()> { + if !has_control_console { + return Ok(()); + } + anyhow::ensure!( + !tokens.iter().any(|token| token.contains('"')) && !tokens.contains(&"--"), + "microVM control-console command line cannot contain quotes or the kernel argument delimiter" + ); + anyhow::ensure!( + !tokens + .iter() + .any(|token| kernel_parameter_name_matches(token, "driver_async_probe")), + "microVM control-console command line cannot override driver probe ordering" + ); + anyhow::ensure!( + tokens + .iter() + .filter(|token| kernel_parameter_name_matches(token, "nvx_control_tty")) + .count() + == 1, + "microVM command line must contain exactly one nvx_control_tty= token" + ); + Ok(()) +} + +fn build_microvm_command_line_inner( + user_args: &[String], + has_console: bool, + reserve_control_console: bool, +) -> anyhow::Result { + for arg in user_args { + if arg.contains('\0') { + anyhow::bail!("microVM kernel command line contains an embedded NUL"); + } + if reserve_control_console + && (arg.contains('"') || arg.split_ascii_whitespace().any(|token| token == "--")) + { + anyhow::bail!( + "microVM control-console command line cannot contain quotes or the kernel argument delimiter" + ); + } + if arg.split_ascii_whitespace().any(|token| { + [ + "earlycon=", + "console=", + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + "virtfs_dir=", + "virtfs_tag=", + "virtfs_mode=", + "nvx_snapshot_tier=", + "nr_cpus=", + "nvx_workload_uid=", + "nvx_workload_gid=", + "nvx_lifecycle=", + ] + .iter() + .any(|reserved| token.starts_with(reserved)) + || (reserve_control_console + && (kernel_parameter_name_matches(token, "nvx_control_tty") + || kernel_parameter_name_matches(token, "driver_async_probe") + || kernel_parameter_name_matches(token, "virtio_mmio.device") + || kernel_parameter_name_matches(token, "microvm_image_slots"))) + }) { + anyhow::bail!( + "microVM kernel command line cannot override profile-owned configuration" + ); + } + } + + let mut cmdline = if has_console { + MICROVM_CONSOLE_COMMAND_LINE + } else { + MICROVM_BASE_COMMAND_LINE + } + .to_owned(); + for arg in user_args.iter().filter(|arg| !arg.is_empty()) { + cmdline.push(' '); + cmdline.push_str(arg); + } + if cmdline.len() >= MICROVM_COMMAND_LINE_MAX_SIZE { + anyhow::bail!("microVM kernel command line exceeds the 64-KiB ABI limit"); + } + Ok(cmdline) +} + +/// The guest-visible machine contract, independent of the hypervisor backend. +#[derive(MeshPayload, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum MachineProfile { + /// The standard OpenVMM machine. + #[default] + Standard, + /// The microVM machine. + Microvm, +} + +/// VM configuration specific to the microVM machine profile +/// ([`MachineProfile::Microvm`]), held in [`Config::microvm`]. +#[derive(MeshPayload, Debug, Default)] +pub struct MicrovmConfig { + /// Static identity for the optional microVM virtio-net device. + pub network: Option, + /// Guest-visible policies of the attached microVM filesystems, in + /// virtio-fs slot order. + pub filesystems: Vec, + /// Stable sandbox block-device roles in virtio-blk device order. + pub sandbox_blocks: Vec, + /// Optional fixed-capacity image slots within microVM ABI 2. + pub image_slots: Option, + /// Whether the effective command line bootstraps every attached microVM + /// filesystem. + pub filesystem_bootstrap: bool, + /// Immutable RAM capacity reserved by the microVM layout. + pub memory_capacity: Option, + /// Snapshot-backed GPA ranges restored from the base `memory.bin`. + pub snapshot_memory_ranges: Vec, + /// Fresh private GPA ranges selected for this restore launch. + pub restore_memory_ranges: Vec, + /// The NVX time ABI v1 parameters. Every microVM uses the time ABI, so + /// the microVM profile requires them. + pub time_abi: Option, +} + +fn validate_machine_load_mode( + machine_profile: MachineProfile, + load_mode: &LoadMode, +) -> anyhow::Result<()> { + if machine_profile == MachineProfile::Standard { + anyhow::ensure!( + !matches!( + load_mode, + LoadMode::Linux { + boot_mode: LinuxDirectBootMode::MpTable, + .. + } + ), + "Linux MP-table boot mode requires the microVM profile" + ); + return Ok(()); + } + + match load_mode { + LoadMode::Linux { + enable_serial, + isolation, + boot_mode, + smbios, + .. + } => { + anyhow::ensure!( + *boot_mode == LinuxDirectBootMode::MpTable, + "microVM Linux direct boot requires MP tables" + ); + anyhow::ensure!( + *isolation == LinuxIsolationConfig::None, + "microVM MP-table boot does not support isolation" + ); + anyhow::ensure!( + !enable_serial, + "microVM MP-table boot does not support emulated serial" + ); + let SmbiosConfig { bios, system } = &**smbios; + let SmbiosBiosOverrides { + vendor, + version: bios_version, + release_date, + release, + } = bios; + let SmbiosSystemOverrides { + manufacturer, + product_name, + version: system_version, + serial_number, + sku_number, + family, + uuid, + } = system; + anyhow::ensure!( + vendor.is_none() + && bios_version.is_none() + && release_date.is_none() + && release.is_none() + && manufacturer.is_none() + && product_name.is_none() + && system_version.is_none() + && serial_number.is_none() + && sku_number.is_none() + && family.is_none() + && *uuid == Guid::ZERO, + "microVM MP-table boot does not expose SMBIOS overrides" + ); + Ok(()) + } + _ => anyhow::bail!("microVM requires Linux MP-table load mode"), + } +} + +struct MicrovmVirtioInventory { + has_network: bool, + has_console: bool, + has_control_console: bool, + block_count: usize, +} + +/// Classifies the virtio-mmio inventory. The fixed transports and, when +/// declared, the image-slot transports are the only permitted devices. The +/// first image slot uses the second virtio-fs slot's window, and virtio-fs +/// devices are counted against their slots separately. +fn microvm_virtio_inventory<'a>( + devices: impl ExactSizeIterator, + image_slots_declared: bool, +) -> anyhow::Result { + let image_slot_capacity = if image_slots_declared { + usize::from(MICROVM_IMAGE_SLOT_CAPACITY) + } else { + 0 + }; + let transport_capacity = if image_slots_declared { + MICROVM_VIRTIO_MMIO_BASES.len() - 1 + image_slot_capacity + } else { + MICROVM_VIRTIO_MMIO_BASES.len() + }; + anyhow::ensure!( + devices.len() <= transport_capacity, + "microVM has too many virtio devices" + ); + let mut inventory = MicrovmVirtioInventory { + has_network: false, + has_console: false, + has_control_console: false, + block_count: 0, + }; + let mut image_slot_count = 0; + for (bus, id) in devices { + anyhow::ensure!( + bus == VirtioBus::Mmio, + "microVM permits only virtio-mmio devices" + ); + match id { + "virtio-net" => anyhow::ensure!( + !std::mem::replace(&mut inventory.has_network, true), + "microVM permits only one virtio-net device" + ), + // Counted against the fixed slots by the filesystem validation. + "virtiofs" => {} + "virtio-console" => anyhow::ensure!( + !std::mem::replace(&mut inventory.has_console, true), + "microVM permits only one virtio-console device" + ), + MICROVM_VIRTIO_CONTROL_CONSOLE_ID => { + anyhow::ensure!( + !std::mem::replace(&mut inventory.has_control_console, true), + "microVM permits only one control console" + ); + } + "virtio-blk" => inventory.block_count += 1, + "virtio-blk-image-slot" if image_slots_declared => image_slot_count += 1, + id => anyhow::bail!("microVM does not permit virtio device '{id}'"), + } + } + anyhow::ensure!( + image_slot_count == image_slot_capacity, + "microVM requires exactly {image_slot_capacity} image-slot transports" + ); + Ok(inventory) +} + +/// Validates the microVM machine contract. Standard-machine configurations are unchanged. +pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> anyhow::Result<()> { + let MachineProfile::Microvm = config.machine_profile else { + validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + anyhow::ensure!( + config.microvm.network.is_none(), + "static microVM network identity requires the microVM profile" + ); + anyhow::ensure!( + config.microvm.filesystems.is_empty(), + "microVM filesystem policy requires the microVM profile" + ); + anyhow::ensure!( + config.microvm.sandbox_blocks.is_empty(), + "microVM sandbox block roles require the microVM profile" + ); + anyhow::ensure!( + config.microvm.image_slots.is_none(), + "microVM image slots require the microVM profile" + ); + anyhow::ensure!( + config.microvm.memory_capacity.is_none() + && config.microvm.snapshot_memory_ranges.is_empty() + && config.microvm.restore_memory_ranges.is_empty(), + "microVM memory expansion configuration requires the microVM profile" + ); + anyhow::ensure!( + config.microvm.time_abi.is_none(), + "the NVX time ABI requires the microVM profile" + ); + return Ok(()); + }; + + validate_microvm_virtio_reservations()?; + validate_microvm_command_line(config, hypervisor_id)?; + validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + anyhow::ensure!( + config.microvm.time_abi.is_some(), + "the microVM profile requires the NVX time ABI parameters" + ); + if let Some(hypervisor_id) = hypervisor_id { + anyhow::ensure!( + matches!(hypervisor_id, "kvm" | "mshv" | "whp"), + "microVM requires the KVM, MSHV, or WHP hypervisor" + ); + } + anyhow::ensure!( + microvm_processor_count_supported(config.processor_topology.proc_count), + "microVM does not support {} vCPUs", + config.processor_topology.proc_count + ); + let has_expected_topology = config.processor_topology.vps_per_socket + == Some(config.processor_topology.proc_count) + && config.processor_topology.enable_smt == Some(false) + && matches!( + &config.processor_topology.arch, + Some(ArchTopologyConfig::X86(X86TopologyConfig { + apic_id_offset: 0, + x2apic: X2ApicConfig::Unsupported, + })) + ); + anyhow::ensure!( + has_expected_topology, + "microVM requires its fixed x86 APIC topology" + ); + anyhow::ensure!( + config.numa.nodes.len() == 1 && config.numa.distances.is_empty(), + "microVM requires a single NUMA node" + ); + let Some(memory) = config.numa.nodes[0].mem.as_ref() else { + anyhow::bail!("microVM requires RAM"); + }; + let memory_size = memory.mem_size; + if let Some(memory_capacity) = config.microvm.memory_capacity { + anyhow::ensure!( + memory_size <= memory_capacity + && memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES) + && memory_capacity.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "microVM RAM size and capacity must be ordered and 128-MiB aligned" + ); + } else { + anyhow::ensure!( + config.microvm.snapshot_memory_ranges.is_empty() + && config.microvm.restore_memory_ranges.is_empty(), + "microVM snapshot RAM ranges require a RAM capacity" + ); + } + anyhow::ensure!( + !config.hypervisor.with_hv + && config.hypervisor.with_vtl2.is_none() + && config.hypervisor.with_isolation.is_none() + && !config.hypervisor.nested_virt, + "microVM does not support Hyper-V enlightenments, VTL2, isolation, or nested virtualization" + ); + + let expected_chipset = BaseChipsetManifest { + with_generic_cmos_rtc: true, + ..BaseChipsetManifest::empty() + }; + anyhow::ensure!( + config.chipset == expected_chipset, + "microVM chipset is not the microVM allowlist" + ); + anyhow::ensure!( + config.chipset_capabilities.with_ioapic + && config.chipset_capabilities.with_pic + && config.chipset_capabilities.with_pit + && !config.chipset_capabilities.with_generic_isa_dma + && !config.chipset_capabilities.with_psp + && !config.chipset_capabilities.with_guest_watchdog + && !config.chipset_capabilities.with_i440bx_host_pci_bridge, + "microVM chipset capabilities do not match the fixed profile" + ); + + let mut chipset_ids = config + .chipset_devices + .iter() + .map(|device| (device.name.as_str(), device.resource.id())) + .collect::>(); + chipset_ids.sort_unstable(); + anyhow::ensure!( + chipset_ids + == [ + ("ioapic", "generic-ioapic"), + ("microvm-portb", "microvm-portb"), + ("microvm-shutdown", "microvm-shutdown"), + ("microvm-snapshot-request", "microvm-snapshot-request"), + ("pic", "pic"), + ("pit", "pit"), + ], + "microVM chipset-device inventory is not exact" + ); + + anyhow::ensure!( + config.floppy_disks.is_empty() && config.ide_disks.is_empty(), + "microVM does not support floppy or IDE devices" + ); + anyhow::ensure!( + config.pcie_root_complexes.is_empty() + && config.pcie_devices.is_empty() + && config.pcie_switches.is_empty() + && config.pcie_generic_initiators.is_empty() + && config.vpci_devices.is_empty() + && config.pci_chipset_devices.is_empty() + && config.isa_dma_controller.is_none(), + "microVM does not support PCI, PCIe, VPCI, or ISA DMA" + ); + anyhow::ensure!( + config.vmbus.is_none() && config.vtl2_vmbus.is_none() && config.vmbus_devices.is_empty(), + "microVM does not support VMBus" + ); + anyhow::ensure!( + config.framebuffer.is_none() && config.vga_firmware.is_none() && !config.vtl2_gfx, + "microVM does not support graphics or VGA firmware" + ); + anyhow::ensure!(config.vmgs.is_none(), "microVM does not support VMGS"); + anyhow::ensure!( + config.firmware_event_send.is_none() && config.debugger_rpc.is_none(), + "microVM does not support firmware or debugger resources" + ); + anyhow::ensure!( + config.rtc_delta_milliseconds == 0, + "microVM RTC must be anchored directly to UTC" + ); + #[cfg(windows)] + anyhow::ensure!( + config.kernel_vmnics.is_empty() && config.vpci_resources.is_empty(), + "microVM does not support kernel NIC or VPCI resources" + ); + + let MicrovmVirtioInventory { + has_network, + has_console, + has_control_console, + block_count, + } = microvm_virtio_inventory( + config + .virtio_devices + .iter() + .map(|(bus, device)| (*bus, device.id())), + config.microvm.image_slots.is_some(), + )?; + anyhow::ensure!( + !has_control_console || has_console, + "microVM control console requires the boot console" + ); + anyhow::ensure!( + block_count == config.microvm.sandbox_blocks.len(), + "microVM sandbox block roles do not match the virtio-blk device inventory" + ); + anyhow::ensure!( + has_network == config.microvm.network.is_some(), + "microVM virtio-net device and static network identity must be configured together" + ); + validate_microvm_filesystem_devices(config)?; + anyhow::ensure!( + config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 + && config.layout.chipset_high_mmio_size == 0 + && config.layout.vtl2_chipset_mmio_size == 0, + "microVM requires the fixed 3-GiB/4-GiB RAM split" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + fn linux_load_mode( + boot_mode: LinuxDirectBootMode, + isolation: LinuxIsolationConfig, + enable_serial: bool, + smbios: SmbiosConfig, + ) -> LoadMode { + LoadMode::Linux { + kernel: File::open(std::env::current_exe().unwrap()).unwrap(), + initrd: None, + cmdline: MICROVM_BASE_COMMAND_LINE.to_owned(), + enable_serial, + isolation, + boot_mode, + smbios: Box::new(smbios), + } + } + + #[test] + fn validates_linux_direct_boot_mode_matrix() { + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .unwrap(); + validate_machine_load_mode( + MachineProfile::Standard, + &linux_load_mode( + LinuxDirectBootMode::Acpi, + LinuxIsolationConfig::None, + true, + SmbiosConfig::default(), + ), + ) + .unwrap(); + + for boot_mode in [LinuxDirectBootMode::Acpi, LinuxDirectBootMode::DeviceTree] { + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + boot_mode, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + } + assert!( + validate_machine_load_mode( + MachineProfile::Standard, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::Snp { + restricted_injection: false, + }, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + true, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + let mut smbios = SmbiosConfig::default(); + smbios.system.product_name = Some("override".to_owned()); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + smbios, + ), + ) + .is_err() + ); + } + + #[test] + fn control_console_restrictions_preserve_existing_command_lines() { + let user_args = [ + r#"note="left right""#.to_owned(), + "--".to_owned(), + "driver_async_probe=virtio_mmio".to_owned(), + "nvx_control_tty=hvc9".to_owned(), + "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), + ]; + let cmdline = build_microvm_command_line(&user_args, true).unwrap(); + let tokens = cmdline.split_ascii_whitespace().collect::>(); + validate_microvm_control_console_command_line(&tokens, false).unwrap(); + + let mut cmdline = cmdline; + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, false, &[], None) + .unwrap(); + + assert!(build_microvm_control_command_line(&user_args, true).is_err()); + } + + #[test] + fn control_console_rejects_ambiguous_or_overridden_discovery() { + for cmdline in [ + r#"earlycon=xe9 console=hvc1 reboot=t panic=-1 note="left right" nvx_control_tty=hvc2"#, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 -- nvx_control_tty=hvc2", + "earlycon=xe9 console=hvc1 reboot=t panic=-1 driver-async-probe=virtio_mmio nvx_control_tty=hvc2", + "earlycon=xe9 console=hvc1 reboot=t panic=-1", + "earlycon=xe9 console=hvc1 reboot=t panic=-1 nvx_control_tty=hvc2 nvx-control-tty=hvc2", + ] { + let tokens = cmdline.split_ascii_whitespace().collect::>(); + assert!(validate_microvm_control_console_command_line(&tokens, true).is_err()); + } + + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + cmdline.push_str(" virtio-mmio.device=0x1000@0xc0000000:1"); + assert!( + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[], None) + .is_err() + ); + } + + #[test] + fn microvm_snapshot_tier_command_line_token_is_host_owned() { + assert!( + build_microvm_command_line(&["nvx_snapshot_tier=platform".to_owned()], false).is_err() + ); + } + + #[test] + fn microvm_processor_limit_is_host_owned() { + let mut cmdline = build_microvm_command_line(&[], false).unwrap(); + append_microvm_processor_limit(&mut cmdline, 8).unwrap(); + assert_eq!(cmdline, format!("{MICROVM_BASE_COMMAND_LINE} nr_cpus=8")); + assert!(append_microvm_processor_limit(&mut cmdline, 3).is_err()); + assert!(build_microvm_command_line(&["nr_cpus=1".to_owned()], false).is_err()); + } + + #[test] + fn microvm_network_identity_has_portable_profile() { + let network: MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + assert_eq!(network.profile, MicrovmNetworkProfile::Portable); + assert_eq!(network.profile.as_str(), "portable"); + } + + #[test] + fn microvm_sandbox_block_slots_are_stable() { + let blocks = [ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Custom, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]; + validate_microvm_sandbox_blocks(&blocks, false).unwrap(); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &blocks, + None, + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0003000:4 \ + virtio_mmio.device=0x1000@0xd0004000:12 \ + virtio_mmio.device=0x1000@0xd0005000:9 \ + virtio_mmio.device=0x1000@0xd0006000:11" + ) + ); + } + + #[test] + fn microvm_control_console_slot_is_stable() { + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[], None) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {MICROVM_CONTROL_TTY_COMMAND_LINE}" + ) + ); + } + + #[test] + fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { + assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); + assert_eq!(MICROVM_VIRTIO_RUNTIME_BLK_IRQ, 12); + assert_eq!(MICROVM_IMAGE_SLOT_IRQS, [1, 13, 14, 15]); + assert!(MICROVM_LEVEL_TRIGGERED_IRQS.is_empty()); + } + + #[test] + fn microvm_shared_status_slots_are_stable() { + let slots = [ + (MICROVM_VIRTIO_NET_MMIO_BASE, 0x3_0000), + (MICROVM_VIRTIO_FS_MMIO_BASE, 0x3_0004), + (MICROVM_VIRTIO_CONSOLE_MMIO_BASE, 0x3_0008), + (MICROVM_VIRTIO_BLK_MMIO_BASE, 0x3_000c), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[1], 0x3_0010), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[2], 0x3_0014), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], 0x3_0018), + (MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, 0x3_001c), + (MICROVM_VIRTIO_FS1_MMIO_BASE, 0x3_0020), + (MICROVM_IMAGE_SLOT_MMIO_BASES[0], 0x3_0020), + (MICROVM_IMAGE_SLOT_MMIO_BASES[1], 0x3_0024), + (MICROVM_IMAGE_SLOT_MMIO_BASES[2], 0x3_0028), + (MICROVM_IMAGE_SLOT_MMIO_BASES[3], 0x3_002c), + ]; + for (mmio_base, expected_gpa) in slots { + assert_eq!(microvm_virtio_status_gpa(mmio_base), Some(expected_gpa)); + assert_eq!(expected_gpa % size_of::() as u64, 0); + assert!( + expected_gpa < MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE + ); + } + assert_eq!(microvm_virtio_status_gpa(0xd000_c000), None); + } + + #[test] + fn microvm_filesystem_slots_are_stable() { + assert_eq!( + MICROVM_FILESYSTEM_SLOTS.map(|slot| ( + slot.stable_id, + slot.tag, + slot.mmio_base, + slot.irq + )), + [ + ("fs:microvm0", "microvm", 0xd000_1000, 6), + ("fs:microvm1", "microvm1", 0xd000_8000, 13), + ] + ); + assert_eq!( + MICROVM_FILESYSTEM_SLOTS[1].discovery_token(), + "virtio_mmio.device=0x1000@0xd0008000:13" + ); + validate_microvm_virtio_reservations().unwrap(); + // Every slot has its own reserved window and an ISA interrupt that no + // other fixed device of any backend uses. + let other_irqs = [ + 0, + 8, + MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + MICROVM_VIRTIO_BLK_IRQ, + MICROVM_VIRTIO_NET_WHP_IRQ, + MICROVM_VIRTIO_CONSOLE_IRQ, + MICROVM_VIRTIO_CUSTOM_BLK_IRQ, + MICROVM_VIRTIO_NET_KVM_IRQ, + MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + MICROVM_VIRTIO_RUNTIME_BLK_IRQ, + ]; + for (index, slot) in MICROVM_FILESYSTEM_SLOTS.iter().enumerate() { + assert!(MICROVM_VIRTIO_MMIO_BASES.contains(&slot.mmio_base)); + assert!(microvm_virtio_status_gpa(slot.mmio_base).is_some()); + assert!(slot.irq < 16 && !other_irqs.contains(&slot.irq)); + for other in &MICROVM_FILESYSTEM_SLOTS[..index] { + assert_ne!(other.stable_id, slot.stable_id); + assert_ne!(other.tag, slot.tag); + assert_ne!(other.irq, slot.irq); + } + } + } + + fn filesystem(target: &str, access: MicrovmFilesystemAccess) -> MicrovmFilesystemConfig { + MicrovmFilesystemConfig::new(target.to_owned(), access).unwrap() + } + + #[test] + fn microvm_filesystem_slot_count_follows_attachments() { + assert_eq!(microvm_filesystem_slot_count(false, 0).unwrap(), 0); + assert_eq!(microvm_filesystem_slot_count(true, 0).unwrap(), 1); + assert_eq!(microvm_filesystem_slot_count(true, 1).unwrap(), 1); + assert_eq!(microvm_filesystem_slot_count(true, 2).unwrap(), 2); + assert!(microvm_filesystem_slot_count(false, 1).is_err()); + assert!(microvm_filesystem_slot_count(true, 3).is_err()); + } + + #[test] + fn microvm_filesystems_reject_overlapping_guest_targets() { + use MicrovmFilesystemAccess::ReadOnly; + use MicrovmFilesystemAccess::ReadWrite; + + validate_microvm_filesystems(&[]).unwrap(); + validate_microvm_filesystems(&[ + filesystem("/workspace", ReadWrite), + filesystem("/opt/hostedtoolcache", ReadOnly), + ]) + .unwrap(); + // A shared prefix that is not a path component does not overlap. + validate_microvm_filesystems(&[ + filesystem("/work", ReadWrite), + filesystem("/workspace", ReadOnly), + ]) + .unwrap(); + for (first, second) in [ + ("/workspace", "/workspace"), + ("/workspace", "/workspace/cache"), + ("/opt/hostedtoolcache/node", "/opt"), + ] { + assert_eq!( + validate_microvm_filesystems(&[ + filesystem(first, ReadWrite), + filesystem(second, ReadOnly), + ]), + Err(InvalidMicrovmFilesystemConfig::OverlappingGuestTargets( + first.to_owned(), + second.to_owned() + )) + ); + } + assert_eq!( + validate_microvm_filesystems(&[ + filesystem("/a", ReadOnly), + filesystem("/b", ReadOnly), + filesystem("/c", ReadOnly), + ]), + Err(InvalidMicrovmFilesystemConfig::TooManyFilesystems) + ); + } + + #[test] + fn microvm_filesystem_access_policy_is_canonical_and_consistent() { + use MicrovmFilesystemAccess::ReadOnly; + use MicrovmFilesystemAccess::ReadWrite; + use MicrovmFilesystemPathKind::Allowed; + use MicrovmFilesystemPathKind::Denied; + use MicrovmFilesystemPathKind::Writable; + + fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() + } + let policy = |access, denied: &[&str], allowed: &[&str], writable: &[&str]| { + filesystem("/workspace", access).with_access_policy( + owned(denied), + owned(allowed), + owned(writable), + ) + }; + + // A denied path may nest inside an allowed path, and lexical order + // puts `logs-old` before `logs/...`. + let config = policy( + ReadWrite, + &["logs", "logs-old", "logs/payloads/private"], + &["logs/payloads"], + &["logs/payloads/out", "out"], + ) + .unwrap(); + assert_eq!(config.allowed_paths, ["logs/payloads"]); + assert_eq!(config.writable_paths, ["logs/payloads/out", "out"]); + assert_eq!( + config + .clone() + .with_owner(MicrovmFilesystemOwner::Caller) + .owner, + MicrovmFilesystemOwner::Caller + ); + assert_eq!( + filesystem("/workspace", ReadOnly) + .with_denied_paths(owned(&["secrets"])) + .unwrap() + .denied_paths, + ["secrets"] + ); + + for (access, denied, allowed, writable, expected) in [ + ( + ReadWrite, + vec!["a", "a/b"], + vec![], + vec![], + InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths, + ), + ( + ReadWrite, + vec!["b", "a"], + vec![], + vec![], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Denied), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/c", "a/b"], + vec![], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Allowed), + ), + ( + ReadWrite, + vec![], + vec![], + vec!["out", "out"], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Writable), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/../b"], + vec![], + InvalidMicrovmFilesystemConfig::InvalidPolicyPath(Allowed, "a/../b".to_owned()), + ), + ( + ReadWrite, + vec![], + vec![], + vec!["/out"], + InvalidMicrovmFilesystemConfig::InvalidPolicyPath(Writable, "/out".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["b"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("b".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["a"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("a".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/b", "a/b/c"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("a/b/c".to_owned()), + ), + ( + ReadOnly, + vec![], + vec![], + vec!["out"], + InvalidMicrovmFilesystemConfig::ReadOnlyWritablePaths, + ), + ( + ReadWrite, + vec![], + vec![], + vec!["out", "out/cache"], + InvalidMicrovmFilesystemConfig::OverlappingWritablePaths, + ), + ( + ReadWrite, + vec!["a"], + vec!["a/b/c"], + vec!["a/b"], + InvalidMicrovmFilesystemConfig::HiddenWritablePath("a/b".to_owned()), + ), + ] { + assert_eq!(policy(access, &denied, &allowed, &writable), Err(expected)); + } + assert_eq!( + policy(ReadWrite, &[], &[], &vec!["out"; 129]), + Err(InvalidMicrovmFilesystemConfig::TooManyPolicyPaths(Writable)) + ); + } + + #[test] + fn microvm_discovery_places_later_filesystem_slots_after_the_control_console() { + let network: MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let filesystems = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/opt/hostedtoolcache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + Some((&network, MICROVM_VIRTIO_NET_KVM_IRQ, false)), + true, + &filesystems, + true, + true, + &[], + None, + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0000000:10 \ + virtio_mmio.device=0x1000@0xd0001000:6 \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {MICROVM_CONTROL_TTY_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0008000:13 \ + virtnet_ip=10.0.0.2 virtnet_mask=255.255.255.0 virtnet_gw=10.0.0.1 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw \ + virtfs_dir=/opt/hostedtoolcache virtfs_tag=microvm1 virtfs_mode=ro" + ) + ); + + // One filesystem keeps the single-slot command line. + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems[..1], + false, + false, + &[], + None, + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw" + ) + ); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &filesystems, + false, + false, + &[], + None + ) + .is_err() + ); + let overlapping = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/workspace/cache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &overlapping, + false, + false, + &[], + None + ) + .is_err() + ); + } + + #[test] + fn microvm_sandbox_block_validation_rejects_invalid_layouts() { + assert!( + validate_microvm_sandbox_blocks( + &[MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: true, + }], + false + ) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Custom, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) + .is_err() + ); + } + + #[test] + fn ramfs_overlay_allows_only_a_read_only_distro_without_scratch() { + let distro = MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }; + let scratch = MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }; + assert!(!ramfs_overlay_requested("quiet").unwrap()); + assert!(ramfs_overlay_requested("quiet nvx_overlay_upper=ramfs").unwrap()); + assert!(ramfs_overlay_requested("nvx_overlay_upper=disk").is_err()); + assert!(ramfs_overlay_requested("nvx_overlay_upper=").is_err()); + assert!(ramfs_overlay_requested("quiet nvx_overlay_upper").is_err()); + assert!( + ramfs_overlay_requested("nvx_overlay_upper=ramfs nvx_overlay_upper=ramfs").is_err() + ); + assert!(ramfs_overlay_requested("nvx_overlay_upper=ramfs nvx_overlay_upper").is_err()); + assert!(ramfs_overlay_requested("nvx_overlay_upper nvx_overlay_upper=ramfs").is_err()); + assert!(validate_microvm_sandbox_blocks(&[distro], false).is_err()); + validate_microvm_sandbox_blocks(&[distro], true).unwrap(); + assert!(validate_microvm_sandbox_blocks(&[], true).is_err()); + assert!(validate_microvm_sandbox_blocks(&[scratch], true).is_err()); + assert!(validate_microvm_sandbox_blocks(&[distro, scratch], true).is_err()); + + let mut cmdline = format!("{MICROVM_BASE_COMMAND_LINE} nvx_overlay_upper=ramfs"); + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &[distro], + None, + ) + .unwrap(); + assert!(cmdline.contains("virtio_mmio.device=0x1000@0xd0003000:4")); + assert!(!cmdline.contains("virtio_mmio.device=0x1000@0xd0006000:11")); + + let mut cmdline = format!("{MICROVM_BASE_COMMAND_LINE} nvx_overlay_upper"); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &[distro, scratch], + None, + ) + .is_err() + ); + } + + #[test] + fn microvm_virtio_inventory_admits_declared_image_slots() { + const FIXED: [&str; 8] = [ + "virtio-net", + "virtiofs", + "virtio-console", + MICROVM_VIRTIO_CONTROL_CONSOLE_ID, + "virtio-blk", + "virtio-blk", + "virtio-blk", + "virtio-blk", + ]; + const SLOT: &str = "virtio-blk-image-slot"; + let inventory = |ids: &[&'static str], declared: bool| { + microvm_virtio_inventory(ids.iter().map(|id| (VirtioBus::Mmio, *id)), declared) + }; + + let full = [&FIXED[..], &[SLOT; 4]].concat(); + let classified = inventory(&full, true).unwrap(); + assert!(classified.has_network); + assert!(classified.has_console && classified.has_control_console); + assert_eq!(classified.block_count, 4); + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT, SLOT], true).is_ok()); + assert!(inventory(&FIXED, false).is_ok()); + // Without image slots, the second virtio-fs slot's window is free. + assert!(inventory(&[&FIXED[..], &["virtiofs"]].concat(), false).is_ok()); + + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT, SLOT], false).is_err()); + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT], true).is_err()); + assert!(inventory(&["virtio-console"], true).is_err()); + assert!(inventory(&[&FIXED[..], &[SLOT; 5]].concat(), true).is_err()); + // The first image slot uses the second virtio-fs slot's window. + assert!(inventory(&[&full[..], &["virtiofs"]].concat(), true).is_err()); + assert!(inventory(&[&FIXED[..], &["virtio-blk", "virtio-blk"]].concat(), false).is_err()); + assert!(inventory(&["virtio-console", "virtio-rng"], false).is_err()); + assert!( + microvm_virtio_inventory([(VirtioBus::Pci, "virtio-console")].into_iter(), false) + .is_err() + ); + } + + #[test] + fn microvm_image_slots_share_the_second_filesystem_slot() { + assert_eq!( + MICROVM_IMAGE_SLOT_MMIO_BASES[0], + MICROVM_FILESYSTEM_SLOTS[1].mmio_base + ); + assert_eq!(MICROVM_IMAGE_SLOT_IRQS[1], MICROVM_FILESYSTEM_SLOTS[1].irq); + assert_eq!( + microvm_virtio_status_gpa(MICROVM_IMAGE_SLOT_MMIO_BASES[0]), + microvm_virtio_status_gpa(MICROVM_VIRTIO_FS1_MMIO_BASE) + ); + validate_microvm_virtio_reservations().unwrap(); + + let image_slots = Some(MicrovmImageSlotsConfig { + boot_count: 1, + active_count: 1, + }); + let filesystems = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/opt/hostedtoolcache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems[..1], + false, + false, + &[], + image_slots, + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 \ + virtio_mmio.device=0x1000@0xd0008000:1 \ + virtio_mmio.device=0x1000@0xd0009000:13 \ + virtio_mmio.device=0x1000@0xd000a000:14 \ + virtio_mmio.device=0x1000@0xd000b000:15 \ + microvm_image_slots=4 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw" + ) + ); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems, + false, + false, + &[], + image_slots, + ) + .is_err() + ); + } +} diff --git a/openvmm/openvmm_defs/src/profile.rs b/openvmm/openvmm_defs/src/profile.rs new file mode 100644 index 00000000000..9bdc618b0d0 --- /dev/null +++ b/openvmm/openvmm_defs/src/profile.rs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Opt-in snapshot lifecycle profiling shared by OpenVMM components. + +use std::fmt::Write as _; +use std::io::Write as _; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +/// Environment variable that enables snapshot lifecycle profiling. +pub const SNAPSHOT_PROFILE_ENV: &str = "OPENVMM_STARTUP_PROFILE"; + +/// Prefix for machine-readable snapshot lifecycle profile records. +pub const SNAPSHOT_PROFILE_PREFIX: &str = "OPENVMM_SNAPSHOT_PROFILE_V1"; + +static ENABLED: OnceLock = OnceLock::new(); +static PROCESS_STARTED: OnceLock = OnceLock::new(); + +/// Optional counters associated with a completed lifecycle phase. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ProfileCounters { + /// Logical artifact bytes handled by the phase. + pub logical_bytes: Option, + /// Physically allocated artifact bytes handled by the phase. + pub allocated_bytes: Option, + /// Guest-physical memory faults observed through the VMM resolver. + pub gpa_faults: Option, + /// Bytes populated while resolving guest-physical memory faults. + pub populated_bytes: Option, +} + +/// An optional monotonic timer that is inert unless profiling is enabled. +#[derive(Debug)] +pub struct ProfileSpan { + started: Option, +} + +/// Initializes the process-relative profile clock when profiling is enabled. +/// +/// Call this as early as possible during process startup. Repeated calls are +/// harmless. +pub fn initialize() { + if enabled() { + PROCESS_STARTED.get_or_init(Instant::now); + } +} + +/// Returns whether snapshot lifecycle profiling is enabled. +pub fn enabled() -> bool { + *ENABLED.get_or_init(|| { + std::env::var_os(SNAPSHOT_PROFILE_ENV) + .is_some_and(|value| !value.is_empty() && value != "0") + }) +} + +impl ProfileSpan { + /// Starts an interval only when profiling is enabled. + pub fn start() -> Self { + let started = enabled().then(|| { + let now = Instant::now(); + PROCESS_STARTED.get_or_init(|| now); + now + }); + Self { started } + } + + /// Emits one exclusive phase interval. + pub fn complete(self, operation: &str, phase: &str, counters: ProfileCounters) { + self.emit(operation, phase, true, counters); + } + + /// Emits one cumulative milestone that may contain nested phase intervals. + pub fn complete_milestone(self, operation: &str, phase: &str, counters: ProfileCounters) { + self.emit(operation, phase, false, counters); + } + + fn emit(self, operation: &str, phase: &str, exclusive: bool, counters: ProfileCounters) { + let Some(started) = self.started else { + return; + }; + let ended = Instant::now(); + let process_started = PROCESS_STARTED.get().copied().unwrap_or(started); + let line = format_record( + operation, + phase, + exclusive, + ended.duration_since(started), + ended.duration_since(process_started), + std::process::id(), + counters, + ); + // Profiling must never change VM behavior. In particular, a closed + // diagnostic stream is not a lifecycle failure. + let _ = writeln!(std::io::stderr().lock(), "{line}"); + } +} + +fn format_record( + operation: &str, + phase: &str, + exclusive: bool, + duration: Duration, + process_elapsed: Duration, + pid: u32, + counters: ProfileCounters, +) -> String { + let mut line = format!( + "{SNAPSHOT_PROFILE_PREFIX} operation={operation} phase={phase} exclusive={} \ + duration_ns={} process_elapsed_ns={} pid={pid}", + u8::from(exclusive), + duration.as_nanos(), + process_elapsed.as_nanos(), + ); + if let Some(value) = counters.logical_bytes { + let _ = write!(line, " logical_bytes={value}"); + } + if let Some(value) = counters.allocated_bytes { + let _ = write!(line, " allocated_bytes={value}"); + } + if let Some(value) = counters.gpa_faults { + let _ = write!(line, " gpa_faults={value}"); + } + if let Some(value) = counters.populated_bytes { + let _ = write!(line, " populated_bytes={value}"); + } + line +} + +#[cfg(test)] +mod tests { + use super::ProfileCounters; + use super::format_record; + use std::time::Duration; + + #[test] + fn profile_record_has_stable_machine_readable_fields() { + assert_eq!( + format_record( + "restore", + "artifact_open", + true, + Duration::from_nanos(12), + Duration::from_nanos(34), + 56, + ProfileCounters { + logical_bytes: Some(78), + allocated_bytes: Some(90), + gpa_faults: None, + populated_bytes: None, + }, + ), + "OPENVMM_SNAPSHOT_PROFILE_V1 operation=restore phase=artifact_open \ + exclusive=1 duration_ns=12 process_elapsed_ns=34 pid=56 \ + logical_bytes=78 allocated_bytes=90" + ); + } +} diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index 58af188f5ef..79080ef23c0 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -13,6 +13,7 @@ use mesh::rpc::FailableRpc; use mesh::rpc::Rpc; use std::fmt; use std::fs::File; +use std::time::Duration; use vm_resource::Resource; use vm_resource::kind::PciDeviceHandleKind; use vm_resource::kind::VmbusDeviceHandleKind; @@ -20,7 +21,13 @@ use vm_resource::kind::VmbusDeviceHandleKind; #[derive(MeshPayload)] pub enum VmRpc { Save(FailableRpc<(), ProtobufMessage>), - Resume(Rpc<(), bool>), + /// Boundedly quiesce the VM and return saved state while leaving it stopped. + QuiesceForSnapshot(Rpc>), + /// Resume a VM after a rollback-safe snapshot failure before commit. + ResumeAfterFailedSnapshot(FailableRpc), + /// Release a post-OUT boundary without starting a snapshot transaction. + ReleaseSnapshotBoundary(FailableRpc<(), ()>), + Resume(FailableRpc<(), bool>), Pause(Rpc<(), bool>), ClearHalt(Rpc<(), bool>), Reset(FailableRpc<(), ()>), @@ -53,12 +60,42 @@ pub enum VmRpc { DumpState(FailableRpc), } +/// State returned after a successful bounded snapshot quiesce. +#[derive(Debug, MeshPayload)] +pub struct SnapshotSaveResponse { + /// Encoded VM saved state. + pub saved_state: ProtobufMessage, + /// Complete state-unit inventory in stable registration order. + pub state_unit_names: Vec, + /// Effective Linux direct command line loaded into the guest. + pub effective_command_line: String, + /// The NVX time ABI records of the capture, which make the manifest + /// version 6. + pub time: crate::time_abi::TimeCapture, +} + +/// Failure classification for a bounded snapshot quiesce/save operation. +#[derive(Debug, MeshPayload, thiserror::Error)] +pub enum SnapshotQuiesceError { + /// The request was rejected before any state transition began. + #[error("snapshot quiesce request was rejected")] + Rejected(#[source] RemoteError), + /// No uncertain transition occurred; the controller may request rollback. + #[error("snapshot quiesce failed without uncertain state")] + RollbackSafe(#[source] RemoteError), + /// A unit may have partially transitioned; the VM must be terminated. + #[error("snapshot quiesce left uncertain state")] + Uncertain(#[source] RemoteError), +} + #[derive(Debug, MeshPayload, thiserror::Error)] pub enum PulseSaveRestoreError { #[error("reset not supported")] ResetNotSupported, #[error("pulse save+restore failed")] Other(#[source] RemoteError), + #[error("save and restore are unavailable for this machine profile")] + UnsupportedMachineProfile, } impl From for PulseSaveRestoreError { @@ -72,6 +109,9 @@ impl fmt::Debug for VmRpc { let s = match self { VmRpc::Reset(_) => "Reset", VmRpc::Save(_) => "Save", + VmRpc::QuiesceForSnapshot(_) => "QuiesceForSnapshot", + VmRpc::ResumeAfterFailedSnapshot(_) => "ResumeAfterFailedSnapshot", + VmRpc::ReleaseSnapshotBoundary(_) => "ReleaseSnapshotBoundary", VmRpc::Resume(_) => "Resume", VmRpc::Pause(_) => "Pause", VmRpc::ClearHalt(_) => "ClearHalt", diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs new file mode 100644 index 00000000000..31056ab5a67 --- /dev/null +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI payloads shared by the controller and the VM worker, including +//! the snapshot manifest's time contract and CPU profile record. + +use chipset_resources::microvm_time::RestoreTimeRecord; +use mesh::MeshPayload; +use mesh::payload::Protobuf; +use virt::time_abi::CaptureTimeRecord; +use virt::time_abi::HostClockKind; +use virt::time_abi::HostIdentity; +use virt::time_abi::HostTimeSample; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiTestHooks; + +/// The manifest's time contract, recorded at the capture anchor. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotTimeContract { + /// The time ABI version: 1. + #[mesh(1)] + pub time_abi_version: u32, + /// `F_s`, the declared TSC rate of the snapshot lineage, in Hz. + #[mesh(2)] + pub tsc_frequency_hz: u64, + /// The TSC rate tolerance: 250. + #[mesh(3)] + pub tsc_tolerance_ppm: u32, + /// `L_s`, the LAPIC timer rate, in Hz. + #[mesh(4)] + pub apic_frequency_hz: u64, + /// `T_c`, VP 0's TSC at the capture anchor. + #[mesh(5)] + pub capture_tsc: u64, + /// Host UTC at the capture anchor, in nanoseconds since the Unix epoch. + #[mesh(6)] + pub capture_utc_ns: u64, + /// Host monotonic time at the capture anchor, in nanoseconds. + #[mesh(7)] + pub capture_monotonic_ns: u64, + /// The host monotonic clock: `linux-boottime` or + /// `windows-interrupt-time`. + #[mesh(8)] + pub host_clock: String, + /// The 16-byte host identity. + #[mesh(9)] + pub host_id: Vec, + /// The 16-byte host boot identity. + #[mesh(10)] + pub host_boot_id: Vec, + /// The generation counter of the captured VM process. + #[mesh(11)] + pub capture_generation: u32, +} + +impl SnapshotTimeContract { + /// Returns the capture record: the capture anchor and the capture host's + /// identities (`E_MANIFEST_TIME` if an identity or the clock is + /// malformed). + pub fn capture_record(&self) -> Result { + let identity = |bytes: &[u8], description: &str| { + <[u8; 16]>::try_from(bytes).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("{description} is {} bytes, not 16", bytes.len()), + ) + }) + }; + Ok(CaptureTimeRecord { + tsc: self.capture_tsc, + sample: HostTimeSample { + utc_ns: self.capture_utc_ns, + monotonic_ns: self.capture_monotonic_ns, + }, + identity: HostIdentity { + host_id: identity(&self.host_id, "host identity")?, + boot_id: identity(&self.host_boot_id, "host boot identity")?, + clock: HostClockKind::from_manifest(&self.host_clock).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("host clock '{}' is unknown", self.host_clock), + ) + })?, + }, + }) + } +} + +/// The manifest's CPU profile record. +/// +/// Capture writes it from constants and a cheap binary encoding, and restore +/// checks it by comparison, so neither encodes a document nor computes a +/// digest: `sha256` and `profile` are the pinned profile's precomputed digest +/// and canonical encoding, and `effective_cpuid` is +/// [`encode_effective_cpuid`] of the partition's effective CPUID. A host +/// profile (`--cpu-profile host`) has no constants: capture records the +/// digest and encoding computed when the VM worker selected it, and restore +/// decodes the document and checks it against the digest. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotCpuProfile { + /// The profile ID. + #[mesh(1)] + pub id: String, + /// The 32-byte profile digest. + #[mesh(2)] + pub sha256: Vec, + /// The canonical profile encoding. + #[mesh(3)] + pub profile: Vec, + /// The effective guest CPUID, as [`encode_effective_cpuid`] writes it. + #[mesh(4)] + pub effective_cpuid: Vec, + /// CPUID.1:EAX of the capture host, for diagnostics. + #[mesh(6)] + pub capture_cpu_signature: u32, +} + +/// The size of one entry of [`SnapshotCpuProfile::effective_cpuid`]. +pub const EFFECTIVE_CPUID_ENTRY_BYTES: usize = 44; + +/// The most entries [`SnapshotCpuProfile::effective_cpuid`] may hold. +pub const MAX_EFFECTIVE_CPUID_ENTRIES: usize = 1024; + +/// One entry of the effective CPUID record: a CPUID result and the mask of +/// the bits it defines, for one leaf and subleaf, or for every subleaf of the +/// leaf when `index` is `None`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EffectiveCpuidEntry { + /// The leaf. + pub function: u32, + /// The subleaf, or `None` for every subleaf. + pub index: Option, + /// `EAX`, `EBX`, `ECX`, and `EDX`. + pub result: [u32; 4], + /// The bits of each register that `result` defines. + pub mask: [u32; 4], +} + +/// Encodes the effective CPUID record: for each entry, in order, eleven +/// little-endian `u32` values, namely the leaf, 1 and the subleaf (or 0 and 0 +/// for every subleaf), the four registers, and their four masks. +pub fn encode_effective_cpuid(entries: impl IntoIterator) -> Vec { + let entries = entries.into_iter(); + let mut out = Vec::with_capacity(entries.size_hint().0 * EFFECTIVE_CPUID_ENTRY_BYTES); + for entry in entries { + let (has_index, index) = match entry.index { + Some(index) => (1, index), + None => (0, 0), + }; + for value in [entry.function, has_index, index] + .into_iter() + .chain(entry.result) + .chain(entry.mask) + { + out.extend_from_slice(&value.to_le_bytes()); + } + } + out +} + +/// Decodes an effective CPUID record that [`encode_effective_cpuid`] wrote, +/// with between 1 and [`MAX_EFFECTIVE_CPUID_ENTRIES`] entries, or describes +/// why it is malformed. +pub fn decode_effective_cpuid(bytes: &[u8]) -> Result, String> { + if bytes.is_empty() || !bytes.len().is_multiple_of(EFFECTIVE_CPUID_ENTRY_BYTES) { + return Err(format!( + "{} bytes is not a whole number of {EFFECTIVE_CPUID_ENTRY_BYTES}-byte entries", + bytes.len() + )); + } + let count = bytes.len() / EFFECTIVE_CPUID_ENTRY_BYTES; + if count > MAX_EFFECTIVE_CPUID_ENTRIES { + return Err(format!( + "{count} entries is more than {MAX_EFFECTIVE_CPUID_ENTRIES}" + )); + } + bytes + .as_chunks::() + .0 + .iter() + .map(|entry| { + let word = |i: usize| u32::from_le_bytes(entry[4 * i..4 * i + 4].try_into().unwrap()); + let index = match (word(1), word(2)) { + (1, index) => Some(index), + (0, 0) => None, + _ => { + return Err(format!( + "the entry for leaf {:#x} has an invalid subleaf", + word(0) + )); + } + }; + Ok(EffectiveCpuidEntry { + function: word(0), + index, + result: [word(3), word(4), word(5), word(6)], + mask: [word(7), word(8), word(9), word(10)], + }) + }) + .collect() +} + +/// The time ABI records the worker returns at capture. +#[derive(Debug, MeshPayload)] +pub struct TimeCapture { + /// The time contract. + pub time: SnapshotTimeContract, + /// The CPU profile record. + pub cpu_profile: SnapshotCpuProfile, +} + +/// The time ABI inputs of a restoring worker, validated by the controller. +#[derive(Debug, MeshPayload)] +pub struct RestoreTimeInput { + /// The snapshot's time contract. + pub contract: SnapshotTimeContract, + /// The snapshot's CPU profile record. + pub cpu_profile: SnapshotCpuProfile, + /// The destination host identity used by the controller's downtime + /// preflight. + pub destination: HostIdentity, + /// Receives the sealed time fields of the restore packet. + pub restore_record: mesh::OneshotSender, + /// With profiling enabled, notified when the guest first selects the + /// restore packet; it divides the guest's resume from its repair in the + /// restore profile. + pub packet_selected: Option>, +} + +/// Time ABI parameters of every microVM worker. +#[derive(Debug, MeshPayload)] +pub struct TimeAbiParameters { + /// The CPU profile: a pinned profile ID, `auto`, or `host`. On restore it + /// is the snapshot's profile ID. + pub cpu_profile: String, + /// The generation counter of this VM process. + pub generation: u32, + /// The active test hooks. + pub hooks: TimeAbiTestHooks, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_cpuid_records_round_trip() { + let entries = [ + EffectiveCpuidEntry { + function: 0, + index: None, + result: [0x16, 1, 2, 3], + mask: [!0; 4], + }, + EffectiveCpuidEntry { + function: 0xb, + index: Some(2), + result: [0, 0, 2, 7], + mask: [!0, !0, !0, 0], + }, + ]; + let bytes = encode_effective_cpuid(entries); + assert_eq!(bytes.len(), 2 * EFFECTIVE_CPUID_ENTRY_BYTES); + assert_eq!(&bytes[44..56], &[0xb, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0]); + assert_eq!(decode_effective_cpuid(&bytes).unwrap(), entries); + } + + #[test] + fn malformed_effective_cpuid_records_fail() { + let entry = EffectiveCpuidEntry { + function: 1, + index: None, + result: [0; 4], + mask: [0; 4], + }; + let bytes = encode_effective_cpuid([entry]); + assert!(decode_effective_cpuid(&[]).is_err()); + assert!(decode_effective_cpuid(&bytes[1..]).is_err()); + + // A subleaf-independent entry must record subleaf 0, and the flag + // is 0 or 1. + let mut stray_index = bytes.clone(); + stray_index[8] = 1; + assert!(decode_effective_cpuid(&stray_index).is_err()); + let mut bad_flag = bytes.clone(); + bad_flag[4] = 2; + assert!(decode_effective_cpuid(&bad_flag).is_err()); + + let full = encode_effective_cpuid(vec![entry; MAX_EFFECTIVE_CPUID_ENTRIES]); + assert_eq!( + decode_effective_cpuid(&full).unwrap().len(), + MAX_EFFECTIVE_CPUID_ENTRIES + ); + let over = encode_effective_cpuid(vec![entry; MAX_EFFECTIVE_CPUID_ENTRIES + 1]); + assert!(decode_effective_cpuid(&over).is_err()); + } +} diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index c380398b497..91b9c82f196 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -7,9 +7,12 @@ use crate::config::Config; use crate::rpc::VmRpc; use hypervisor_resources::HypervisorKind; use mesh::MeshPayload; +use mesh::payload::Protobuf; use mesh::payload::message::ProtobufMessage; use mesh_worker::WorkerId; +use state_unit::SavedStateUnit; use vm_resource::Resource; +use vmcore::save_restore::SavedStateRoot; use vmm_core_defs::HaltReason; /// File descriptor (Unix) or handle (Windows) for file-backed guest RAM. @@ -21,6 +24,33 @@ pub type SharedMemoryFd = std::os::windows::io::OwnedHandle; pub const VM_WORKER: WorkerId = WorkerId::new("VmWorker"); +/// Complete event written before a restored VM can execute. +pub const RESTORE_READY_EVENT_V1: &[u8] = b"OPENVMM_RESTORE_READY_V1\n"; + +/// Exact snapshot-generation handles retained for a restored VM's lifetime. +#[derive(MeshPayload)] +pub struct SnapshotRestoreGuards { + /// Open snapshot directory used for relative artifact access and resume claims. + pub directory: std::fs::File, + /// Open manifest artifact. + pub manifest: std::fs::File, + /// Open saved-state artifact. + pub state: std::fs::File, + /// Open memory artifact backing the copy-on-write section. + pub memory: std::fs::File, +} + +/// Complete saved state consumed by the VM worker. +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "openvmm")] +pub struct SavedState { + #[mesh(1)] + pub units: Vec, + /// Complete state-unit inventory, including units with no mutable state. + #[mesh(2)] + pub inventory: Vec, +} + /// Launch parameters for the VM worker. #[derive(MeshPayload)] pub struct VmWorkerParameters { @@ -33,6 +63,26 @@ pub struct VmWorkerParameters { /// File-backed guest RAM handle. When set, guest memory uses this /// fd/handle instead of allocating anonymous memory. pub shared_memory: Option, + /// Whether writes to `shared_memory` must remain private to this VM. + pub shared_memory_copy_on_write: bool, + /// Snapshot generation handles that must outlive the restored VM. + pub snapshot_restore_guards: Option, + /// Deferred microVM PMIO requests awaiting an exact post-OUT boundary. + pub snapshot_boundary_requests: + Option>, + /// Notifies the controller after the worker establishes the boundary. + pub snapshot_ready: + Option>, + /// The time ABI inputs of a restored microVM, validated by the + /// controller. + pub restore_time: Option, + /// Single-use process-local sink for the restore readiness event. + pub restore_ready_sink: Option, + /// Timeout for the microVM post-restore input gate, when required. + pub restore_gate_timeout: Option, + /// Explicit restore-time activation target. MSHV instantiates and binds + /// only VPs `0..N-1`; other backends instantiate the full VP capacity. + pub restore_vp_count: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, /// The notification channel. diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index b5dec3dcb17..12d10a90670 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -15,6 +15,7 @@ ttrpc = [] [dependencies] chipset_resources.workspace = true chipset_device_worker_defs.workspace = true +cpu_profile.workspace = true crypto.workspace = true cxl_spec.workspace = true debug_worker_defs.workspace = true @@ -60,6 +61,7 @@ vmgs_format.workspace = true vmgs_resources.workspace = true vm_manifest_builder.workspace = true vm_resource.workspace = true +virt.workspace = true vtl2_settings_proto.workspace = true pcie_remote_resources.workspace = true @@ -94,6 +96,8 @@ prost.workspace = true rustyline = { workspace = true, features = ["derive"] } shell-words.workspace = true socket2 = { workspace = true, features = ["all"] } +serde.workspace = true +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true thiserror.workspace = true tracing.workspace = true @@ -101,6 +105,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } unicycle.workspace = true [target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true net_tap.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 545954d0655..c879d4689ac 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -18,6 +18,8 @@ // anything else on this file though. #![warn(missing_docs)] +pub mod microvm; + use anyhow::Context; use clap::Parser; use clap::ValueEnum; @@ -149,6 +151,10 @@ pub struct NumaDistanceCli { long_version = openvmm_build_info::get().long_version(), )] pub struct Options { + /// guest machine profile + #[clap(long, value_enum, default_value = "standard")] + pub machine: microvm::MachineProfileCli, + /// processor count #[clap(short = 'p', long, value_name = "COUNT", default_value = "1")] pub processors: u32, @@ -158,7 +164,7 @@ pub struct Options { short = 'm', long, value_name = "PARAMS", - default_value = "1GB", + default_value = "", value_parser = parse_memory_config, conflicts_with = "numa", long_help = r#"Configure guest RAM. @@ -252,10 +258,15 @@ Examples: #[clap( long, value_name = "DIR", - conflicts_with_all = ["deprecated_memory_backing_file", "numa"] + conflicts_with_all = ["deprecated_memory_backing_file", "numa", "kernel", "initrd"] )] pub restore_snapshot: Option, + /// Write OPENVMM_RESTORE_READY_V1 to this Unix socket or Windows named pipe + /// after restore startup completes and before guest execution begins. + #[clap(long, value_name = "PATH", requires = "restore_snapshot")] + pub restore_ready_path: Option, + /// use private anonymous memory for guest RAM #[clap(long = "private-memory", hide = true, conflicts_with_all = ["deprecated_memory_backing_file", "restore_snapshot", "numa"])] pub deprecated_private_memory: bool, @@ -835,7 +846,7 @@ options: /// virtio console device backed by a serial backend (/dev/hvc0 in guest) /// - /// Accepts serial config (console | stderr | listen=\ | + /// Accepts serial config (console | stderr | listen=\ | connect=\ | /// file=\ (overwrites) | listen=tcp:\:\ | /// term[=\]\[,name=\\] | none) #[clap(long)] @@ -1164,6 +1175,36 @@ flags: #[clap(long)] pub write_saved_state_proto: Option, + /// write a CPU fingerprint of this host for the hypervisor selected by + /// `--hypervisor` to the specified path (`-` for stdout), and exit + #[clap( + long, + value_name = "PATH", + long_help = r#"Write a CPU fingerprint of this host to PATH ("-" for stdout), and exit. + +The fingerprint records the guest CPU surface that the backend selected by +--hypervisor supports on this host (or the first available backend): every +CPUID leaf and subleaf, the XSAVE features and layout, the feature MSRs, and +the time capabilities (TSC and LAPIC timer rates, invariant TSC, TSC-deadline, +TSC_ADJUST, TSC offset control and scaling, and MSR interception). It also +records the host CPU, OS, and hypervisor identity. + +The output is deterministic, sorted JSON with SHA-256 digests. It needs no +guest; the backend may create, and then destroys, a transient probe partition. + +After writing the fingerprint, OpenVMM checks it against the pinned CPU profile +of the host's generation and prints one "NVX-CPU-PROFILE:" line to stderr. It +exits with status 1 and the failure code (E_PROFILE_HOST_UNKNOWN, +E_PROFILE_UNSUPPORTED, or E_CPU_UNLISTED) if no profile serves the +host, the backend does not support it, or MSHV or WHP presents a non-zero CPUID +entry outside the profile's tables. + +Examples: + --hypervisor kvm --cpu-fingerprint host.json + --hypervisor whp --cpu-fingerprint -"# + )] + pub cpu_fingerprint: Option, + /// specify the IMC hive file for booting Windows #[clap(long)] pub imc: Option, @@ -1407,6 +1448,10 @@ Syntax: id= #[cfg(target_os = "linux")] #[clap(long, conflicts_with("pcat"))] pub iommu: Vec, + + /// microVM machine profile options. + #[clap(flatten)] + pub microvm: microvm::MicrovmCli, } impl Options { @@ -2023,7 +2068,9 @@ fn parse_smbios(s: &str) -> anyhow::Result { fn parse_memory_config(s: &str) -> anyhow::Result { // Bare shortcut: `--memory 64G` sets only the size. - let memory = if !s.contains('=') && !s.contains(',') { + let memory = if s.is_empty() { + MemoryCli::default() + } else if !s.contains('=') && !s.contains(',') { MemoryCli { size: Some(s.parse::()?), ..Default::default() @@ -2246,6 +2293,13 @@ impl FromStr for DiskCliKind { Self::parse_autocache(arg, std::env::var("OPENVMM_AUTO_CACHE_PATH"))? } "prwrap" => DiskCliKind::PersistentReservationsWrapper(Box::new(arg.parse()?)), + "delay" => { + let (delay_ms, kind) = arg.split_once(':').context("expected delay_ms:kind")?; + DiskCliKind::DelayDiskWrapper { + delay_ms: delay_ms.parse().context("invalid disk delay")?, + disk: Box::new(kind.parse()?), + } + } "file" => { let FileOpts { path, @@ -2804,7 +2858,7 @@ impl FromStr for ComSerialConfigCli { } } -/// (console | stderr | listen=\ | file=\ (overwrites) | listen=tcp:\:\ | term[=\]\[,name=\\] | none) +/// (console | stderr | listen=\ | connect=\ | file=\ (overwrites) | listen=tcp:\:\ | connect=tcp:\:\ | term[=\]\[,name=\\] | none) #[derive(Clone, Debug, PartialEq)] pub enum SerialConfigCli { None, @@ -2813,6 +2867,8 @@ pub enum SerialConfigCli { Stderr, Pipe(PathBuf), Tcp(SocketAddr), + ConnectPipe(PathBuf), + ConnectTcp(SocketAddr), File(PathBuf), } @@ -2861,6 +2917,21 @@ impl FromStr for SerialConfigCli { "invalid serial configuration: listen requires a value of tcp:addr or pipe", )?, }, + "connect" => match first_value { + Some(path) => { + if let Some(tcp) = path.strip_prefix("tcp:") { + let addr = tcp + .parse() + .map_err(|err| format!("invalid tcp address: {err}"))?; + SerialConfigCli::ConnectTcp(addr) + } else { + SerialConfigCli::ConnectPipe(path.into()) + } + } + None => Err( + "invalid serial configuration: connect requires a value of tcp:addr or pipe", + )?, + }, _ => { return Err(format!( "invalid serial configuration: '{}' is not a known option", @@ -2911,6 +2982,7 @@ pub enum EndpointConfigCli { Tap { name: String, }, + Microvm(openvmm_defs::microvm::MicrovmNetworkConfig), } /// Parsed host port forwarding configuration from the CLI. @@ -3031,6 +3103,7 @@ impl FromStr for EndpointConfigCli { ["tap", name] => EndpointConfigCli::Tap { name: (*name).to_owned(), }, + [network] if network.contains('/') => microvm::parse_endpoint(network)?, _ => return Err("invalid network backend".into()), }; @@ -4297,11 +4370,26 @@ mod tests { _ => panic!("Expected Pipe variant"), } + match SerialConfigCli::from_str("connect=tcp:127.0.0.1:1234").unwrap() { + SerialConfigCli::ConnectTcp(addr) => { + assert_eq!(addr.to_string(), "127.0.0.1:1234"); + } + _ => panic!("Expected ConnectTcp variant"), + } + + match SerialConfigCli::from_str("connect=/path/to/pipe").unwrap() { + SerialConfigCli::ConnectPipe(path) => { + assert_eq!(path.to_str().unwrap(), "/path/to/pipe"); + } + _ => panic!("Expected ConnectPipe variant"), + } + // Test error cases assert!(SerialConfigCli::from_str("").is_err()); assert!(SerialConfigCli::from_str("unknown").is_err()); assert!(SerialConfigCli::from_str("file").is_err()); assert!(SerialConfigCli::from_str("listen").is_err()); + assert!(SerialConfigCli::from_str("connect").is_err()); } #[test] @@ -5765,6 +5853,28 @@ mod tests { assert_eq!(opt.pidfile, Some(PathBuf::from("/tmp/test.pid"))); } + #[test] + fn test_cpu_fingerprint_option() { + let opt = Options::try_parse_from(["openvmm"]).unwrap(); + assert_eq!(opt.cpu_fingerprint, None); + + let opt = Options::try_parse_from([ + "openvmm", + "--hypervisor", + "kvm", + "--cpu-fingerprint", + "host.json", + ]) + .unwrap(); + assert_eq!(opt.cpu_fingerprint, Some(PathBuf::from("host.json"))); + assert_eq!(opt.hypervisor.as_deref(), Some("kvm")); + + let opt = Options::try_parse_from(["openvmm", "--cpu-fingerprint", "-"]).unwrap(); + assert_eq!(opt.cpu_fingerprint, Some(PathBuf::from("-"))); + + assert!(Options::try_parse_from(["openvmm", "--cpu-fingerprint"]).is_err()); + } + #[test] fn test_tpm_version_option() { let opt = Options::try_parse_from(["openvmm"]).unwrap(); diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs new file mode 100644 index 00000000000..8d46acdb5d1 --- /dev/null +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -0,0 +1,2956 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Command-line options of the microVM machine profile. + +use super::DiskCli; +use super::EndpointConfigCli; +use super::Options; +use super::SerialConfigCli; +use super::SmtConfigCli; +use anyhow::Context; +use clap::ValueEnum; +use openvmm_defs::config::DeviceVtl; +use openvmm_defs::config::X2ApicConfig; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmNetworkProfile; +use openvmm_defs::microvm::MicrovmSandboxBlockRole; +use std::path::PathBuf; +use std::str::FromStr; + +/// Guest-visible machine profile. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MachineProfileCli { + /// The standard OpenVMM machine. + Standard, + /// The microVM fixed-topology shared-status machine. + Microvm, +} + +/// Required host-network implementation contract for a microVM NIC. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmNetworkProfileCli { + /// Use the cross-platform user-mode Consomme NAT implementation. + Portable, +} + +/// Default action for one direction of microVM network traffic. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmNetworkActionCli { + /// Permit traffic in this direction. + Allow, + /// Deny traffic in this direction. + Deny, +} + +/// Fixed numeric identity for microVM workloads. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct MicrovmWorkloadIdentityCli { + pub(crate) uid: u32, + pub(crate) gid: u32, +} + +impl FromStr for MicrovmWorkloadIdentityCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let (uid, gid) = value + .split_once(':') + .filter(|(_, gid)| !gid.contains(':')) + .context("expected :")?; + let uid = uid.parse::().context("invalid workload UID")?; + let gid = gid.parse::().context("invalid workload GID")?; + anyhow::ensure!(uid != 0, "microVM workload UID must be nonzero"); + anyhow::ensure!(gid != 0, "microVM workload GID must be nonzero"); + Ok(Self { uid, gid }) + } +} + +/// Guest workload lifecycle for a microVM. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmLifecycleCli { + /// Start one workload and destroy the VM when it exits. + OneShot, + /// Keep the VM resident and accept multiple control-session workloads. + Managed, +} + +/// Host identity that performs the guest's operations on the microVM share. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmMountOwnerCli { + /// Run every operation as OpenVMM, which owns the files the guest creates. + Vmm, + /// Run each operation as its guest caller's UID and GID, with root + /// squashed to the owner of the export root (Linux only). + Caller, +} + +impl From for openvmm_defs::microvm::MicrovmFilesystemOwner { + fn from(owner: MicrovmMountOwnerCli) -> Self { + match owner { + MicrovmMountOwnerCli::Vmm => Self::Vmm, + MicrovmMountOwnerCli::Caller => Self::Caller, + } + } +} + +/// Protocol for a localhost-to-guest port forward. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum MicrovmLoopbackForwardProtocol { + /// Forward a TCP listener. + Tcp, + /// Forward a UDP socket. + Udp, +} + +/// Explicit localhost port allowed to initiate traffic toward the guest. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MicrovmLoopbackForwardCli { + pub(crate) protocol: MicrovmLoopbackForwardProtocol, + pub(crate) host_port: u16, + pub(crate) guest_port: u16, +} + +/// Host-only microVM control endpoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MicrovmHostControlCli { + pub(crate) path: PathBuf, +} + +impl FromStr for MicrovmHostControlCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let path = value + .strip_prefix("listen=") + .filter(|path| !path.is_empty()) + .context("expected listen=")?; + Ok(Self { + path: PathBuf::from(path), + }) + } +} + +impl FromStr for MicrovmLoopbackForwardCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let fields = value.split(':').collect::>(); + let [protocol, host_port, guest_port] = fields.as_slice() else { + anyhow::bail!("expected ::"); + }; + let protocol = match *protocol { + "tcp" => MicrovmLoopbackForwardProtocol::Tcp, + "udp" => MicrovmLoopbackForwardProtocol::Udp, + other => anyhow::bail!("invalid loopback-forward protocol '{other}'"), + }; + let host_port = host_port + .parse::() + .context("invalid loopback-forward host port")?; + let guest_port = guest_port + .parse::() + .context("invalid loopback-forward guest port")?; + anyhow::ensure!(host_port != 0, "loopback-forward host port must be nonzero"); + anyhow::ensure!( + guest_port != 0, + "loopback-forward guest port must be nonzero" + ); + Ok(Self { + protocol, + host_port, + guest_port, + }) + } +} + +/// Capture tier for a microVM sandbox snapshot. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum SnapshotTierCli { + /// Fleet-wide clone point before image or sandbox configuration is consumed. + Platform, + /// Tenant-scoped reusable clone point at the workload handoff. + WorkloadStart, + /// Single-use continuation of one stopped instance. + InstanceCheckpoint, +} + +/// Identity policy for sandbox blocks captured in a microVM snapshot. +#[derive(Debug, Copy, Clone, Default, ValueEnum, PartialEq, Eq)] +pub enum SnapshotBlockIdentityCli { + /// Compute and verify whole-file SHA-256 identities. + #[default] + Sha256, + /// Trust the caller-supplied immutable storage generation. + Generation, +} + +/// Restore-time materialization policy for paired scratch. +#[derive(Debug, Copy, Clone, Default, ValueEnum, PartialEq, Eq)] +pub enum SnapshotScratchRestoreModeCli { + /// Create an independent private file, falling back to sparse copying. + #[default] + PrivateCopy, + /// Require a filesystem copy-on-write clone. + CopyOnWrite, + /// Attach the paired scratch file after the resume snapshot is claimed. + DirectClaimed, +} + +impl SnapshotScratchRestoreModeCli { + pub(crate) fn manifest_name(self) -> &'static str { + match self { + Self::PrivateCopy => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + } + Self::CopyOnWrite => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE + } + Self::DirectClaimed => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED + } + } + } +} + +/// Caller-supplied immutable storage generation. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct SnapshotGenerationIdCli(pub(crate) [u8; 16]); + +impl FromStr for SnapshotGenerationIdCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + anyhow::ensure!( + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "snapshot generation ID must be exactly 32 hexadecimal characters" + ); + let mut generation = [0_u8; 16]; + for (index, byte) in generation.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16) + .context("snapshot generation ID contains invalid hexadecimal")?; + } + anyhow::ensure!( + generation.iter().any(|byte| *byte != 0), + "snapshot generation ID must be nonzero" + ); + Ok(Self(generation)) + } +} + +impl SnapshotTierCli { + pub(crate) fn manifest_name(self) -> &'static str { + match self { + Self::Platform => openvmm_helpers::snapshot::format::SNAPSHOT_TIER_PLATFORM, + Self::WorkloadStart => openvmm_helpers::snapshot::format::SNAPSHOT_TIER_WORKLOAD_START, + Self::InstanceCheckpoint => { + openvmm_helpers::snapshot::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT + } + } + } + + pub(crate) fn restore_policy(self) -> &'static str { + match self { + Self::Platform | Self::WorkloadStart => { + openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_CLONE + } + Self::InstanceCheckpoint => { + openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_RESUME + } + } + } + + pub(crate) fn requires_paired_scratch(self) -> bool { + !matches!(self, Self::Platform) + } +} + +impl From for MicrovmNetworkProfile { + fn from(value: MicrovmNetworkProfileCli) -> Self { + match value { + MicrovmNetworkProfileCli::Portable => Self::Portable, + } + } +} + +impl From for net_backend_resources::egress::EgressAction { + fn from(value: MicrovmNetworkActionCli) -> Self { + match value { + MicrovmNetworkActionCli::Allow => Self::Allow, + MicrovmNetworkActionCli::Deny => Self::Deny, + } + } +} + +impl From for MachineProfile { + fn from(value: MachineProfileCli) -> Self { + match value { + MachineProfileCli::Standard => Self::Standard, + MachineProfileCli::Microvm => Self::Microvm, + } + } +} + +/// Options of the microVM machine profile. +#[derive(clap::Args)] +pub struct MicrovmCli { + /// Declare four optional bind-once read-only image slots in microVM ABI 2. + #[clap(long)] + pub microvm_image_slots: bool, + + /// Number of image slots active at cold boot. + #[clap(long, value_name = "COUNT", requires = "microvm_image_slots")] + pub microvm_image_slot_boot_count: Option, + + /// Number of image slots active for this restore launch. + #[clap(long, value_name = "COUNT", requires = "restore_snapshot")] + pub restore_image_slots: Option, + + /// Accepted for compatibility: every microVM restore exposes restore packet + /// version 4, which carries fresh entropy, through the private portb restore + /// channel. + #[clap(long, requires = "restore_snapshot")] + pub restore_entropy: bool, + + /// Bring this contiguous prefix of capacity VPs online before restore readiness. + #[clap(long, value_name = "COUNT", requires = "restore_snapshot")] + pub restore_processors: Option, + + /// Restore a capable microVM snapshot with this total guest RAM size. + #[clap(long, value_name = "SIZE", requires = "restore_snapshot")] + pub restore_memory: Option, + + /// Maximum time allowed for a microVM guest to complete post-restore repair. + #[clap(long, value_name = "MILLISECONDS", default_value_t = 60000)] + pub restore_gate_timeout_ms: u64, + + /// Capture a microVM snapshot to this directory when the guest writes PMIO 0x605. + #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] + pub snapshot_destination: Option, + + /// Reserve this immutable total RAM capacity in a captured microVM snapshot. + #[clap(long, value_name = "SIZE", requires = "snapshot_destination")] + pub memory_capacity: Option, + + /// Sandbox capture tier. Required for microVM snapshot capture with sandbox blocks. + #[clap( + long, + value_enum, + value_name = "TIER", + requires = "snapshot_destination" + )] + pub snapshot_tier: Option, + + /// Sandbox-block identity policy for this capture. + /// + /// `sha256` is the default. `generation` avoids whole-file hashing and + /// requires an immutable generation supplied by the storage owner. + #[clap( + long, + value_enum, + value_name = "MODE", + requires = "snapshot_destination" + )] + pub snapshot_block_identity: Option, + + /// Immutable storage generation used by `--snapshot-block-identity generation`. + #[clap(long, value_name = "HEX", requires = "snapshot_destination")] + pub snapshot_generation_id: Option, + + /// Restore-time materialization policy recorded for paired scratch. + #[clap( + long, + value_enum, + value_name = "MODE", + requires = "snapshot_destination" + )] + pub snapshot_scratch_restore_mode: Option, + + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. + #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] + pub snapshot_quiesce_timeout_ms: u64, + + /// Attach a fixed-role microVM sandbox block device. + /// + /// The value is `:`, where the roles are `distro`, `runtime`, + /// `custom`, and `scratch`. Lower-layer roles must use `,ro`; `scratch` + /// must be writable. The profile assigns each role a fixed virtio-mmio + /// address and IRQ independent of option order. + #[clap(long, value_name = "ROLE:DISK")] + pub microvm_sandbox_block: Vec, + + /// Run guest workloads under this fixed non-root numeric identity. + /// + /// The identity is part of the initial-boot command line and cannot be + /// replaced when restoring a snapshot. + #[clap(long, value_name = "UID:GID")] + pub microvm_workload_identity: Option, + + /// Select one-shot or managed microVM workload lifecycle. + #[clap(long, value_enum, value_name = "MODE")] + pub microvm_lifecycle: Option, + + /// Write one bounded local JSON outcome report after microVM teardown. + #[clap(long, value_name = "PATH")] + pub microvm_report: Option, + + /// Required host-network implementation contract for microVM `--net`. + #[clap(long, value_enum, value_name = "PROFILE")] + pub network_profile: Option, + + /// Default action for connections initiated by the microVM guest. + #[clap(long, value_enum, value_name = "ACTION")] + pub network_egress: Option, + + /// Default action for new connections initiated toward the microVM guest. + /// + /// The portable profile supports only `deny`: its NAT admits responses to + /// guest-initiated flows but exposes no listener for new inbound connections. + #[clap(long, value_enum, value_name = "ACTION")] + pub network_ingress: Option, + + /// Permit matching IPv4 destinations, optionally restricted to TCP, UDP, + /// or ICMP, and for TCP or UDP optionally to one destination port or to + /// an inclusive range of them. + #[clap( + long = "network-egress-allow", + value_name = "IPv4[/PREFIX][:tcp[:PORT[-PORT]]|:udp[:PORT[-PORT]]|:icmp]" + )] + pub network_egress_allow: Vec, + + /// Deny matching IPv4 destinations before evaluating allow rules. + #[clap( + long = "network-egress-deny", + value_name = "IPv4[/PREFIX][:tcp[:PORT[-PORT]]|:udp[:PORT[-PORT]]|:icmp]" + )] + pub network_egress_deny: Vec, + + /// Deny host-loopback access, or allow it with explicit localhost forwards. + /// + /// Explicit `allow` without forwards is unsupported: portable NAT cannot + /// provide generic bidirectional host-loopback connectivity. + #[clap(long, value_enum, value_name = "ACTION")] + pub host_loopback: Option, + + /// Exact guest-gateway TCP endpoint retained when host loopback is denied. + #[clap(long, value_name = "IPv4:TCP-PORT")] + pub network_proxy: Option, + + /// Forward one localhost TCP/UDP port into the guest. + #[clap(long, value_name = "tcp|udp:HOST-PORT:GUEST-PORT")] + pub host_loopback_forward: Vec, + + /// Select a preconfigured Linux TAP for a microVM NIC. + /// + /// This is incompatible with the portable microVM network profile. + #[clap(long, value_name = "NAME")] + pub net_tap: Option, + + /// Permit only these IPv4 destinations or CIDRs from the microVM guest. + #[clap(long, value_name = "IPv4[/PREFIX]", conflicts_with_all = ["block_host", "allow_endpoint"])] + pub allow_host: Vec, + + /// Permit IPv4 except for these destinations or CIDRs from the microVM guest. + #[clap(long, value_name = "IPv4[/PREFIX]", conflicts_with_all = ["allow_host", "allow_endpoint"])] + pub block_host: Vec, + + /// Permit only these exact IPv4 TCP destinations from the microVM guest. + #[clap(long, value_name = "IPv4:TCP-PORT", conflicts_with_all = ["allow_host", "block_host"])] + pub allow_endpoint: Vec, + + /// attach a host directory to the next fixed microVM virtio-fs slot + /// + /// Repeat once to attach a second directory. The first directory uses the + /// `microvm` tag and the second the `microvm1` tag. Guest targets and host + /// directories must not overlap. An active snapshot requires the same + /// canonical host paths, guest targets, and modes, in the same order. A + /// dormant-slot snapshot may bind one new attachment on restore; the + /// resumed guest must mount the `microvm` tag explicitly. + #[clap( + long = "mount", + value_name = "GUEST_TARGET,HOST_PATH[,ro|rw]", + conflicts_with_all = ["virtio_fs", "virtio_fs_shmem"] + )] + pub microvm_mount: Vec, + + /// Hide an existing host path inside a microVM filesystem export. + /// + /// The path is hidden in the `--mount` whose host directory contains it. + /// A relative path is relative to the host directory of the only + /// `--mount`; with several, the path must be absolute. + #[clap( + long = "mount-deny", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_deny: Vec, + + /// Expose an existing host path inside a `--mount-deny` path again + /// + /// The path and everything below it become visible in the `--mount` + /// whose host directory contains it, subject to the export's write + /// policy. The hidden directories on the way to it become traverse-only: + /// the guest can look them up and list the entries that lead to allowed + /// paths, but sees nothing else in them and cannot modify them. The + /// nearest `--mount-deny` or `--mount-allow` path that contains the path + /// must be a `--mount-deny` path, which may itself lie inside another + /// `--mount-allow` path. A relative path is relative to the host + /// directory of the only `--mount`; with several, the path must be + /// absolute. + #[clap( + long = "mount-allow", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_allow: Vec, + + /// Limit the guest's writes in a read-write `--mount` to an existing host + /// path + /// + /// Repeat to declare more writable files or directories. With any, they + /// and everything below them are the only parts of the `--mount` whose + /// host directory contains them that the guest can modify; the rest of + /// it is read-only, and the guest's modifications there fail with EROFS. + /// The paths must not overlap or be hidden by `--mount-deny`. A relative + /// path is relative to the host directory of the only `--mount`; with + /// several, the path must be absolute. + #[clap( + long = "mount-write", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_write: Vec, + + /// Select the host identity of the guest's `--mount` operations + /// + /// `vmm` (the default) performs every operation as OpenVMM. `caller` + /// performs each operation as the guest caller's UID and GID, without + /// supplementary groups or capabilities, and squashes guest UID 0 and + /// GID 0 to the owner of the export root, which must not be root. + /// `caller` requires a Linux host. It also requires CAP_SETUID and + /// CAP_SETGID unless every caller has OpenVMM's own UID and GID and + /// OpenVMM has no other supplementary groups; an operation that cannot + /// run as its caller fails with EPERM. The mode applies to every + /// `--mount`. + #[clap( + long = "mount-owner", + value_enum, + value_name = "OWNER", + requires = "microvm_mount" + )] + pub microvm_mount_owner: Option, + + /// dedicated microVM control console backed by a local serial endpoint + /// + /// Accepts listen=\ or none. The boot + /// virtio-console is required and remains the only kernel console. + #[clap(long, value_name = "SERIAL")] + pub microvm_control_console: Option, + + /// read the control-console capability from a prepared stdin pipe + #[clap( + long, + hide = true, + conflicts_with_all = ["rpc", "ttrpc", "grpc", "relay_console_path", "write_saved_state_proto", "cpu_fingerprint", "paused"] + )] + pub microvm_control_auth_stdin: bool, + + /// authenticated host-only control endpoint + #[clap(long, value_name = "listen=PATH")] + pub microvm_host_control: Option, + + /// maximum time for a control-console client to authenticate + #[clap( + long = "microvm-control-auth-timeout-ms", + value_name = "MILLISECONDS", + default_value_t = 5000, + hide = true + )] + pub microvm_control_auth_timeout_ms: u64, + + /// microVM CPU profile: a pinned profile ID, `auto` (the default) to + /// select the host's profile, falling back with a warning to a development + /// profile derived from this host where no pinned profile serves its CPU, + /// or `host` to derive that development profile; a restore must name the + /// snapshot's profile + #[clap(long = "cpu-profile", value_name = "ID", hide = true)] + pub cpu_profile: Option, + + /// time ABI test hook (repeatable; testing only) + #[clap(long = "x-time-abi-test-hook", value_name = "HOOK", hide = true)] + pub x_time_abi_test_hook: Vec, + + /// build the partition and run the time ABI preflight without running + /// the guest, print one `NVX-TIME-ABI-VERIFY:` line, and exit with status + /// 0 or 1 (host qualification) + #[clap( + long = "x-time-abi-verify", + hide = true, + conflicts_with_all = ["restore_snapshot", "snapshot_destination"] + )] + pub x_time_abi_verify: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MicrovmMountCli { + /// Absolute guest mount target. + pub guest_target: String, + /// Live host directory supplied for this run. + pub host_path: PathBuf, + /// Snapshot-authoritative access policy. + pub access: openvmm_defs::microvm::MicrovmFilesystemAccess, +} + +impl FromStr for MicrovmMountCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let mut fields = value.splitn(3, ','); + let guest_target = fields + .next() + .filter(|value| !value.is_empty()) + .context("expected ,[,ro|rw]")?; + let host_path = fields + .next() + .filter(|value| !value.is_empty()) + .context("expected ,[,ro|rw]")?; + let access = match fields.next().unwrap_or("ro") { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("invalid microVM mount mode '{mode}'; expected ro or rw"), + }; + openvmm_defs::microvm::MicrovmFilesystemConfig::new(guest_target.to_owned(), access)?; + Ok(Self { + guest_target: guest_target.to_owned(), + host_path: PathBuf::from(host_path), + access, + }) + } +} + +/// A fixed-role microVM sandbox block-device CLI argument. +#[derive(Clone)] +pub struct MicrovmSandboxBlockCli { + /// The stable guest-visible role. + pub role: MicrovmSandboxBlockRole, + /// The generic disk backend and access mode. + pub disk: DiskCli, +} + +impl FromStr for MicrovmSandboxBlockCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> anyhow::Result { + let (role, disk) = value + .split_once(':') + .context("expected ROLE:DISK for --microvm-sandbox-block")?; + let role = match role { + "distro" => MicrovmSandboxBlockRole::Distro, + "runtime" => MicrovmSandboxBlockRole::Runtime, + "custom" => MicrovmSandboxBlockRole::Custom, + "scratch" => MicrovmSandboxBlockRole::Scratch, + _ => anyhow::bail!( + "unknown microVM sandbox block role '{role}'; expected distro, runtime, custom, or scratch" + ), + }; + Ok(Self { + role, + disk: disk.parse()?, + }) + } +} + +/// Parses a bare `/` `--net` endpoint into the microVM network +/// configuration. +pub(super) fn parse_endpoint(network: &str) -> Result { + network + .parse() + .map(EndpointConfigCli::Microvm) + .map_err(|error| format!("invalid microVM network: {error}")) +} + +impl Options { + /// Rejects unsupported microVM combinations before opening host resources. + pub(crate) fn validate_microvm_host_loopback(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.machine != MachineProfileCli::Microvm + || self.microvm.host_loopback != Some(MicrovmNetworkActionCli::Allow) + || !self.microvm.host_loopback_forward.is_empty(), + "the portable microVM network profile does not support generic host-loopback connectivity; explicit --host-loopback allow requires --host-loopback-forward for deliberate port publishing" + ); + Ok(()) + } + + pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { + self.validate_microvm_host_loopback()?; + if self.machine != MachineProfileCli::Microvm { + anyhow::ensure!( + self.microvm.net_tap.is_none() + && self.microvm.network_profile.is_none() + && self.microvm.network_egress.is_none() + && self.microvm.network_ingress.is_none() + && self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + && self.microvm.host_loopback.is_none() + && self.microvm.network_proxy.is_none() + && self.microvm.host_loopback_forward.is_empty() + && self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty() + && self.microvm.microvm_mount.is_empty() + && self.microvm.microvm_mount_deny.is_empty() + && self.microvm.microvm_mount_allow.is_empty() + && self.microvm.microvm_mount_write.is_empty() + && self.microvm.microvm_sandbox_block.is_empty() + && self.microvm.microvm_workload_identity.is_none() + && self.microvm.microvm_lifecycle.is_none() + && self.microvm.microvm_report.is_none() + && self.microvm.restore_processors.is_none() + && !self.microvm.microvm_image_slots + && self.microvm.microvm_image_slot_boot_count.is_none() + && self.microvm.restore_image_slots.is_none() + && self.microvm.restore_memory.is_none() + && self.microvm.memory_capacity.is_none() + && self.microvm.snapshot_block_identity.is_none() + && self.microvm.snapshot_generation_id.is_none() + && self.microvm.snapshot_scratch_restore_mode.is_none() + && self.microvm.microvm_control_console.is_none() + && self.microvm.microvm_host_control.is_none() + && !self.microvm.microvm_control_auth_stdin, + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-image-slots, --microvm-image-slot-boot-count, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-host-control, --microvm-control-auth-stdin, --restore-processors, --restore-image-slots, --restore-memory, --memory-capacity, snapshot block policy, and microVM network policy require a microVM machine" + ); + return Ok(()); + } + + anyhow::ensure!( + cfg!(guest_arch = "x86_64"), + "microVM requires an x86-64 guest" + ); + let ramfs_overlay = + openvmm_defs::microvm::ramfs_overlay_requested(&self.cmdline.join(" "))?; + if ramfs_overlay { + anyhow::ensure!( + self.microvm.snapshot_destination.is_none() && self.restore_snapshot.is_none(), + "microVM RAM-backed overlay does not support snapshot or restore" + ); + } + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(self.processors), + "microVM does not support {} vCPUs", + self.processors + ); + if self.microvm.microvm_image_slots { + openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: self.microvm.microvm_image_slot_boot_count.unwrap_or(1), + active_count: self.microvm.microvm_image_slot_boot_count.unwrap_or(1), + } + .validate()?; + anyhow::ensure!( + self.microvm.microvm_host_control.is_some(), + "microVM image slots require --microvm-host-control" + ); + anyhow::ensure!( + self.microvm.microvm_mount.len() <= 1, + "--microvm-image-slots uses the second virtio-fs slot's window; pass at most one --mount" + ); + } + if let Some(count) = self.microvm.restore_image_slots { + anyhow::ensure!( + (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY).contains(&count), + "restore image-slot count must be between 1 and {}", + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ); + } + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_control_console.is_some() + || self.microvm.microvm_host_control.is_some(), + "--microvm-control-auth-stdin requires a control console or host-control endpoint" + ); + anyhow::ensure!( + self.microvm.microvm_host_control.is_none() || self.microvm.microvm_control_auth_stdin, + "live microVM host control requires --microvm-control-auth-stdin" + ); + anyhow::ensure!( + self.numa.is_none() && self.numa_distance.is_none(), + "microVM does not support custom NUMA topology" + ); + anyhow::ensure!( + self.vps_per_socket.is_none() + && self.smt == SmtConfigCli::Auto + && self.apic_id_offset == 0 + && matches!(self.x2apic, X2ApicConfig::Auto), + "microVM owns CPU topology and APIC configuration" + ); + if self.microvm.snapshot_destination.is_some() { + anyhow::ensure!( + !self.private_memory(), + "microVM snapshot capture requires shared file-backed RAM" + ); + anyhow::ensure!( + !self.memory.hugepages, + "microVM snapshot capture does not support explicit hugepage backing" + ); + anyhow::ensure!( + self.microvm.snapshot_quiesce_timeout_ms != 0, + "microVM snapshot quiesce timeout must be nonzero" + ); + anyhow::ensure!( + self.microvm.snapshot_tier.is_some() + != self.microvm.microvm_sandbox_block.is_empty(), + "--snapshot-tier is required exactly for microVM snapshot capture with sandbox blocks" + ); + let identity = self + .microvm + .snapshot_block_identity + .unwrap_or(SnapshotBlockIdentityCli::Sha256); + anyhow::ensure!( + !self.microvm.microvm_sandbox_block.is_empty() + || self.microvm.snapshot_block_identity.is_none(), + "--snapshot-block-identity requires microVM sandbox blocks" + ); + anyhow::ensure!( + matches!( + (identity, self.microvm.snapshot_generation_id), + (SnapshotBlockIdentityCli::Sha256, None) + | ( + SnapshotBlockIdentityCli::Generation, + Some(SnapshotGenerationIdCli(_)) + ) + ), + "--snapshot-generation-id is required exactly for --snapshot-block-identity generation" + ); + if let Some(mode) = self.microvm.snapshot_scratch_restore_mode { + let tier = self + .microvm + .snapshot_tier + .context("--snapshot-scratch-restore-mode requires --snapshot-tier")?; + #[cfg(not(target_os = "linux"))] + anyhow::ensure!( + mode != SnapshotScratchRestoreModeCli::CopyOnWrite, + "--snapshot-scratch-restore-mode copy-on-write is unsupported on this platform" + ); + anyhow::ensure!( + tier.requires_paired_scratch(), + "--snapshot-scratch-restore-mode requires a paired-scratch snapshot tier" + ); + anyhow::ensure!( + match mode { + SnapshotScratchRestoreModeCli::PrivateCopy => true, + SnapshotScratchRestoreModeCli::CopyOnWrite => { + tier == SnapshotTierCli::WorkloadStart + } + SnapshotScratchRestoreModeCli::DirectClaimed => { + tier == SnapshotTierCli::InstanceCheckpoint + } + }, + "copy-on-write scratch requires workload-start clone semantics, and direct-claimed scratch requires instance-checkpoint resume semantics" + ); + } + if let Some(memory_capacity) = self.microvm.memory_capacity { + anyhow::ensure!( + memory_capacity.0 >= self.memory_size(), + "--memory-capacity must be at least the base --memory size" + ); + anyhow::ensure!( + self.memory_size().is_multiple_of( + openvmm_helpers::snapshot::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES + ) && memory_capacity.0.is_multiple_of( + openvmm_helpers::snapshot::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES + ), + "--memory and --memory-capacity must be aligned to the 128-MiB microVM memory block size" + ); + } + } + if self.restore_snapshot.is_some() { + anyhow::ensure!( + self.microvm.microvm_workload_identity.is_none(), + "--microvm-workload-identity is fixed by the captured microVM command line" + ); + anyhow::ensure!( + self.microvm.microvm_lifecycle.is_none(), + "--microvm-lifecycle is fixed by the captured microVM command line" + ); + anyhow::ensure!( + self.net.is_empty(), + "microVM restore takes network addressing from saved state; do not pass --net" + ); + anyhow::ensure!( + self.microvm.restore_gate_timeout_ms != 0, + "microVM post-restore gate timeout must be nonzero" + ); + if let Some(restore_processors) = self.microvm.restore_processors { + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(restore_processors,), + "microVM does not support a restore-online count of {restore_processors}" + ); + } + } + anyhow::ensure!( + self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, + "microVM requires Linux direct boot" + ); + anyhow::ensure!( + self.deprecated_uefi_firmware.is_none() + && !self.deprecated_uefi_debug + && !self.deprecated_uefi_enable_memory_protections + && !self.deprecated_uefi_force_dma_bounce + && !self.deprecated_uefi_force_firmware_version + && !self.deprecated_disable_frontpage + && self.pcat_firmware.is_none() + && self.pcat_boot_order.is_none() + && self.vga_firmware.is_none() + && !self.secure_boot + && self.secure_boot_template.is_none() + && self.custom_uefi_json.is_none() + && self.deprecated_uefi_console_mode.is_none() + && self.deprecated_efi_diagnostics_log_level.is_none() + && !self.deprecated_default_boot_always_attempt, + "microVM does not support firmware options" + ); + anyhow::ensure!( + !self.hv + && !self.vtl2 + && self.isolation.is_none() + && !self.nested_virt + && !self.get + && !self.vmbus_redirect + && self.vmbus_vsock_path.is_none() + && self.vmbus_vtl2_vsock_path.is_none() + && self.openhcl_dump_path.is_none() + && self.gdb.is_none(), + "microVM does not support Hyper-V, VTL2, isolation, nested virtualization, GET, or VMBus" + ); + if let Some(hypervisor) = self.hypervisor.as_deref() { + let name = hypervisor.split(':').next().unwrap_or(hypervisor); + anyhow::ensure!( + (cfg!(target_os = "linux") && matches!(name, "kvm" | "mshv")) + || (cfg!(windows) && name == "whp"), + "microVM requires KVM or MSHV on Linux, or WHP on Windows" + ); + } + + anyhow::ensure!( + self.com1.is_none() + && self.com2.is_none() + && self.com3.is_none() + && self.com4.is_none() + && self.vmbus_com1_serial.is_none() + && self.vmbus_com2_serial.is_none() + && self.debugcon.is_none() + && !self.serial_tx_only, + "microVM exposes only portb and virtio-console serial devices" + ); + anyhow::ensure!( + self.virtio_console_pcie_port.is_none(), + "microVM requires virtio-console on its fixed MMIO transport" + ); + if let Some(console) = &self.virtio_console { + anyhow::ensure!( + matches!( + console, + SerialConfigCli::Pipe(_) + | SerialConfigCli::Tcp(_) + | SerialConfigCli::ConnectPipe(_) + | SerialConfigCli::ConnectTcp(_) + | SerialConfigCli::Console + | SerialConfigCli::None + ), + "microVM virtio-console requires listen=..., connect=..., console, or none" + ); + } + anyhow::ensure!( + self.virtio_console.is_some() || self.virtio_console_pcie_port.is_none(), + "--virtio-console-pcie-port requires --virtio-console" + ); + self.validate_control_stdin_console(self.virtio_console.as_ref())?; + if let Some(control_console) = &self.microvm.microvm_control_console { + anyhow::ensure!( + self.virtio_console.is_some() || self.restore_snapshot.is_some(), + "--microvm-control-console requires --virtio-console for a fresh boot" + ); + anyhow::ensure!( + matches!( + control_console, + SerialConfigCli::Pipe(_) | SerialConfigCli::None + ), + "microVM control console requires listen=... or none" + ); + anyhow::ensure!( + (1..=60_000).contains(&self.microvm.microvm_control_auth_timeout_ms), + "--microvm-control-auth-timeout-ms must be between 1 and 60000" + ); + if matches!(control_console, SerialConfigCli::None) { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_host_control.is_some(), + "--microvm-control-auth-stdin is not used with a disconnected control console" + ); + } else { + anyhow::ensure!( + cfg!(any(target_os = "linux", windows)), + "live microVM control consoles require Linux Unix sockets or Windows named pipes" + ); + anyhow::ensure!( + self.microvm.microvm_control_auth_stdin, + "live microVM control console requires --microvm-control-auth-stdin" + ); + } + } else { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_host_control.is_some(), + "--microvm-control-auth-stdin requires --microvm-control-console or --microvm-host-control" + ); + } + if self.microvm.microvm_lifecycle == Some(MicrovmLifecycleCli::Managed) { + anyhow::ensure!( + self.microvm.microvm_workload_identity.is_some(), + "--microvm-lifecycle managed requires --microvm-workload-identity" + ); + anyhow::ensure!( + self.microvm + .microvm_control_console + .as_ref() + .is_some_and(|console| !matches!(console, SerialConfigCli::None)), + "--microvm-lifecycle managed requires a live --microvm-control-console" + ); + anyhow::ensure!( + self.microvm.microvm_control_auth_stdin, + "--microvm-lifecycle managed requires --microvm-control-auth-stdin" + ); + } + anyhow::ensure!( + self.disk.is_empty() + && self.nvme.is_empty() + && self.nvme_pci.is_empty() + && self.vmbus_scsi.is_empty() + && self.openhcl_controller.is_empty() + && self.ide.is_empty() + && self.floppy.is_empty(), + "microVM supports only its fixed MMIO storage devices" + ); + anyhow::ensure!( + self.virtio_blk.is_empty(), + "microVM requires --microvm-sandbox-block instead of --virtio-blk" + ); + anyhow::ensure!( + self.microvm.microvm_sandbox_block.len() <= 4, + "microVM permits at most three read-only layers and one writable scratch device" + ); + anyhow::ensure!( + self.microvm.microvm_mount_deny.len() <= 128, + "microVM filesystem permits at most 128 denied paths" + ); + anyhow::ensure!( + self.microvm.microvm_mount_allow.len() <= 128, + "microVM filesystem permits at most 128 allowed paths" + ); + anyhow::ensure!( + self.microvm.microvm_mount_write.len() <= 128, + "microVM filesystem permits at most 128 writable paths" + ); + openvmm_defs::microvm::validate_microvm_filesystems( + &self + .microvm + .microvm_mount + .iter() + .map(|mount| { + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + mount.guest_target.clone(), + mount.access, + ) + }) + .collect::, _>>()?, + ) + .context("invalid --mount options")?; + anyhow::ensure!( + cfg!(target_os = "linux") + || self.microvm.microvm_mount_owner != Some(MicrovmMountOwnerCli::Caller), + "--mount-owner caller requires a Linux host" + ); + for (index, block) in self.microvm.microvm_sandbox_block.iter().enumerate() { + anyhow::ensure!( + block.disk.read_only == block.role.is_read_only(), + "microVM sandbox block role {:?} must be {}", + block.role, + if block.role.is_read_only() { + "read-only" + } else { + "writable" + } + ); + if let Some(previous) = index + .checked_sub(1) + .and_then(|index| self.microvm.microvm_sandbox_block.get(index)) + { + anyhow::ensure!( + previous.role < block.role, + "microVM sandbox block roles must be unique and in fixed order" + ); + } + } + if ramfs_overlay { + anyhow::ensure!( + self.microvm.microvm_sandbox_block.len() == 1 + && self.microvm.microvm_sandbox_block[0].role + == MicrovmSandboxBlockRole::Distro + && self.microvm.microvm_sandbox_block[0].disk.read_only, + "microVM RAM-backed overlay requires exactly one read-only distro block" + ); + } else if !self.microvm.microvm_sandbox_block.is_empty() && self.restore_snapshot.is_none() + { + anyhow::ensure!( + self.microvm + .microvm_sandbox_block + .last() + .is_some_and(|block| block.role == MicrovmSandboxBlockRole::Scratch), + "microVM sandbox block topology requires a writable scratch device" + ); + } + anyhow::ensure!( + self.virtio_9p.is_empty() + && self.virtio_fs.is_empty() + && self.virtio_fs_shmem.is_empty() + && self.virtio_pmem.is_none() + && !self.virtio_rng + && self.virtio_vsock_path.is_none() + && self.virtio_net.is_empty(), + "microVM does not expose additional virtio devices" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.vhost_user.is_empty(), + "microVM does not support vhost-user devices" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.virtio_vsock_vhost_cid.is_none(), + "microVM does not support vhost-vsock" + ); + anyhow::ensure!( + !self.nic + && self.mana.is_empty() + && !self.gfx + && !self.vtl2_gfx + && !self.vnc.vnc + && self.tpm.is_none() + && !self.guest_watchdog + && self.imc.is_none() + && !self.battery + && self.vmgs.is_none(), + "microVM does not expose legacy NIC, MANA, graphics, TPM, watchdog, IMC, battery, or VMGS devices" + ); + anyhow::ensure!( + self.net.len() <= 1, + "microVM permits at most one virtio-net device" + ); + anyhow::ensure!( + self.net.is_empty() + || self.microvm.network_profile == Some(MicrovmNetworkProfileCli::Portable), + "microVM --net requires --network-profile portable" + ); + anyhow::ensure!( + self.microvm.network_profile.is_none() + || !self.net.is_empty() + || self.restore_snapshot.is_some(), + "--network-profile portable requires --net or --restore-snapshot" + ); + anyhow::ensure!( + self.net.iter().all(|network| { + matches!(network.endpoint, EndpointConfigCli::Microvm(_)) + && network.vtl == DeviceVtl::Vtl0 + && network.max_queues.is_none() + && !network.underhill + && network.pcie_port.is_none() + }), + "microVM --net requires a bare IPv4/prefix and does not permit queue, VTL, Underhill, or PCIe modifiers" + ); + if let [network] = self.net.as_slice() + && let EndpointConfigCli::Microvm(config) = &network.endpoint + { + self.microvm_egress_policy(config)?; + } + anyhow::ensure!( + self.microvm.net_tap.is_none(), + "--net-tap is incompatible with the portable microVM network profile" + ); + anyhow::ensure!( + self.microvm.network_ingress != Some(MicrovmNetworkActionCli::Allow), + "--network-ingress allow is unsupported by the portable microVM network profile" + ); + anyhow::ensure!( + self.microvm.network_egress_allow.len() <= 256 + && self.microvm.network_egress_deny.len() <= 256, + "microVM egress policy permits at most 256 allow rules and 256 deny rules" + ); + anyhow::ensure!( + self.microvm.host_loopback_forward.len() <= 64, + "microVM host loopback permits at most 64 explicit port forwards" + ); + if !self.microvm.host_loopback_forward.is_empty() { + anyhow::ensure!( + self.microvm.host_loopback == Some(MicrovmNetworkActionCli::Allow), + "--host-loopback-forward requires explicit --host-loopback allow" + ); + anyhow::ensure!( + self.microvm.snapshot_destination.is_none() && self.restore_snapshot.is_none(), + "microVM snapshots do not support live host-loopback port forwards" + ); + let mut forwards = self.microvm.host_loopback_forward.clone(); + forwards.sort_unstable(); + forwards.dedup(); + anyhow::ensure!( + forwards.len() == self.microvm.host_loopback_forward.len(), + "microVM host-loopback port forwards must be unique" + ); + anyhow::ensure!( + forwards.windows(2).all(|pair| { + pair[0].protocol != pair[1].protocol || pair[0].host_port != pair[1].host_port + }), + "microVM host-loopback forwards cannot bind one protocol and host port more than once" + ); + } + anyhow::ensure!( + self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + || self.microvm.network_egress.is_some(), + "--network-egress is required with --network-egress-allow or --network-egress-deny" + ); + anyhow::ensure!( + (self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty()) + || (self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty()), + "L3/L4 egress rules cannot be combined with legacy --allow-host, --block-host, or --allow-endpoint policy" + ); + anyhow::ensure!( + !matches!( + self.microvm.network_egress, + Some(MicrovmNetworkActionCli::Allow) + ) || (self.microvm.allow_host.is_empty() && self.microvm.allow_endpoint.is_empty()), + "--network-egress allow conflicts with default-deny egress allow rules" + ); + anyhow::ensure!( + self.microvm.network_egress != Some(MicrovmNetworkActionCli::Deny) + || self.microvm.block_host.is_empty(), + "--network-egress deny conflicts with default-allow egress block rules" + ); + anyhow::ensure!( + (self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty() + && self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + && self.microvm.host_loopback.is_none() + && self.microvm.network_proxy.is_none() + && self.microvm.host_loopback_forward.is_empty() + && self.microvm.network_egress.is_none() + && self.microvm.network_ingress.is_none()) + || !self.net.is_empty() + || self.restore_snapshot.is_some(), + "microVM network policy requires --net or a networked snapshot restore" + ); + anyhow::ensure!( + self.cxl_test.is_empty() + && self.pcie_root_complex.is_empty() + && self.pcie_root_port.is_empty() + && self.pcie_switch.is_empty() + && self.pcie_generic_initiator.is_empty() + && self.pcie_remote.is_empty() + && self.amd_iommu.is_empty() + && self.intel_vtd.is_empty(), + "microVM does not support PCIe or IOMMU devices" + ); + #[cfg(windows)] + anyhow::ensure!( + self.device.is_empty() && self.kernel_vmnic.is_empty(), + "microVM does not support assigned devices or kernel VM NICs" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.vfio.is_empty() && self.iommu.is_empty(), + "microVM does not support VFIO or IOMMU devices" + ); + + Ok(()) + } + + pub(crate) fn validate_control_stdin_console( + &self, + boot_console: Option<&SerialConfigCli>, + ) -> anyhow::Result<()> { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || !matches!(boot_console, Some(SerialConfigCli::Console)), + "--microvm-control-auth-stdin reserves stdin; use a socket or none for the boot console" + ); + Ok(()) + } + + pub(crate) fn microvm_egress_policy( + &self, + network: &openvmm_defs::microvm::MicrovmNetworkConfig, + ) -> Result< + net_backend_resources::egress::EgressPolicy, + net_backend_resources::egress::InvalidEgressPolicy, + > { + use net_backend_resources::egress::EgressPolicyMode; + + let mode = if !self.microvm.network_egress_allow.is_empty() + || !self.microvm.network_egress_deny.is_empty() + { + EgressPolicyMode::Rules { + default_action: self + .microvm + .network_egress + .unwrap_or(MicrovmNetworkActionCli::Allow) + .into(), + allow: self.microvm.network_egress_allow.clone(), + deny: self.microvm.network_egress_deny.clone(), + } + } else if !self.microvm.allow_host.is_empty() { + EgressPolicyMode::AllowList(self.microvm.allow_host.clone()) + } else if !self.microvm.block_host.is_empty() { + EgressPolicyMode::BlockList(self.microvm.block_host.clone()) + } else if !self.microvm.allow_endpoint.is_empty() { + EgressPolicyMode::TcpEndpoints(self.microvm.allow_endpoint.clone()) + } else if self.microvm.network_egress == Some(MicrovmNetworkActionCli::Deny) { + EgressPolicyMode::DenyAll + } else { + EgressPolicyMode::AllowAll + }; + net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + mode, + ) + .and_then(|policy| { + policy.with_host_loopback( + self.microvm + .host_loopback + .unwrap_or(MicrovmNetworkActionCli::Allow) + .into(), + self.microvm.network_proxy, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use openvmm_defs::microvm::MICROVM_BASE_COMMAND_LINE; + use openvmm_defs::microvm::MICROVM_COMMAND_LINE_MAX_SIZE; + use openvmm_defs::microvm::MICROVM_CONSOLE_COMMAND_LINE; + use openvmm_defs::microvm::append_microvm_virtio_discovery; + use openvmm_defs::microvm::build_microvm_command_line; + use openvmm_defs::microvm::build_microvm_control_command_line; + use test_with_tracing::test; + + #[test] + fn test_machine_profile_option_parsed() { + let opt = Options::try_parse_from(["openvmm"]).unwrap(); + assert_eq!(opt.machine, MachineProfileCli::Standard); + assert_eq!(MachineProfile::from(opt.machine), MachineProfile::Standard); + + let opt = Options::try_parse_from(["openvmm", "--machine", "microvm"]).unwrap(); + assert_eq!(opt.machine, MachineProfileCli::Microvm); + assert_eq!(MachineProfile::from(opt.machine), MachineProfile::Microvm); + + assert!(Options::try_parse_from(["openvmm", "--machine", "microvm-v2"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "microvm-v3"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "nvx"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "unknown"]).is_err()); + } + + #[test] + fn parses_microvm_image_slot_options() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-image-slot-boot-count", + "4", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]) + .unwrap(); + assert!(options.microvm.microvm_image_slots); + assert_eq!(options.microvm.microvm_image_slot_boot_count, Some(4)); + assert_eq!( + options.microvm.microvm_host_control.as_ref().unwrap().path, + PathBuf::from("slot-control") + ); + options.validate_microvm_options().unwrap(); + + let invalid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-image-slot-boot-count", + "0", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]) + .unwrap(); + assert!(invalid.validate_microvm_options().is_err()); + + // The first image slot uses the second virtio-fs slot's window. + let slots_with_mounts = |mounts: &[&'static str]| { + let mut args = vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]; + for mount in mounts { + args.extend(["--mount", mount]); + } + Options::try_parse_from(args).unwrap() + }; + slots_with_mounts(&["/workspace,work,rw"]) + .validate_microvm_options() + .unwrap(); + let err = slots_with_mounts(&["/workspace,work,rw", "/opt/hostedtoolcache,tools"]) + .validate_microvm_options() + .unwrap_err(); + assert!(err.to_string().contains("at most one --mount"), "{err:#}"); + } + + #[test] + fn test_microvm_processor_validation() { + for processors in [1, 2, 4, 8] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + &processors.to_string(), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + } + + for processors in [0, 3, 5, 16] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + &processors.to_string(), + ]) + .unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + + for restore_processors in [1, 2, 4, 8] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-processors", + &restore_processors.to_string(), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + } + let noncanonical_restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-processors", + "3", + ]) + .unwrap(); + assert!(noncanonical_restore.validate_microvm_options().is_err()); + + for args in [ + vec!["openvmm", "--machine", "microvm", "--vps-per-socket", "1"], + vec!["openvmm", "--machine", "microvm", "--smt", "off"], + vec!["openvmm", "--machine", "microvm", "--apic-id-offset", "1"], + vec!["openvmm", "--machine", "microvm", "--x2apic", "on"], + vec!["openvmm", "--machine", "microvm", "--numa", "size=128M"], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_memory_capacity_and_restore_target_parsing() { + let capture = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--memory", + "512M", + "--snapshot-destination", + "snapshot", + "--memory-capacity", + "2G", + ]) + .unwrap(); + capture.validate_microvm_options().unwrap(); + assert_eq!( + capture.microvm.memory_capacity.unwrap().0, + 2 * 1024 * 1024 * 1024 + ); + + let restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-memory", + "512M", + ]) + .unwrap(); + restore.validate_microvm_options().unwrap(); + assert_eq!(restore.microvm.restore_memory.unwrap().0, 512 * 1024 * 1024); + + assert!( + Options::try_parse_from( + ["openvmm", "--machine", "microvm", "--memory-capacity", "2G",] + ) + .is_err() + ); + assert!( + Options::try_parse_from(["openvmm", "--machine", "microvm", "--restore-memory", "1G",]) + .is_err() + ); + + let unaligned = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--memory", + "513M", + "--snapshot-destination", + "snapshot", + "--memory-capacity", + "2G", + ]) + .unwrap(); + assert!(unaligned.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_sandbox_block_parser_and_validation() { + let valid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "runtime:mem:1M,ro", + "--microvm-sandbox-block", + "custom:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + valid.validate_microvm_options().unwrap(); + + for args in [ + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "scratch:mem:1M,ro", + ], + vec!["openvmm", "--machine", "microvm", "--virtio-blk", "mem:1M"], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_ramfs_overlay_requires_one_distro_and_no_snapshot() { + let valid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "quiet nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ]) + .unwrap(); + valid.validate_microvm_options().unwrap(); + + for args in [ + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs nvx_overlay_upper", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + ], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_snapshot_tier_is_explicit() { + for tier in ["platform", "workload-start", "instance-checkpoint"] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + tier, + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert_eq!(options.microvm.restore_gate_timeout_ms, 60_000); + options.validate_microvm_options().unwrap(); + } + + let missing = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(missing.validate_microvm_options().is_err()); + + let blockless = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + "2", + "--snapshot-destination", + "snapshot", + ]) + .unwrap(); + blockless.validate_microvm_options().unwrap(); + + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-tier", + "platform", + ]) + .is_err() + ); + + let zero_timeout = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-gate-timeout-ms", + "0", + ]) + .unwrap(); + assert!(zero_timeout.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_snapshot_identity_and_scratch_restore_modes_are_explicit() { + let generation = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-block-identity", + "generation", + "--snapshot-generation-id", + "00112233445566778899aabbccddeeff", + "--snapshot-scratch-restore-mode", + "copy-on-write", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + #[cfg(target_os = "linux")] + generation.validate_microvm_options().unwrap(); + #[cfg(not(target_os = "linux"))] + assert!( + generation + .validate_microvm_options() + .unwrap_err() + .to_string() + .contains("copy-on-write is unsupported on this platform") + ); + + let missing_generation = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-block-identity", + "generation", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(missing_generation.validate_microvm_options().is_err()); + + let direct_clone = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-scratch-restore-mode", + "direct-claimed", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(direct_clone.validate_microvm_options().is_err()); + + let resume = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "instance-checkpoint", + "--snapshot-scratch-restore-mode", + "direct-claimed", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + resume.validate_microvm_options().unwrap(); + } + + #[test] + fn test_microvm_command_line_is_owned_and_bounded() { + assert_eq!( + build_microvm_command_line(&[], false).unwrap(), + MICROVM_BASE_COMMAND_LINE + ); + assert_eq!( + build_microvm_command_line(&["foo=bar".into()], false).unwrap(), + format!("{MICROVM_BASE_COMMAND_LINE} foo=bar") + ); + assert_eq!( + build_microvm_command_line(&[], true).unwrap(), + MICROVM_CONSOLE_COMMAND_LINE + ); + + let mut with_devices = build_microvm_command_line(&[], true).unwrap(); + let blocks = [ + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]; + append_microvm_virtio_discovery( + &mut with_devices, + None, + false, + &[], + true, + false, + &blocks, + None, + ) + .unwrap(); + assert_eq!( + with_devices, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7 virtio_mmio.device=0x1000@0xd0003000:4 virtio_mmio.device=0x1000@0xd0006000:11" + ) + ); + let mut with_control_console = build_microvm_control_command_line(&[], true).unwrap(); + append_microvm_virtio_discovery( + &mut with_control_console, + None, + false, + &[], + true, + true, + &[], + None, + ) + .unwrap(); + assert_eq!( + with_control_console, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {}", + openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE + ) + ); + + let network = "10.0.0.2/24".parse().unwrap(); + let mut with_network = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery( + &mut with_network, + Some(( + &network, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_KVM_IRQ, + false, + )), + false, + &[], + false, + false, + &[], + None, + ) + .unwrap(); + assert_eq!( + with_network, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0000000:10 virtnet_ip=10.0.0.2 virtnet_mask=255.255.255.0 virtnet_gw=10.0.0.1" + ) + ); + let mut with_whp_network = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery( + &mut with_whp_network, + Some(( + &network, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_WHP_IRQ, + true, + )), + false, + &[], + false, + false, + &[], + None, + ) + .unwrap(); + assert!(with_whp_network.ends_with("virtnet_dns=10.0.0.1")); + + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(); + let mut with_filesystem = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery( + &mut with_filesystem, + None, + true, + std::slice::from_ref(&filesystem), + false, + false, + &[], + None, + ) + .unwrap(); + assert_eq!( + with_filesystem, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 virtfs_dir=/mnt/share virtfs_tag=microvm virtfs_mode=ro" + ) + ); + + for reserved in [ + "earlycon=uart", + "console=ttyS0", + "virtio_mmio.device=bad", + "virtnet_ip=10.0.0.3", + "virtnet_mask=255.255.255.0", + "virtnet_gw=10.0.0.1", + "virtnet_dns=10.0.0.1", + "nr_cpus=1", + "virtfs_dir=/other", + "virtfs_tag=other", + "virtfs_mode=rw", + ] { + assert!(build_microvm_command_line(&[reserved.into()], false).is_err()); + } + for reserved in [ + "nvx_control_tty=hvc9", + "nvx-control-tty=hvc9", + "driver_async_probe=virtio_console", + "driver-async-probe=virtio_console", + "virtio-mmio.device=0x1000@0xd0007000:3", + ] { + assert!(build_microvm_control_command_line(&[reserved.into()], false).is_err()); + assert!(build_microvm_command_line(&[reserved.into()], false).is_ok()); + } + for delimiter in ["--", "\"driver-async-probe=virtio_console\""] { + assert!(build_microvm_control_command_line(&[delimiter.into()], false).is_err()); + assert!(build_microvm_command_line(&[delimiter.into()], false).is_ok()); + } + assert!(build_microvm_command_line(&["foo=bar\0baz".into()], false).is_err()); + assert!( + build_microvm_command_line(&["x".repeat(MICROVM_COMMAND_LINE_MAX_SIZE)], false) + .is_err() + ); + } + + #[test] + fn test_microvm_preflight_rejects_unsupported_combinations() { + let valid = Options::try_parse_from(["openvmm", "--machine", "microvm"]).unwrap(); + valid.validate_microvm_options().unwrap(); + if cfg!(target_os = "linux") { + let valid_mshv = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--hypervisor", + "mshv", + ]) + .unwrap(); + valid_mshv.validate_microvm_options().unwrap(); + } + let valid_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "listen=tcp:127.0.0.1:5555", + ]) + .unwrap(); + valid_console.validate_microvm_options().unwrap(); + let valid_control_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + ]) + .unwrap(); + valid_control_console.validate_microvm_options().unwrap(); + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-control-protocol-version", + "1", + ]) + .is_err() + ); + let control_endpoint = if cfg!(windows) { + "listen=//./pipe/openvmm-microvm-restore-test" + } else { + "listen=control.sock" + }; + let valid_restore_control_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--microvm-control-console", + control_endpoint, + "--microvm-control-auth-stdin", + ]) + .unwrap(); + if cfg!(any(target_os = "linux", windows)) { + valid_restore_control_console + .validate_microvm_options() + .unwrap(); + } else { + assert!( + valid_restore_control_console + .validate_microvm_options() + .is_err() + ); + } + let valid_network = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ]) + .unwrap(); + valid_network.validate_microvm_options().unwrap(); + match EndpointConfigCli::from_str("10.0.0.2/24").unwrap() { + EndpointConfigCli::Microvm(network) => { + assert_eq!(network.guest_ipv4, std::net::Ipv4Addr::new(10, 0, 0, 2)); + assert_eq!(network.prefix_length, 24); + assert_eq!(network.netmask(), std::net::Ipv4Addr::new(255, 255, 255, 0)); + assert_eq!( + network.derived_gateway_ipv4, + std::net::Ipv4Addr::new(10, 0, 0, 1) + ); + assert_eq!(network.guest_mac.to_bytes(), [0x52, 0x54, 0, 0, 0, 2]); + assert_eq!(network.gateway_mac.to_bytes(), [0x52, 0x54, 0, 0, 0, 1]); + } + _ => panic!("Expected microVM network variant"), + } + for invalid in [ + "10.0.0.2", + "10.0.0.2/0", + "10.0.0.2/31", + "10.0.0.0/24", + "10.0.0.1/24", + "10.0.0.255/24", + "fd00::2/64", + ] { + assert!(EndpointConfigCli::from_str(invalid).is_err(), "{invalid}"); + } + let valid_filesystem = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--mount", + "/mnt/share,host,ro", + ]) + .unwrap(); + valid_filesystem.validate_microvm_options().unwrap(); + + for args in [ + vec!["openvmm", "--machine", "microvm", "--net", "10.0.0.2/24"], + vec![ + "openvmm", + "--machine", + "microvm", + "--network-profile", + "portable", + "--net-tap", + "tap0", + ], + vec!["openvmm", "--machine", "microvm", "--uefi"], + vec!["openvmm", "--machine", "microvm", "--hypervisor", "unknown"], + vec!["openvmm", "--machine", "microvm", "--virtio-rng"], + vec!["openvmm", "--machine", "microvm", "--com1", "none"], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "stderr", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "listen=tcp:127.0.0.1:5555", + "--virtio-console-pcie-port", + "port0", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-control-console", + "none", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "listen=tcp:127.0.0.1:5555", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "listen=control.sock", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + "--microvm-control-auth-stdin", + ], + vec!["openvmm", "--machine", "microvm", "--net", "consomme"], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--net", + "10.0.1.2/24", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "queues=1:10.0.0.2/24", + ], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn control_auth_stdin_is_explicit_and_exclusive() { + let endpoint = if cfg!(windows) { + "listen=//./pipe/openvmm-microvm-test" + } else { + "listen=control.sock" + }; + let args = [ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + endpoint, + "--microvm-control-auth-stdin", + ]; + let options = Options::try_parse_from(args).unwrap(); + assert!(options.microvm.microvm_control_auth_stdin); + assert_eq!( + options.validate_microvm_options().is_ok(), + cfg!(any(target_os = "linux", windows)) + ); + assert!( + options + .validate_control_stdin_console(Some(&SerialConfigCli::Console)) + .unwrap_err() + .to_string() + .contains("reserves stdin") + ); + for console in [ + SerialConfigCli::None, + SerialConfigCli::Pipe("boot.sock".into()), + ] { + options + .validate_control_stdin_console(Some(&console)) + .unwrap(); + } + for extra in [ + vec!["--rpc", "path=management.sock"], + vec!["--ttrpc", "management.sock"], + vec!["--grpc", "management.sock"], + vec!["--relay-console-path", "console.sock"], + vec!["--write-saved-state-proto", "proto"], + vec!["--paused"], + vec!["--microvm-control-auth-handle", "0"], + ] { + assert!(Options::try_parse_from(args.into_iter().chain(extra)).is_err()); + } + let auth_only = + Options::try_parse_from(["openvmm", "--microvm-control-auth-stdin"]).unwrap(); + assert!(auth_only.validate_microvm_options().is_err()); + assert!( + Options::try_parse_from([ + "openvmm", + "--microvm-control-console", + "listen=control.sock", + "--microvm-control-auth-stdin=0", + ]) + .is_err() + ); + } + + #[test] + fn test_microvm_host_loopback_generic_allow_is_rejected() { + let common = [ + "openvmm", + "--machine", + "microvm", + "--network-profile", + "portable", + "--host-loopback", + "allow", + ]; + for extra in [ + vec!["--net", "10.0.0.2/24"], + vec!["--net", "10.0.0.2/24", "--snapshot-destination", "snapshot"], + vec!["--restore-snapshot", "snapshot"], + ] { + let options = Options::try_parse_from(common.into_iter().chain(extra)).unwrap(); + let error = options.validate_microvm_options().unwrap_err(); + assert!( + error + .to_string() + .contains("generic host-loopback connectivity"), + "{error}" + ); + } + let forwarded = Options::try_parse_from(common.into_iter().chain([ + "--net", + "10.0.0.2/24", + "--network-ingress", + "deny", + "--host-loopback-forward", + "tcp:3000:8080", + "--host-loopback-forward", + "udp:3000:8080", + ])) + .unwrap(); + forwarded.validate_microvm_options().unwrap(); + } + + #[test] + fn test_microvm_egress_policy_is_typed_and_canonical() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-host", + "192.168.1.9/24", + "--allow-host", + "10.0.0.1", + "--allow-host", + "192.168.1.0/24", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::AllowList(rules) = policy.mode() + else { + panic!("expected allow-list policy") + }; + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].network(), std::net::Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(rules[1].network(), std::net::Ipv4Addr::new(192, 168, 1, 0)); + assert!(policy.allows_gateway_dns()); + + let endpoint = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-endpoint", + "10.0.0.9:8443", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + ]) + .unwrap(); + endpoint.validate_microvm_options().unwrap(); + let endpoint_policy = endpoint.microvm_egress_policy(&network).unwrap(); + assert_eq!( + endpoint_policy.next_hops(), + &[ + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 9), + ] + ); + + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--allow-host", + "192.0.2.0/24", + "--block-host", + "198.51.100.1", + ]) + .is_err() + ); + let missing_network = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(missing_network.validate_microvm_options().is_err()); + let standard = + Options::try_parse_from(["openvmm", "--allow-endpoint", "192.0.2.7:443"]).unwrap(); + assert!(standard.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_l3_l4_rules_are_explicit_and_composable() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24:tcp:443", + "--network-egress-allow", + "192.0.2.7:udp:53", + "--network-egress-deny", + "192.0.2.9:tcp:443", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = policy.mode() + else { + panic!("expected rule-based egress policy") + }; + assert_eq!( + *default_action, + net_backend_resources::egress::EgressAction::Deny + ); + assert_eq!((allow.len(), deny.len()), (2, 1)); + + let missing_default = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress-allow", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(missing_default.validate_microvm_options().is_err()); + + let mixed_legacy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(mixed_legacy.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_protocol_wide_egress_rules_parse_before_resources() { + use net_backend_resources::egress::EgressRule; + use net_backend_resources::egress::EgressTransport; + + let network_options = [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + ]; + let options = Options::try_parse_from(network_options.into_iter().chain([ + "--network-egress-allow", + "198.51.100.0/24:udp", + "--network-egress-allow", + "198.51.100.0/24:tcp", + "--network-egress-allow", + "198.51.100.0/24:icmp", + "--network-egress-deny", + "198.51.100.7:tcp:443", + "--network-egress-deny", + "198.51.100.7:icmp", + ])) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { allow, deny, .. } = + policy.mode() + else { + panic!("expected rule-based egress policy") + }; + let selectors = |rules: &[EgressRule]| { + rules + .iter() + .map(|rule| (rule.transport(), rule.port())) + .collect::>() + }; + assert_eq!( + selectors(allow), + [ + (EgressTransport::Tcp, 0), + (EgressTransport::Udp, 0), + (EgressTransport::Icmp, 0), + ] + ); + assert_eq!( + selectors(deny), + [(EgressTransport::Tcp, 443), (EgressTransport::Icmp, 0)] + ); + + for rule in [ + "192.0.2.1:icmp:8", + "192.0.2.1:any", + "192.0.2.1:any:443", + "192.0.2.1:tcp:0", + ] { + assert!( + Options::try_parse_from( + network_options + .into_iter() + .chain(["--network-egress-allow", rule]) + ) + .is_err(), + "{rule}" + ); + } + } + + #[test] + fn test_microvm_egress_port_ranges_parse_before_resources() { + use net_backend_resources::egress::EgressRule; + use net_backend_resources::egress::EgressTransport; + + let network_options = [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + ]; + let options = Options::try_parse_from(network_options.into_iter().chain([ + "--network-egress-allow", + "198.51.100.0/24:udp:5000-5010", + "--network-egress-allow", + "198.51.100.0/24:tcp:8000-8010", + "--network-egress-allow", + "198.51.100.0/24:tcp:443-443", + "--network-egress-deny", + "198.51.100.7:tcp:8005", + ])) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { allow, deny, .. } = + policy.mode() + else { + panic!("expected rule-based egress policy") + }; + let selectors = |rules: &[EgressRule]| { + rules + .iter() + .map(|rule| (rule.transport(), rule.port(), rule.end_port())) + .collect::>() + }; + assert_eq!( + selectors(allow), + [ + (EgressTransport::Tcp, 443, 443), + (EgressTransport::Tcp, 8000, 8010), + (EgressTransport::Udp, 5000, 5010), + ] + ); + assert_eq!(selectors(deny), [(EgressTransport::Tcp, 8005, 8005)]); + + for rule in [ + "192.0.2.1:tcp:8010-8000", + "192.0.2.1:tcp:-8010", + "192.0.2.1:udp:8000-", + "192.0.2.1:tcp:0-8010", + "192.0.2.1:udp:8000-65536", + "192.0.2.1:icmp:1-2", + ] { + assert!( + Options::try_parse_from( + network_options + .into_iter() + .chain(["--network-egress-deny", rule]) + ) + .is_err(), + "{rule}" + ); + } + } + + #[test] + fn test_microvm_workload_identity_is_non_root_and_not_restorable() { + for identity in ["0:1", "1:0", "root:1", "1:root", "1", "1:2:3"] { + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + identity, + ]) + .is_err() + ); + } + + fn check_microvm_host_loopback_policy_and_explicit_forwards() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = + "10.0.0.2/24".parse().unwrap(); + let denied = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:8443", + ]) + .unwrap(); + denied.validate_microvm_options().unwrap(); + let policy = denied.microvm_egress_policy(&network).unwrap(); + assert_eq!( + policy.host_loopback_action(), + net_backend_resources::egress::EgressAction::Deny + ); + assert_eq!(policy.proxy_endpoint().unwrap().port(), 8443); + + let wrong_proxy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--network-proxy", + "192.0.2.1:8443", + ]) + .unwrap(); + assert!(wrong_proxy.validate_microvm_options().is_err()); + + let denied_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--host-loopback-forward", + "tcp:3000:8080", + ]) + .unwrap(); + assert!(denied_forward.validate_microvm_options().is_err()); + + let allowed_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:3000:8080", + ]) + .unwrap(); + allowed_forward.validate_microvm_options().unwrap(); + + let duplicate_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:3000:8080", + "--host-loopback-forward", + "tcp:3000:8081", + ]) + .unwrap(); + assert!(duplicate_forward.validate_microvm_options().is_err()); + } + check_microvm_host_loopback_policy_and_explicit_forwards(); + + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + "65534:65534", + ]) + .unwrap(); + assert_eq!( + options.microvm.microvm_workload_identity, + Some(MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }) + ); + options.validate_microvm_options().unwrap(); + + let restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--microvm-workload-identity", + "65534:65534", + ]) + .unwrap(); + assert!(restore.validate_microvm_options().is_err()); + } + + #[test] + fn test_managed_lifecycle_requires_authenticated_control() { + let missing_control = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + "65534:65534", + "--microvm-lifecycle", + "managed", + ]) + .unwrap(); + assert!(missing_control.validate_microvm_options().is_err()); + + let disconnected_control = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + "--microvm-workload-identity", + "65534:65534", + "--microvm-lifecycle", + "managed", + ]) + .unwrap(); + assert!(disconnected_control.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_directional_network_policy_is_independent_and_fail_closed() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + for (arguments, expected_mode) in [ + ( + ["--network-egress", "allow", "--network-ingress", "deny"].as_slice(), + "allow-all", + ), + ( + ["--network-egress", "deny", "--network-ingress", "deny"].as_slice(), + "deny-all", + ), + (["--network-egress", "deny"].as_slice(), "deny-all"), + (["--network-ingress", "deny"].as_slice(), "allow-all"), + ( + ["--network-egress", "allow", "--block-host", "192.0.2.1"].as_slice(), + "block-list", + ), + ( + ["--network-egress", "deny", "--allow-host", "192.0.2.1"].as_slice(), + "allow-list", + ), + ] { + let options = Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ] + .into_iter() + .chain(arguments.iter().copied()), + ) + .unwrap(); + options.validate_microvm_options().unwrap(); + assert_eq!( + options.microvm_egress_policy(&network).unwrap().mode_name(), + expected_mode + ); + } + + for arguments in [ + ["--network-ingress", "allow"].as_slice(), + ["--network-egress", "deny", "--network-ingress", "allow"].as_slice(), + ["--network-egress", "allow", "--allow-host", "192.0.2.1"].as_slice(), + ["--network-egress", "deny", "--block-host", "192.0.2.1"].as_slice(), + ] { + let options = Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ] + .into_iter() + .chain(arguments.iter().copied()), + ) + .unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_endpoint_policy_rejects_invalid_identities_before_resources() { + for address in [ + "0.0.0.0", + "127.0.0.1", + "169.254.1.1", + "224.0.0.1", + "240.0.0.1", + "10.0.0.0", + "10.0.0.2", + "10.0.0.255", + ] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-endpoint", + &format!("{address}:443"), + ]) + .unwrap(); + assert!( + options.validate_microvm_options().is_err(), + "invalid endpoint address {address} was accepted" + ); + } + } + + #[test] + fn test_microvm_mount_from_str() { + let read_only = MicrovmMountCli::from_str("/mnt/share,host").unwrap(); + assert_eq!(read_only.guest_target, "/mnt/share"); + assert_eq!(read_only.host_path, PathBuf::from("host")); + assert_eq!( + read_only.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ); + + let read_write = MicrovmMountCli::from_str("/srv/data,host,rw").unwrap(); + assert_eq!( + read_write.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ); + assert!(MicrovmMountCli::from_str("relative,host").is_err()); + assert!(MicrovmMountCli::from_str("/mnt/../escape,host").is_err()); + assert!(MicrovmMountCli::from_str("/mnt/share,host,write").is_err()); + } + + #[test] + fn test_microvm_mount_is_repeatable_up_to_the_slot_count() { + let parse = |mounts: &[&str]| { + let mut args = vec!["openvmm", "--machine", "microvm"]; + for mount in mounts { + args.extend(["--mount", mount]); + } + Options::try_parse_from(args).unwrap() + }; + let options = parse(&["/workspace,work,rw", "/opt/hostedtoolcache,tools"]); + options.validate_microvm_options().unwrap(); + let targets = options + .microvm + .microvm_mount + .iter() + .map(|mount| (mount.guest_target.as_str(), mount.access)) + .collect::>(); + assert_eq!( + targets, + [ + ( + "/workspace", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ), + ( + "/opt/hostedtoolcache", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ), + ] + ); + + for (mounts, expected) in [ + (&["/a,a", "/b,b", "/c,c"][..], "at most 2 filesystems"), + (&["/workspace,a,rw", "/workspace,b"][..], "overlap"), + (&["/workspace,a,rw", "/workspace/cache,b"][..], "overlap"), + (&["/opt/tools/node,a", "/opt,b,rw"][..], "overlap"), + ] { + let error = parse(mounts).validate_microvm_options().unwrap_err(); + assert!(format!("{error:#}").contains(expected), "{error:#}"); + } + + // --mount-deny and --mount-owner still require a --mount. + assert!( + Options::try_parse_from(["openvmm", "--machine", "microvm", "--mount-deny", "x"]) + .is_err() + ); + } +} diff --git a/openvmm/openvmm_entry/src/cpu_fingerprint.rs b/openvmm/openvmm_entry/src/cpu_fingerprint.rs new file mode 100644 index 00000000000..7f3395e9829 --- /dev/null +++ b/openvmm/openvmm_entry/src/cpu_fingerprint.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Writes and checks the host CPU fingerprint for `--cpu-fingerprint`. + +use anyhow::Context; +use cpu_profile::ProfileError; +use cpu_profile::ProfileErrorCode; +use cpu_profile::fingerprint::CpuFingerprint; +use cpu_profile::fingerprint::ToolIdentity; +use cpu_profile::host::HostIdentity; +use std::io::Write; +use std::path::Path; + +/// Fingerprints this host for the backend selected by `hypervisor` (or the +/// first available one), writes the fingerprint to `path`, or to stdout for +/// `-`, and checks it against the CPU profile of the host's generation. +/// +/// The check prints one `NVX-CPU-PROFILE:` line to stderr and fails with its +/// code (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNSUPPORTED`, or, for MSHV and +/// WHP, `E_CPU_UNLISTED`) after the fingerprint is written, so hosts +/// of new generations can still be fingerprinted. A backend that can +/// configure a probe partition from the profile, as MSHV and WHP can, is +/// checked for `E_CPU_UNLISTED` on that partition, as a cold boot checks its +/// own. +pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> { + let backend = openvmm_helpers::hypervisor::fingerprint_backend(hypervisor) + .context("failed to fingerprint the hypervisor backend")?; + let backend_fingerprint = backend + .cpu_fingerprint() + .context("failed to fingerprint the hypervisor backend")?; + let host = HostIdentity::collect().context("failed to identify the host")?; + let fingerprint = CpuFingerprint::new( + ToolIdentity { + name: "openvmm".to_owned(), + version: openvmm_build_info::get().version().to_owned(), + }, + host, + backend_fingerprint, + ); + let json = fingerprint.to_json(); + if path == Path::new("-") { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(json.as_bytes()) + .and_then(|()| stdout.flush()) + .context("failed to write the CPU fingerprint to stdout")?; + } else { + fs_err::write(path, json).context("failed to write the CPU fingerprint")?; + } + tracing::info!( + backend = fingerprint.backend.name, + surface_digest = fingerprint.surface_digest, + digest = fingerprint.digest, + "wrote CPU fingerprint" + ); + + let check = cpu_profile::check_fingerprint_with(&fingerprint, |profile| { + backend.profile_unlisted_cpuid(profile).map_err(|err| { + ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "cannot configure a probe partition from CPU profile {}: {err:#}", + profile.id() + ), + ) + }) + }); + eprintln!("{}", check.summary_line(&fingerprint)); + Ok(check.result?) +} diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 40d178dd792..30f74668b37 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -8,12 +8,15 @@ #![forbid(unsafe_code)] mod cli_args; +mod cpu_fingerprint; mod crash_dump; mod kvp; mod meshworker; +mod microvm; mod pidfile; mod repl; mod serial_io; +mod snapshot_restore; mod storage_builder; mod tracing_init; mod ttrpc; @@ -39,6 +42,7 @@ use cli_args::TpmVersionCli; use cli_args::UefiConsoleModeCli; use cli_args::VirtioBusCli; use cli_args::VmgsCli; +use cli_args::microvm::MachineProfileCli; use crash_dump::spawn_dump_handler; use cxl_spec::test::CxlTestDeviceHandle; use disk_backend_resources::DelayDiskHandle; @@ -149,6 +153,11 @@ use vmotherboard::ChipsetDeviceHandle; use vnc_worker_defs::VncParameters; pub fn openvmm_main() { + #[cfg(target_os = "linux")] + if let Err(error) = pal::unix::fd_table::expand_fd_table() { + eprintln!("warning: failed to expand the file descriptor table: {error}"); + } + // Save the current state of the terminal so we can restore it back to // normal before exiting. #[cfg(unix)] @@ -158,7 +167,7 @@ pub fn openvmm_main() { let exit_code = match do_main(&mut pidfile_guard) { Ok(code) => code, Err(err) => { - eprintln!("fatal error: {:?}", err); + eprintln!("{}", microvm::fatal_error_message(&err)); 1 } }; @@ -195,6 +204,7 @@ struct VmResources { consomme_rpc: Option>, ged_rpc: Option>, vtl2_settings: Option, + microvm: microvm::MicrovmResources, /// Receives dirty rectangles from the synthetic video device for the VNC worker. dirty_rect_recv: Option>>, #[cfg(windows)] @@ -231,7 +241,9 @@ fn build_switch_list(all_switches: &[cli_args::GenericPcieSwitchCli]) -> Vec BaseChipsetType { - if opt.igvm.is_some() { + if opt.machine == MachineProfileCli::Microvm { + BaseChipsetType::Microvm + } else if opt.igvm.is_some() { match opt.igvm_personality { None => BaseChipsetType::HclHost, Some(IgvmPersonalityCli::Uefi) => BaseChipsetType::HypervGen2Uefi, @@ -318,9 +330,11 @@ async fn vm_config_from_command_line( spawner: impl Spawn, mesh: &VmmMesh, opt: &Options, + microvm_restore: µvm::MicrovmRestore, ) -> anyhow::Result<(Config, VmResources)> { opt.validate_isolation_options()?; opt.validate_igvm_options()?; + let mut microvm = microvm::MicrovmConfigBuilder::new(opt, microvm_restore)?; let (_, serial_driver) = DefaultPool::spawn_on_thread("serial"); let uefi = opt.effective_uefi()?; @@ -389,6 +403,19 @@ async fn vm_config_from_command_line( SerialConfigCli::Tcp(addr) => { Some(serial_io::bind_tcp_serial(&addr).context("failed to bind serial")?) } + SerialConfigCli::ConnectPipe(path) => Some( + serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .context("failed to connect serial")?, + ), + SerialConfigCli::ConnectTcp(addr) => Some(serial_io::connect::connect_tcp_serial( + &addr, + Duration::from_millis(openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS), + )?), SerialConfigCli::NewConsole(app, window_title) => { let path = console_relay::random_console_path(); let config = @@ -419,11 +446,13 @@ async fn vm_config_from_command_line( opt.com4.as_ref().is_some_and(|c| c.debugger_mode), ]; + microvm.setup_portb(&console_state, &serial_driver)?; + let serial0_cfg = setup_serial( "com1", opt.com1 .clone() - .map_or(SerialConfigCli::Console, |c| c.backend), + .map_or(microvm.default_com1_backend(), |c| c.backend), if cfg!(guest_arch = "x86_64") { "ttyS0" } else { @@ -510,7 +539,9 @@ async fn vm_config_from_command_line( "debugcon", )?; - let virtio_console_backend = if let Some(serial_cfg) = opt.virtio_console.clone() { + let virtio_console_backend = if microvm.is_active() { + microvm.setup_virtio_consoles(&console_state, &serial_driver)? + } else if let Some(serial_cfg) = opt.virtio_console.clone() { setup_serial("virtio-console", serial_cfg, "hvc0")? } else { None @@ -653,6 +684,8 @@ async fn vm_config_from_command_line( .await?; } + microvm.add_sandbox_blocks(&mut storage).await?; + for &cli_args::IdeDiskCli { ref kind, read_only, @@ -771,6 +804,9 @@ async fn vm_config_from_command_line( let mut nic_index = 0; for cli_cfg in &opt.net { + if microvm.is_active() { + continue; + } if cli_cfg.pcie_port.is_some() { anyhow::bail!("`--net` does not support PCIe"); } @@ -1333,7 +1369,12 @@ async fn vm_config_from_command_line( TpmVersionCli::V185 => TpmVersion::V185, }); - if opt.restore_snapshot.is_some() { + microvm.add_chipset_devices(&mut chipset_devices)?; + + if let Some(microvm_load_mode) = microvm.load_mode(arch)? { + load_mode = microvm_load_mode; + with_hv = false; + } else if opt.restore_snapshot.is_some() { // Snapshot restore: skip firmware loading entirely. Device state and // memory come from the snapshot directory. load_mode = LoadMode::None; @@ -1517,6 +1558,7 @@ async fn vm_config_from_command_line( }; } + microvm.validate_vmgs()?; let mut vmgs = Some(if let Some(VmgsCli { kind, provision }) = &opt.vmgs { let disk = VmgsDisk { disk: disk_open(kind, false) @@ -1536,6 +1578,7 @@ async fn vm_config_from_command_line( } else { VmgsResource::Ephemeral }); + microvm.filter_vmgs(&mut vmgs); if with_get && with_hv { let has_vtl0_nvme = storage.has_vtl0_nvme(); @@ -1862,6 +1905,11 @@ async fn vm_config_from_command_line( }), }; + microvm.add_virtio_devices( + &mut |bus, resource| add_virtio_device(bus, resource, &mut pcie_devices), + &mut resources, + )?; + for cli_cfg in &opt.virtio_net { if cli_cfg.underhill { anyhow::bail!("use --net uh:[...] to add underhill NICs") @@ -1871,6 +1919,10 @@ async fn vm_config_from_command_line( max_queues: vport.max_queues, mac_address: vport.mac_address, endpoint: vport.endpoint, + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(); if let Some(pcie_port) = &cli_cfg.pcie_port { @@ -1890,6 +1942,7 @@ async fn vm_config_from_command_line( root_path: args.path.clone(), mount_options: args.options.clone(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(); if let Some(pcie_port) = &args.pcie_port { @@ -1908,6 +1961,7 @@ async fn vm_config_from_command_line( fs: virtio_resources::fs::VirtioFsBackend::SectionFs { root_path: args.path.clone(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(); if let Some(pcie_port) = &args.pcie_port { @@ -1967,8 +2021,10 @@ async fn vm_config_from_command_line( if let Some(backend) = virtio_console_backend { let resource: Resource = - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource(); - if let Some(pcie_port) = &opt.virtio_console_pcie_port { + microvm.virtio_console_handle(backend).into_resource(); + if microvm.is_active() { + add_virtio_device(VirtioBusCli::Mmio, resource, &mut pcie_devices); + } else if let Some(pcie_port) = &opt.virtio_console_pcie_port { pcie_devices.push(PcieDeviceConfig { port_name: pcie_port.clone(), resource: VirtioPciDeviceHandle(resource).into_resource(), @@ -1977,6 +2033,9 @@ async fn vm_config_from_command_line( add_virtio_device(VirtioBusCli::Auto, resource, &mut pcie_devices); } } + microvm.add_control_console(&mut |bus, resource| { + add_virtio_device(bus, resource, &mut pcie_devices) + })?; // Handle --vhost-user arguments. #[cfg(target_os = "linux")] @@ -2063,6 +2122,8 @@ async fn vm_config_from_command_line( pcie_devices.extend(vfio_pcie_devices); let mut cfg = Config { + machine_profile: opt.machine.into(), + microvm: Default::default(), chipset, load_mode, floppy_disks, @@ -2211,6 +2272,7 @@ async fn vm_config_from_command_line( device.port_name ); } + microvm.finish(&mut cfg, &mut resources)?; resources.serial_driver = Some(serial_driver); validate_snp_config(&cfg)?; Ok((cfg, resources)) @@ -2350,8 +2412,12 @@ fn parse_endpoint( }; net_backend_resources::consomme::ConsommeHandle { cidr: cidr.clone(), + static_ipv4: None, ports, recv, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource() } @@ -2390,6 +2456,9 @@ fn parse_endpoint( bail!("TAP backend is only supported on Linux") } } + EndpointConfigCli::Microvm(_) => { + bail!("a bare IPv4/prefix --net is only supported by the microVM profile") + } }; // Pick a random MAC address. @@ -2648,54 +2717,8 @@ pub(crate) const GUEST_ARCH: &str = if cfg!(guest_arch = "x86_64") { "aarch64" }; -/// Open a snapshot directory and validate it against the current VM config. -/// Returns the shared memory fd (from memory.bin) and the saved device state. -fn prepare_snapshot_restore( - snapshot_dir: &Path, - opt: &Options, -) -> anyhow::Result<( - openvmm_defs::worker::SharedMemoryFd, - mesh::payload::message::ProtobufMessage, -)> { - let (manifest, state_bytes) = openvmm_helpers::snapshot::read_snapshot(snapshot_dir)?; - - // Validate manifest against current VM config. - openvmm_helpers::snapshot::validate_manifest( - &manifest, - GUEST_ARCH, - opt.memory_size(), - opt.processors, - system_page_size(), - )?; - - // Open memory.bin (existing file, no create, no resize). - let memory_file = fs_err::OpenOptions::new() - .read(true) - .write(true) - .open(snapshot_dir.join("memory.bin"))?; - - // Validate file size matches expected memory size. - let file_size = memory_file.metadata()?.len(); - if file_size != manifest.memory_size_bytes { - anyhow::bail!( - "memory.bin size ({file_size} bytes) doesn't match manifest ({} bytes)", - manifest.memory_size_bytes, - ); - } - - let shared_memory_fd = - openvmm_helpers::shared_memory::file_to_shared_memory_fd(memory_file.into())?; - - // Reconstruct ProtobufMessage from the saved state bytes. - // The save side wrote mesh::payload::encode(ProtobufMessage), so we decode - // back to ProtobufMessage. - let state_msg: mesh::payload::message::ProtobufMessage = mesh::payload::decode(&state_bytes) - .context("failed to decode saved state from snapshot")?; - - Ok((shared_memory_fd, state_msg)) -} - fn do_main(pidfile_guard: &mut Option) -> anyhow::Result { + openvmm_defs::profile::initialize(); #[cfg(windows)] pal::windows::disable_hard_error_dialog(); @@ -2712,6 +2735,7 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result // with --version and --help. tracing::info!(version = openvmm_build_info::get().version()); + microvm::report::validate_early_options(&opt)?; if let Some(path) = &opt.write_saved_state_proto { mesh::payload::protofile::DescriptorWriter::new(vmcore::save_restore::saved_state_roots()) .write_to_path(path) @@ -2719,6 +2743,11 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result return Ok(0); } + if let Some(path) = &opt.cpu_fingerprint { + cpu_fingerprint::write(path, opt.hypervisor.as_deref())?; + return Ok(0); + } + if let Some(ref path) = opt.pidfile { *pidfile_guard = Some(pidfile::Pidfile::new(path).context("failed to create pidfile")?); } @@ -2774,7 +2803,7 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result } } - DefaultPool::run_with(async |driver| run_control(&driver, opt).await) + DefaultPool::run_with(async |driver| microvm::report::run_control(&driver, opt).await) } fn new_hvsock_service_id(port: u32) -> Guid { @@ -2800,10 +2829,19 @@ async fn run_control(driver: &DefaultDriver, opt: Options) -> anyhow::Result, - opt: Options, + mut opt: Options, ) -> anyhow::Result { let mesh = mesh_slot.as_ref().unwrap(); - let (mut vm_config, mut resources) = vm_config_from_command_line(driver, mesh, &opt).await?; + let mut restore = snapshot_restore::SnapshotRestore::open(&opt)?; + let microvm_restore = microvm::prepare_restore(&mut opt, restore.snapshot())?; + let (mut vm_config, mut resources) = + vm_config_from_command_line(driver, mesh, &opt, µvm_restore).await?; + let mut microvm = microvm::MicrovmLaunch::new( + &opt, + &vm_config, + std::mem::take(&mut resources.microvm), + microvm_restore, + )?; let mut vnc_worker = None; if opt.gfx || opt.vnc.vnc { @@ -2924,12 +2962,24 @@ async fn run_control_inner( // spin up the VM let (vm_rpc, rpc_recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); + let (snapshot_boundary_requests, snapshot_ready, snapshot_requests) = + microvm.snapshot_channels(); + let hypervisor = match &opt.hypervisor { + Some(name) => openvmm_helpers::hypervisor::hypervisor_resource(name)?, + None if opt.machine == MachineProfileCli::Microvm => { + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? + } + None => openvmm_helpers::hypervisor::choose_hypervisor()?, + }; + let source_hypervisor = hypervisor.id().to_owned(); let vm_worker = { let vm_host = mesh.make_host("vm", opt.log_file.clone()).await?; - let (shared_memory, saved_state) = if let Some(snapshot_dir) = &opt.restore_snapshot { - let (fd, state_msg) = prepare_snapshot_restore(snapshot_dir, &opt)?; + let (shared_memory, saved_state) = if opt.restore_snapshot.is_some() { + let (fd, state_msg) = restore.prepare(&opt, &mut microvm, &source_hypervisor)?; (Some(fd), Some(state_msg)) + } else if let Some(shared_memory) = microvm.capture_shared_memory()? { + (Some(shared_memory), None) } else { let shared_memory = opt .memory_backing_file() @@ -2942,22 +2992,34 @@ async fn run_control_inner( .transpose()?; (shared_memory, None) }; + let restore = restore.into_worker(); + let restore_ready_sink = snapshot_restore::restore_ready_sink(&opt)?; let params = VmWorkerParameters { - hypervisor: match &opt.hypervisor { - Some(name) => openvmm_helpers::hypervisor::hypervisor_resource(name)?, - None => openvmm_helpers::hypervisor::choose_hypervisor()?, - }, + hypervisor, cfg: vm_config, saved_state, shared_memory, + shared_memory_copy_on_write: restore.shared_memory_copy_on_write, + snapshot_restore_guards: restore.guards, + snapshot_boundary_requests, + snapshot_ready, + restore_time: restore.time, + restore_ready_sink, + restore_gate_timeout: microvm.restore_gate_timeout(&opt), + restore_vp_count: opt.microvm.restore_processors, rpc: rpc_recv, notify: notify_send, }; - vm_host + let worker_launch = openvmm_defs::profile::ProfileSpan::start(); + let launched = vm_host .launch_worker(VM_WORKER, params) .await - .context("failed to launch vm worker")? + .context("failed to launch vm worker"); + if opt.microvm.x_time_abi_verify { + return microvm::report_time_abi_verification(launched, &source_hypervisor).await; + } + launched.inspect(|_| snapshot_restore::worker_launched(worker_launch))? }; if opt.restore_snapshot.is_some() { @@ -2965,7 +3027,10 @@ async fn run_control_inner( } if !opt.paused { - vm_rpc.call(VmRpc::Resume, ()).await?; + anyhow::ensure!( + vm_rpc.call_failable(VmRpc::Resume, ()).await?, + "VM failed to start; inspect the worker log for the device startup error" + ); } let paravisor_diag = Arc::new(diag_client::DiagClient::from_dialer( @@ -3005,11 +3070,12 @@ async fn run_control_inner( vm_rpc: vm_rpc.clone(), paravisor_diag: Some(paravisor_diag), igvm_path: opt.igvm.clone(), - memory_backing_file: opt.memory_backing_file().cloned(), + memory_backing_file: microvm.memory_backing_file(&opt), memory: opt.memory_size(), processors: opt.processors, log_file: opt.log_file.clone(), crash_dump_path: opt.crash_dump_path.clone(), + microvm: microvm.into_controller(&opt, source_hypervisor, snapshot_requests), guest_power_actions: vm_controller::GuestPowerActions { shutdown: opt.guest_shutdown_action, reset: opt.guest_reset_action, @@ -3038,18 +3104,19 @@ async fn run_control_inner( kvp_ic: resources.kvp_ic, console_in: resources.console_in, has_vtl2, + launch: repl::launch::ReplLaunch::from_options(&opt), }, ) .await; // Wait for the controller task to finish (it stops the VM worker and // shuts down the mesh). - controller_task.await; + let teardown = controller_task.await; drop(serial_driver); // run_repl returns the exit status: the code the guest drove via an opt-in // exit (VmControllerEvent::ExitRequested), or 0 when the VM stopped normally. - repl_result + teardown.enforce(opt.machine == MachineProfileCli::Microvm, repl_result) } struct DiagDialer { @@ -3251,9 +3318,14 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let (config, _resources) = vm_config_from_command_line(driver, &mesh, &opt) - .await - .unwrap(); + let (config, _resources) = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .unwrap(); assert_eq!(config.pcie_devices.len(), 1); assert_eq!(config.pcie_devices[0].port_name, "custom"); @@ -3288,9 +3360,14 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let (config, _resources) = vm_config_from_command_line(driver, &mesh, &opt) - .await - .unwrap(); + let (config, _resources) = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .unwrap(); let port_names: Vec<_> = config .pcie_devices @@ -3327,10 +3404,15 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let error = vm_config_from_command_line(driver, &mesh, &opt) - .await - .err() - .unwrap(); + let error = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .err() + .unwrap(); assert_eq!(error.to_string(), "multiple devices use PCIe port 'custom'"); mesh.shutdown().await; @@ -3361,10 +3443,15 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let error = vm_config_from_command_line(driver, &mesh, &opt) - .await - .err() - .unwrap(); + let error = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .err() + .unwrap(); assert_eq!(error.to_string(), "multiple devices use PCIe port 'custom'"); mesh.shutdown().await; diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs new file mode 100644 index 00000000000..1c48186aeba --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -0,0 +1,1035 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM parts of the VM configuration built from the command line. + +use super::MicrovmResources; +use super::MicrovmRestore; +use super::console::ConsoleEndpoint; +use super::console::MicrovmControlAuthentication; +use super::console::effective_microvm_console; +use super::console::effective_microvm_control_console; +use super::console::microvm_console_attachments_share_endpoint; +use super::console::microvm_console_socket_cleanup; +use super::console::microvm_control_authentication; +use super::console::microvm_control_broker_config; +use super::console::validate_microvm_console_attachment_namespace; +use super::filesystem::EffectiveMicrovmFilesystem; +use super::filesystem::effective_microvm_filesystems; +use super::filesystem::microvm_filesystem_slot_from_snapshot; +use super::network::EffectiveMicrovmNetwork; +use super::network::effective_microvm_network; +use super::network::microvm_network_endpoint; +use super::output::MicrovmOutputDrain; +use super::output::OutputCompletion; +use super::output::spawn_output; +use super::restore::fresh_microvm_generation_id; +use crate::ConsoleState; +use crate::Options; +use crate::VmResources; +use crate::cli_args; +use crate::cli_args::SerialConfigCli; +use crate::cli_args::VirtioBusCli; +use crate::cli_args::microvm::MachineProfileCli; +use crate::serial_io; +use crate::storage_builder::StorageBuilder; +use anyhow::Context; +use anyhow::bail; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use futures::AsyncReadExt; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; +use openvmm_defs::config::Config; +use openvmm_defs::config::DeviceVtl; +use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::build_microvm_command_line; +use openvmm_defs::microvm::build_microvm_control_command_line; +use pal_async::DefaultDriver; +use serial_core::resources::DisconnectedSerialBackendHandle; +use std::cell::RefCell; +use std::path::PathBuf; +use std::time::Duration; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use vm_manifest_builder::MachineArch; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; +use vmgs_resources::VmgsResource; +use vmotherboard::ChipsetDeviceHandle; + +/// MicroVM state computed while building a VM configuration from the command +/// line. All methods are no-ops for the standard machine profile. +pub(crate) struct MicrovmConfigBuilder<'a> { + opt: &'a Options, + restore: &'a MicrovmRestore, + active: bool, + network: Option, + filesystem_slot: bool, + filesystems: Vec, + image_slots: Option, + gateway_dns: bool, + console: Option, + control_console: Option, + control_broker_config: Option, + control_authentication: Option, + control_console_backend: Option>, + host_control: Option, + portb: Option<(Resource, OutputCompletion)>, + resources: MicrovmResources, +} + +impl<'a> MicrovmConfigBuilder<'a> { + /// Validates the microVM options and resolves the effective network, + /// filesystem, and console attachments. + pub(crate) fn new(opt: &'a Options, restore: &'a MicrovmRestore) -> anyhow::Result { + let active = opt.machine == MachineProfileCli::Microvm; + let restore_machine_contract = restore.machine_contract.as_ref(); + if let Some(contract) = restore_machine_contract { + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + } + opt.validate_microvm_options()?; + let network = if active { + effective_microvm_network(opt, restore_machine_contract)? + } else { + None + }; + let filesystem_slot = if active { + restore_machine_contract + .map(microvm_filesystem_slot_from_snapshot) + .transpose()? + .unwrap_or(true) + } else { + false + }; + let filesystems = if active { + effective_microvm_filesystems( + &opt.microvm.microvm_mount, + super::filesystem::MicrovmFilesystemPolicyPaths { + denied: &opt.microvm.microvm_mount_deny, + allowed: &opt.microvm.microvm_mount_allow, + writable: &opt.microvm.microvm_mount_write, + }, + opt.microvm.microvm_mount_owner.map_or( + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, + Into::into, + ), + restore_machine_contract, + )? + } else { + Vec::new() + }; + let image_slots = if active { + if let Some(contract) = restore_machine_contract { + if contract.microvm_image_slot_capacity == 0 { + anyhow::ensure!( + opt.microvm.restore_image_slots.is_none(), + "snapshot does not declare restore-time image-slot activation support" + ); + None + } else { + let active_count = opt + .microvm + .restore_image_slots + .unwrap_or(contract.boot_active_image_slot_count); + let config = openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: contract.boot_active_image_slot_count, + active_count, + }; + config.validate()?; + Some(config) + } + } else if opt.microvm.microvm_image_slots { + let boot_count = opt.microvm.microvm_image_slot_boot_count.unwrap_or(1); + Some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count, + active_count: boot_count, + }) + } else { + None + } + } else { + None + }; + anyhow::ensure!( + image_slots.is_none() || opt.microvm.microvm_host_control.is_some(), + "microVM image slots require --microvm-host-control" + ); + anyhow::ensure!( + image_slots.is_none() || filesystems.len() <= 1, + "microVM image slots use the second virtio-fs slot's window; attach at most one filesystem" + ); + if opt.microvm.snapshot_destination.is_some() { + for filesystem in &filesystems { + tracing::warn!( + stable_id = filesystem.attachment.stable_id, + access_mode = filesystem.config.access.as_str(), + "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" + ); + } + } + let gateway_dns = network + .as_ref() + .is_some_and(|network| network.policy.allows_gateway_dns()); + + if active + && (opt.com1.is_some() + || opt.com2.is_some() + || opt.com3.is_some() + || opt.com4.is_some() + || opt.vmbus_com1_serial.is_some() + || opt.vmbus_com2_serial.is_some() + || opt.debugcon.is_some()) + { + bail!("microVM does not expose UART, debugcon, or VMBus serial"); + } + + let console = if active { + effective_microvm_console(opt.virtio_console.as_ref(), restore_machine_contract)? + } else { + None + }; + let control_console = if active { + effective_microvm_control_console( + opt.microvm.microvm_control_console.as_ref(), + restore_machine_contract, + )? + } else { + None + }; + let live_control_console = control_console + .as_ref() + .is_some_and(|(config, _, _)| !matches!(config, SerialConfigCli::None)); + let control_authentication = + if active && (live_control_console || opt.microvm.microvm_host_control.is_some()) { + Some(microvm_control_authentication(opt)?) + } else { + None + }; + opt.validate_control_stdin_console(console.as_ref().map(|(config, _, _)| config))?; + let control_broker_config = control_console + .as_ref() + .map(|(endpoint, _, _)| { + microvm_control_broker_config(opt, endpoint, control_authentication.as_ref()) + }) + .transpose()?; + anyhow::ensure!( + control_console.is_none() || console.is_some(), + "microVM control console requires the boot virtio-console" + ); + if let (Some((_, _, boot)), Some((_, _, control))) = (&console, &control_console) + && control.identity_kind != "disconnected" + { + anyhow::ensure!( + !microvm_console_attachments_share_endpoint(boot, control), + "microVM boot and control consoles require distinct endpoints" + ); + } + if let Some((_, _, attachment)) = &console + && let Some(snapshot_dir) = opt + .restore_snapshot + .as_deref() + .or(opt.microvm.snapshot_destination.as_deref()) + { + validate_microvm_console_attachment_namespace(attachment, snapshot_dir)?; + } + if let Some((_, _, attachment)) = &control_console + && let Some(snapshot_dir) = opt + .restore_snapshot + .as_deref() + .or(opt.microvm.snapshot_destination.as_deref()) + { + validate_microvm_console_attachment_namespace(attachment, snapshot_dir)?; + } + + let resources = MicrovmResources { + console_attachment: console + .as_ref() + .map(|(_, _, attachment)| attachment.clone()), + control_console_attachment: control_console + .as_ref() + .map(|(_, _, attachment)| attachment.clone()), + network_attachment: network.as_ref().map(|network| network.attachment.clone()), + egress_policy: network.as_ref().map(|network| network.policy.clone()), + filesystem_attachments: filesystems + .iter() + .map(|filesystem| filesystem.attachment.clone()) + .collect(), + filesystem_root_paths: filesystems + .iter() + .map(|filesystem| PathBuf::from(&filesystem.root_path)) + .collect(), + ..Default::default() + }; + + Ok(Self { + opt, + restore, + active, + network, + filesystem_slot, + filesystems, + image_slots, + gateway_dns, + console, + control_console, + control_broker_config, + control_authentication, + control_console_backend: None, + host_control: None, + portb: None, + resources, + }) + } + + /// Returns whether the microVM machine profile is selected. + pub(crate) fn is_active(&self) -> bool { + self.active + } + + /// Returns the backend used for `--com1` when it is not specified. + pub(crate) fn default_com1_backend(&self) -> SerialConfigCli { + if self.active { + SerialConfigCli::None + } else { + SerialConfigCli::Console + } + } + + /// Connects the host side of the portb console (`hvc0`). + /// + /// This must run before the other serial devices so that portb owns the + /// interactive console when the boot virtio-console does not. + pub(crate) fn setup_portb( + &mut self, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, + ) -> anyhow::Result<()> { + if !self.active { + return Ok(()); + } + let backend = if self.opt.microvm.microvm_control_auth_stdin + || self + .console + .as_ref() + .is_some_and(|(config, _, _)| matches!(config, SerialConfigCli::Console)) + { + SerialConfigCli::Stderr + } else { + SerialConfigCli::Console + }; + self.portb = Some(setup_host_console( + "microvm-portb", + backend, + "hvc0", + console_state, + serial_driver, + )?); + Ok(()) + } + + /// Connects the host side of the boot virtio-console and the dedicated + /// control console, returning the boot console backend. + pub(crate) fn setup_virtio_consoles( + &mut self, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, + ) -> anyhow::Result>> { + let virtio_console_backend = if let Some(serial_cfg) = + self.console.as_ref().map(|(config, _, _)| config.clone()) + { + match serial_cfg { + SerialConfigCli::Pipe(path) => { + let backend = serial_io::connect::bind_serial_without_cleanup(&path) + .with_context(|| { + format!( + "failed to bind microVM virtio console listener {}", + path.display() + ) + })?; + self.resources.console_socket_cleanup = microvm_console_socket_cleanup(path)?; + Some(backend) + } + SerialConfigCli::Tcp(address) => Some(serial_io::bind_tcp_serial(&address)?), + SerialConfigCli::ConnectPipe(path) => Some( + serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { + format!( + "failed to reconnect microVM virtio console client {}", + path.display() + ) + })?, + ), + SerialConfigCli::ConnectTcp(address) => { + Some(serial_io::connect::connect_tcp_serial( + &address, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + )?) + } + SerialConfigCli::Console => { + let (backend, _) = setup_host_console( + "virtio-console", + SerialConfigCli::Console, + "hvc1", + console_state, + serial_driver, + )?; + Some(backend) + } + SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), + _ => unreachable!("microVM console backend was validated as an attachment"), + } + } else { + None + }; + if let Some(serial_cfg) = self + .control_console + .as_ref() + .map(|(config, _, _)| config.clone()) + { + self.control_console_backend = match serial_cfg { + SerialConfigCli::Pipe(path) => { + let backend = + serial_io::microvm::bind_control_serial(&path).with_context(|| { + format!( + "failed to bind microVM control console listener {}", + path.display() + ) + })?; + self.resources.control_console_socket_cleanup = + microvm_console_socket_cleanup(path)?; + Some(backend) + } + SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), + _ => unreachable!("microVM control console backend was validated"), + }; + } + if let Some(host_control) = &self.opt.microvm.microvm_host_control { + let authentication = self + .control_authentication + .clone() + .context("microVM host control is missing shared authentication")?; + self.host_control = Some(super::host_control::MicrovmHostControlServer::bind( + &host_control.path, + serial_driver, + authentication, + )?); + self.resources.host_control_socket_cleanup = + microvm_console_socket_cleanup(host_control.path.clone())?; + } + Ok(virtio_console_backend) + } + + /// Adds the fixed-role sandbox block devices. + pub(crate) async fn add_sandbox_blocks( + &self, + storage: &mut StorageBuilder, + ) -> anyhow::Result<()> { + let opt = self.opt; + for block in &opt.microvm.microvm_sandbox_block { + let disk = &block.disk; + anyhow::ensure!( + disk.vtl == DeviceVtl::Vtl0 + && !disk.is_dvd + && disk.underhill.is_none() + && disk.pcie_port.is_none() + && disk.controller.is_none() + && disk.nsid.is_none() + && disk.lun.is_none() + && disk.relay.is_none(), + "--microvm-sandbox-block accepts only a plain VTL0 disk backend" + ); + storage + .add_microvm_sandbox_block( + block.role, + &disk.kind, + disk.read_only, + opt.microvm.snapshot_destination.is_some() || opt.restore_snapshot.is_some(), + ) + .await?; + } + storage.add_microvm_image_slots(self.image_slots)?; + Ok(()) + } + + /// Adds the portb, shutdown, and snapshot-request chipset devices. + pub(crate) fn add_chipset_devices( + &mut self, + chipset_devices: &mut Vec, + ) -> anyhow::Result<()> { + let Some((io, output_completion)) = self.portb.take() else { + return Ok(()); + }; + let opt = self.opt; + let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); + self.resources.output_drain = Some(drain); + let (generation_id, time_abi_restore) = if self.restore.machine_contract.is_some() { + let (generation_id, base) = self.restore.time_abi_restore_packet( + opt.microvm.restore_processors, + opt.microvm.restore_image_slots, + )?; + ( + generation_id, + Some(self.resources.time_abi_restore_packet(base)), + ) + } else { + (fresh_microvm_generation_id()?, None) + }; + let hooks = time_abi_test_hooks(opt)?; + let time_abi = super::time_abi_chipset(chipset_devices, &hooks, time_abi_restore); + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { + io, + generation_id, + output_drain: Some(output_drain), + time_abi, + } + .into_resource(), + }); + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }); + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: { + let (notify, requests) = mesh::channel(); + self.resources.snapshot_requests = Some(requests); + MicrovmSnapshotRequestHandle { + notify: Some(notify), + input_gate_timeout: Duration::from_millis( + opt.microvm.snapshot_quiesce_timeout_ms, + ), + } + .into_resource() + }, + }); + Ok(()) + } + + /// Returns the MP-table Linux load mode of the microVM profile. + pub(crate) fn load_mode(&self, arch: MachineArch) -> anyhow::Result> { + if !self.active { + return Ok(None); + } + let opt = self.opt; + if arch != MachineArch::X86_64 { + bail!("the microVM profile requires an x86-64 guest"); + } + if opt.igvm.is_some() || opt.pcat || opt.uefi.is_some() { + bail!("the microVM profile requires Linux direct boot"); + } + + let (kernel, initrd, cmdline) = if let Some(contract) = &self.restore.machine_contract { + ( + tempfile::tempfile().context("failed to create inert restore kernel handle")?, + None, + contract.effective_command_line.clone(), + ) + } else { + let kernel = fs_err::File::open( + (opt.kernel.0) + .as_ref() + .context("must provide a Linux kernel when using --machine microvm")?, + ) + .context("failed to open Linux kernel")?; + let initrd = (opt.initrd.0) + .as_ref() + .map(fs_err::File::open) + .transpose() + .context("failed to open Linux initrd")?; + ( + kernel.into(), + initrd.map(Into::into), + build_effective_microvm_command_line( + &opt.cmdline, + opt.processors, + self.console.is_some(), + self.control_console.is_some(), + opt.microvm.microvm_workload_identity, + opt.microvm.microvm_lifecycle, + )?, + ) + }; + + Ok(Some(LoadMode::Linux { + kernel, + initrd, + cmdline, + enable_serial: false, + isolation: openvmm_defs::config::LinuxIsolationConfig::None, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + smbios: Box::default(), + })) + } + + /// Rejects VMGS before the standard VMGS resource is opened. + pub(crate) fn validate_vmgs(&self) -> anyhow::Result<()> { + if self.active && self.opt.vmgs.is_some() { + bail!("microVM does not support VMGS"); + } + Ok(()) + } + + /// Drops the default VMGS resource, which the microVM does not expose. + pub(crate) fn filter_vmgs(&self, vmgs: &mut Option) { + if self.active { + *vmgs = None; + } + } + + /// Adds the fixed-slot virtio-net and virtio-fs devices. + pub(crate) fn add_virtio_devices( + &mut self, + add_virtio_device: &mut impl FnMut(VirtioBusCli, Resource), + resources: &mut VmResources, + ) -> anyhow::Result<()> { + if let Some(network) = self.network.as_ref() { + let config = &network.config; + let endpoint = microvm_network_endpoint( + config, + &network.policy, + &self.opt.microvm.host_loopback_forward, + resources, + )?; + add_virtio_device( + VirtioBusCli::Mmio, + virtio_resources::net::VirtioNetHandle { + max_queues: Some(1), + mac_address: config.guest_mac, + endpoint, + egress_policy: Some(network.policy.clone()), + save_restore: true, + static_ipv4: Some(StaticIpv4Config { + guest_ipv4: config.guest_ipv4, + prefix_length: config.prefix_length, + gateway_ipv4: config.derived_gateway_ipv4, + gateway_mac: config.gateway_mac, + }), + effective_features: Some(openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES), + } + .into_resource(), + ); + } + + let slot_count = openvmm_defs::microvm::microvm_filesystem_slot_count( + self.filesystem_slot, + self.filesystems.len(), + )?; + for (index, slot) in openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .take(slot_count) + .enumerate() + { + let (fs, profile) = if let Some(filesystem) = self.filesystems.get(index) { + ( + virtio_resources::fs::VirtioFsBackend::HostFs { + root_path: filesystem.root_path.clone(), + mount_options: String::new(), + }, + virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: slot.stable_id.to_owned(), + root_identity: filesystem.attachment.identity.clone(), + read_only: filesystem.config.access.is_read_only(), + denied_paths: filesystem.config.denied_paths.clone(), + allowed_paths: filesystem.config.allowed_paths.clone(), + writable_paths: filesystem.config.writable_paths.clone(), + caller_identity: filesystem.config.owner.is_caller(), + }, + ) + } else { + ( + virtio_resources::fs::VirtioFsBackend::Dormant, + virtio_resources::fs::microvm::VirtioFsProfile::MicrovmDormant { + stable_id: slot.stable_id.to_owned(), + }, + ) + }; + add_virtio_device( + VirtioBusCli::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: slot.tag.to_owned(), + fs, + profile, + } + .into_resource(), + ); + } + Ok(()) + } + + /// Builds the boot virtio-console device handle. + pub(crate) fn virtio_console_handle( + &self, + backend: Resource, + ) -> virtio_resources::console::VirtioConsoleHandle { + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: if self.active + && !self.console.as_ref().is_some_and(|(_, attachment, _)| { + attachment.reconnect_policy + == VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + }) { + VirtioConsoleDisconnectPolicy::Retain + } else { + VirtioConsoleDisconnectPolicy::Discard + }, + attachment: self + .console + .as_ref() + .map(|(_, attachment, _)| attachment.clone()), + } + } + + /// Adds the dedicated control virtio-console device. + pub(crate) fn add_control_console( + &mut self, + add_virtio_device: &mut impl FnMut(VirtioBusCli, Resource), + ) -> anyhow::Result<()> { + if let Some(backend) = self.control_console_backend.take() { + let broker_config = self.control_broker_config.take().context( + "control-console backend is missing broker authentication configuration", + )?; + let resource: Resource = + virtio_resources::console::control::VirtioControlConsoleHandle { + backend, + broker_config, + attachment: self + .control_console + .as_ref() + .map(|(_, attachment, _)| attachment.clone()), + } + .into_resource(); + add_virtio_device(VirtioBusCli::Mmio, resource); + } + Ok(()) + } + + /// Completes the microVM configuration after the storage devices have been + /// added, and validates the machine contract of every profile. + pub(crate) fn finish( + mut self, + cfg: &mut Config, + resources: &mut VmResources, + ) -> anyhow::Result<()> { + let opt = self.opt; + let restore_machine_contract = self.restore.machine_contract.as_ref(); + if self.active { + cfg.processor_topology.vps_per_socket = Some(opt.processors); + cfg.processor_topology.enable_smt = Some(false); + #[cfg(guest_arch = "x86_64")] + { + cfg.processor_topology.arch = Some(openvmm_defs::config::ArchTopologyConfig::X86( + openvmm_defs::config::X86TopologyConfig { + apic_id_offset: 0, + x2apic: openvmm_defs::config::X2ApicConfig::Unsupported, + }, + )); + } + } + cfg.microvm.network = self.network.as_ref().map(|network| network.config.clone()); + let microvm_filesystems = self + .filesystems + .iter() + .map(|filesystem| filesystem.config.clone()) + .collect::>(); + cfg.microvm.filesystem_bootstrap = restore_machine_contract + .map(|contract| contract.microvm_filesystem.is_some()) + .unwrap_or_else(|| !microvm_filesystems.is_empty()); + cfg.microvm.filesystems = microvm_filesystems; + anyhow::ensure!( + cfg.microvm.image_slots == self.image_slots, + "microVM image-slot configuration changed during storage construction" + ); + cfg.microvm.memory_capacity = restore_machine_contract + .and_then(|contract| { + (contract.memory_expansion_version != 0).then_some(contract.memory_capacity_bytes) + }) + .or(opt.microvm.memory_capacity.map(|capacity| capacity.0)); + cfg.microvm.snapshot_memory_ranges = restore_machine_contract + .filter(|contract| contract.memory_expansion_version != 0) + .map(|contract| { + contract + .memory_ranges + .iter() + .map(|range| { + memory_range::MemoryRange::new( + range.gpa_start..range.gpa_start + range.length, + ) + }) + .collect() + }) + .unwrap_or_default(); + cfg.microvm.restore_memory_ranges = self + .restore + .memory_ranges + .iter() + .map(|range| { + memory_range::MemoryRange::new(range.gpa_start..range.gpa_start + range.length) + }) + .collect(); + if cfg.machine_profile == MachineProfile::Microvm { + let hooks = time_abi_test_hooks(opt)?; + if hooks.active() { + tracing::warn!(?hooks, "time ABI test hooks are active"); + } + let requested_profile = opt.microvm.cpu_profile.as_deref().unwrap_or("auto"); + let (cpu_profile, generation) = match self.restore.time_abi_records() { + // The controller's restore preflight checks the requested + // profile against the snapshot's. + Some((time, record)) => { + (record.id.clone(), time.capture_generation.saturating_add(1)) + } + None => (requested_profile.to_owned(), 0), + }; + cfg.microvm.time_abi = Some(openvmm_defs::time_abi::TimeAbiParameters { + cpu_profile, + generation, + hooks, + }); + } else { + anyhow::ensure!( + opt.microvm.cpu_profile.is_none() + && opt.microvm.x_time_abi_test_hook.is_empty() + && !opt.microvm.x_time_abi_verify, + "--cpu-profile, --x-time-abi-test-hook, and --x-time-abi-verify require the microVM machine profile" + ); + } + + let requested_hypervisor = opt + .hypervisor + .as_deref() + .and_then(|spec| spec.split(':').next()); + if cfg.machine_profile == MachineProfile::Microvm && restore_machine_contract.is_none() { + let cmdline = match &mut cfg.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => cmdline, + _ => { + unreachable!("microVM configuration was constructed with a supported load mode") + } + }; + let has_console = cfg + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + let has_control_console = cfg.virtio_devices.iter().any(|(_, device)| { + device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID + }); + let network_irq = cfg + .microvm + .network + .as_ref() + .map(|_| openvmm_defs::microvm::microvm_virtio_net_irq(requested_hypervisor)) + .transpose()?; + let network = cfg + .microvm + .network + .as_ref() + .zip(network_irq) + .map(|(network, irq)| (network, irq, self.gateway_dns)); + if let Some(snapshot_tier) = opt.microvm.snapshot_tier { + anyhow::ensure!( + !cmdline + .split_ascii_whitespace() + .any(|token| token.starts_with("nvx_snapshot_tier=")), + "nvx_snapshot_tier is reserved for the host snapshot policy" + ); + if !cmdline.is_empty() { + cmdline.push(' '); + } + cmdline.push_str("nvx_snapshot_tier="); + cmdline.push_str(snapshot_tier.manifest_name()); + } + openvmm_defs::microvm::append_microvm_virtio_discovery( + cmdline, + network, + self.filesystem_slot, + &cfg.microvm.filesystems, + has_console, + has_control_console, + &cfg.microvm.sandbox_blocks, + cfg.microvm.image_slots, + )?; + } + openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; + + let sandbox_block_sources = std::mem::take(&mut resources.microvm.sandbox_block_sources); + let image_slot_requests = std::mem::take(&mut resources.microvm.image_slot_requests); + if let Some(host_control) = self.host_control.as_mut() { + host_control.set_image_slots( + image_slot_requests.clone(), + self.resources.image_slot_transition.clone(), + ); + } + self.resources.host_control = self + .host_control + .take() + .map(|server| std::sync::Arc::new(futures::lock::Mutex::new(server))); + resources.microvm = MicrovmResources { + sandbox_block_sources, + image_slot_requests, + ..std::mem::take(&mut self.resources) + }; + Ok(()) + } +} + +/// Connects a portb or boot virtio-console endpoint to the host console or +/// stderr, returning its backend and output-relay completion. +fn setup_host_console( + name: &str, + backend: SerialConfigCli, + device: &'static str, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, +) -> anyhow::Result<(Resource, OutputCompletion)> { + Ok(match backend { + SerialConfigCli::Console => { + if let Some(console_state) = console_state.borrow().as_ref() { + bail!("console already set by {}", console_state.device); + } + let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; + let (serial_read, serial_write) = AsyncReadExt::split(serial); + *console_state.borrow_mut() = Some(ConsoleState { + device, + input: Box::new(serial_write), + }); + let completed = spawn_output(name, serial_read, term::raw_stdout())?; + (config, completed) + } + SerialConfigCli::Stderr => { + let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; + let completed = spawn_output(name, serial, term::raw_stderr())?; + (config, completed) + } + _ => unreachable!("microVM host consoles use the console or stderr"), + }) +} + +/// Returns the time ABI test hooks selected on the command line. +fn time_abi_test_hooks(opt: &Options) -> anyhow::Result { + Ok(virt::time_abi::TimeAbiTestHooks::parse( + &opt.microvm.x_time_abi_test_hook, + )?) +} + +fn build_effective_microvm_command_line( + user_args: &[String], + processor_count: u32, + has_console: bool, + has_control_console: bool, + workload_identity: Option, + lifecycle: Option, +) -> anyhow::Result { + let mut cmdline = if has_control_console { + build_microvm_control_command_line(user_args, has_console) + } else { + build_microvm_command_line(user_args, has_console) + }?; + openvmm_defs::microvm::append_microvm_processor_limit(&mut cmdline, processor_count)?; + if let Some(identity) = workload_identity { + openvmm_defs::microvm::append_microvm_workload_identity( + &mut cmdline, + identity.uid, + identity.gid, + )?; + } + if let Some(lifecycle) = lifecycle { + openvmm_defs::microvm::append_microvm_lifecycle( + &mut cmdline, + lifecycle == cli_args::microvm::MicrovmLifecycleCli::Managed, + )?; + } + Ok(cmdline) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn boot_only_command_line_preserves_control_free_arguments() { + let user_args = [ + r#"note="left right""#.to_owned(), + "--".to_owned(), + "driver_async_probe=virtio_mmio".to_owned(), + "nvx_control_tty=hvc9".to_owned(), + "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), + ]; + assert!( + build_effective_microvm_command_line(&user_args, 1, true, false, None, None).is_ok() + ); + assert!( + build_effective_microvm_command_line(&user_args, 1, true, true, None, None).is_err() + ); + } + + #[test] + fn workload_identity_is_host_owned_and_fixed() { + let identity = cli_args::microvm::MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }; + let command_line = + build_effective_microvm_command_line(&[], 1, false, false, Some(identity), None) + .unwrap(); + assert!(command_line.contains("nvx_workload_uid=65534")); + assert!(command_line.contains("nvx_workload_gid=65534")); + assert!( + build_effective_microvm_command_line( + &["nvx_workload_uid=1".to_owned()], + 1, + false, + false, + Some(identity), + None, + ) + .is_err() + ); + } + + #[test] + fn workload_lifecycle_is_host_owned() { + let command_line = build_effective_microvm_command_line( + &[], + 1, + true, + true, + Some(cli_args::microvm::MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }), + Some(cli_args::microvm::MicrovmLifecycleCli::Managed), + ) + .unwrap(); + assert!(command_line.contains("nvx_lifecycle=managed")); + assert!( + build_effective_microvm_command_line( + &["nvx_lifecycle=one-shot".to_owned()], + 1, + true, + true, + None, + Some(cli_args::microvm::MicrovmLifecycleCli::Managed), + ) + .is_err() + ); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs new file mode 100644 index 00000000000..317d292ccd9 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -0,0 +1,1179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM virtio-console and control-console attachments. + +use crate::Options; +use crate::cli_args::SerialConfigCli; +use crate::serial_io; +use anyhow::Context; +#[cfg(unix)] +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// A microVM console endpoint: its effective serial backend, device attachment +/// configuration, and snapshot attachment identity. +pub(super) type ConsoleEndpoint = ( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +); + +pub(crate) const MICROVM_CONSOLE_STABLE_ID: &str = "console:microvm-virtio0"; +pub(crate) const MICROVM_CONTROL_CONSOLE_STABLE_ID: &str = "console:microvm-control0"; +pub(crate) const MICROVM_CONSOLE_ATTACHMENT_KIND: &str = "virtio-console"; +pub(crate) const MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND: &str = "virtio-control-console"; + +pub(super) fn microvm_console_attachments_share_endpoint( + left: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + right: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, +) -> bool { + left.identity_kind == right.identity_kind + && if left.identity_kind == "named-pipe" { + std::str::from_utf8(&left.identity) + .ok() + .zip(std::str::from_utf8(&right.identity).ok()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + } else { + left.identity == right.identity + } +} + +pub(super) fn microvm_console_listener_replacement_matches( + saved: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, +) -> bool { + matches!( + saved.reconnect_policy.as_str(), + "recreate-listener" | "broker-authenticated-listener" + ) && saved.stable_id == requested.stable_id + && saved.kind == requested.kind + && saved.required == requested.required + && saved.reconnect_policy == requested.reconnect_policy + && saved.identity_kind == requested.identity_kind + && saved.length == requested.length + && saved.reconnect_timeout_ms == requested.reconnect_timeout_ms +} + +pub(crate) struct MicrovmConsoleSocketCleanup { + #[cfg(unix)] + path: PathBuf, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl MicrovmConsoleSocketCleanup { + #[cfg(unix)] + fn new(path: PathBuf) -> anyhow::Result { + use std::os::unix::fs::FileTypeExt as _; + use std::os::unix::fs::MetadataExt as _; + + let metadata = fs_err::symlink_metadata(&path) + .with_context(|| format!("failed to inspect console socket {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_socket(), + "microVM console path is not a Unix socket: {}", + path.display() + ); + Ok(Self { + path, + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + pub(crate) fn remove_if_owned(&self) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt as _; + use std::os::unix::fs::MetadataExt as _; + + let metadata = match fs_err::symlink_metadata(&self.path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + anyhow::ensure!( + metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode, + "microVM console socket ownership changed: {}", + self.path.display() + ); + fs_err::remove_file(&self.path).with_context(|| { + format!("failed to remove console socket {}", self.path.display()) + })?; + } + Ok(()) + } +} + +impl Drop for MicrovmConsoleSocketCleanup { + fn drop(&mut self) { + let _ = self.remove_if_owned(); + } +} + +pub(crate) fn microvm_console_socket_cleanup( + path: PathBuf, +) -> anyhow::Result> { + #[cfg(unix)] + { + Ok(Some(MicrovmConsoleSocketCleanup::new(path)?)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +fn canonical_microvm_console_path(path: &Path) -> anyhow::Result { + #[cfg(windows)] + if path.starts_with("//./pipe") { + return Ok(path.to_owned()); + } + + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("failed to resolve current directory for console endpoint")? + .join(path) + }; + let file_name = absolute + .file_name() + .context("microVM console endpoint must name a socket or pipe")?; + let parent = absolute + .parent() + .context("microVM console endpoint has no parent directory")?; + let parent = fs_err::canonicalize(parent).with_context(|| { + format!( + "failed to canonicalize microVM console endpoint parent {}", + parent.display() + ) + })?; + let canonical = parent.join(file_name); + if let Ok(metadata) = fs_err::symlink_metadata(&canonical) { + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM console endpoint must not be a symbolic link: {}", + canonical.display() + ); + } + Ok(canonical) +} + +fn microvm_console_attachment_from_cli_with_identity( + config: &SerialConfigCli, + stable_id: &'static str, + attachment_kind: &'static str, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + use virtio_resources::console::attachment::VirtioConsoleAttachment; + use virtio_resources::console::attachment::VirtioConsoleAttachmentMode; + use virtio_resources::console::attachment::VirtioConsoleBackendKind; + use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + + let ( + effective, + backend_kind, + identity_kind, + endpoint_identity, + mode, + reconnect_policy, + reconnect_policy_name, + required, + reconnect_timeout_ms, + ) = match config { + SerialConfigCli::Pipe(path) | SerialConfigCli::ConnectPipe(path) => { + let path = canonical_microvm_console_path(path)?; + let identity = path + .to_str() + .context("microVM console endpoint path is not valid UTF-8")? + .to_owned(); + #[cfg(windows)] + let (backend_kind, identity_kind) = { + let name = path + .to_str() + .and_then(|path| path.strip_prefix("//./pipe/openvmm-microvm-")); + anyhow::ensure!( + name.is_some_and(|name| { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }), + "Windows microVM console pipes must use //./pipe/openvmm-microvm-" + ); + (VirtioConsoleBackendKind::NamedPipe, "named-pipe") + }; + #[cfg(not(windows))] + let (backend_kind, identity_kind) = + (VirtioConsoleBackendKind::UnixSocket, "unix-socket"); + let is_client = matches!(config, SerialConfigCli::ConnectPipe(_)); + ( + if is_client { + SerialConfigCli::ConnectPipe(path) + } else { + SerialConfigCli::Pipe(path) + }, + backend_kind, + identity_kind, + identity, + if is_client { + VirtioConsoleAttachmentMode::Connect + } else { + VirtioConsoleAttachmentMode::Listen + }, + if is_client { + VirtioConsoleReconnectPolicy::ReconnectClient + } else { + VirtioConsoleReconnectPolicy::RecreateListener + }, + if is_client { + "reconnect-client" + } else { + "recreate-listener" + }, + is_client, + if is_client { + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + } else { + 0 + }, + ) + } + SerialConfigCli::Tcp(address) | SerialConfigCli::ConnectTcp(address) => { + anyhow::ensure!( + address.port() != 0, + "microVM console TCP endpoint requires a nonzero stable port" + ); + anyhow::ensure!( + address.ip().is_loopback(), + "microVM console TCP endpoints must use a loopback address" + ); + let is_client = matches!(config, SerialConfigCli::ConnectTcp(_)); + ( + if is_client { + SerialConfigCli::ConnectTcp(*address) + } else { + SerialConfigCli::Tcp(*address) + }, + VirtioConsoleBackendKind::Tcp, + "tcp", + address.to_string(), + if is_client { + VirtioConsoleAttachmentMode::Connect + } else { + VirtioConsoleAttachmentMode::Listen + }, + if is_client { + VirtioConsoleReconnectPolicy::ReconnectClient + } else { + VirtioConsoleReconnectPolicy::RecreateListener + }, + if is_client { + "reconnect-client" + } else { + "recreate-listener" + }, + is_client, + if is_client { + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + } else { + 0 + }, + ) + } + SerialConfigCli::Console => ( + SerialConfigCli::Console, + VirtioConsoleBackendKind::Inherited, + "provider", + "console".to_owned(), + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::RequireInheritedAttachment, + "require-inherited-attachment", + true, + 0, + ), + SerialConfigCli::None => ( + SerialConfigCli::None, + VirtioConsoleBackendKind::Disconnected, + "disconnected", + "discard".to_owned(), + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected, + "discard-while-disconnected", + false, + 0, + ), + _ => anyhow::bail!( + "microVM virtio-console requires listen=..., connect=..., console, or none" + ), + }; + anyhow::ensure!( + !endpoint_identity.is_empty() && endpoint_identity.len() <= 4096, + "microVM console endpoint identity is empty or exceeds 4096 bytes" + ); + + let attachment = VirtioConsoleAttachment { + stable_id: stable_id.to_owned(), + backend_kind, + mode, + endpoint_identity: endpoint_identity.clone(), + reconnect_policy, + required, + reconnect_timeout_ms, + }; + let snapshot_attachment = openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: stable_id.to_owned(), + kind: attachment_kind.to_owned(), + required, + reconnect_policy: reconnect_policy_name.to_owned(), + identity_kind: identity_kind.to_owned(), + identity: endpoint_identity.into_bytes(), + length: 0, + reconnect_timeout_ms, + }; + Ok((effective, attachment, snapshot_attachment)) +} + +pub(crate) fn microvm_console_attachment_from_cli( + config: &SerialConfigCli, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + microvm_console_attachment_from_cli_with_identity( + config, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + ) +} + +fn microvm_control_console_attachment_from_cli( + config: &SerialConfigCli, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + anyhow::ensure!( + matches!(config, SerialConfigCli::Pipe(_) | SerialConfigCli::None), + "microVM control console requires listen=... or none" + ); + let config = match config { + #[cfg(unix)] + SerialConfigCli::Pipe(path) => { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let absolute = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir() + .context("failed to resolve current directory for control endpoint")? + .join(path) + }; + let parent = absolute + .parent() + .context("microVM control endpoint has no parent directory")?; + let metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!( + "failed to inspect microVM control endpoint parent {}", + parent.display() + ) + })?; + anyhow::ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == pal::unix::effective_user_id() + && metadata.permissions().mode() & 0o7777 == 0o700, + "microVM control endpoint parent must be a non-symlink directory owned by OpenVMM with mode 0700" + ); + SerialConfigCli::Pipe(canonical_microvm_console_path(&absolute)?) + } + _ => config.clone(), + }; + let (effective, attachment, mut snapshot) = microvm_console_attachment_from_cli_with_identity( + &config, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + )?; + snapshot.reconnect_policy = match config { + SerialConfigCli::Pipe(_) => "broker-authenticated-listener", + SerialConfigCli::None => "broker-disconnected", + _ => unreachable!("control-console backend was validated"), + } + .to_owned(); + Ok((effective, attachment, snapshot)) +} + +pub(crate) fn validate_microvm_console_attachment_namespace( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + snapshot_dir: &Path, +) -> anyhow::Result<()> { + match attachment.identity_kind.as_str() { + "unix-socket" => { + let identity = std::str::from_utf8(&attachment.identity) + .context("microVM console socket identity is not valid UTF-8")?; + let endpoint = Path::new(identity); + let endpoint_parent = endpoint + .parent() + .context("microVM console socket identity has no parent")?; + let snapshot_parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let snapshot_parent = fs_err::canonicalize(snapshot_parent).with_context(|| { + format!( + "failed to canonicalize snapshot parent {}", + snapshot_parent.display() + ) + })?; + anyhow::ensure!( + endpoint_parent == snapshot_parent, + "microVM console socket must be inside the snapshot parent namespace {}", + snapshot_parent.display() + ); + } + "named-pipe" => { + let identity = std::str::from_utf8(&attachment.identity) + .context("microVM console pipe identity is not valid UTF-8")?; + let name = identity + .strip_prefix("//./pipe/openvmm-microvm-") + .filter(|name| !name.is_empty() && !name.contains(['/', '\\'])); + anyhow::ensure!( + name.is_some(), + "microVM console pipe is outside //./pipe/openvmm-microvm-" + ); + } + "tcp" => {} + "provider" => {} + "disconnected" => {} + kind => anyhow::bail!("unsupported microVM console identity kind '{kind}'"), + } + Ok(()) +} + +fn microvm_console_attachment_from_snapshot_with_identity( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: Option<&SerialConfigCli>, + stable_id: &'static str, + attachment_kind: &'static str, + control_console: bool, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + anyhow::ensure!( + attachment.stable_id == stable_id + && attachment.kind == attachment_kind + && attachment.length == 0, + "snapshot has an invalid microVM console attachment" + ); + let identity = std::str::from_utf8(&attachment.identity) + .context("snapshot microVM console identity is not valid UTF-8")?; + if attachment.reconnect_policy == "reconnect-client" { + anyhow::ensure!( + requested.is_some(), + "snapshot requires an explicitly approved restore-time client attachment" + ); + } + if control_console + && matches!( + attachment.reconnect_policy.as_str(), + "broker-authenticated-listener" + ) + { + anyhow::ensure!( + requested.is_some(), + "snapshot requires an explicitly approved restore-time control attachment" + ); + } + if matches!( + attachment.reconnect_policy.as_str(), + "recreate-listener" | "broker-authenticated-listener" + ) { + if let Some(requested) = requested { + let requested = if control_console { + microvm_control_console_attachment_from_cli(requested)? + } else { + microvm_console_attachment_from_cli(requested)? + }; + anyhow::ensure!( + microvm_console_listener_replacement_matches(attachment, &requested.2), + "restore-time virtio-console listener does not match the snapshot attachment contract" + ); + return Ok(requested); + } + } + let config = match ( + attachment.reconnect_policy.as_str(), + attachment.identity_kind.as_str(), + ) { + ("recreate-listener", "unix-socket" | "named-pipe") + | ("broker-authenticated-listener", "unix-socket" | "named-pipe") + if !control_console + || attachment.reconnect_policy == "broker-authenticated-listener" => + { + SerialConfigCli::Pipe(PathBuf::from(identity)) + } + ("recreate-listener", "tcp") => SerialConfigCli::Tcp( + identity + .parse() + .context("snapshot microVM console TCP identity is invalid")?, + ), + ("reconnect-client", "unix-socket" | "named-pipe") if !control_console => { + SerialConfigCli::ConnectPipe(PathBuf::from(identity)) + } + ("reconnect-client", "tcp") => SerialConfigCli::ConnectTcp( + identity + .parse() + .context("snapshot microVM console TCP identity is invalid")?, + ), + ("require-inherited-attachment", "provider") => requested + .cloned() + .context("snapshot requires --virtio-console console as a replacement attachment")?, + ("discard-while-disconnected", "disconnected") + | ("broker-disconnected", "disconnected") + if !control_console || attachment.reconnect_policy == "broker-disconnected" => + { + SerialConfigCli::None + } + (policy, kind) => anyhow::bail!( + "snapshot microVM console policy '{policy}' and backend kind '{kind}' are unsupported" + ), + }; + let reconstructed = if control_console { + microvm_control_console_attachment_from_cli(&config)? + } else { + microvm_console_attachment_from_cli(&config)? + }; + anyhow::ensure!( + reconstructed.2 == *attachment, + "snapshot microVM console identity is not canonical" + ); + if let Some(requested) = requested { + let requested = if control_console { + microvm_control_console_attachment_from_cli(requested)? + } else { + microvm_console_attachment_from_cli(requested)? + }; + anyhow::ensure!( + requested.2 == *attachment, + "restore-time virtio-console does not match the snapshot attachment" + ); + } + + Ok(reconstructed) +} + +#[cfg(any(test, feature = "ttrpc", feature = "grpc"))] +pub(crate) fn microvm_console_attachment_from_snapshot( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: Option<&SerialConfigCli>, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + microvm_console_attachment_from_snapshot_with_identity( + attachment, + requested, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + false, + ) +} + +fn effective_microvm_console_with_identity( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + stable_id: &'static str, + attachment_kind: &'static str, + control_console: bool, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + let Some(restore) = restore else { + return requested + .map(|requested| { + if control_console { + microvm_control_console_attachment_from_cli(requested) + } else { + microvm_console_attachment_from_cli(requested) + } + }) + .transpose(); + }; + let has_console = restore + .devices + .iter() + .any(|device| device.stable_id == stable_id); + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == stable_id); + anyhow::ensure!( + has_console == saved_attachment.is_some(), + "snapshot microVM console device and attachment inventories disagree" + ); + let Some(saved_attachment) = saved_attachment else { + anyhow::ensure!( + requested.is_none(), + "a restore-time virtio-console cannot be added to a snapshot without one" + ); + return Ok(None); + }; + Ok(Some( + microvm_console_attachment_from_snapshot_with_identity( + saved_attachment, + requested, + stable_id, + attachment_kind, + control_console, + )?, + )) +} + +pub(super) fn effective_microvm_console( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + effective_microvm_console_with_identity( + requested, + restore, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + false, + ) +} + +pub(super) fn effective_microvm_control_console( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + effective_microvm_console_with_identity( + requested, + restore, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) +} + +fn random_nonzero_bytes(description: &'static str) -> anyhow::Result<[u8; N]> { + loop { + let mut bytes = [0; N]; + getrandom::fill(&mut bytes).with_context(|| format!("failed to generate {description}"))?; + if bytes != [0; N] { + return Ok(bytes); + } + } +} + +#[derive(Clone)] +pub(crate) struct MicrovmControlAuthentication { + pub(super) capability: [u8; 32], + pub(super) expected_peer_identity: serial_core::LocalPeerIdentity, + pub(super) auth_timeout_ms: u64, +} + +pub(super) fn microvm_control_authentication( + opt: &Options, +) -> anyhow::Result { + anyhow::ensure!( + opt.microvm.microvm_control_auth_stdin, + "live microVM control endpoints require --microvm-control-auth-stdin" + ); + #[cfg(any(target_os = "linux", windows))] + let capability = serial_io::microvm::read_control_capability_from_stdin() + .context("failed to read microVM control authentication capability from stdin")?; + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("secure live microVM control endpoints are unavailable on this platform"); + + microvm_control_authentication_from_capability( + capability, + opt.microvm.microvm_control_auth_timeout_ms, + ) +} + +pub(crate) fn microvm_control_authentication_from_capability( + capability: [u8; 32], + auth_timeout_ms: u64, +) -> anyhow::Result { + anyhow::ensure!(capability != [0; 32], "control capability must not be zero"); + #[cfg(target_os = "linux")] + let expected_peer_identity = + serial_core::LocalPeerIdentity::UnixUid(pal::unix::effective_user_id()); + #[cfg(windows)] + let expected_peer_identity = { + let sid = pal::windows::security::user_sid::current_process_user_sid() + .context("failed to resolve the OpenVMM process user SID")?; + let (bytes, length) = sid.to_fixed_bytes(); + serial_core::LocalPeerIdentity::windows_sid(bytes, length) + .context("failed to encode the OpenVMM process user SID")? + }; + #[cfg(not(any(target_os = "linux", windows)))] + let expected_peer_identity = serial_core::LocalPeerIdentity::Unsupported; + + Ok(MicrovmControlAuthentication { + capability, + expected_peer_identity, + auth_timeout_ms, + }) +} + +pub(super) fn microvm_control_broker_config( + opt: &Options, + endpoint: &SerialConfigCli, + authentication: Option<&MicrovmControlAuthentication>, +) -> anyhow::Result { + let capability = if matches!(endpoint, SerialConfigCli::None) { + random_nonzero_bytes("disconnected control-console capability")? + } else { + authentication + .context("live microVM control console is missing shared authentication")? + .capability + }; + let expected_peer_identity = authentication + .map(|authentication| authentication.expected_peer_identity.clone()) + .unwrap_or(serial_core::LocalPeerIdentity::Unsupported); + + Ok( + virtio_resources::console::control::VirtioControlConsoleBrokerConfig { + instance_id: random_nonzero_bytes("control-console instance ID")?, + capability, + expected_peer_identity, + auth_timeout_ms: authentication + .map_or(opt.microvm.microvm_control_auth_timeout_ms, |auth| { + auth.auth_timeout_ms + }), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser as _; + use test_with_tracing::test; + use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + + fn socket_attachment(path: &Path) -> openvmm_helpers::snapshot::microvm::SnapshotAttachment { + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: path.to_string_lossy().into_owned().into_bytes(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + #[test] + fn socket_attachment_must_stay_in_snapshot_parent() { + let allowed = tempfile::tempdir().unwrap(); + let allowed = fs_err::canonicalize(allowed.path()).unwrap(); + let snapshot = allowed.join("snapshot"); + let attachment = socket_attachment(&allowed.join("console.sock")); + validate_microvm_console_attachment_namespace(&attachment, &snapshot).unwrap(); + + let outside = tempfile::tempdir().unwrap(); + let outside = fs_err::canonicalize(outside.path()).unwrap(); + let attachment = socket_attachment(&outside.join("console.sock")); + assert!(validate_microvm_console_attachment_namespace(&attachment, &snapshot).is_err()); + } + + #[test] + fn named_pipe_attachment_uses_dedicated_namespace() { + let mut attachment = socket_attachment(Path::new("unused")); + attachment.identity_kind = "named-pipe".to_owned(); + attachment.identity = b"//./pipe/openvmm-microvm-console0".to_vec(); + validate_microvm_console_attachment_namespace(&attachment, Path::new("snapshot")).unwrap(); + + attachment.identity = b"//./pipe/unrelated".to_vec(); + assert!( + validate_microvm_console_attachment_namespace(&attachment, Path::new("snapshot")) + .is_err() + ); + } + + #[test] + fn named_pipe_endpoint_identity_is_case_insensitive() { + let mut left = socket_attachment(Path::new("unused")); + left.identity_kind = "named-pipe".to_owned(); + left.identity = b"//./pipe/openvmm-microvm-Control".to_vec(); + let mut right = left.clone(); + right.identity = b"//./pipe/openvmm-microvm-control".to_vec(); + assert!(microvm_console_attachments_share_endpoint(&left, &right)); + + left.identity_kind = "unix-socket".to_owned(); + right.identity_kind = "unix-socket".to_owned(); + assert!(!microvm_console_attachments_share_endpoint(&left, &right)); + } + + #[test] + fn listener_replacement_contract_changes_only_endpoint_identity() { + let saved = socket_attachment(Path::new("source.sock")); + let mut requested = saved.clone(); + requested.identity = b"restored.sock".to_vec(); + assert!(microvm_console_listener_replacement_matches( + &saved, &requested + )); + + requested.kind = MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND.to_owned(); + assert!(!microvm_console_listener_replacement_matches( + &saved, &requested + )); + } + + #[test] + fn client_attachment_is_required_and_bounded() { + let requested = SerialConfigCli::ConnectTcp("127.0.0.1:5555".parse().unwrap()); + let (_, resource, snapshot) = microvm_console_attachment_from_cli(&requested).unwrap(); + assert!(snapshot.required); + assert_eq!(snapshot.reconnect_policy, "reconnect-client"); + assert_eq!( + snapshot.reconnect_timeout_ms, + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::ReconnectClient + ); + + assert!(microvm_console_attachment_from_snapshot(&snapshot, None).is_err()); + let (restored, _, restored_snapshot) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + assert!(matches!(restored, SerialConfigCli::ConnectTcp(_))); + assert_eq!(restored_snapshot, snapshot); + } + + #[cfg(unix)] + #[test] + fn listener_attachment_accepts_fresh_restore_identity_after_source_cleanup() { + let source_directory = tempfile::tempdir().unwrap(); + let source = SerialConfigCli::Pipe(source_directory.path().join("source.sock")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + source_directory.close().unwrap(); + let restore_directory = tempfile::tempdir().unwrap(); + let requested = SerialConfigCli::Pipe(restore_directory.path().join("restored.sock")); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a listener"); + }; + assert!(restored_path.ends_with("restored.sock")); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(unix)] + #[test] + fn authenticated_control_listener_accepts_fresh_identity_after_source_cleanup() { + use std::os::unix::fs::PermissionsExt as _; + + let source_directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions( + source_directory.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let source = SerialConfigCli::Pipe(source_directory.path().join("source-control.sock")); + let (_, _, snapshot) = microvm_control_console_attachment_from_cli(&source).unwrap(); + source_directory.close().unwrap(); + let restore_directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions( + restore_directory.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let requested = + SerialConfigCli::Pipe(restore_directory.path().join("restored-control.sock")); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + Some(&requested), + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a control listener"); + }; + assert!(restored_path.ends_with("restored-control.sock")); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_eq!( + restored_attachment.reconnect_policy, + "broker-authenticated-listener" + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(windows)] + #[test] + fn named_pipe_listener_accepts_fresh_restore_identity() { + let source = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-source-console")); + let requested = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-restored-console")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a named-pipe listener"); + }; + assert_eq!( + restored_path, + PathBuf::from("//./pipe/openvmm-microvm-restored-console") + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(windows)] + #[test] + fn authenticated_named_pipe_listener_accepts_fresh_restore_identity() { + let source = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-source-control")); + let requested = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-restored-control")); + let (_, _, snapshot) = microvm_control_console_attachment_from_cli(&source).unwrap(); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + Some(&requested), + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain an authenticated named-pipe listener"); + }; + assert_eq!( + restored_path, + PathBuf::from("//./pipe/openvmm-microvm-restored-control") + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_eq!( + restored_attachment.reconnect_policy, + "broker-authenticated-listener" + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(unix)] + #[test] + fn listener_attachment_rejects_restore_client_substitution() { + let directory = tempfile::tempdir().unwrap(); + let source = SerialConfigCli::Pipe(directory.path().join("source.sock")); + let requested = SerialConfigCli::ConnectPipe(directory.path().join("restored.sock")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + + assert!(microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).is_err()); + } + + #[test] + fn tcp_attachment_rejects_non_loopback_addresses() { + assert!( + microvm_console_attachment_from_cli(&SerialConfigCli::Tcp( + "0.0.0.0:5555".parse().unwrap() + )) + .is_err() + ); + assert!( + microvm_console_attachment_from_cli(&SerialConfigCli::ConnectTcp( + "192.0.2.1:5555".parse().unwrap() + )) + .is_err() + ); + } + + #[test] + fn inherited_attachment_requires_matching_replacement() { + let (_, _, snapshot) = + microvm_console_attachment_from_cli(&SerialConfigCli::Console).unwrap(); + assert!(snapshot.required); + assert_eq!(snapshot.reconnect_policy, "require-inherited-attachment"); + assert!(microvm_console_attachment_from_snapshot(&snapshot, None).is_err()); + assert!( + microvm_console_attachment_from_snapshot(&snapshot, Some(&SerialConfigCli::Console)) + .is_ok() + ); + assert!( + microvm_console_attachment_from_snapshot( + &snapshot, + Some(&SerialConfigCli::ConnectTcp( + "127.0.0.1:5555".parse().unwrap() + )) + ) + .is_err() + ); + } + + #[test] + fn disconnected_attachment_preserves_discard_policy() { + let (config, resource, snapshot) = + microvm_console_attachment_from_cli(&SerialConfigCli::None).unwrap(); + assert!(matches!(config, SerialConfigCli::None)); + assert!(!snapshot.required); + assert_eq!(snapshot.reconnect_policy, "discard-while-disconnected"); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + ); + + let (restored, _, restored_snapshot) = + microvm_console_attachment_from_snapshot(&snapshot, None).unwrap(); + assert!(matches!(restored, SerialConfigCli::None)); + assert_eq!(restored_snapshot, snapshot); + } + + #[test] + fn control_attachment_has_distinct_identity_and_rejects_tcp() { + let (_, resource, snapshot) = + microvm_control_console_attachment_from_cli(&SerialConfigCli::None).unwrap(); + assert_eq!(resource.stable_id, MICROVM_CONTROL_CONSOLE_STABLE_ID); + assert_eq!(snapshot.stable_id, MICROVM_CONTROL_CONSOLE_STABLE_ID); + assert_eq!(snapshot.kind, MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND); + assert_ne!( + snapshot.stable_id, + microvm_console_attachment_from_cli(&SerialConfigCli::None) + .unwrap() + .2 + .stable_id + ); + + let (_, restored_resource, restored_snapshot) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + None, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + assert_eq!(restored_resource.stable_id, resource.stable_id); + assert_eq!(restored_snapshot, snapshot); + + assert!( + microvm_control_console_attachment_from_cli(&SerialConfigCli::Tcp( + "127.0.0.1:5555".parse().unwrap() + )) + .is_err() + ); + } + + #[test] + fn disconnected_control_console_uses_fresh_nonzero_runtime_secrets() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + ]) + .unwrap(); + let first = microvm_control_broker_config(&options, &SerialConfigCli::None, None).unwrap(); + let second = microvm_control_broker_config(&options, &SerialConfigCli::None, None).unwrap(); + assert_ne!(first.instance_id, [0; 16]); + assert_ne!(second.instance_id, [0; 16]); + assert_ne!(first.instance_id, second.instance_id); + assert_ne!(first.capability, [0; 32]); + assert_ne!(second.capability, [0; 32]); + } + + #[cfg(unix)] + #[test] + fn restore_bind_does_not_unlink_existing_socket() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("console.sock"); + let _existing = unix_socket::UnixListener::bind(&path).unwrap(); + assert!(serial_io::connect::bind_serial_without_cleanup(&path).is_err()); + assert!(fs_err::symlink_metadata(&path).is_ok()); + } + + #[cfg(unix)] + #[test] + fn cleanup_guard_does_not_remove_replaced_socket() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("console.sock"); + let listener = unix_socket::UnixListener::bind(&path).unwrap(); + let cleanup = MicrovmConsoleSocketCleanup::new(path.clone()).unwrap(); + fs_err::remove_file(&path).unwrap(); + let replacement = unix_socket::UnixListener::bind(&path).unwrap(); + + assert!(cleanup.remove_if_owned().is_err()); + assert!(fs_err::symlink_metadata(&path).is_ok()); + drop((cleanup, replacement, listener)); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs new file mode 100644 index 00000000000..cf804e536e7 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -0,0 +1,1832 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM virtio-fs attachment and filesystem policy. + +use crate::cli_args; +use anyhow::Context; +use std::path::Path; +use std::path::PathBuf; + +pub(super) const MICROVM_FILESYSTEM_STABLE_ID: &str = + openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0].stable_id; + +#[derive(Clone, Debug)] +pub(super) struct EffectiveMicrovmFilesystem { + pub(super) config: openvmm_defs::microvm::MicrovmFilesystemConfig, + pub(super) root_path: String, + pub(super) attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +fn canonical_microvm_filesystem_root( + path: &Path, +) -> anyhow::Result<(PathBuf, &'static str, Vec)> { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "microVM filesystem host path is empty" + ); + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("failed to resolve current directory for microVM filesystem")? + .join(path) + }; + + let mut current = PathBuf::new(); + for component in absolute.components() { + use std::path::Component; + match component { + Component::Prefix(_) | Component::RootDir => { + current.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + anyhow::bail!("microVM filesystem host path contains a parent component") + } + Component::Normal(_) => { + current.push(component.as_os_str()); + let metadata = fs_err::symlink_metadata(¤t).with_context(|| { + format!( + "failed to inspect microVM filesystem path component {}", + current.display() + ) + })?; + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM filesystem path component is a symbolic link: {}", + current.display() + ); + #[cfg(windows)] + anyhow::ensure!( + std::os::windows::fs::MetadataExt::file_attributes(&metadata) & 0x400 == 0, + "microVM filesystem path component is a reparse point: {}", + current.display() + ); + } + } + } + + let canonical = fs_err::canonicalize(&absolute).with_context(|| { + format!( + "failed to canonicalize microVM filesystem root {}", + absolute.display() + ) + })?; + let metadata = fs_err::symlink_metadata(&canonical).with_context(|| { + format!( + "failed to inspect microVM filesystem root {}", + canonical.display() + ) + })?; + anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "microVM filesystem root is not a plain directory: {}", + canonical.display() + ); + + #[cfg(unix)] + let (identity_kind, identity) = { + use std::os::unix::fs::MetadataExt as _; + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&metadata.dev().to_le_bytes()); + identity.extend_from_slice(&metadata.ino().to_le_bytes()); + ("unix-device-inode-v1", identity) + }; + #[cfg(windows)] + let (identity_kind, identity) = { + use std::os::windows::ffi::OsStrExt as _; + let stat = pal::windows::fs::query_stat_lx_by_name(&canonical) + .context("failed to query the microVM filesystem root identity")?; + anyhow::ensure!( + stat.FileId != 0, + "microVM filesystem root has no stable file identity" + ); + let volume = canonical + .components() + .next() + .and_then(|component| match component { + std::path::Component::Prefix(prefix) => Some(prefix.as_os_str()), + _ => None, + }) + .context("microVM filesystem root has no volume prefix")?; + let volume = volume.encode_wide().collect::>(); + let volume_bytes = u32::try_from(volume.len()) + .context("microVM filesystem volume identity is too long")? + .to_le_bytes(); + let mut identity = b"openvmm-microvm-fs-windows-v1\0".to_vec(); + identity.extend_from_slice(&volume_bytes); + identity.extend(volume.into_iter().flat_map(u16::to_le_bytes)); + identity.extend_from_slice(&stat.FileId.to_le_bytes()); + ("windows-volume-file-id-v1", identity) + }; + #[cfg(not(any(unix, windows)))] + let (identity_kind, identity) = + { anyhow::bail!("microVM virtio-fs requires Linux KVM/MSHV or Windows WHP") }; + anyhow::ensure!( + !identity.is_empty() && identity.len() <= 4096, + "microVM filesystem root identity is empty or exceeds 4096 bytes" + ); + + Ok((canonical, identity_kind, identity)) +} + +/// Resolves the host paths that one of `--mount-deny`, `--mount-allow`, or +/// `--mount-write` requests in the export root `root_path` to unique, canonical +/// relative paths in lexical order. Each must exist inside the export root, on +/// its filesystem, and be reachable without a symbolic link. +fn canonical_microvm_filesystem_policy_paths( + root_path: &Path, + requested: &[PathBuf], + kind: openvmm_defs::microvm::MicrovmFilesystemPathKind, +) -> anyhow::Result> { + #[cfg(unix)] + let root_metadata = fs_err::symlink_metadata(root_path).with_context(|| { + format!( + "failed to inspect microVM filesystem root {}", + root_path.display() + ) + })?; + let mut relative_paths = Vec::with_capacity(requested.len()); + for path in requested { + anyhow::ensure!( + !path.as_os_str().is_empty() + && !path.components().any(|component| matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + )), + "microVM {kind} path is empty or contains a dot or parent component: {}", + path.display() + ); + let absolute = if path.is_absolute() { + path.clone() + } else { + root_path.join(path) + }; + let canonical = fs_err::canonicalize(&absolute).with_context(|| { + format!( + "failed to canonicalize microVM {kind} path {}", + absolute.display() + ) + })?; + // Canonicalizing resolves links, so inspect the requested path itself. + let mut current = PathBuf::new(); + for component in absolute.components() { + current.push(component.as_os_str()); + if !matches!(component, std::path::Component::Normal(_)) { + continue; + } + let metadata = fs_err::symlink_metadata(¤t).with_context(|| { + format!( + "failed to inspect microVM {kind} path component {}", + current.display() + ) + })?; + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM {kind} path component is a symbolic link: {}", + current.display() + ); + #[cfg(windows)] + anyhow::ensure!( + std::os::windows::fs::MetadataExt::file_attributes(&metadata) & 0x400 == 0, + "microVM {kind} path component is a reparse point: {}", + current.display() + ); + } + let relative = canonical.strip_prefix(root_path).with_context(|| { + format!( + "microVM {kind} path resolves outside the filesystem export root: {}", + path.display() + ) + })?; + anyhow::ensure!( + !relative.as_os_str().is_empty(), + "microVM {kind} path cannot {} the complete filesystem export", + if kind == openvmm_defs::microvm::MicrovmFilesystemPathKind::Denied { + "hide" + } else { + "be" + } + ); + anyhow::ensure!( + relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))), + "microVM {kind} path contains a non-normal component" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = fs_err::symlink_metadata(&canonical)?; + anyhow::ensure!( + metadata.dev() == root_metadata.dev(), + "microVM {kind} path crosses a nested mount: {}", + path.display() + ); + } + relative_paths.push(relative.to_owned()); + } + let mut encoded = relative_paths + .iter() + .map(|path| { + path.iter() + .map(|component| { + component + .to_str() + .with_context(|| format!("microVM {kind} path is not valid UTF-8")) + }) + .collect::>>() + .map(|components| components.join("/")) + }) + .collect::>>()?; + // The snapshot contract records the paths in lexical order. + encoded.sort_unstable(); + for pair in encoded.windows(2) { + anyhow::ensure!( + pair[0] != pair[1], + "microVM {kind} paths must be unique{}", + match kind { + openvmm_defs::microvm::MicrovmFilesystemPathKind::Allowed => "", + _ => " and non-overlapping", + } + ); + } + openvmm_defs::microvm::validate_microvm_filesystem_policy_paths(kind, &encoded)?; + Ok(encoded) +} + +/// Returns the canonical host root of `host_path` and its live attachment +/// identity in the virtio-fs slot `slot`. +pub(crate) fn microvm_filesystem_attachment( + host_path: &Path, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, +) -> anyhow::Result<( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + let (canonical, identity_kind, identity) = canonical_microvm_filesystem_root(host_path)?; + let root_path = canonical + .to_str() + .context("microVM filesystem host path is not valid UTF-8")? + .to_owned(); + Ok(( + root_path, + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: slot.stable_id.to_owned(), + kind: "virtio-fs".to_owned(), + required: true, + reconnect_policy: "live-revalidate".to_owned(), + identity_kind: identity_kind.to_owned(), + identity, + length: 0, + reconnect_timeout_ms: 0, + }, + )) +} + +/// The host paths that `--mount-deny`, `--mount-allow`, and `--mount-write` +/// request, each in the `--mount` whose host directory contains it. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct MicrovmFilesystemPolicyPaths<'a> { + /// Paths hidden from the guest. + pub(crate) denied: &'a [PathBuf], + /// Paths inside denied paths that the guest can reach again. + pub(crate) allowed: &'a [PathBuf], + /// The only paths of a read-write export that the guest can modify. + pub(crate) writable: &'a [PathBuf], +} + +/// Builds the filesystem of `requested` in its canonical host root, with the +/// access policy that `--mount-deny`, `--mount-allow`, and `--mount-write` +/// attributed to it. +fn microvm_filesystem_from_root( + requested: &cli_args::microvm::MicrovmMountCli, + (root_path, attachment): ( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + ), + policy: MicrovmFilesystemPolicyPaths<'_>, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, +) -> anyhow::Result { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + + if owner.is_caller() { + validate_caller_owned_root(Path::new(&root_path))?; + } + let root = Path::new(&root_path); + let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + requested.guest_target.clone(), + requested.access, + )? + .with_access_policy( + canonical_microvm_filesystem_policy_paths( + root, + policy.denied, + MicrovmFilesystemPathKind::Denied, + )?, + canonical_microvm_filesystem_policy_paths( + root, + policy.allowed, + MicrovmFilesystemPathKind::Allowed, + )?, + canonical_microvm_filesystem_policy_paths( + root, + policy.writable, + MicrovmFilesystemPathKind::Writable, + )?, + )? + .with_owner(owner); + Ok(EffectiveMicrovmFilesystem { + config, + root_path, + attachment, + }) +} + +/// Rejects host roots that overlap, so that no share can reach the files of +/// another share, or the paths that another share denies, under a different +/// access policy. Equal root identities catch one directory reached through +/// two canonical paths, such as a bind mount. +fn validate_microvm_filesystem_roots( + roots: &[( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )], +) -> anyhow::Result<()> { + for (index, (root, attachment)) in roots.iter().enumerate() { + for (other, other_attachment) in &roots[..index] { + anyhow::ensure!( + !Path::new(root).starts_with(other) + && !Path::new(other).starts_with(root) + && attachment.identity != other_attachment.identity, + "microVM filesystem host directories must not overlap: {other} and {root}" + ); + } + } + #[cfg(target_os = "linux")] + if roots.len() > 1 { + let mountinfo = fs_err::read_to_string("/proc/self/mountinfo")?; + let roots = roots + .iter() + .map(|(root, _)| Path::new(root)) + .collect::>(); + mount_sources::validate_disjoint(&mountinfo, &roots)?; + } + Ok(()) +} + +/// Compares where the files of each host root come from. +/// +/// A bind mount gives a directory a second canonical path and root identity, +/// so it can expose part of one share as another share's root, or inside +/// another share's tree. Each root reaches the source directory of the mount +/// that contains it and the source of every mount below it, and two roots +/// overlap when any of those ranges of one filesystem contains another. +#[cfg(target_os = "linux")] +mod mount_sources { + use anyhow::Context; + use std::path::Path; + use std::path::PathBuf; + + struct Mount { + device: String, + root: PathBuf, + mount_point: PathBuf, + } + + /// A directory tree of one filesystem, named by its device and its path + /// within that filesystem. + struct Source { + device: String, + path: PathBuf, + } + + impl Source { + fn overlaps(&self, other: &Source) -> bool { + self.device == other.device + && (self.path.starts_with(&other.path) || other.path.starts_with(&self.path)) + } + } + + fn unescape(field: &str) -> anyhow::Result { + use std::os::unix::ffi::OsStringExt as _; + let bytes = field.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'\\' { + let digits = bytes + .get(index + 1..index + 4) + .and_then(|digits| std::str::from_utf8(digits).ok()) + .and_then(|digits| u8::from_str_radix(digits, 8).ok()) + .with_context(|| format!("invalid escape in mountinfo field '{field}'"))?; + decoded.push(digits); + index += 4; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + Ok(PathBuf::from(std::ffi::OsString::from_vec(decoded))) + } + + fn parse(mountinfo: &str) -> anyhow::Result> { + mountinfo + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let fields = line.split(' ').collect::>(); + anyhow::ensure!(fields.len() >= 5, "malformed mountinfo line '{line}'"); + Ok(Mount { + device: fields[2].to_owned(), + root: unescape(fields[3])?, + mount_point: unescape(fields[4])?, + }) + }) + .collect() + } + + fn sources(mounts: &[Mount], root: &Path) -> anyhow::Result> { + // The deepest mount point that contains the root, and among mounts + // stacked there, the latest, which hides the others. + let (_, containing) = mounts + .iter() + .enumerate() + .filter(|(_, mount)| root.starts_with(&mount.mount_point)) + .max_by_key(|(index, mount)| (mount.mount_point.components().count(), *index)) + .with_context(|| format!("no mount contains {}", root.display()))?; + let relative = root.strip_prefix(&containing.mount_point)?; + let mut sources = vec![Source { + device: containing.device.clone(), + path: containing.root.join(relative), + }]; + sources.extend( + mounts + .iter() + .filter(|mount| mount.mount_point != root && mount.mount_point.starts_with(root)) + .map(|mount| Source { + device: mount.device.clone(), + path: mount.root.clone(), + }), + ); + Ok(sources) + } + + /// Rejects canonical roots that reach a common directory of a filesystem. + pub(super) fn validate_disjoint(mountinfo: &str, roots: &[&Path]) -> anyhow::Result<()> { + let mounts = parse(mountinfo)?; + let sources = roots + .iter() + .map(|root| sources(&mounts, root)) + .collect::>>()?; + for (index, root_sources) in sources.iter().enumerate() { + for (other_index, other_sources) in sources[..index].iter().enumerate() { + anyhow::ensure!( + !root_sources + .iter() + .any(|source| other_sources.iter().any(|other| source.overlaps(other))), + "microVM filesystem host directories must not overlap: {} and {} reach the same files through a mount", + roots[other_index].display(), + roots[index].display() + ); + } + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::validate_disjoint; + use std::path::Path; + + const BASE: &str = "\ +22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw +23 22 0:21 / /proc rw,nosuid shared:2 - proc proc rw +"; + + fn check(mountinfo: &str, first: &str, second: &str) -> anyhow::Result<()> { + validate_disjoint(mountinfo, &[Path::new(first), Path::new(second)]) + } + + #[test] + fn separate_directories_of_one_filesystem_are_disjoint() { + check(BASE, "/workspace", "/toolcache").unwrap(); + check(BASE, "/work", "/workspace").unwrap(); + } + + #[test] + fn a_bind_mount_of_a_subdirectory_overlaps_its_source() { + let mountinfo = + format!("{BASE}30 22 8:1 /workspace/cache /toolcache rw - ext4 /dev/sda1 rw\n"); + let error = check(&mountinfo, "/workspace", "/toolcache").unwrap_err(); + assert!(error.to_string().contains("through a mount"), "{error:#}"); + check(&mountinfo, "/toolcache", "/workspace").unwrap_err(); + // The bind mount's source is not inside an unrelated directory. + check(&mountinfo, "/srv", "/toolcache").unwrap(); + } + + #[test] + fn a_mount_below_one_root_overlaps_the_other_root() { + let mountinfo = + format!("{BASE}31 22 8:1 /toolcache /workspace/tools rw - ext4 /dev/sda1 rw\n"); + check(&mountinfo, "/workspace", "/toolcache").unwrap_err(); + check(&mountinfo, "/workspace", "/toolcache/node").unwrap_err(); + check(&mountinfo, "/workspace", "/opt").unwrap(); + } + + #[test] + fn other_filesystems_and_unrelated_mounts_do_not_overlap() { + let mountinfo = format!( + "{BASE}40 22 8:2 / /data rw - ext4 /dev/sda2 rw\n\ + 41 22 0:50 / /workspace/tmp rw - tmpfs tmpfs rw\n" + ); + check(&mountinfo, "/workspace", "/data").unwrap(); + // A second mount of the same filesystem reaches the same files. + let twice = format!("{mountinfo}42 22 8:2 / /mnt/data rw - ext4 /dev/sda2 rw\n"); + check(&twice, "/data/a", "/mnt/data/a/b").unwrap_err(); + check(&twice, "/data/a", "/mnt/data/b").unwrap(); + } + + #[test] + fn escaped_paths_and_stacked_mounts_resolve() { + let mountinfo = format!( + "{BASE}50 22 8:1 /src\\040dir /mnt/share\\040one rw - ext4 /dev/sda1 rw\n\ + 51 22 8:2 / /mnt/share\\040one rw - ext4 /dev/sda2 rw\n" + ); + // The later mount hides the earlier one at the same mount point. + check(&mountinfo, "/mnt/share one", "/src dir").unwrap(); + check(&mountinfo, "/mnt/share one", "/data").unwrap(); + let bind = format!("{BASE}52 22 8:1 /src\\040dir /mnt/share rw - ext4 /dev/sda1 rw\n"); + check(&bind, "/mnt/share", "/src dir/sub").unwrap_err(); + assert!(validate_disjoint("22 1 8:1", &[Path::new("/a"), Path::new("/b")]).is_err()); + } + } +} + +/// Attributes the host paths of one access-policy option to the export roots +/// that contain them: every path to the only root, which resolves a relative +/// path, or each absolute path to the root that contains it. +fn attribute_microvm_filesystem_policy_paths( + roots: &[( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )], + requested: &[PathBuf], + option: &str, + kind: openvmm_defs::microvm::MicrovmFilesystemPathKind, +) -> anyhow::Result>> { + let mut attributed = vec![Vec::new(); roots.len()]; + match roots.len() { + 0 => anyhow::ensure!(requested.is_empty(), "{option} requires --mount"), + 1 => attributed[0].extend_from_slice(requested), + _ => { + for path in requested { + anyhow::ensure!( + path.is_absolute(), + "with several --mount options, {option} requires an absolute host path: {}", + path.display() + ); + let canonical = fs_err::canonicalize(path).with_context(|| { + format!( + "failed to canonicalize microVM {kind} path {}", + path.display() + ) + })?; + let index = roots + .iter() + .position(|(root, _)| canonical.starts_with(root)) + .with_context(|| { + format!( + "microVM {kind} path resolves outside the filesystem export roots: {}", + path.display() + ) + })?; + attributed[index].push(path.clone()); + } + } + } + Ok(attributed) +} + +/// Builds the filesystems of the `--mount` options, which occupy the fixed +/// virtio-fs slots in order. Each `--mount-deny`, `--mount-allow`, and +/// `--mount-write` applies to the export root that contains it; a relative +/// path is relative to the only export root. +fn microvm_filesystems_from_mounts( + requested: &[cli_args::microvm::MicrovmMountCli], + policy: MicrovmFilesystemPolicyPaths<'_>, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, +) -> anyhow::Result> { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + + let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; + anyhow::ensure!( + requested.len() <= slots.len(), + openvmm_defs::microvm::InvalidMicrovmFilesystemConfig::TooManyFilesystems + ); + let roots = requested + .iter() + .zip(slots) + .map(|(mount, slot)| microvm_filesystem_attachment(&mount.host_path, slot)) + .collect::>>()?; + validate_microvm_filesystem_roots(&roots)?; + + let denied = attribute_microvm_filesystem_policy_paths( + &roots, + policy.denied, + "--mount-deny", + MicrovmFilesystemPathKind::Denied, + )?; + let allowed = attribute_microvm_filesystem_policy_paths( + &roots, + policy.allowed, + "--mount-allow", + MicrovmFilesystemPathKind::Allowed, + )?; + let writable = attribute_microvm_filesystem_policy_paths( + &roots, + policy.writable, + "--mount-write", + MicrovmFilesystemPathKind::Writable, + )?; + + let filesystems = requested + .iter() + .zip(roots) + .enumerate() + .map(|(index, (mount, root))| { + microvm_filesystem_from_root( + mount, + root, + MicrovmFilesystemPolicyPaths { + denied: &denied[index], + allowed: &allowed[index], + writable: &writable[index], + }, + owner, + ) + }) + .collect::>>()?; + openvmm_defs::microvm::validate_microvm_filesystems( + &filesystems + .iter() + .map(|filesystem| filesystem.config.clone()) + .collect::>(), + )?; + Ok(filesystems) +} + +/// Rejects an export root that caller ownership cannot squash guest root to. +fn validate_caller_owned_root(root_path: &Path) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = fs_err::symlink_metadata(root_path)?; + anyhow::ensure!( + metadata.uid() != 0 && metadata.gid() != 0, + "--mount-owner caller squashes guest root to the owner of the export root, so {} must not be owned by UID 0 or GID 0", + root_path.display() + ); + Ok(()) + } + #[cfg(not(unix))] + { + let _ = root_path; + anyhow::bail!("--mount-owner caller requires a Linux host") + } +} + +pub(crate) fn validate_microvm_filesystem_private_storage( + root_path: &Path, + snapshot_destination: Option<&Path>, + restore_snapshot: Option<&Path>, + memory_backing_file: Option<&Path>, +) -> anyhow::Result<()> { + let root_path = fs_err::canonicalize(root_path).with_context(|| { + format!( + "failed to canonicalize microVM filesystem export root {}", + root_path.display() + ) + })?; + let ensure_outside = |path: &Path, description: &str| -> anyhow::Result<()> { + anyhow::ensure!( + !path.starts_with(&root_path), + "{description} must be outside the microVM filesystem export root {}", + root_path.display() + ); + Ok(()) + }; + + if let Some(destination) = snapshot_destination { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent = fs_err::canonicalize(parent).with_context(|| { + format!( + "failed to canonicalize snapshot destination parent {}", + parent.display() + ) + })?; + ensure_outside(&parent, "snapshot destination")?; + } + if let Some(snapshot) = restore_snapshot { + let snapshot = fs_err::canonicalize(snapshot).with_context(|| { + format!( + "failed to canonicalize restore snapshot {}", + snapshot.display() + ) + })?; + ensure_outside(&snapshot, "restore snapshot")?; + } + if let Some(memory) = memory_backing_file { + let memory = fs_err::canonicalize(memory).with_context(|| { + format!( + "failed to canonicalize guest memory backing file {}", + memory.display() + ) + })?; + ensure_outside(&memory, "guest memory backing file")?; + } + Ok(()) +} + +pub(crate) fn microvm_filesystem_from_snapshot( + saved: &openvmm_helpers::snapshot::microvm::SnapshotMicrovmFilesystem, +) -> anyhow::Result { + let access = match saved.access_mode.as_str() { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("snapshot microVM filesystem access mode '{mode}' is unsupported"), + }; + openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access)? + .with_access_policy( + saved.denied_paths.clone(), + saved.allowed_paths.clone(), + saved.writable_paths.clone(), + ) + .context("snapshot microVM filesystem policy is invalid") + .and_then(|config| { + Ok(config.with_owner( + openvmm_helpers::snapshot::microvm::snapshot_microvm_filesystem_owner( + &saved.owner_mode, + )?, + )) + }) +} + +pub(crate) fn microvm_filesystem_slot_from_snapshot( + contract: &openvmm_helpers::snapshot::microvm::SnapshotMachineContract, +) -> anyhow::Result { + let has_device = contract + .devices + .iter() + .any(|device| device.stable_id == MICROVM_FILESYSTEM_STABLE_ID); + match contract.microvm_filesystem_slot_version { + 0 => Ok(has_device), + openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION => { + anyhow::ensure!( + has_device, + "snapshot advertises a restore-attachable microVM filesystem slot but omits its fixed device" + ); + Ok(true) + } + version => anyhow::bail!( + "snapshot microVM filesystem slot capability version {version} is unsupported" + ), + } +} + +/// Returns the effective filesystems of the `--mount` options, in virtio-fs +/// slot order. A restore must supply exactly the snapshot's filesystems, in +/// the same order, unless the snapshot's first slot is dormant, in which case +/// one `--mount` may bind a new filesystem to it. +pub(super) fn effective_microvm_filesystems( + requested: &[cli_args::microvm::MicrovmMountCli], + policy: MicrovmFilesystemPolicyPaths<'_>, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result> { + let Some(restore) = restore else { + return microvm_filesystems_from_mounts(requested, policy, owner); + }; + + let has_device = microvm_filesystem_slot_from_snapshot(restore)?; + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == MICROVM_FILESYSTEM_STABLE_ID); + anyhow::ensure!( + saved_attachment.is_some() == restore.microvm_filesystem.is_some() + && (restore.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION + || has_device == restore.microvm_filesystem.is_some()), + "snapshot microVM filesystem slot, policy, and attachment inventories disagree" + ); + let saved = restore.microvm_filesystems().collect::>(); + if saved.is_empty() { + if requested.is_empty() { + return Ok(Vec::new()); + } + anyhow::ensure!( + restore.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, + "snapshot does not support restore-time microVM filesystem attachment" + ); + // Only the dormant first slot exists in the snapshot's machine. + anyhow::ensure!( + requested.len() == 1, + "a dormant-slot snapshot can attach only one --mount on restore" + ); + return microvm_filesystems_from_mounts(requested, policy, owner); + } + anyhow::ensure!( + !requested.is_empty(), + "snapshot restore requires a fresh --mount attachment for fs:microvm0" + ); + anyhow::ensure!( + requested.len() == saved.len(), + "snapshot restore requires {} --mount attachments in snapshot order, but {} were supplied", + saved.len(), + requested.len() + ); + let mut configs = Vec::with_capacity(saved.len()); + for (requested, saved) in requested.iter().zip(&saved) { + let config = microvm_filesystem_from_snapshot(saved)?; + anyhow::ensure!( + requested.guest_target == config.guest_mount_target + && requested.access == config.access, + "restore-time mount target or access mode does not match the snapshot contract" + ); + anyhow::ensure!( + owner == config.owner, + "restore-time --mount-owner {} does not match the snapshot contract ({})", + owner.as_str(), + config.owner.as_str() + ); + configs.push(config); + } + let effective = microvm_filesystems_from_mounts(requested, policy, owner)?; + for ((effective, saved), config) in effective.iter().zip(&saved).zip(&configs) { + anyhow::ensure!( + !saved.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + anyhow::ensure!( + effective.root_path == saved.canonical_host_path, + "restore-time filesystem canonical host path does not match the snapshot contract" + ); + anyhow::ensure!( + restore.attachments.contains(&effective.attachment), + "restore-time filesystem root identity does not match the snapshot attachment" + ); + anyhow::ensure!( + effective.config == *config, + "restore-time filesystem access policy (denied, allowed, or writable paths) does not match the snapshot contract" + ); + } + Ok(effective) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Options; + use crate::microvm::network::tests::network_contract; + use clap::Parser as _; + use openvmm_defs::microvm::build_microvm_command_line; + use test_with_tracing::test; + + const VMM: openvmm_defs::microvm::MicrovmFilesystemOwner = + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm; + const DENIED: openvmm_defs::microvm::MicrovmFilesystemPathKind = + openvmm_defs::microvm::MicrovmFilesystemPathKind::Denied; + const NO_POLICY: MicrovmFilesystemPolicyPaths<'static> = MicrovmFilesystemPolicyPaths { + denied: &[], + allowed: &[], + writable: &[], + }; + + fn denied(paths: &[PathBuf]) -> MicrovmFilesystemPolicyPaths<'_> { + MicrovmFilesystemPolicyPaths { + denied: paths, + ..NO_POLICY + } + } + + /// Resolves at most one `--mount`, like the single-share tests expect. + fn effective_microvm_filesystem( + requested: &[cli_args::microvm::MicrovmMountCli], + denied_paths: &[PathBuf], + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + ) -> anyhow::Result> { + let policy = MicrovmFilesystemPolicyPaths { + denied: denied_paths, + ..Default::default() + }; + let mut filesystems = effective_microvm_filesystems(requested, policy, owner, restore)?; + assert!(filesystems.len() <= 1); + Ok(filesystems.pop()) + } + + fn filesystem_contract( + root: &Path, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + filesystem_contract_with_owner(root, VMM) + } + + fn filesystem_contract_with_owner( + root: &Path, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap() + .with_owner(owner); + filesystems_contract(&[(filesystem, root)]) + } + + fn dormant_filesystem_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract + { + filesystems_contract(&[]) + } + + /// Builds the contract of a cold-booted machine with `shares` attached to + /// the virtio-fs slots in order, or with a dormant first slot. + fn filesystems_contract( + shares: &[(openvmm_defs::microvm::MicrovmFilesystemConfig, &Path)], + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + let filesystems = shares + .iter() + .map(|(filesystem, _)| filesystem.clone()) + .collect::>(); + let roots = shares + .iter() + .zip(&openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS) + .map(|((_, root), slot)| microvm_filesystem_attachment(root, slot).unwrap()) + .collect::>(); + let mut command_line = build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + true, + &filesystems, + false, + false, + &[], + None, + ) + .unwrap(); + let mut state_unit_names = [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + ] + .map(str::to_owned) + .to_vec(); + state_unit_names.extend( + openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .take(shares.len().max(1)) + .map(|slot| format!("virtiofs-{}", slot.mmio_base)), + ); + openvmm_helpers::snapshot::microvm::microvm_machine_contract( + if cfg!(windows) { "whp" } else { "kvm" }, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + true, + filesystems + .iter() + .zip(&roots) + .map(|(filesystem, (root_path, attachment))| { + (filesystem, Path::new(root_path), attachment.clone()) + }) + .collect(), + None, + None, + Vec::new(), + 1, + 1024, + None, + state_unit_names, + crate::microvm::restore::tests::test_time_contract(), + crate::microvm::restore::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn restore_mount_options(root: &Path, mode: &str) -> Options { + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--mount", + &format!("/mnt/share,{},{}", root.display(), mode), + ]) + .unwrap() + } + + #[test] + fn filesystem_denied_paths_are_canonical_and_root_scoped() { + let root = tempfile::tempdir().unwrap(); + let secrets = root.path().join("secrets"); + std::fs::create_dir(&secrets).unwrap(); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--mount", + &format!("/mnt/share,{},rw", root.path().display()), + "--mount-deny", + secrets.to_str().unwrap(), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &options.microvm.microvm_mount_deny, + VMM, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.denied_paths, vec!["secrets".to_owned()]); + + let outside = tempfile::tempdir().unwrap(); + assert!( + canonical_microvm_filesystem_policy_paths( + root.path(), + &[outside.path().to_owned()], + DENIED + ) + .is_err() + ); + assert!( + canonical_microvm_filesystem_policy_paths( + root.path(), + &[secrets.clone(), secrets], + DENIED + ) + .is_err() + ); + } + + #[test] + fn filesystem_restore_requires_same_live_root_identity() { + let root = tempfile::tempdir().unwrap(); + let contract = filesystem_contract(root.path()); + let options = restore_mount_options(root.path(), "ro"); + let restored = effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract), + ) + .unwrap() + .unwrap(); + assert_eq!(restored.config.guest_mount_target, "/mnt/share"); + assert_eq!(restored.attachment, contract.attachments[0]); + + let replacement = tempfile::tempdir().unwrap(); + let replacement_options = restore_mount_options(replacement.path(), "ro"); + assert!( + effective_microvm_filesystem( + replacement_options.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract) + ) + .is_err() + ); + } + + #[test] + fn filesystem_restore_rejects_same_root_at_a_new_path() { + let parent = tempfile::tempdir().unwrap(); + let original = parent.path().join("original"); + let moved = parent.path().join("moved"); + fs_err::create_dir(&original).unwrap(); + let contract = filesystem_contract(&original); + fs_err::rename(&original, &moved).unwrap(); + + let options = restore_mount_options(&moved, "ro"); + assert!( + effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract) + ) + .is_err() + ); + } + + #[test] + fn filesystem_restore_rejects_missing_or_changed_policy() { + let root = tempfile::tempdir().unwrap(); + let contract = filesystem_contract(root.path()); + assert!(effective_microvm_filesystem(&[], &[], VMM, Some(&contract)).is_err()); + + let changed_mode = restore_mount_options(root.path(), "rw"); + assert!( + effective_microvm_filesystem( + changed_mode.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract), + ) + .is_err() + ); + } + + #[test] + fn filesystem_restore_without_mount_preserves_dormant_slot() { + let contract = dormant_filesystem_contract(); + assert!( + effective_microvm_filesystem(&[], &[], VMM, Some(&contract)) + .unwrap() + .is_none() + ); + } + + #[test] + fn filesystem_restore_attaches_mount_to_dormant_slot() { + let root = tempfile::tempdir().unwrap(); + let contract = dormant_filesystem_contract(); + let options = restore_mount_options(root.path(), "rw"); + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract), + ) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.guest_mount_target, "/mnt/share"); + assert_eq!( + filesystem.config.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ); + assert_eq!( + filesystem.root_path, + fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + ); + } + + #[test] + fn filesystem_restore_rejects_mount_for_legacy_snapshot_without_slot() { + let root = tempfile::tempdir().unwrap(); + let contract = network_contract(); + let options = restore_mount_options(root.path(), "ro"); + let error = match effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + VMM, + Some(&contract), + ) { + Err(error) => error, + Ok(_) => panic!("legacy snapshot unexpectedly accepted a restore-time mount"), + }; + assert!( + error + .to_string() + .contains("does not support restore-time microVM filesystem attachment") + ); + } + + #[test] + fn filesystem_root_rejects_parent_components() { + let root = tempfile::tempdir().unwrap(); + assert!(canonical_microvm_filesystem_root(&root.path().join("child").join("..")).is_err()); + } + + #[test] + fn mount_owner_defaults_to_the_vmm_and_requires_a_mount() { + let root = tempfile::tempdir().unwrap(); + let mount = format!("/mnt/share,{},rw", root.path().display()); + let parse = |extra: &[&str]| { + Options::try_parse_from( + ["openvmm", "--machine", "microvm"] + .into_iter() + .chain(extra.iter().copied()), + ) + }; + let options = parse(&["--mount", &mount]).unwrap(); + assert_eq!(options.microvm.microvm_mount_owner, None); + let options = parse(&["--mount", &mount, "--mount-owner", "vmm"]).unwrap(); + assert_eq!( + options.microvm.microvm_mount_owner, + Some(cli_args::microvm::MicrovmMountOwnerCli::Vmm) + ); + options.validate_microvm_options().unwrap(); + assert!(parse(&["--mount-owner", "caller"]).is_err()); + assert!(parse(&["--mount", &mount, "--mount-owner", "root"]).is_err()); + + let options = parse(&["--mount", &mount, "--mount-owner", "caller"]).unwrap(); + let validation = options.validate_microvm_options(); + if cfg!(target_os = "linux") { + validation.unwrap(); + } else { + assert!( + validation + .unwrap_err() + .to_string() + .contains("--mount-owner caller requires a Linux host") + ); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn caller_owner_rejects_a_root_owned_export_root() { + use std::os::unix::fs::MetadataExt as _; + let caller = openvmm_defs::microvm::MicrovmFilesystemOwner::Caller; + let root_owned = Path::new("/"); + let metadata = fs_err::metadata(root_owned).unwrap(); + if metadata.uid() == 0 && metadata.gid() == 0 { + let options = restore_mount_options(root_owned, "ro"); + let error = match effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + caller, + None, + ) { + Err(error) => error, + Ok(_) => panic!("a root-owned export root was accepted for caller ownership"), + }; + assert!( + error + .to_string() + .contains("must not be owned by UID 0 or GID 0") + ); + } + + let root = tempfile::tempdir().unwrap(); + let metadata = fs_err::metadata(root.path()).unwrap(); + if metadata.uid() != 0 && metadata.gid() != 0 { + let options = restore_mount_options(root.path(), "rw"); + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_slice(), + &[], + caller, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.owner, caller); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn filesystem_restore_requires_the_same_owner_mode() { + use std::os::unix::fs::MetadataExt as _; + let caller = openvmm_defs::microvm::MicrovmFilesystemOwner::Caller; + let root = tempfile::tempdir().unwrap(); + let metadata = fs_err::metadata(root.path()).unwrap(); + if metadata.uid() == 0 || metadata.gid() == 0 { + return; + } + let options = restore_mount_options(root.path(), "ro"); + let requested = options.microvm.microvm_mount.as_slice(); + + let contract = filesystem_contract_with_owner(root.path(), caller); + assert_eq!( + contract.microvm_filesystem.as_ref().unwrap().owner_mode, + "caller" + ); + let restored = effective_microvm_filesystem(requested, &[], caller, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(restored.config.owner, caller); + let error = match effective_microvm_filesystem(requested, &[], VMM, Some(&contract)) { + Err(error) => error, + Ok(_) => panic!("restore accepted a different ownership mode"), + }; + assert!(error.to_string().contains("--mount-owner vmm")); + + // Snapshots record VMM ownership as an empty mode, like those that + // predate the field. + let contract = filesystem_contract(root.path()); + assert_eq!(contract.microvm_filesystem.as_ref().unwrap().owner_mode, ""); + assert!(effective_microvm_filesystem(requested, &[], caller, Some(&contract)).is_err()); + } + + #[cfg(unix)] + #[test] + fn filesystem_root_rejects_symbolic_link_components() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("target"); + fs_err::create_dir(&target).unwrap(); + let link = root.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(canonical_microvm_filesystem_root(&link).is_err()); + } + + #[test] + fn filesystem_export_rejects_snapshot_and_memory_storage() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("share"); + fs_err::create_dir(&root).unwrap(); + let memory = root.join("memory.bin"); + fs_err::write(&memory, b"memory").unwrap(); + let restore = root.join("restore"); + fs_err::create_dir(&restore).unwrap(); + + assert!( + validate_microvm_filesystem_private_storage( + &root, + Some(&root.join("snapshot")), + None, + None, + ) + .is_err() + ); + assert!( + validate_microvm_filesystem_private_storage(&root, None, Some(&restore), None,) + .is_err() + ); + assert!( + validate_microvm_filesystem_private_storage(&root, None, None, Some(&memory),).is_err() + ); + validate_microvm_filesystem_private_storage( + &root, + Some(&parent.path().join("snapshot")), + None, + None, + ) + .unwrap(); + } + + fn mount_options(mounts: &[String], extra: &[&str]) -> Options { + let mut args = vec!["openvmm", "--machine", "microvm"]; + for mount in mounts { + args.extend(["--mount", mount.as_str()]); + } + args.extend(extra); + Options::try_parse_from(args).unwrap() + } + + fn workspace_and_toolcache( + workspace: &Path, + toolcache: &Path, + toolcache_mode: &str, + ) -> Vec { + vec![ + format!("/workspace,{},rw", workspace.display()), + format!( + "/opt/hostedtoolcache,{},{toolcache_mode}", + toolcache.display() + ), + ] + } + + #[test] + fn mounts_occupy_the_fixed_slots_in_order() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let options = mount_options( + &workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"), + &[], + ); + options.validate_microvm_options().unwrap(); + let filesystems = + effective_microvm_filesystems(&options.microvm.microvm_mount, NO_POLICY, VMM, None) + .unwrap(); + let summary = filesystems + .iter() + .map(|filesystem| { + ( + filesystem.attachment.stable_id.as_str(), + filesystem.config.guest_mount_target.as_str(), + filesystem.config.access, + ) + }) + .collect::>(); + assert_eq!( + summary, + [ + ( + "fs:microvm0", + "/workspace", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ), + ( + "fs:microvm1", + "/opt/hostedtoolcache", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ), + ] + ); + for (filesystem, root) in filesystems.iter().zip([&workspace, &toolcache]) { + assert_eq!( + filesystem.root_path, + fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + ); + } + assert_ne!( + filesystems[0].attachment.identity, + filesystems[1].attachment.identity + ); + } + + #[test] + fn mount_deny_applies_to_the_share_that_contains_it() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let workspace_secrets = workspace.path().join("secrets"); + let toolcache_secrets = toolcache.path().join("credentials"); + fs_err::create_dir(&workspace_secrets).unwrap(); + fs_err::create_dir(&toolcache_secrets).unwrap(); + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let filesystems = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + denied(&[toolcache_secrets.clone(), workspace_secrets.clone()]), + VMM, + None, + ) + .unwrap(); + assert_eq!(filesystems[0].config.denied_paths, ["secrets"]); + assert_eq!(filesystems[1].config.denied_paths, ["credentials"]); + + // With several shares, a relative path names no export root. + let error = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + denied(&[PathBuf::from("secrets")]), + VMM, + None, + ) + .unwrap_err(); + assert!( + error.to_string().contains("absolute host path"), + "{error:#}" + ); + + let outside = tempfile::tempdir().unwrap(); + let error = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + denied(&[outside.path().to_owned()]), + VMM, + None, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("outside the filesystem export roots") + ); + } + + #[test] + fn mounts_reject_overlapping_host_directories_and_guest_targets() { + let parent = tempfile::tempdir().unwrap(); + let nested = parent.path().join("nested"); + fs_err::create_dir(&nested).unwrap(); + for (first, second) in [ + (parent.path(), nested.as_path()), + (nested.as_path(), parent.path()), + (parent.path(), parent.path()), + ] { + let options = mount_options(&workspace_and_toolcache(first, second, "ro"), &[]); + let error = match effective_microvm_filesystems( + &options.microvm.microvm_mount, + NO_POLICY, + VMM, + None, + ) { + Err(error) => error, + Ok(_) => panic!("overlapping host directories were accepted"), + }; + assert!(error.to_string().contains("must not overlap"), "{error:#}"); + } + + let other = tempfile::tempdir().unwrap(); + let options = mount_options( + &[ + format!("/workspace,{},rw", parent.path().display()), + format!("/workspace/cache,{},ro", other.path().display()), + ], + &[], + ); + assert!(options.validate_microvm_options().is_err()); + assert!( + effective_microvm_filesystems(&options.microvm.microvm_mount, NO_POLICY, VMM, None) + .is_err() + ); + } + + fn two_share_contract( + workspace: &Path, + toolcache: &Path, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + filesystems_contract(&[ + ( + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/workspace".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .unwrap(), + workspace, + ), + ( + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(), + toolcache, + ), + ]) + } + + #[test] + fn filesystem_restore_requires_every_share_in_snapshot_order() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let contract = two_share_contract(workspace.path(), toolcache.path()); + assert_eq!(contract.microvm_additional_filesystems.len(), 1); + let restore = |mounts: &[String]| { + let options = mount_options(mounts, &["--restore-snapshot", "snapshot"]); + effective_microvm_filesystems( + &options.microvm.microvm_mount, + NO_POLICY, + VMM, + Some(&contract), + ) + }; + + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let restored = restore(&mounts).unwrap(); + assert_eq!(restored.len(), 2); + assert_eq!( + restored + .iter() + .map(|filesystem| filesystem.attachment.clone()) + .collect::>(), + contract.attachments + ); + + let error = restore(&[]).unwrap_err(); + assert!(error.to_string().contains("requires a fresh --mount")); + let error = restore(&mounts[..1]).unwrap_err(); + assert!( + error.to_string().contains("2 --mount attachments"), + "{error:#}" + ); + let swapped = [mounts[1].clone(), mounts[0].clone()]; + assert!(restore(&swapped).is_err()); + let writable = workspace_and_toolcache(workspace.path(), toolcache.path(), "rw"); + assert!(restore(&writable).is_err()); + + let replacement = tempfile::tempdir().unwrap(); + let replaced = workspace_and_toolcache(workspace.path(), replacement.path(), "ro"); + let error = restore(&replaced).unwrap_err(); + assert!( + error.to_string().contains("canonical host path"), + "{error:#}" + ); + } + + #[test] + fn dormant_snapshot_attaches_only_one_mount_on_restore() { + let contract = dormant_filesystem_contract(); + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let options = mount_options( + &workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"), + &["--restore-snapshot", "snapshot"], + ); + let error = match effective_microvm_filesystems( + &options.microvm.microvm_mount, + NO_POLICY, + VMM, + Some(&contract), + ) { + Err(error) => error, + Ok(_) => panic!("a dormant-slot snapshot attached two shares"), + }; + assert!(error.to_string().contains("only one --mount")); + } + + /// Resolves the access policy of the `--mount` options in `options`. + fn options_filesystems( + options: &Options, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + ) -> anyhow::Result> { + effective_microvm_filesystems( + &options.microvm.microvm_mount, + MicrovmFilesystemPolicyPaths { + denied: &options.microvm.microvm_mount_deny, + allowed: &options.microvm.microvm_mount_allow, + writable: &options.microvm.microvm_mount_write, + }, + VMM, + restore, + ) + } + + /// Creates a share with a denied `logs` directory that holds an allowed + /// `logs/payloads` directory, a writable `out` directory, and a writable + /// `build.log` file. + fn policy_share() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + fs_err::create_dir_all(root.path().join("logs").join("payloads")).unwrap(); + fs_err::create_dir(root.path().join("out")).unwrap(); + fs_err::write(root.path().join("build.log"), b"log").unwrap(); + root + } + + fn policy_options(root: &Path, mode: &str, policy: &[&str]) -> Options { + mount_options(&[format!("/workspace,{},{mode}", root.display())], policy) + } + + #[test] + fn mount_allow_and_mount_write_build_the_access_policy() { + let root = policy_share(); + let options = policy_options( + root.path(), + "rw", + &[ + "--mount-deny", + "logs", + "--mount-allow", + &root + .path() + .join("logs") + .join("payloads") + .display() + .to_string(), + "--mount-write", + "out", + "--mount-write", + "build.log", + ], + ); + let filesystems = options_filesystems(&options, None).unwrap(); + options.validate_microvm_options().unwrap(); + let config = &filesystems[0].config; + assert_eq!(config.denied_paths, ["logs"]); + assert_eq!(config.allowed_paths, ["logs/payloads"]); + assert_eq!(config.writable_paths, ["build.log", "out"]); + + for (mode, policy, expected) in [ + ( + "ro", + &["--mount-write", "out"][..], + "read-only microVM filesystem cannot have writable paths", + ), + ( + "rw", + &["--mount-deny", "logs", "--mount-allow", "out"][..], + "must be inside a denied path", + ), + ( + "rw", + &["--mount-deny", "logs", "--mount-write", "logs/payloads"][..], + "hidden by a denied path", + ), + ( + "rw", + &["--mount-write", "out", "--mount-write", "out"][..], + "writable paths must be unique", + ), + ("rw", &["--mount-write", "."][..], "dot or parent component"), + ( + "rw", + &["--mount-write", "missing"][..], + "failed to canonicalize microVM writable path", + ), + ] { + let options = policy_options(root.path(), mode, policy); + let error = match options_filesystems(&options, None) { + Err(error) => format!("{error:#}"), + Ok(_) => panic!("unsafe access policy {policy:?} was accepted"), + }; + assert!(error.contains(expected), "{error}"); + } + let root_path = root.path().display().to_string(); + let options = policy_options(root.path(), "rw", &["--mount-write", &root_path]); + let error = format!("{:#}", options_filesystems(&options, None).unwrap_err()); + assert!( + error.contains("cannot be the complete filesystem export"), + "{error}" + ); + } + + #[cfg(unix)] + #[test] + fn policy_paths_reject_symbolic_link_components() { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + + let root = policy_share(); + std::os::unix::fs::symlink("logs", root.path().join("link")).unwrap(); + // Each path resolves inside the export, but only through the link. + for kind in [ + MicrovmFilesystemPathKind::Denied, + MicrovmFilesystemPathKind::Allowed, + MicrovmFilesystemPathKind::Writable, + ] { + for requested in [ + PathBuf::from("link"), + PathBuf::from("link/payloads"), + root.path().join("link").join("payloads"), + ] { + let error = canonical_microvm_filesystem_policy_paths( + root.path(), + std::slice::from_ref(&requested), + kind, + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("component is a symbolic link"), + "{requested:?}: {error:#}" + ); + } + } + assert_eq!( + canonical_microvm_filesystem_policy_paths( + root.path(), + &[PathBuf::from("logs/payloads")], + MicrovmFilesystemPathKind::Writable, + ) + .unwrap(), + ["logs/payloads"] + ); + } + + #[test] + fn mount_write_applies_to_the_share_that_contains_it() { + let workspace = policy_share(); + let toolcache = tempfile::tempdir().unwrap(); + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let out = workspace.path().join("out").display().to_string(); + let options = mount_options(&mounts, &["--mount-write", &out]); + let filesystems = options_filesystems(&options, None).unwrap(); + assert_eq!(filesystems[0].config.writable_paths, ["out"]); + assert!(filesystems[1].config.writable_paths.is_empty()); + + let options = mount_options(&mounts, &["--mount-write", "out"]); + let error = options_filesystems(&options, None).unwrap_err(); + assert!( + error + .to_string() + .contains("--mount-write requires an absolute host path"), + "{error:#}" + ); + } + + #[test] + fn filesystem_restore_requires_the_same_access_policy() { + let root = policy_share(); + let policy = [ + "--mount-deny", + "logs", + "--mount-allow", + "logs/payloads", + "--mount-write", + "out", + ]; + let saved = options_filesystems(&policy_options(root.path(), "rw", &policy), None) + .unwrap() + .remove(0) + .config; + let contract = filesystems_contract(&[(saved, root.path())]); + let restore = |policy: &[&str]| { + let mut arguments = policy.to_vec(); + arguments.extend(["--restore-snapshot", "snapshot"]); + options_filesystems( + &policy_options(root.path(), "rw", &arguments), + Some(&contract), + ) + }; + restore(&policy).unwrap(); + for changed in [ + &policy[..4], + &["--mount-deny", "logs", "--mount-write", "out"][..], + &[ + "--mount-deny", + "logs", + "--mount-allow", + "logs/payloads", + "--mount-write", + "build.log", + ][..], + ] { + let error = restore(changed).unwrap_err(); + assert!(error.to_string().contains("access policy"), "{error:#}"); + } + } +} diff --git a/openvmm/openvmm_entry/src/microvm/host_control.rs b/openvmm/openvmm_entry/src/microvm/host_control.rs new file mode 100644 index 00000000000..dd7dc6bdfb2 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/host_control.rs @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated host-only control for the microVM profile. + +use super::console::MicrovmControlAuthentication; +use anyhow::Context; +use futures::AsyncReadExt; +use futures::AsyncWriteExt; +use mesh::CancelContext; +use mesh::rpc::RpcSend; +use pal_async::DefaultDriver; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +const PROTOCOL_VERSION: u32 = 1; +const MAX_FRAME_SIZE: usize = 64 * 1024; +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +pub(crate) struct MicrovmHostControlServer { + io: Box, + authentication: MicrovmControlAuthentication, + slots: Vec>>, + transition: Arc, +} + +#[derive(Deserialize)] +struct Request { + version: u32, + request_id: u64, + #[serde(flatten)] + operation: Operation, +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum Operation { + QueryImageSlots, + BindImageSlot { + slot: u8, + path: String, + identity: String, + }, +} + +#[derive(Serialize)] +struct Response { + version: u32, + request_id: u64, + #[serde(flatten)] + result: ResponseResult, +} + +#[derive(Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum ResponseResult { + Ok { result: ResponseBody }, + Error { code: &'static str, message: String }, +} + +#[derive(Serialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum ResponseBody { + QueryImageSlots { slots: Vec }, + BindImageSlot { slot: u8, capacity_sectors: u64 }, +} + +#[derive(Serialize)] +struct SlotStatus { + slot: u8, + name: String, + state: &'static str, + identity: Option, + capacity_sectors: u64, +} + +struct TransitionGuard(Arc); + +impl Drop for TransitionGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl MicrovmHostControlServer { + pub(crate) fn bind( + path: &Path, + driver: &DefaultDriver, + authentication: MicrovmControlAuthentication, + ) -> anyhow::Result { + Ok(Self { + io: crate::serial_io::microvm::bind_host_control(path, driver) + .with_context(|| format!("failed to bind host control {}", path.display()))?, + authentication, + slots: Vec::new(), + transition: Arc::new(AtomicBool::new(false)), + }) + } + + pub(crate) fn set_image_slots( + &mut self, + slots: Vec>>, + transition: Arc, + ) { + self.slots = slots; + self.transition = transition; + } + + pub(crate) async fn run(&mut self) -> anyhow::Result<()> { + loop { + futures::future::poll_fn(|cx| self.io.poll_connect(cx)) + .await + .context("failed to accept a host-control connection")?; + if let Err(error) = self.serve_connection().await { + tracelimit::warn_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "host-control connection failed" + ); + } + self.io + .disconnect_current() + .context("failed to reset the host-control listener")?; + } + } + + async fn serve_connection(&mut self) -> anyhow::Result<()> { + let peer = self + .io + .local_peer_identity() + .context("failed to identify the host-control peer")? + .context("host-control endpoint did not authenticate its local peer")?; + anyhow::ensure!( + peer == self.authentication.expected_peer_identity, + "host-control peer identity does not match the OpenVMM process owner" + ); + + let mut capability = [0; 32]; + CancelContext::new() + .with_timeout(Duration::from_millis(self.authentication.auth_timeout_ms)) + .until_cancelled(self.io.read_exact(&mut capability)) + .await + .context("host-control authentication timed out")? + .context("failed to read host-control capability")?; + anyhow::ensure!( + capability_matches(&capability, &self.authentication.capability), + "host-control capability is invalid" + ); + + loop { + let Some(request) = self.read_request().await? else { + return Ok(()); + }; + let response = self.handle_request(request).await; + self.write_response(&response).await?; + } + } + + async fn read_request(&mut self) -> anyhow::Result> { + let Some(length) = CancelContext::new() + .with_timeout(IDLE_TIMEOUT) + .until_cancelled(read_frame_length(&mut self.io)) + .await + .context("host-control connection was idle")? + .context("failed to read host-control frame length")? + else { + return Ok(None); + }; + let length = length as usize; + anyhow::ensure!( + (1..=MAX_FRAME_SIZE).contains(&length), + "host-control frame length is invalid" + ); + let payload = read_frame_payload(&mut self.io, length, IDLE_TIMEOUT).await?; + serde_json::from_slice(&payload) + .map(Some) + .context("host-control request is invalid JSON") + } + + async fn write_response(&mut self, response: &Response) -> anyhow::Result<()> { + let payload = encode_response(response)?; + let length = u32::try_from(payload.len()).context("host-control response is too large")?; + self.io + .write_all(&length.to_le_bytes()) + .await + .context("failed to write host-control frame length")?; + self.io + .write_all(&payload) + .await + .context("failed to write host-control response")?; + self.io + .flush() + .await + .context("failed to flush host-control response") + } + + async fn handle_request(&mut self, request: Request) -> Response { + let result = if request.version != PROTOCOL_VERSION { + Err(( + "unsupported_version", + format!( + "host-control protocol version {} is unsupported", + request.version + ), + )) + } else { + match request.operation { + Operation::QueryImageSlots => self.query_image_slots().await, + Operation::BindImageSlot { + slot, + path, + identity, + } => self.bind_image_slot(slot, Path::new(&path), identity).await, + } + }; + Response { + version: PROTOCOL_VERSION, + request_id: request.request_id, + result: match result { + Ok(result) => ResponseResult::Ok { result }, + Err((code, message)) => ResponseResult::Error { code, message }, + }, + } + } + + async fn query_image_slots(&mut self) -> Result { + let mut slots = Vec::with_capacity(self.slots.len()); + for (index, requests) in self.slots.iter().enumerate() { + let (state, identity, capacity_sectors) = if let Some(requests) = requests { + let state = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .map_err(|error| ("device_unavailable", error.to_string()))?; + ( + if state.identity.is_some() { + "bound" + } else { + "empty" + }, + state.identity, + state.capacity_sectors, + ) + } else { + ("inactive", None, 0) + }; + slots.push(SlotStatus { + slot: index as u8, + name: openvmm_defs::microvm::microvm_image_slot_name(index as u8) + .map_err(|error| ("invalid_slot", error.to_string()))?, + state, + identity, + capacity_sectors, + }); + } + Ok(ResponseBody::QueryImageSlots { slots }) + } + + async fn bind_image_slot( + &mut self, + slot: u8, + path: &Path, + identity: String, + ) -> Result { + let transition = self.transition.clone(); + transition + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + ( + "busy", + "image binding is excluded by a lifecycle transition".to_owned(), + ) + })?; + let _guard = TransitionGuard(transition); + let requests = self + .slots + .get(slot as usize) + .ok_or_else(|| ("invalid_slot", format!("image slot {slot} is out of range")))? + .as_ref() + .ok_or_else(|| ("inactive_slot", format!("image slot {slot} is inactive")))?; + let current = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .map_err(|error| ("device_unavailable", error.to_string()))?; + if let Some(current_identity) = current.identity { + return if current_identity == identity { + Ok(ResponseBody::BindImageSlot { + slot, + capacity_sectors: current.capacity_sectors, + }) + } else { + Err(( + "already_bound", + format!("image slot {slot} is already bound to a different identity"), + )) + }; + } + let media = + open_read_only_media(path).map_err(|error| ("invalid_media", format!("{error:#}")))?; + let length = media + .metadata() + .map_err(|error| ("invalid_media", error.to_string()))? + .len(); + let state = requests + .call( + virtio_resources::blk::ImageSlotRequest::Bind, + virtio_resources::blk::BindImageSlotRequest { + media, + identity, + length, + logical_block_size: 512, + physical_block_size: 4096, + }, + ) + .await + .map_err(|error| ("device_unavailable", error.to_string()))? + .map_err(|error| match error { + virtio_resources::blk::BindImageSlotError::AlreadyBound => { + ("already_bound", error.to_string()) + } + virtio_resources::blk::BindImageSlotError::GeometryMismatch => { + ("geometry_mismatch", error.to_string()) + } + virtio_resources::blk::BindImageSlotError::InvalidMedia(_) => { + ("invalid_media", error.to_string()) + } + })?; + Ok(ResponseBody::BindImageSlot { + slot, + capacity_sectors: state.capacity_sectors, + }) + } +} + +fn capability_matches(left: &[u8; 32], right: &[u8; 32]) -> bool { + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} + +/// Reads a little-endian frame length. Returns `None` when the peer closed the connection +/// at a frame boundary, which is how a client ends a session. +async fn read_frame_length( + io: &mut (impl futures::AsyncRead + Unpin), +) -> std::io::Result> { + let mut length = [0; 4]; + let mut filled = 0; + while filled < length.len() { + let count = match io.read(&mut length[filled..]).await { + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if count == 0 { + return if filled == 0 { + Ok(None) + } else { + Err(std::io::ErrorKind::UnexpectedEof.into()) + }; + } + filled += count; + } + Ok(Some(u32::from_le_bytes(length))) +} + +/// Reads a frame payload. A peer that stalls inside a frame is treated like an idle +/// peer, so it cannot hold the single-connection endpoint. +async fn read_frame_payload( + io: &mut (impl futures::AsyncRead + Unpin), + length: usize, + timeout: Duration, +) -> anyhow::Result> { + let mut payload = vec![0; length]; + CancelContext::new() + .with_timeout(timeout) + .until_cancelled(io.read_exact(&mut payload)) + .await + .context("host-control frame stalled")? + .context("failed to read host-control frame")?; + Ok(payload) +} + +/// Encodes a response within the frame limit. Error messages can echo request +/// input, so a response that would exceed the limit becomes a bounded error. +fn encode_response(response: &Response) -> anyhow::Result> { + let payload = serde_json::to_vec(response).context("failed to encode host-control response")?; + if payload.len() <= MAX_FRAME_SIZE { + return Ok(payload); + } + serde_json::to_vec(&Response { + version: PROTOCOL_VERSION, + request_id: response.request_id, + result: ResponseResult::Error { + code: "response_too_large", + message: format!("host-control response exceeds the {MAX_FRAME_SIZE}-byte frame limit"), + }, + }) + .context("failed to encode host-control response") +} + +fn open_read_only_media(path: &Path) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); + } + let file = options + .open(path) + .with_context(|| format!("failed to open image media {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect image media {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "image media is not a regular file: {}", + path.display() + ); + anyhow::ensure!( + metadata.len() != 0 && metadata.len().is_multiple_of(512), + "image media length must be nonzero and 512-byte aligned" + ); + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::IDLE_TIMEOUT; + use super::MAX_FRAME_SIZE; + use super::PROTOCOL_VERSION; + use super::Response; + use super::ResponseResult; + use super::encode_response; + use super::read_frame_length; + use super::read_frame_payload; + use futures::executor::block_on; + use futures::io::Cursor; + use std::pin::Pin; + use std::task::Context; + use std::task::Poll; + use std::time::Duration; + + fn error_response(request_id: u64, message: String) -> Response { + Response { + version: PROTOCOL_VERSION, + request_id, + result: ResponseResult::Error { + code: "invalid_media", + message, + }, + } + } + + #[test] + fn oversized_responses_become_bounded_errors() { + let payload = encode_response(&error_response(7, "x".repeat(MAX_FRAME_SIZE))).unwrap(); + assert!(payload.len() <= MAX_FRAME_SIZE); + let value: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(value["request_id"], 7); + assert_eq!(value["status"], "error"); + assert_eq!(value["code"], "response_too_large"); + + let payload = encode_response(&error_response(8, "short".to_owned())).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(value["request_id"], 8); + assert_eq!(value["code"], "invalid_media"); + assert_eq!(value["message"], "short"); + } + + #[test] + fn stalled_frame_payload_times_out() { + struct Stalled; + + impl futures::AsyncRead for Stalled { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut [u8], + ) -> Poll> { + Poll::Pending + } + } + + let error = block_on(read_frame_payload( + &mut Stalled, + 4, + Duration::from_millis(10), + )) + .unwrap_err(); + assert!(format!("{error:#}").contains("host-control frame stalled")); + + let mut io = Cursor::new(b"{}".to_vec()); + assert_eq!( + block_on(read_frame_payload(&mut io, 2, IDLE_TIMEOUT)).unwrap(), + b"{}" + ); + } + + #[test] + fn close_at_frame_boundary_ends_the_session() { + let mut io = Cursor::new(Vec::new()); + assert_eq!(block_on(read_frame_length(&mut io)).unwrap(), None); + } + + #[test] + fn frame_length_is_little_endian() { + let mut io = Cursor::new(vec![0x34, 0x12, 0, 0, 0xff]); + assert_eq!(block_on(read_frame_length(&mut io)).unwrap(), Some(0x1234)); + assert_eq!(io.position(), 4); + } + + #[test] + fn close_inside_frame_length_is_an_error() { + let mut io = Cursor::new(vec![1, 0]); + assert_eq!( + block_on(read_frame_length(&mut io)).unwrap_err().kind(), + std::io::ErrorKind::UnexpectedEof + ); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs new file mode 100644 index 00000000000..11de921200e --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM launch state carried from the VM configuration to the VM worker +//! and the VM controller. + +use super::MicrovmResources; +use super::MicrovmRestore; +use super::restore::ExpectedRestoreContract; +use super::validate_microvm_filesystem_private_storage; +use crate::Options; +use crate::cli_args::microvm::MachineProfileCli; +use crate::cli_args::microvm::SnapshotBlockIdentityCli; +use crate::cli_args::microvm::SnapshotScratchRestoreModeCli; +use crate::vm_controller::MicrovmController; +use anyhow::Context; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; +use openvmm_defs::config::Config; +use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MicrovmFilesystemConfig; +use openvmm_defs::microvm::MicrovmNetworkConfig; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_helpers::snapshot::SnapshotManifest; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +/// MicroVM state derived from the VM configuration before the VM worker is +/// launched. +pub(crate) struct MicrovmLaunch { + active: bool, + effective_command_line: Option, + resources: MicrovmResources, + network: Option, + filesystem_slot: bool, + filesystems: Vec, + image_slots: Option, + snapshot_destination: Option, + snapshot_block_identity: SnapshotBlockIdentityCli, + snapshot_generation_id: Option<[u8; 16]>, + snapshot_scratch_restore_mode: SnapshotScratchRestoreModeCli, + snapshot_memory_file: Option, + snapshot_memory_handle: Option, + restore: MicrovmRestore, +} + +impl MicrovmLaunch { + /// Captures the microVM state of `vm_config` and prepares the RAM backing + /// used by guest-requested snapshot capture. + pub(crate) fn new( + opt: &Options, + vm_config: &Config, + resources: MicrovmResources, + restore: MicrovmRestore, + ) -> anyhow::Result { + let effective_command_line = match &vm_config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => Some(cmdline.clone()), + _ => None, + }; + let filesystem_slot = vm_config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtiofs"); + + let snapshot_destination = opt.microvm.snapshot_destination.as_ref().map(|path| { + if path.is_absolute() { + path.clone() + } else { + std::env::current_dir().unwrap_or_default().join(path) + } + }); + for root_path in &resources.filesystem_root_paths { + validate_microvm_filesystem_private_storage( + root_path, + snapshot_destination.as_deref(), + opt.restore_snapshot.as_deref(), + opt.memory_backing_file().map(PathBuf::as_path), + )?; + } + let snapshot_memory_file = if let Some(destination) = &snapshot_destination + && opt.memory_backing_file().is_none() + { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent_metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!("failed to inspect snapshot parent {}", parent.display()) + })?; + anyhow::ensure!( + parent_metadata.file_type().is_dir(), + "snapshot parent is not a directory: {}", + parent.display() + ); + anyhow::ensure!( + fs_err::symlink_metadata(destination) + .is_err_and(|error| { error.kind() == io::ErrorKind::NotFound }), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let file = tempfile::Builder::new() + .prefix(".openvmm-microvm-memory-") + .tempfile_in(parent) + .context("failed to create snapshot memory backing")?; + openvmm_helpers::snapshot::fs::initialize_snapshot_memory_backing_file( + file.as_file(), + opt.memory_size(), + ) + .context("failed to initialize snapshot memory backing")?; + Some(file) + } else { + None + }; + let snapshot_memory_handle = if snapshot_destination.is_some() { + if let Some(file) = &snapshot_memory_file { + Some( + file.reopen() + .context("failed to duplicate automatic snapshot RAM handle")?, + ) + } else { + opt.memory_backing_file() + .map(|path| { + openvmm_helpers::shared_memory::open_memory_backing_file_handle( + path, + opt.memory_size(), + ) + }) + .transpose()? + } + } else { + None + }; + + Ok(Self { + active: opt.machine == MachineProfileCli::Microvm, + effective_command_line, + resources, + network: vm_config.microvm.network.clone(), + filesystem_slot, + filesystems: vm_config.microvm.filesystems.clone(), + image_slots: vm_config.microvm.image_slots, + snapshot_destination, + snapshot_block_identity: opt + .microvm + .snapshot_block_identity + .unwrap_or(SnapshotBlockIdentityCli::Sha256), + snapshot_generation_id: opt + .microvm + .snapshot_generation_id + .map(|generation| generation.0), + snapshot_scratch_restore_mode: opt + .microvm + .snapshot_scratch_restore_mode + .unwrap_or(SnapshotScratchRestoreModeCli::PrivateCopy), + snapshot_memory_file, + snapshot_memory_handle, + restore, + }) + } + + /// Duplicates the exact RAM handle used for guest-requested snapshot + /// capture, as the VM worker's shared guest RAM. + pub(crate) fn capture_shared_memory(&self) -> anyhow::Result> { + let Some(file) = &self.snapshot_memory_handle else { + return Ok(None); + }; + let file = file + .try_clone() + .context("failed to duplicate snapshot RAM handle for worker")?; + let shared_memory = openvmm_helpers::shared_memory::file_to_shared_memory_fd(file)?; + Ok(Some(shared_memory)) + } + + /// Returns the time allowed for post-restore guest repair, if required. + pub(crate) fn restore_gate_timeout(&self, opt: &Options) -> Option { + self.restore + .gate_required + .then_some(Duration::from_millis(opt.microvm.restore_gate_timeout_ms)) + } + + /// Takes the sender that seals the time fields of a time ABI restore + /// packet, for the restoring worker. + pub(crate) fn take_restore_time_record( + &mut self, + ) -> Option> { + self.resources.restore_time_record.take() + } + + /// Takes the receiver notified when the guest first selects a time ABI + /// restore packet, for the restoring worker's profile. + pub(crate) fn take_restore_packet_selected(&mut self) -> Option> { + self.resources.restore_packet_selected.take() + } + + /// Creates the channels that carry guest-requested snapshot boundaries from + /// the VM worker to the VM controller. + pub(crate) fn snapshot_channels( + &mut self, + ) -> ( + Option>, + Option>, + Option>, + ) { + let snapshot_boundary_requests = self.resources.snapshot_requests.take(); + let (snapshot_ready, snapshot_requests) = if snapshot_boundary_requests.is_some() { + let (ready, requests) = mesh::channel(); + (Some(ready), Some(requests)) + } else { + (None, None) + }; + ( + snapshot_boundary_requests, + snapshot_ready, + snapshot_requests, + ) + } + + /// Returns the machine contract a microVM snapshot must match to be + /// restored with this configuration. + pub(crate) fn expected_restore_contract<'a>( + &'a self, + opt: &Options, + manifest: &SnapshotManifest, + expected_hypervisor: &'a str, + ) -> anyhow::Result>> { + if opt.machine != MachineProfileCli::Microvm { + return Ok(None); + } + let scratch_policy = if manifest + .machine_contract + .as_ref() + .and_then(|contract| contract.microvm_sandbox_blocks.last()) + .is_some_and(|block| { + block.artifact == openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME + }) { + MicrovmSnapshotScratchPolicy::Paired + } else { + MicrovmSnapshotScratchPolicy::Fresh + }; + let saved_blocks = manifest + .machine_contract + .as_ref() + .context("microVM restore requires a machine contract")? + .microvm_sandbox_blocks + .as_slice(); + let sandbox_blocks = crate::storage_builder::microvm::restore_snapshot_block_contract( + &self.resources.sandbox_block_sources, + scratch_policy, + saved_blocks, + )?; + let resources = &self.resources; + Ok(Some(( + expected_hypervisor, + self.effective_command_line + .as_deref() + .context("microVM restore requires an effective command line")?, + self.network + .as_ref() + .zip(resources.egress_policy.as_ref()) + .zip(resources.network_attachment.as_ref()) + .map(|((network, policy), attachment)| (network, policy, attachment)), + self.filesystems + .iter() + .zip(&resources.filesystem_root_paths) + .zip(&resources.filesystem_attachments) + .map(|((filesystem, root_path), attachment)| { + (filesystem, root_path.as_path(), attachment) + }) + .collect(), + resources.console_attachment.as_ref(), + resources.control_console_attachment.as_ref(), + sandbox_blocks, + ))) + } + + /// Returns the path of the file backing guest RAM, including the automatic + /// snapshot RAM backing. + pub(crate) fn memory_backing_file(&self, opt: &Options) -> Option { + opt.memory_backing_file().cloned().or_else(|| { + self.snapshot_memory_file + .as_ref() + .map(|file| file.path().to_owned()) + }) + } + + /// Hands the microVM state over to the VM controller. + pub(crate) fn into_controller( + self, + opt: &Options, + source_hypervisor: String, + snapshot_requests: Option>, + ) -> MicrovmController { + MicrovmController { + active: self.active, + snapshot_memory_handle: self.snapshot_memory_handle, + memory_capacity: opt.microvm.memory_capacity.map(|capacity| capacity.0), + snapshot_requests, + snapshot_destination: self.snapshot_destination, + snapshot_tier: opt.microvm.snapshot_tier, + snapshot_block_identity: self.snapshot_block_identity, + snapshot_generation_id: self.snapshot_generation_id, + snapshot_scratch_restore_mode: self.snapshot_scratch_restore_mode, + snapshot_quiesce_timeout: Duration::from_millis( + opt.microvm.snapshot_quiesce_timeout_ms, + ), + source_hypervisor, + effective_command_line: self.effective_command_line, + resources: self.resources, + network: self.network, + filesystem_slot: self.filesystem_slot, + filesystems: self.filesystems, + image_slots: self.image_slots, + snapshot_memory_file: self.snapshot_memory_file, + _private_scratch_dir: self.restore.private_scratch_dir, + } + } +} diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs new file mode 100644 index 00000000000..0b905adcee6 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM machine profile support for the OpenVMM entry point. + +mod config; +mod console; +mod filesystem; +mod host_control; +mod launch; +mod network; +pub(crate) mod output; +pub(crate) mod report; +mod restore; +mod verify; + +pub(crate) use config::MicrovmConfigBuilder; +#[cfg(test)] +pub(crate) use console::MICROVM_CONSOLE_ATTACHMENT_KIND; +#[cfg(test)] +pub(crate) use console::MICROVM_CONSOLE_STABLE_ID; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::MICROVM_CONTROL_CONSOLE_STABLE_ID; +pub(crate) use console::MicrovmConsoleSocketCleanup; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::microvm_console_attachment_from_cli; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::microvm_console_attachment_from_snapshot; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::microvm_console_socket_cleanup; +pub(crate) use console::microvm_control_authentication_from_capability; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use console::validate_microvm_console_attachment_namespace; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use filesystem::microvm_filesystem_attachment; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use filesystem::microvm_filesystem_from_snapshot; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use filesystem::microvm_filesystem_slot_from_snapshot; +pub(crate) use filesystem::validate_microvm_filesystem_private_storage; +pub(crate) use host_control::MicrovmHostControlServer; +pub(crate) use launch::MicrovmLaunch; +pub(crate) use restore::ExpectedRestoreContract; +pub(crate) use restore::MicrovmRestore; +pub(crate) use restore::TimeAbiRestore; +pub(crate) use restore::TimeAbiRestoreOptions; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use restore::fresh_microvm_generation_id; +pub(crate) use restore::prepare_restore; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use restore::restore_packet_base; +pub(crate) use restore::validate_restore_contract; +pub(crate) use verify::fatal_error_message; +pub(crate) use verify::report_time_abi_verification; + +use crate::storage_builder::microvm::MicrovmSandboxBlockSource; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use net_backend_resources::egress::EgressPolicy; +use openvmm_helpers::snapshot::microvm::SnapshotAttachment; +use output::MicrovmOutputDrain; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +/// Host-side microVM resources produced while building the VM configuration +/// and consumed by the snapshot, restore, and teardown paths. +#[derive(Default)] +pub(crate) struct MicrovmResources { + /// Drains portb and its host output relay before a guest-requested exit. + pub(crate) output_drain: Option, + /// Guest-requested snapshot boundaries from the snapshot-request port. + pub(crate) snapshot_requests: Option>, + /// Snapshot sources of the fixed-role sandbox blocks, in role order. + pub(crate) sandbox_block_sources: Vec, + /// Host control channels for active image slots; inactive entries are absent. + pub(crate) image_slot_requests: + Vec>>, + /// Excludes image binding while capture or another lifecycle transition begins. + pub(crate) image_slot_transition: Arc, + /// Authenticated versioned host-control service. + pub(crate) host_control: Option>>, + /// Removes the host-control Unix socket on teardown. + pub(crate) host_control_socket_cleanup: Option, + /// Snapshot identity of the boot virtio-console endpoint. + pub(crate) console_attachment: Option, + /// Removes the boot console Unix socket on teardown. + pub(crate) console_socket_cleanup: Option, + /// Snapshot identity of the control virtio-console endpoint. + pub(crate) control_console_attachment: Option, + /// Removes the control console Unix socket on teardown. + pub(crate) control_console_socket_cleanup: Option, + /// Snapshot identity of the portable network attachment. + pub(crate) network_attachment: Option, + /// The bound run-scoped egress policy. + pub(crate) egress_policy: Option, + /// Snapshot identities of the live filesystem roots, in virtio-fs slot + /// order. + pub(crate) filesystem_attachments: Vec, + /// Canonical host paths of the live filesystem roots, in virtio-fs slot + /// order. + pub(crate) filesystem_root_paths: Vec, + /// Under the time ABI, seals the time fields of the restore packet; the + /// restoring worker takes it. + pub(crate) restore_time_record: + Option>, + /// Under the time ABI with profiling enabled, notified when the guest + /// first selects the restore packet; the restoring worker takes it. + pub(crate) restore_packet_selected: Option>, +} + +impl MicrovmResources { + /// Returns the portb source of a time ABI restore packet with `base`, and + /// keeps the restoring worker's ends of its channels. + pub(crate) fn time_abi_restore_packet( + &mut self, + base: chipset_resources::microvm_time::RestorePacketBase, + ) -> chipset_resources::microvm::MicrovmRestorePacketSource { + let (record, time) = mesh::oneshot(); + self.restore_time_record = Some(record); + let selected = openvmm_defs::profile::enabled().then(|| { + let (selected, recv) = mesh::oneshot(); + self.restore_packet_selected = Some(recv); + selected + }); + chipset_resources::microvm::MicrovmRestorePacketSource { + base, + time, + selected, + } + } +} + +/// Configures the chipset devices of a microVM for the time ABI: puts the PIT +/// in strict mode, which forbids a periodic channel 0 at capture and restore +/// (`E_PIT_ACTIVE`), and returns the portb device's time ABI configuration, +/// with the restore packet of a restore. +pub(crate) fn time_abi_chipset( + chipset_devices: &mut [vmotherboard::ChipsetDeviceHandle], + hooks: &virt::time_abi::TimeAbiTestHooks, + restore: Option, +) -> chipset_resources::microvm::MicrovmPortbTimeAbi { + use vm_resource::IntoResource; + use vm_resource::ResourceId; + + for device in chipset_devices.iter_mut() { + if device.name == chipset_resources::pit::PitDeviceHandle::ID { + device.resource = + chipset_resources::pit::PitDeviceHandle { time_abi: true }.into_resource(); + } + } + chipset_resources::microvm::MicrovmPortbTimeAbi { + generation: restore + .as_ref() + .map_or(0, |restore| restore.base.generation), + utc_offset_ms: hooks.utc_offset_ms, + sample_delay_us: hooks.sample_delay_us, + test_hooks: hooks.active(), + restore, + } +} diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs new file mode 100644 index 00000000000..f254966712d --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM static network identity and egress policy. + +use crate::Options; +use crate::VmResources; +use crate::cli_args; +use crate::cli_args::EndpointConfigCli; +use anyhow::Context; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::NetEndpointHandleKind; + +const MICROVM_NETWORK_STABLE_ID: &str = "net:microvm0"; + +#[derive(Clone)] +pub(super) struct EffectiveMicrovmNetwork { + pub(super) config: openvmm_defs::microvm::MicrovmNetworkConfig, + pub(super) policy: net_backend_resources::egress::EgressPolicy, + pub(super) attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +fn microvm_network_attachment() -> openvmm_helpers::snapshot::microvm::SnapshotAttachment { + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: MICROVM_NETWORK_STABLE_ID.to_owned(), + kind: "virtio-net".to_owned(), + required: false, + reconnect_policy: "recreate-endpoint".to_owned(), + identity_kind: "user-mode-nat".to_owned(), + identity: b"consomme".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } +} + +fn microvm_network_from_snapshot( + saved: &openvmm_helpers::snapshot::microvm::SnapshotMicrovmNetwork, +) -> anyhow::Result { + let prefix_length = + u8::try_from(saved.prefix_length).context("snapshot network prefix does not fit in u8")?; + let config = format!( + "{}/{}", + std::net::Ipv4Addr::from(saved.guest_ipv4), + prefix_length + ) + .parse::() + .context("snapshot static network identity is invalid")?; + anyhow::ensure!( + saved.gateway_ipv4 == u32::from(config.derived_gateway_ipv4) + && saved.guest_mac == config.guest_mac.to_bytes() + && saved.gateway_mac == config.gateway_mac.to_bytes(), + "snapshot static network identity is not canonical" + ); + Ok(config) +} + +pub(super) fn effective_microvm_network( + opt: &Options, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result> { + let requested = match opt.net.as_slice() { + [] => None, + [network] => match &network.endpoint { + EndpointConfigCli::Microvm(config) => Some(config.clone()), + _ => anyhow::bail!("microVM --net was not validated as a static IPv4 identity"), + }, + _ => anyhow::bail!("microVM permits at most one virtio-net device"), + }; + let Some(restore) = restore else { + return requested + .map(|config| { + let policy = opt.microvm_egress_policy(&config)?; + Ok(EffectiveMicrovmNetwork { + config, + policy, + attachment: microvm_network_attachment(), + }) + }) + .transpose(); + }; + anyhow::ensure!( + requested.is_none(), + "restore takes microVM network addressing from saved state" + ); + + let has_device = restore + .devices + .iter() + .any(|device| device.stable_id == MICROVM_NETWORK_STABLE_ID); + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == MICROVM_NETWORK_STABLE_ID); + anyhow::ensure!( + has_device == restore.microvm_network.is_some() && has_device == saved_attachment.is_some(), + "snapshot microVM network device, identity, and attachment inventories disagree" + ); + let Some(saved) = restore.microvm_network.as_ref() else { + anyhow::ensure!( + opt.microvm.network_profile.is_none() + && opt.microvm.net_tap.is_none() + && opt.microvm.network_egress.is_none() + && opt.microvm.network_ingress.is_none() + && opt.microvm.allow_host.is_empty() + && opt.microvm.block_host.is_empty() + && opt.microvm.allow_endpoint.is_empty() + && opt.microvm.network_egress_allow.is_empty() + && opt.microvm.network_egress_deny.is_empty() + && opt.microvm.host_loopback.is_none() + && opt.microvm.network_proxy.is_none() + && opt.microvm.host_loopback_forward.is_empty(), + "restore-time network resources cannot be added to a snapshot without a NIC" + ); + return Ok(None); + }; + anyhow::ensure!( + saved.profile == openvmm_defs::microvm::MicrovmNetworkProfile::Portable.as_str(), + "snapshot microVM network profile is unsupported" + ); + anyhow::ensure!( + opt.microvm.network_profile == Some(cli_args::microvm::MicrovmNetworkProfileCli::Portable), + "networked microVM snapshot restore requires --network-profile portable" + ); + let config = microvm_network_from_snapshot(saved)?; + let policy = opt.microvm_egress_policy(&config)?; + openvmm_helpers::snapshot::microvm::validate_microvm_network_policy(saved, &policy)?; + let attachment = microvm_network_attachment(); + anyhow::ensure!( + Some(&attachment) == saved_attachment, + "restore-time network endpoint does not match the snapshot attachment" + ); + Ok(Some(EffectiveMicrovmNetwork { + config, + policy, + attachment, + })) +} + +pub(super) fn microvm_network_endpoint( + network: &openvmm_defs::microvm::MicrovmNetworkConfig, + policy: &net_backend_resources::egress::EgressPolicy, + loopback_forwards: &[cli_args::microvm::MicrovmLoopbackForwardCli], + _resources: &mut VmResources, +) -> anyhow::Result> { + let ports = loopback_forwards + .iter() + .map(|forward| net_backend_resources::consomme::HostPortConfig { + protocol: match forward.protocol { + cli_args::microvm::MicrovmLoopbackForwardProtocol::Tcp => { + net_backend_resources::consomme::HostPortProtocol::Tcp + } + cli_args::microvm::MicrovmLoopbackForwardProtocol::Udp => { + net_backend_resources::consomme::HostPortProtocol::Udp + } + }, + host_address: Some(net_backend_resources::consomme::HostIpAddress::Ipv4( + std::net::Ipv4Addr::LOCALHOST, + )), + host_port: net_backend_resources::consomme::HostPort::Fixed(forward.host_port), + guest_port: forward.guest_port, + }) + .collect(); + let allow_host_loopback = + policy.host_loopback_action() == net_backend_resources::egress::EgressAction::Allow; + Ok(net_backend_resources::consomme::ConsommeHandle { + cidr: None, + static_ipv4: Some(StaticIpv4Config { + guest_ipv4: network.guest_ipv4, + prefix_length: network.prefix_length, + gateway_ipv4: network.derived_gateway_ipv4, + gateway_mac: network.gateway_mac, + }), + ports, + recv: None, + allow_host_local_access: Some(allow_host_loopback), + map_gateway_to_host_loopback: Some(allow_host_loopback), + gateway_loopback_proxy_port: policy.proxy_endpoint().map(|endpoint| endpoint.port()), + } + .into_resource()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::microvm::restore::align_legacy_network_policy_contract; + use clap::Parser as _; + use openvmm_defs::microvm::build_microvm_command_line; + use test_with_tracing::test; + + pub(crate) fn network_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract + { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + net_backend_resources::egress::EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:8443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + ]), + ) + .unwrap(); + let source_hypervisor = if cfg!(windows) { "whp" } else { "kvm" }; + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); + let mut command_line = build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + Some((&network, irq, policy.allows_gateway_dns())), + false, + &[], + false, + false, + &[], + None, + ) + .unwrap(); + openvmm_helpers::snapshot::microvm::microvm_machine_contract( + source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + Some((&network, &policy, microvm_network_attachment())), + false, + Vec::new(), + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-net-3489660928", + ] + .map(str::to_owned) + .to_vec(), + crate::microvm::restore::tests::test_time_contract(), + crate::microvm::restore::tests::test_cpu_profile(), + ) + .unwrap() + } + + #[test] + fn network_restore_rebinds_endpoint_next_hops_and_requires_policy() { + let contract = network_contract(); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + "--allow-endpoint", + "10.0.0.9:8443", + ]) + .unwrap(); + let restored = effective_microvm_network(&options, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!( + restored.config.guest_ipv4, + std::net::Ipv4Addr::new(10, 0, 0, 2) + ); + assert_eq!( + restored.policy.next_hops(), + &[ + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 9), + ] + ); + let restored_again = effective_microvm_network(&options, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(restored_again.policy, restored.policy); + + let missing_policy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + ]) + .unwrap(); + assert!(effective_microvm_network(&missing_policy, Some(&contract)).is_err()); + } + + #[test] + fn legacy_network_policy_contract_alignment_preserves_validated_digest() { + let mut saved = network_contract(); + let saved_network = saved.microvm_network.as_mut().unwrap(); + saved_network.egress_policy_encoding_version = 0; + saved_network.egress_policy_sha256 = vec![0x5a; 32]; + let mut expected = network_contract(); + + align_legacy_network_policy_contract(&saved, &mut expected); + assert_eq!(saved.microvm_network, expected.microvm_network); + } + + #[test] + fn network_restore_rejects_attachment_identity_change() { + let mut contract = network_contract(); + let attachment = contract + .attachments + .iter_mut() + .find(|attachment| attachment.stable_id == MICROVM_NETWORK_STABLE_ID) + .unwrap(); + attachment.identity.push(b'x'); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + "--allow-endpoint", + "10.0.0.9:8443", + ]) + .unwrap(); + assert!(effective_microvm_network(&options, Some(&contract)).is_err()); + } + + #[test] + fn network_restore_requires_portable_profile() { + let contract = network_contract(); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(effective_microvm_network(&options, Some(&contract)).is_err()); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/output.rs b/openvmm/openvmm_entry/src/microvm/output.rs new file mode 100644 index 00000000000..0f4b79da283 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/output.rs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Delivers microVM portb output to the host before a guest-requested snapshot +//! capture and drains it, with its host output relay, before process exit. + +use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbDrain; +use futures::AsyncRead; +use futures::AsyncWriteExt; +use futures::executor::block_on; +use futures::io::AllowStdIo; +use mesh::CancelContext; +use mesh::OneshotReceiver; +use mesh::oneshot; +use mesh::rpc::FailableRpc; +use mesh::rpc::RpcSend; +use std::io; +use std::thread; +use std::time::Duration; + +pub(crate) type OutputCompletion = OneshotReceiver>; + +pub(crate) fn spawn_output( + name: &str, + input: impl AsyncRead + Send + Unpin + 'static, + output: impl io::Write + Send + 'static, +) -> io::Result { + let (complete, completed) = oneshot(); + thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let result = block_on(async { + let mut output = AllowStdIo::new(output); + futures::io::copy(input, &mut output).await?; + output.flush().await + }); + complete.send(result); + })?; + Ok(completed) +} + +pub(crate) struct MicrovmOutputDrain { + portb: mesh::Sender>, + output: Option, +} + +impl MicrovmOutputDrain { + pub(crate) fn new( + output: Option, + ) -> (Self, mesh::Receiver>) { + let (portb, requests) = mesh::channel(); + (Self { portb, output }, requests) + } + + /// Writes and flushes the output accepted by portb to its endpoint, which + /// stays open, and fails after `timeout`. + /// + /// The host relay is not awaited because it completes only once the + /// endpoint closes. + pub(crate) async fn flush(&self, timeout: Duration) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(timeout) + .until_cancelled( + self.portb + .call_failable(std::convert::identity, MicrovmPortbDrain::Flush), + ) + .await + .context("microVM console output flush timed out")? + .context("failed to flush the microVM portb endpoint") + } + + pub(crate) async fn drain(self) -> anyhow::Result<()> { + self.drain_with_timeout(Duration::from_secs(5)).await + } + + async fn drain_with_timeout(self, timeout: Duration) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(timeout) + .until_cancelled(async { + self.portb + .call_failable(std::convert::identity, MicrovmPortbDrain::Close) + .await + .context("failed to drain the microVM portb endpoint")?; + if let Some(output) = self.output { + output + .await + .context("microVM console output relay stopped without completing")? + .context("microVM console output relay failed")?; + } + tracing::debug!("microVM console output drained"); + anyhow::Ok(()) + }) + .await + .context("microVM console output drain timed out")? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + use futures::poll; + use parking_lot::Mutex; + use std::pin::pin; + use std::sync::Arc; + use std::sync::mpsc; + use test_with_tracing::test; + + #[test] + fn drain_waits_for_both_device_and_relay() { + block_on(async { + let (complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + let request = requests.recv().await.unwrap(); + assert_eq!(*request.input(), MicrovmPortbDrain::Close); + request.complete(Ok(())); + assert!(poll!(&mut drain).is_pending()); + complete.send(Ok(())); + drain.await.unwrap(); + }); + } + + #[test] + fn flush_keeps_the_endpoint_open_without_waiting_for_the_relay() { + block_on(async { + let (_complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + { + let mut flush = pin!(drain.flush(Duration::from_secs(5))); + assert!(poll!(&mut flush).is_pending()); + let request = requests.recv().await.unwrap(); + assert_eq!(*request.input(), MicrovmPortbDrain::Flush); + request.complete(Ok(())); + flush.await.unwrap(); + } + // The same handle still drains and closes portb at process exit. + let mut exit = pin!(drain.drain()); + assert!(poll!(&mut exit).is_pending()); + assert_eq!( + *requests.recv().await.unwrap().input(), + MicrovmPortbDrain::Close + ); + }); + } + + #[test] + fn flush_reports_endpoint_failure() { + block_on(async { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut flush = pin!(drain.flush(Duration::from_secs(5))); + assert!(poll!(&mut flush).is_pending()); + requests + .recv() + .await + .unwrap() + .fail(io::Error::from(io::ErrorKind::BrokenPipe)); + assert!(format!("{:#}", flush.await.unwrap_err()).contains("portb endpoint")); + }); + } + + #[test] + fn flush_has_a_bounded_deadline() { + block_on(async { + let (drain, _requests) = MicrovmOutputDrain::new(None); + let error = drain.flush(Duration::from_millis(10)).await.unwrap_err(); + assert!(format!("{error:#}").contains("timed out")); + }); + } + + #[test] + fn drain_reports_endpoint_failure() { + block_on(async { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + requests + .recv() + .await + .unwrap() + .fail(io::Error::from(io::ErrorKind::BrokenPipe)); + assert!(format!("{:#}", drain.await.unwrap_err()).contains("portb endpoint")); + }); + } + + #[test] + fn drain_reports_relay_failure() { + block_on(async { + let (complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + requests.recv().await.unwrap().complete(Ok(())); + complete.send(Err(io::Error::from(io::ErrorKind::BrokenPipe))); + assert!(format!("{:#}", drain.await.unwrap_err()).contains("relay failed")); + }); + } + + #[test] + fn drain_has_a_bounded_deadline() { + block_on(async { + let (drain, _requests) = MicrovmOutputDrain::new(None); + let error = drain + .drain_with_timeout(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("timed out")); + }); + } + + #[test] + fn output_completion_follows_the_final_write() { + struct DelayedOutput { + bytes: Arc>>, + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + } + impl io::Write for DelayedOutput { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.entered.send(()).unwrap(); + self.release.recv().unwrap(); + self.bytes.lock().extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n"; + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered, waiting) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let mut completed = spawn_output( + "microvm-output-test", + futures::io::Cursor::new(payload), + DelayedOutput { + bytes: bytes.clone(), + entered, + release: released, + }, + ) + .unwrap(); + waiting.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!((&mut completed).now_or_never().is_none()); + release.send(()).unwrap(); + block_on(completed).unwrap().unwrap(); + assert_eq!(*bytes.lock(), payload); + } + + #[test] + fn output_completion_reports_write_failure() { + struct FailingOutput; + impl io::Write for FailingOutput { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let completed = spawn_output( + "microvm-output-error-test", + futures::io::Cursor::new(b"marker"), + FailingOutput, + ) + .unwrap(); + assert_eq!( + block_on(completed).unwrap().unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + } + + #[test] + fn drain_without_relay_only_waits_for_the_endpoint() { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut drain = Box::pin(drain.drain()); + assert!(drain.as_mut().now_or_never().is_none()); + requests.try_recv().unwrap().complete(Ok(())); + block_on(drain).unwrap(); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/report.rs b/openvmm/openvmm_entry/src/microvm/report.rs new file mode 100644 index 00000000000..83ca586b647 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/report.rs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bounded local outcome reporting for direct microVM runs. + +use crate::cli_args::Options; +use crate::cli_args::microvm::MachineProfileCli; +use crate::cli_args::microvm::MicrovmLifecycleCli; +use crate::cli_args::microvm::MicrovmNetworkActionCli; +use crate::meshworker::VmmMesh; +use anyhow::Context as _; +use pal_async::DefaultDriver; +use serde::Serialize; +use std::io::Write as _; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Serialize)] +struct MicrovmOutcomeReport { + schema_version: u32, + instance_id: String, + backend: String, + outcome: Outcome, + network_policy: NetworkPolicyOutcome, + teardown: TeardownOutcome, +} + +#[derive(Serialize)] +struct Outcome { + operation: &'static str, + category: &'static str, + status_code: i32, +} + +#[derive(Serialize)] +struct NetworkPolicyOutcome { + status: &'static str, + status_code: u32, + mode: &'static str, + allow_rule_count: usize, + deny_rule_count: usize, + host_loopback: &'static str, +} + +#[derive(Serialize)] +struct TeardownOutcome { + guest_workload_stopped: bool, + vm_stopped: bool, + openvmm_process_terminated: bool, + virtiofs_released: bool, + network_released: bool, + temporary_storage_removed: bool, + control_channels_closed: bool, +} + +pub(crate) struct MicrovmReportPlan { + path: PathBuf, + instance_id: String, + backend: String, + operation: &'static str, + policy_requested: bool, + policy_mode: &'static str, + allow_rule_count: usize, + deny_rule_count: usize, + host_loopback: &'static str, +} + +impl MicrovmReportPlan { + pub(crate) fn from_options(opt: &Options) -> anyhow::Result> { + let Some(path) = &opt.microvm.microvm_report else { + return Ok(None); + }; + anyhow::ensure!( + opt.machine == MachineProfileCli::Microvm, + "--microvm-report requires a microVM machine" + ); + let path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir() + .context("failed to resolve the microVM report path")? + .join(path) + }; + validate_destination(&path)?; + + let mut instance = [0u8; 16]; + getrandom::fill(&mut instance).context("failed to generate a report instance ID")?; + let instance_id = instance + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let backend = opt + .hypervisor + .as_deref() + .and_then(|value| value.split(':').next()) + .unwrap_or("auto"); + anyhow::ensure!( + matches!(backend, "auto" | "kvm" | "mshv" | "whp"), + "microVM report backend category is unsupported" + ); + let operation = if opt.restore_snapshot.is_some() { + "restore" + } else if opt.microvm.snapshot_destination.is_some() { + "capture" + } else if opt.microvm.microvm_lifecycle == Some(MicrovmLifecycleCli::Managed) { + "managed" + } else { + "run" + }; + let policy_requested = !opt.net.is_empty() + || opt.microvm.network_profile.is_some() + || opt.microvm.network_egress.is_some() + || opt.microvm.network_ingress.is_some() + || !opt.microvm.network_egress_allow.is_empty() + || !opt.microvm.network_egress_deny.is_empty() + || !opt.microvm.allow_host.is_empty() + || !opt.microvm.block_host.is_empty() + || !opt.microvm.allow_endpoint.is_empty() + || opt.microvm.host_loopback.is_some() + || opt.microvm.network_proxy.is_some() + || !opt.microvm.host_loopback_forward.is_empty(); + let policy_mode = if !opt.microvm.network_egress_allow.is_empty() + || !opt.microvm.network_egress_deny.is_empty() + { + "rules" + } else if !opt.microvm.allow_host.is_empty() { + "allow-list" + } else if !opt.microvm.block_host.is_empty() { + "block-list" + } else if !opt.microvm.allow_endpoint.is_empty() { + "endpoint" + } else if opt.microvm.network_egress == Some(MicrovmNetworkActionCli::Deny) { + "deny-all" + } else if policy_requested { + "allow-all" + } else { + "none" + }; + let allow_rule_count = opt.microvm.network_egress_allow.len() + + opt.microvm.allow_host.len() + + opt.microvm.allow_endpoint.len(); + let deny_rule_count = opt.microvm.network_egress_deny.len() + opt.microvm.block_host.len(); + let host_loopback = if policy_requested { + match opt + .microvm + .host_loopback + .unwrap_or(MicrovmNetworkActionCli::Allow) + { + MicrovmNetworkActionCli::Allow => "allow", + MicrovmNetworkActionCli::Deny => "deny", + } + } else { + "none" + }; + Ok(Some(Self { + path, + instance_id, + backend: backend.to_owned(), + operation, + policy_requested, + policy_mode, + allow_rule_count, + deny_rule_count, + host_loopback, + })) + } + + pub(crate) fn write(self, result: &anyhow::Result) -> anyhow::Result<()> { + let teardown_failure = result + .as_ref() + .err() + .and_then(|error| error.downcast_ref::()); + let (category, status_code) = match (result, teardown_failure) { + (_, Some(_)) => ("teardown-failure", 1), + (Ok(0), None) => ("success", 0), + (Ok(code), None) => ("guest-exit", *code), + (Err(_), None) => ("vmm-failure", 1), + }; + let policy_status = if !self.policy_requested { + ("not-requested", 0) + } else if result.is_ok() || teardown_failure.is_some() { + ("applied", 0) + } else { + ("failed", 1) + }; + let teardown = teardown_failure.map(|error| error.0).unwrap_or( + crate::vm_controller::MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }, + ); + let report = MicrovmOutcomeReport { + schema_version: 1, + instance_id: self.instance_id, + backend: self.backend, + outcome: Outcome { + operation: self.operation, + category, + status_code, + }, + network_policy: NetworkPolicyOutcome { + status: policy_status.0, + status_code: policy_status.1, + mode: self.policy_mode, + allow_rule_count: self.allow_rule_count, + deny_rule_count: self.deny_rule_count, + host_loopback: self.host_loopback, + }, + teardown: TeardownOutcome { + guest_workload_stopped: teardown.vm_worker_stopped, + vm_stopped: teardown.vm_worker_stopped, + openvmm_process_terminated: true, + virtiofs_released: teardown.vm_worker_stopped, + network_released: teardown.vm_worker_stopped, + temporary_storage_removed: true, + control_channels_closed: teardown.vm_worker_stopped + && teardown.auxiliary_workers_stopped, + }, + }; + let parent = self.path.parent().unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .context("failed to create the microVM report staging file")?; + serde_json::to_writer_pretty(temporary.as_file_mut(), &report) + .context("failed to encode the microVM report")?; + temporary + .as_file_mut() + .write_all(b"\n") + .context("failed to terminate the microVM report")?; + temporary + .as_file_mut() + .sync_all() + .context("failed to flush the microVM report")?; + temporary + .persist_noclobber(&self.path) + .map_err(|error| error.error) + .context("failed to publish the microVM report")?; + Ok(()) + } +} + +fn validate_destination(path: &Path) -> anyhow::Result<()> { + anyhow::ensure!(!path.as_os_str().is_empty(), "microVM report path is empty"); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!( + "failed to inspect the microVM report parent {}", + parent.display() + ) + })?; + anyhow::ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "microVM report parent is not a plain directory" + ); + anyhow::ensure!( + fs_err::symlink_metadata(path) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + "microVM report destination already exists or cannot be inspected" + ); + Ok(()) +} + +/// Rejects microVM options that must fail before any host resource is opened, +/// writing the bounded outcome report for the failure. +pub(crate) fn validate_early_options(opt: &Options) -> anyhow::Result<()> { + if let Err(error) = opt.validate_microvm_host_loopback() { + let result = Err(error); + if let Some(report) = MicrovmReportPlan::from_options(opt)? { + report.write(&result)?; + } + return result.map(|_: i32| ()); + } + Ok(()) +} + +/// Runs the VM control process and, when requested, writes the bounded microVM +/// outcome report after the run completes. +pub(crate) async fn run_control(driver: &DefaultDriver, opt: Options) -> anyhow::Result { + let Some(report) = MicrovmReportPlan::from_options(&opt)? else { + return crate::run_control(driver, opt).await; + }; + let mut mesh = match VmmMesh::new(&driver, opt.single_process) { + Ok(mesh) => Some(mesh), + Err(error) => { + let result = Err(error); + report.write(&result)?; + return result; + } + }; + let result = crate::run_control_inner(driver, &mut mesh, opt).await; + // If setup failed before the mesh was handed to the controller, shut it + // down so the child host process exits cleanly without noisy logs. + if let Some(mesh) = mesh { + mesh.shutdown().await; + } + let report_result = report.write(&result); + match (result, report_result) { + (Ok(code), Ok(())) => Ok(code), + (Ok(_), Err(error)) => Err(error), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(report_error)) => Err(error.context(format!( + "microVM run failed and its bounded report could not be written: {report_error:#}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm_controller::MicrovmTeardownError; + use crate::vm_controller::MicrovmTeardownStatus; + use serde_json::Value; + use test_with_tracing::test; + + fn test_plan(path: PathBuf) -> MicrovmReportPlan { + MicrovmReportPlan { + path, + instance_id: "0123456789abcdef0123456789abcdef".to_owned(), + backend: "whp".to_owned(), + operation: "run", + policy_requested: true, + policy_mode: "rules", + allow_rule_count: 2, + deny_rule_count: 1, + host_loopback: "deny", + } + } + + fn read_report(path: &Path) -> Value { + serde_json::from_str(&fs_err::read_to_string(path).expect("the report should be readable")) + .expect("the report should be valid JSON") + } + + #[test] + fn report_has_an_exact_bounded_schema() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + test_plan(path.clone()).write(&Ok(23)).unwrap(); + + let report = read_report(&path); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["instance_id"], "0123456789abcdef0123456789abcdef"); + assert_eq!(report["backend"], "whp"); + assert_eq!( + report["outcome"], + serde_json::json!({ + "operation": "run", + "category": "guest-exit", + "status_code": 23, + }) + ); + assert_eq!( + report["network_policy"], + serde_json::json!({ + "status": "applied", + "status_code": 0, + "mode": "rules", + "allow_rule_count": 2, + "deny_rule_count": 1, + "host_loopback": "deny", + }) + ); + assert_eq!( + report["teardown"], + serde_json::json!({ + "guest_workload_stopped": true, + "vm_stopped": true, + "openvmm_process_terminated": true, + "virtiofs_released": true, + "network_released": true, + "temporary_storage_removed": true, + "control_channels_closed": true, + }) + ); + assert_eq!(report.as_object().unwrap().len(), 6); + + let encoded = serde_json::to_string(&report).unwrap(); + for forbidden in [ + "command", + "environment", + "path", + "destination", + "proxy", + "output", + "credential", + "error", + ] { + assert!( + !encoded.contains(forbidden), + "report exposed forbidden field category {forbidden:?}" + ); + } + } + + #[test] + fn teardown_failures_are_structured() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + let result = Err(anyhow::Error::new(MicrovmTeardownError( + MicrovmTeardownStatus { + vm_worker_stopped: false, + auxiliary_workers_stopped: true, + }, + ))); + test_plan(path.clone()).write(&result).unwrap(); + + let report = read_report(&path); + assert_eq!(report["outcome"]["category"], "teardown-failure"); + assert_eq!(report["outcome"]["status_code"], 1); + assert_eq!(report["network_policy"]["status"], "applied"); + assert_eq!(report["teardown"]["guest_workload_stopped"], false); + assert_eq!(report["teardown"]["vm_stopped"], false); + assert_eq!(report["teardown"]["virtiofs_released"], false); + assert_eq!(report["teardown"]["network_released"], false); + assert_eq!(report["teardown"]["temporary_storage_removed"], true); + assert_eq!(report["teardown"]["control_channels_closed"], false); + } + + #[test] + fn destination_must_not_exist() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + fs_err::write(&path, b"existing").unwrap(); + let error = validate_destination(&path).unwrap_err(); + assert!(error.to_string().contains("destination already exists")); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs new file mode 100644 index 00000000000..ceddebed341 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -0,0 +1,772 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM snapshot restore packets and restore-time contract checks. + +use super::console::MICROVM_CONSOLE_STABLE_ID; +use super::console::MICROVM_CONTROL_CONSOLE_STABLE_ID; +use super::console::microvm_console_listener_replacement_matches; +use super::filesystem::microvm_filesystem_slot_from_snapshot; +use crate::Options; +use crate::cli_args; +use crate::cli_args::DiskCliKind; +use crate::cli_args::microvm::MachineProfileCli; +use anyhow::Context; +use chipset_resources::microvm_time::RESTORE_ENTROPY_LEN; +use chipset_resources::microvm_time::RestoreMemoryRange; +use chipset_resources::microvm_time::RestorePacketBase; +use chipset_resources::microvm_time::RestoreTimeRecord; +use net_backend_resources::egress::EgressPolicy; +use openvmm_defs::config::DeviceVtl; +use openvmm_defs::microvm::MicrovmFilesystemConfig; +use openvmm_defs::microvm::MicrovmNetworkConfig; +use openvmm_defs::time_abi::RestoreTimeInput; +use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; +use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::microvm::SnapshotAttachment; +use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; +use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; +use openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock; +use openvmm_helpers::snapshot::restore::OpenedSnapshot; +use std::path::Path; +use virt::time_abi::HostIdentity; +use virt::time_abi::TimeAbiTestHooks; + +fn microvm_generation_id(entropy: &[u8; RESTORE_ENTROPY_LEN]) -> [u8; 16] { + let mut generation_id = [0; 16]; + generation_id.copy_from_slice(&entropy[..16]); + generation_id +} + +pub(crate) fn fresh_microvm_generation_id() -> anyhow::Result<[u8; 16]> { + let mut generation_id = [0; 16]; + getrandom::fill(&mut generation_id).context("failed to generate microVM generation ID")?; + Ok(generation_id) +} + +pub(super) fn align_legacy_network_policy_contract( + saved: &SnapshotMachineContract, + expected: &mut SnapshotMachineContract, +) { + let (Some(saved), Some(expected)) = ( + saved.microvm_network.as_ref(), + expected.microvm_network.as_mut(), + ) else { + return; + }; + if matches!(saved.egress_policy_encoding_version, 0 | 1) { + expected.egress_policy_encoding_version = saved.egress_policy_encoding_version; + expected + .egress_policy_sha256 + .clone_from(&saved.egress_policy_sha256); + } +} + +fn align_restore_console_listener_contract( + saved: &SnapshotMachineContract, + expected: &mut SnapshotMachineContract, +) { + align_restore_console_listener_attachments(&saved.attachments, &mut expected.attachments); +} + +fn align_restore_console_listener_attachments( + saved: &[SnapshotAttachment], + expected: &mut [SnapshotAttachment], +) { + for stable_id in [MICROVM_CONSOLE_STABLE_ID, MICROVM_CONTROL_CONSOLE_STABLE_ID] { + let Some(saved_attachment) = saved + .iter() + .find(|attachment| attachment.stable_id == stable_id) + else { + continue; + }; + let Some(expected_attachment) = expected + .iter_mut() + .find(|attachment| attachment.stable_id == stable_id) + else { + continue; + }; + if microvm_console_listener_replacement_matches(saved_attachment, expected_attachment) { + expected_attachment.clone_from(saved_attachment); + } + } +} + +/// Restore-time inputs that shape a microVM configuration. +#[derive(Default)] +pub(crate) struct MicrovmRestore { + /// The authoritative machine contract of the snapshot being restored. + pub(crate) machine_contract: Option, + /// Whether the guest must complete post-restore repair before readiness. + pub(crate) gate_required: bool, + /// Whether `--restore-memory` selected an explicit RAM target. + pub(crate) memory_target_requested: bool, + /// Fresh private GPA ranges selected for this restore. + pub(crate) memory_ranges: Vec, + /// Private copy of a paired scratch image, kept alive for the VM lifetime. + pub(crate) private_scratch_dir: Option, +} + +impl MicrovmRestore { + /// Returns the time ABI records of the snapshot being restored: its time + /// contract and CPU profile record. + pub(crate) fn time_abi_records(&self) -> Option<(&SnapshotTimeContract, &SnapshotCpuProfile)> { + let contract = self.machine_contract.as_ref()?; + contract.time.as_ref().zip(contract.cpu_profile.as_ref()) + } + + /// Returns the controller's fields of restore packet version 4 for a time + /// ABI restore, and the generation ID they imply: restore step 7 of the + /// specification. The generation counter is the snapshot's plus one. + pub(crate) fn time_abi_restore_packet( + &self, + restore_online_vp_count: Option, + restore_active_image_slot_count: Option, + ) -> anyhow::Result<([u8; 16], RestorePacketBase)> { + let (time, _) = self + .time_abi_records() + .context("time ABI restore requires the snapshot's time contract")?; + restore_packet_base( + time, + restore_online_vp_count, + restore_active_image_slot_count, + self.memory_target_requested, + &self.memory_ranges, + self.gate_required, + ) + } +} + +/// Returns the controller's fields of restore packet version 4 for a time +/// ABI restore of a snapshot with the time contract `time`, and the +/// generation ID they imply: restore step 7 of the specification. The +/// generation counter is the snapshot's plus one, and `ack_required` makes +/// the guest acknowledge the restore through port `0x605`. An explicit +/// image-slot target occupies header byte 7. +pub(crate) fn restore_packet_base( + time: &SnapshotTimeContract, + restore_online_vp_count: Option, + restore_active_image_slot_count: Option, + memory_target_requested: bool, + memory_ranges: &[SnapshotMemoryExpansionRange], + ack_required: bool, +) -> anyhow::Result<([u8; 16], RestorePacketBase)> { + let generation = time.capture_generation.checked_add(1).ok_or_else(|| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::GenerationExhausted, + "the snapshot's generation counter cannot be incremented", + ) + })?; + anyhow::ensure!( + memory_target_requested || memory_ranges.is_empty(), + "restore memory ranges require an explicit memory target" + ); + let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); + let mut entropy = [0_u8; RESTORE_ENTROPY_LEN]; + getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; + let base = RestorePacketBase { + online_vp_count: restore_online_vp_count + .map(u8::try_from) + .transpose() + .context("restore-online VP count does not fit in u8")? + .unwrap_or(0), + image_slot_target: restore_active_image_slot_count.unwrap_or(0), + memory_target: memory_target_requested, + ack_required, + generation, + ranges: memory_ranges + .iter() + .map(|range| RestoreMemoryRange { + gpa_start: range.gpa_start, + length: range.length, + }) + .collect(), + entropy, + }; + base.validate() + .context("restore packet version 4 is invalid")?; + generation_id_create.complete("restore", "generation_id_create", Default::default()); + Ok((microvm_generation_id(&entropy), base)) +} + +/// Validates a snapshot restore against the microVM profile and prepares the +/// restore-time inputs of the microVM configuration. +/// +/// This may adjust `opt`: the snapshot selects the RAM size, a paired scratch +/// image is replaced by a private copy, and restore gates require entropy. +pub(crate) fn prepare_restore( + opt: &mut Options, + restore_snapshot: Option<&OpenedSnapshot>, +) -> anyhow::Result { + let mut private_scratch_dir = None; + let mut restore_gate_required = false; + let restore_memory_target_requested = opt.microvm.restore_memory.is_some(); + let mut restore_memory_ranges = Vec::new(); + if let Some(contract) = + restore_snapshot.and_then(|snapshot| snapshot.manifest().machine_contract.as_ref()) + && contract.machine_profile == "microvm" + { + anyhow::ensure!( + opt.machine == MachineProfileCli::Microvm, + "microVM snapshot restore requires --machine microvm" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + } + let restore_machine_contract = if opt.machine == MachineProfileCli::Microvm + && let Some(snapshot) = restore_snapshot + { + let manifest = snapshot.manifest(); + let snapshot_dir = opt + .restore_snapshot + .as_deref() + .expect("restore manifest requires a snapshot path"); + restore_gate_required = + openvmm_helpers::snapshot::microvm::requires_post_restore_gate(manifest); + let contract = openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; + anyhow::ensure!( + contract.machine_profile == "microvm", + "snapshot machine profile does not match the requested microVM machine" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + if let Some(time) = &contract.time { + openvmm_helpers::snapshot::time::validate_time_contract(time)?; + } + if let Some(restore_processors) = opt.microvm.restore_processors { + openvmm_helpers::snapshot::microvm::validate_restore_online_vp_count( + manifest, + restore_processors, + )?; + restore_gate_required = true; + } + if let Some(restore_image_slots) = opt.microvm.restore_image_slots { + openvmm_helpers::snapshot::microvm::validate_restore_active_image_slot_count( + manifest, + restore_image_slots, + )?; + // The restore packet's image-slot target directs the guest to + // verify the active prefix and acknowledge, even when no slot + // becomes active. + restore_gate_required = true; + } + let restore_memory_size = opt + .microvm + .restore_memory + .map(|memory| memory.0) + .unwrap_or(manifest.memory_size_bytes); + if restore_memory_target_requested { + restore_memory_ranges = + openvmm_helpers::snapshot::microvm::validate_restore_memory_target( + manifest, + restore_memory_size, + )?; + } + if !restore_memory_ranges.is_empty() { + restore_gate_required = true; + } + anyhow::ensure!( + opt.cmdline.is_empty(), + "restore-time command-line overrides are not allowed" + ); + anyhow::ensure!( + opt.memory == Default::default() + && !opt.deprecated_private_memory + && !opt.deprecated_prefetch + && !opt.deprecated_thp + && opt.deprecated_memory_backing_file.is_none(), + "restore-time memory overrides are not allowed" + ); + opt.memory.size = Some(vmm_cli::MemorySize(restore_memory_size)); + if !contract.microvm_sandbox_blocks.is_empty() { + let scratch = contract + .microvm_sandbox_blocks + .last() + .filter(|block| block.role == "scratch") + .context("microVM snapshot is missing its scratch contract")?; + if scratch.artifact == openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME { + anyhow::ensure!( + !opt.microvm.microvm_sandbox_block.iter().any(|block| { + block.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }), + "paired snapshot restore supplies scratch.img; do not pass a scratch block" + ); + let source = snapshot + .open_paired_scratch_file()? + .context("paired snapshot is missing scratch.img")?; + let restore_mode = if scratch.restore_mode.is_empty() { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + } else { + scratch.restore_mode.as_str() + }; + let (private_path, temp_dir) = match restore_mode { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED => { + anyhow::ensure!( + manifest.restore_policy + == openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_RESUME, + "direct-claimed scratch requires a resume snapshot" + ); + let parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp_dir = tempfile::Builder::new() + .prefix(".openvmm-claimed-scratch-") + .tempdir_in(parent) + .context("failed to create claimed restore scratch directory")?; + let private_path = temp_dir + .path() + .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); + openvmm_helpers::snapshot::restore::link_claimed_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + (private_path, Some(temp_dir)) + } + mode => { + let parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp_dir = tempfile::Builder::new() + .prefix(".openvmm-private-scratch-") + .tempdir_in(parent) + .context("failed to create private restore scratch directory")?; + let private_path = temp_dir + .path() + .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); + match mode { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY => { + openvmm_helpers::snapshot::restore::copy_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + } + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE => { + openvmm_helpers::snapshot::restore::clone_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + } + _ => anyhow::bail!( + "snapshot scratch restore mode '{mode}' is unsupported" + ), + } + (private_path, Some(temp_dir)) + } + }; + opt.microvm + .microvm_sandbox_block + .push(cli_args::microvm::MicrovmSandboxBlockCli { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + disk: cli_args::DiskCli { + vtl: DeviceVtl::Vtl0, + kind: DiskCliKind::File { + path: private_path, + create_with_len: None, + direct: false, + }, + read_only: false, + is_dvd: false, + underhill: None, + pcie_port: None, + controller: None, + nsid: None, + lun: None, + relay: None, + }, + }); + private_scratch_dir = temp_dir; + } else { + anyhow::ensure!( + opt.microvm.microvm_sandbox_block.iter().any(|block| { + block.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }), + "fresh-scratch snapshot restore requires a scratch block" + ); + } + } + Some(contract.clone()) + } else { + None + }; + Ok(MicrovmRestore { + machine_contract: restore_machine_contract, + gate_required: restore_gate_required, + memory_target_requested: restore_memory_target_requested, + memory_ranges: restore_memory_ranges, + private_scratch_dir, + }) +} + +/// The machine contract a microVM snapshot must match to be restored: the +/// hypervisor, effective command line, network, filesystems in virtio-fs slot +/// order, boot and control console attachments, and sandbox blocks. +pub(crate) type ExpectedRestoreContract<'a> = ( + &'a str, + &'a str, + Option<( + &'a MicrovmNetworkConfig, + &'a EgressPolicy, + &'a SnapshotAttachment, + )>, + Vec<( + &'a MicrovmFilesystemConfig, + &'a Path, + &'a SnapshotAttachment, + )>, + Option<&'a SnapshotAttachment>, + Option<&'a SnapshotAttachment>, + Vec, +); + +/// The time ABI options of a microVM restore. +#[derive(Clone, Copy)] +pub(crate) struct TimeAbiRestoreOptions<'a> { + /// The requested CPU profile: `auto` or a profile ID. + pub(crate) cpu_profile: &'a str, + /// The active test hooks. + pub(crate) hooks: &'a TimeAbiTestHooks, +} + +/// A time ABI restore validated by the controller: restore steps 1 to 6 of +/// the specification. +pub(crate) struct TimeAbiRestore { + contract: SnapshotTimeContract, + cpu_profile: SnapshotCpuProfile, + destination: HostIdentity, +} + +impl TimeAbiRestore { + /// Returns the snapshot's time contract. + #[cfg(any(feature = "ttrpc", feature = "grpc"))] + pub(crate) fn contract(&self) -> &SnapshotTimeContract { + &self.contract + } + + /// Returns the snapshot's CPU profile record. + #[cfg(any(feature = "ttrpc", feature = "grpc"))] + pub(crate) fn cpu_profile(&self) -> &SnapshotCpuProfile { + &self.cpu_profile + } + + /// Returns the worker's restore input. The worker seals the time fields + /// of the restore packet through `restore_record`, and `packet_selected`, + /// when present, reports the guest's first selection of the packet. + pub(crate) fn into_input( + self, + restore_record: mesh::OneshotSender, + packet_selected: Option>, + ) -> RestoreTimeInput { + RestoreTimeInput { + contract: self.contract, + cpu_profile: self.cpu_profile, + destination: self.destination, + restore_record, + packet_selected, + } + } +} + +/// Validates the authoritative machine contract of a microVM snapshot against +/// the restore-time configuration. +/// +/// The manifest, version 6 like every readable one, must carry the time ABI +/// records: the time ABI preflight checks them, the backend, the CPU profile, +/// and the downtime against this host before the rest of the contract is +/// compared. +pub(crate) fn validate_restore_contract( + manifest: &SnapshotManifest, + expected_memory_size: u64, + expected_vp_count: u32, + ( + expected_hypervisor, + effective_command_line, + network, + filesystems, + console_attachment, + control_console_attachment, + sandbox_blocks, + ): ExpectedRestoreContract<'_>, + time_abi: TimeAbiRestoreOptions<'_>, +) -> anyhow::Result { + let saved_contract = openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; + let destination = preflight_time_abi_restore(saved_contract, expected_hypervisor, time_abi)?; + let (Some(contract), Some(cpu_profile)) = ( + saved_contract.time.clone(), + saved_contract.cpu_profile.clone(), + ) else { + unreachable!("the time ABI preflight requires both records"); + }; + let network = network.map(|(config, policy, attachment)| (config, policy, attachment.clone())); + let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; + // A dormant-slot snapshot captured no filesystem, so a filesystem that the + // restore attaches to the slot is not part of the snapshot's contract. + let filesystems = if saved_contract.microvm_filesystem.is_some() { + filesystems + .into_iter() + .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())) + .collect() + } else { + Vec::new() + }; + let image_slots = (saved_contract.microvm_image_slot_capacity != 0).then_some( + openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: saved_contract.boot_active_image_slot_count, + active_count: saved_contract.boot_active_image_slot_count, + }, + ); + let mut expected_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_image_slots( + expected_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + effective_command_line.to_owned(), + network, + filesystem_slot, + filesystems, + console_attachment.cloned(), + control_console_attachment.cloned(), + sandbox_blocks, + image_slots, + expected_vp_count, + expected_memory_size, + (saved_contract.memory_expansion_version != 0) + .then_some(saved_contract.memory_capacity_bytes), + saved_contract.state_unit_names.clone(), + contract, + cpu_profile, + )?; + align_legacy_network_policy_contract(saved_contract, &mut expected_contract); + align_restore_console_listener_contract(saved_contract, &mut expected_contract); + openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( + manifest, + &expected_contract, + )?; + // The validation leaves the time ABI records to the preflight, so take + // them back rather than copying them. + let (Some(contract), Some(cpu_profile)) = ( + expected_contract.time.take(), + expected_contract.cpu_profile.take(), + ) else { + unreachable!("the expected contract holds the records it was built with"); + }; + Ok(TimeAbiRestore { + contract, + cpu_profile, + destination, + }) +} + +/// Runs the controller's time ABI preflight (restore steps 2 to 5 of the +/// specification) against this host, and returns the host's identity. +fn preflight_time_abi_restore( + contract: &SnapshotMachineContract, + hypervisor: &str, + options: TimeAbiRestoreOptions<'_>, +) -> anyhow::Result { + let destination = virt::time_abi::host::host_identity()?; + let now = virt::time_abi::host::sample_host_time()?; + let preflight = openvmm_helpers::snapshot::time::preflight_time_abi_restore( + contract, + hypervisor, + options.cpu_profile, + openvmm_helpers::snapshot::time::host_cpu(), + &destination, + &now, + options.hooks, + )?; + if let Some(step_ns) = preflight.downtime.host_wall_clock_step_ns { + tracing::warn!( + step_ns, + "host wall clock was stepped since capture; the downtime uses host monotonic time" + ); + } + tracing::info!( + downtime_ns = preflight.downtime.nanos, + source = ?preflight.downtime.source, + generation = preflight.generation, + cpu_profile = preflight.cpu_profile, + "time ABI restore preflight passed" + ); + Ok(destination) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; + use test_with_tracing::test; + + /// Returns a valid time contract for test machine contracts. + pub(crate) fn test_time_contract() -> SnapshotTimeContract { + SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: virt::time_abi::rate::LAPIC_HZ_HYPERV, + capture_tsc: 1, + capture_utc_ns: 2, + capture_monotonic_ns: 3, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation: 0, + } + } + + /// Returns a well-formed CPU profile record for test machine contracts. + pub(crate) fn test_cpu_profile() -> SnapshotCpuProfile { + SnapshotCpuProfile { + id: "intel.skylake-sp.v1".to_owned(), + sha256: vec![0; 32], + profile: vec![1], + effective_cpuid: openvmm_defs::time_abi::encode_effective_cpuid([ + openvmm_defs::time_abi::EffectiveCpuidEntry { + function: 0, + index: None, + result: [0xd, 0, 0, 0], + mask: [!0, 0, 0, 0], + }, + ]), + capture_cpu_signature: 0, + } + } + + #[test] + fn listener_replacement_alignment_preserves_saved_machine_contract() { + let saved_attachment = SnapshotAttachment { + stable_id: MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: b"source.sock".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + }; + let mut requested_attachment = saved_attachment.clone(); + requested_attachment.identity = b"restored.sock".to_vec(); + let saved = vec![saved_attachment.clone()]; + let mut expected = vec![requested_attachment]; + + align_restore_console_listener_attachments(&saved, &mut expected); + + assert_eq!(expected, vec![saved_attachment]); + } + + #[test] + fn generation_id_is_the_entropy_prefix() { + let mut entropy = [0x5a; RESTORE_ENTROPY_LEN]; + entropy[16] = 0xa5; + assert_eq!(microvm_generation_id(&entropy), [0x5a; 16]); + } + fn time_abi_restore(capture_generation: u32) -> MicrovmRestore { + let mut contract: SnapshotMachineContract = mesh::payload::decode(&[]).unwrap(); + contract.time = Some(SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: 200_000_000, + capture_tsc: 1, + capture_utc_ns: 2, + capture_monotonic_ns: 3, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation, + }); + contract.cpu_profile = Some(SnapshotCpuProfile { + id: "intel.skylake-sp.v1".to_owned(), + sha256: Vec::new(), + profile: Vec::new(), + effective_cpuid: Vec::new(), + capture_cpu_signature: 0, + }); + MicrovmRestore { + machine_contract: Some(contract), + gate_required: true, + memory_target_requested: true, + memory_ranges: vec![SnapshotMemoryExpansionRange { + gpa_start: 0x1_0000_0000, + length: 0x4000_0000, + }], + private_scratch_dir: None, + } + } + + #[test] + fn time_abi_restore_packet_carries_the_controller_fields() { + let restore = time_abi_restore(6); + let (generation_id, base) = restore.time_abi_restore_packet(Some(4), None).unwrap(); + assert_eq!(base.generation, 7); + assert_eq!(base.online_vp_count, 4); + assert!(base.memory_target && base.ack_required); + assert_eq!( + base.ranges, + [RestoreMemoryRange { + gpa_start: 0x1_0000_0000, + length: 0x4000_0000, + }] + ); + assert_eq!(generation_id, microvm_generation_id(&base.entropy)); + assert_eq!(base.image_slot_target, 0); + let (_, other) = restore.time_abi_restore_packet(None, None).unwrap(); + assert_eq!(other.online_vp_count, 0); + assert_ne!(other.entropy, base.entropy); + + assert!(restore.time_abi_restore_packet(Some(3), None).is_err()); + let err = time_abi_restore(u32::MAX) + .time_abi_restore_packet(None, None) + .unwrap_err(); + assert!( + format!("{err:#}").contains("[E_GENERATION_EXHAUSTED]"), + "{err:#}" + ); + assert!( + MicrovmRestore::default() + .time_abi_restore_packet(None, None) + .is_err() + ); + } + + #[test] + fn time_abi_restore_packet_carries_the_image_slot_target() { + let restore = time_abi_restore(6); + let (_, base) = restore.time_abi_restore_packet(None, Some(3)).unwrap(); + assert_eq!(base.image_slot_target, 3); + assert_eq!(base.online_vp_count, 0); + assert!(base.ack_required); + let packet = chipset_resources::microvm_time::RestorePacketV4 { + base, + time: RestoreTimeRecord { + downtime_ns: 0, + downtime_utc: false, + rate_deviation: 0, + test_hooks: false, + }, + utc_ns: 0, + } + .encode() + .unwrap(); + assert_ne!( + packet[4] & chipset_resources::microvm_time::FLAG_IMAGE_SLOT_TARGET, + 0 + ); + assert_eq!(packet[7], 3); + + assert!( + restore + .time_abi_restore_packet( + None, + Some(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + 1) + ) + .is_err() + ); + assert_eq!( + chipset_resources::microvm_time::MAX_IMAGE_SLOT_TARGET, + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/verify.rs b/openvmm/openvmm_entry/src/microvm/verify.rs new file mode 100644 index 00000000000..0797ad09481 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/verify.rs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The time ABI verification mode for host qualification +//! (`--x-time-abi-verify`): the VM worker builds the microVM partition and +//! runs the time ABI preflight, and the controller prints one +//! `NVX-TIME-ABI-VERIFY:` line and exits without running the guest. + +use anyhow::Context; +use inspect::Inspect; +use mesh::CancelContext; +use mesh_worker::WorkerHandle; +use std::time::Duration; + +/// The prefix of the verification line. +const PREFIX: &str = "NVX-TIME-ABI-VERIFY:"; +/// The longest failure detail the verification line carries. +const MAX_DETAIL_LEN: usize = 400; +/// How long the controller waits for the worker's time ABI report. +const REPORT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Reports the outcome of a verification-mode worker launch: prints the +/// verification line and returns the process exit status, 0 when the time +/// ABI preflight passed and 1 when it failed. +pub(crate) async fn report_time_abi_verification( + launched: anyhow::Result, + hypervisor: &str, +) -> anyhow::Result { + let mut worker = match launched { + Ok(worker) => worker, + Err(err) => { + tracing::error!(error = format!("{err:#}"), "time ABI verification failed"); + println!("{}", failure_line(hypervisor, &err)); + return Ok(1); + } + }; + let mut inspection = inspect::InspectionBuilder::new("time-abi") + .depth(Some(1)) + .inspect(inspect::adhoc(|req| worker.inspect(req))); + let _ = CancelContext::new() + .with_timeout(REPORT_TIMEOUT) + .until_cancelled(inspection.resolve()) + .await; + let report = inspection.results(); + let line = success_line(hypervisor, &report)?; + worker.stop(); + worker + .join() + .await + .context("VM worker failed after time ABI verification")?; + println!("{line}"); + Ok(0) +} + +/// Returns the string value of `name` in the worker's `time-abi` inspect +/// node. +fn report_field(node: &inspect::Node, name: &str) -> anyhow::Result { + let inspect::Node::Dir(entries) = node else { + anyhow::bail!("the VM worker returned no time ABI report: {node:?}"); + }; + let entry = entries + .iter() + .find(|entry| entry.name == name) + .with_context(|| format!("the time ABI report lacks '{name}'"))?; + match &entry.node { + inspect::Node::Value(value) => Ok(match &value.kind { + inspect::ValueKind::String(text) => text.clone(), + _ => value.to_string(), + }), + node => anyhow::bail!("the time ABI report field '{name}' is {node:?}"), + } +} + +/// Formats the line of a passed verification from the worker's `time-abi` +/// inspect node. +fn success_line(hypervisor: &str, node: &inspect::Node) -> anyhow::Result { + let field = |name: &str| report_field(node, name); + Ok(format!( + "{PREFIX} v=1 status=ok backend={hypervisor} cpu_profile={} tsc_hz={} native_tsc_hz={} lapic_hz={} msr_route={} sync={}", + field("cpu_profile")?, + field("tsc_hz")?, + field("native_tsc_hz")?, + field("apic_hz")?, + field("msr_route")?, + field("sync")?, + )) +} + +/// Formats the line of a failed verification: the stable time ABI code in +/// the error chain, or `none`, and the escaped error chain. +fn failure_line(hypervisor: &str, err: &anyhow::Error) -> String { + let detail = format!("{err:#}"); + let code = time_abi_code(&detail).unwrap_or("none"); + let mut escaped = String::new(); + for c in detail.chars() { + if escaped.len() >= MAX_DETAIL_LEN { + escaped.push_str("..."); + break; + } + match c { + '"' | '\\' => { + escaped.push('\\'); + escaped.push(c); + } + c if c.is_control() => escaped.push(' '), + c => escaped.push(c), + } + } + format!("{PREFIX} v=1 status=fail backend={hypervisor} code={code} detail=\"{escaped}\"") +} + +/// Formats the process's fatal-error message. A time ABI failure leads with +/// its stable code wherever the error chain carries it, so callers can match +/// the start of the message. +pub(crate) fn fatal_error_message(err: &anyhow::Error) -> String { + let chain = format!("{err:#}"); + match time_abi_code(&chain) { + Some(code) if !chain.starts_with(&format!("[{code}]")) => { + format!("fatal error: [{code}] {err:?}") + } + _ => format!("fatal error: {err:?}"), + } +} + +/// Returns the first bracketed time ABI code, such as `E_TSC_SYNC_UNSUPPORTED`, +/// in an error chain. +fn time_abi_code(detail: &str) -> Option<&str> { + let mut rest = detail; + while let Some(start) = rest.find("[E_") { + let candidate = &rest[start + 1..]; + if let Some(end) = candidate.find(']') { + let code = &candidate[..end]; + if code + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + return Some(code); + } + } + rest = candidate; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, value: impl Into) -> inspect::Entry { + inspect::Entry { + name: name.to_owned(), + node: inspect::Node::Value(value.into()), + sensitivity: Default::default(), + } + } + + #[test] + fn success_line_reports_the_preflight() { + let node = inspect::Node::Dir(vec![ + entry("tsc_hz", 2_100_000_000_u64), + entry("apic_hz", 200_000_000_u64), + entry("tsc_invariant_control", 0_u64), + entry("hypervisor", "mshv".to_owned()), + entry("cpu_profile", "intel.skylake-sp.v1".to_owned()), + entry("msr_route", "ExitToVmm".to_owned()), + entry("sync", "FrozenWrite".to_owned()), + entry("native_tsc_hz", 2_100_000_000_u64), + ]); + assert_eq!( + success_line("mshv", &node).unwrap(), + "NVX-TIME-ABI-VERIFY: v=1 status=ok backend=mshv cpu_profile=intel.skylake-sp.v1 tsc_hz=2100000000 native_tsc_hz=2100000000 lapic_hz=200000000 msr_route=ExitToVmm sync=FrozenWrite" + ); + let inspect::Node::Dir(mut entries) = node else { + unreachable!() + }; + entries.retain(|entry| entry.name != "sync"); + let err = success_line("mshv", &inspect::Node::Dir(entries)).unwrap_err(); + assert!(err.to_string().contains("'sync'"), "{err}"); + assert!(success_line("mshv", &inspect::Node::Unevaluated).is_err()); + } + + #[test] + fn failure_line_carries_the_code_and_escaped_detail() { + let err = anyhow::anyhow!("[E_TSC_SYNC_UNSUPPORTED] no \"sync\"\nset") + .context("failed to launch vm worker"); + assert_eq!( + failure_line("kvm", &err), + "NVX-TIME-ABI-VERIFY: v=1 status=fail backend=kvm code=E_TSC_SYNC_UNSUPPORTED detail=\"failed to launch vm worker: [E_TSC_SYNC_UNSUPPORTED] no \\\"sync\\\" set\"" + ); + let err = anyhow::anyhow!("kvm device unavailable [E_lower] x"); + assert!(failure_line("kvm", &err).contains(" code=none ")); + let long = anyhow::anyhow!("{}", "x".repeat(1000)); + assert!(failure_line("kvm", &long).ends_with("...\"")); + } + + #[test] + fn fatal_error_message_leads_with_the_time_abi_code() { + let err = anyhow::anyhow!("[E_TSC_SYNC_UNSUPPORTED] no synchronized set") + .context("failed to create the partition") + .context("failed to launch vm worker"); + let message = fatal_error_message(&err); + assert!( + message.starts_with("fatal error: [E_TSC_SYNC_UNSUPPORTED] failed to launch vm worker"), + "{message}" + ); + + let err = anyhow::anyhow!("[E_TEST_HOOK] unknown hook"); + let message = fatal_error_message(&err); + assert_eq!(message, "fatal error: [E_TEST_HOOK] unknown hook"); + + let err = anyhow::anyhow!("no code here"); + assert_eq!(fatal_error_message(&err), "fatal error: no code here"); + } +} diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 3e6aca91776..7ec596518f1 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -68,6 +68,10 @@ use tracing_helpers::AnyhowValueExt; use vm_resource::IntoResource; use vm_resource::Resource; +mod headless; +pub(crate) mod launch; +mod restore_ready; + fn maybe_with_radix_u64(s: &str) -> Result { let (radix, prefix_len) = if s.starts_with("0x") || s.starts_with("0X") { (16, 2) @@ -438,6 +442,7 @@ pub(crate) struct ReplResources { pub kvp_ic: Option>, pub console_in: Option>, pub has_vtl2: bool, + pub launch: launch::ReplLaunch, } /// Run the interactive REPL. @@ -456,8 +461,13 @@ pub(crate) async fn run_repl( kvp_ic, console_in, has_vtl2, + mut launch, } = resources; + if !launch.stdin_enabled { + return headless::run(&mut vm_controller_events).await; + } + let (console_command_send, console_command_recv) = mesh::channel(); let (inspect_completion_engine_send, inspect_completion_engine_recv) = mesh::channel(); @@ -526,7 +536,8 @@ pub(crate) async fn run_repl( let mut stdin = io::stdin(); loop { // Raw console text until Ctrl-Q. - crossterm::terminal::enable_raw_mode().expect("failed to enable raw console mode"); + let terminal = + headless::enable_raw_mode(&stdin).expect("failed to enable raw console mode"); if let Some(input) = console_in.as_mut() { let mut buf = [0; 32]; @@ -546,8 +557,7 @@ pub(crate) async fn run_repl( } } - crossterm::terminal::disable_raw_mode() - .expect("failed to disable raw console mode"); + headless::disable_raw_mode(terminal).expect("failed to disable raw console mode"); loop { let line = rl.readline("openvmm> "); @@ -604,7 +614,7 @@ pub(crate) async fn run_repl( enum StateChange { Pause(bool), - Resume(bool), + Resume(Result), Reset(Result<(), RemoteError>), PulseSaveRestore(Result<(), PulseSaveRestoreError>), ServiceVtl2(anyhow::Result), @@ -706,13 +716,17 @@ pub(crate) async fn run_repl( tracing::warn!("already paused"); } } - StateChange::Resume(success) => { + StateChange::Resume(Ok(success)) => { if success { + launch.restore_ready_pending = false; tracing::info!("resumed complete"); } else { tracing::warn!("already running"); } } + StateChange::Resume(Err(err)) => { + restore_ready::resume_failed(err, launch.restore_ready_pending)? + } StateChange::Reset(r) => match r { Ok(()) => tracing::info!("reset complete"), Err(err) => tracing::error!( @@ -792,6 +806,7 @@ pub(crate) async fn run_repl( tracing::info!(reason = reason.as_str(), "guest halted"); } VmControllerEvent::ExitRequested { code } => break code, + VmControllerEvent::ExitFailed { error } => return Err(anyhow::anyhow!(error)), } continue; } diff --git a/openvmm/openvmm_entry/src/repl/headless.rs b/openvmm/openvmm_entry/src/repl/headless.rs new file mode 100644 index 00000000000..7b7db621ea0 --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/headless.rs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Headless operation of the REPL, for runs driven by another process instead +//! of an interactive console. +//! +//! Without stdin (`--microvm-control-auth-stdin` reserves stdin for the +//! control-console capability), the REPL starts no stdio thread and handles +//! only controller events, until one of them ends the run. With a stdin that +//! is not a terminal, such as a pipe, the stdio thread reads it without +//! switching the console to raw mode. + +use crate::vm_controller::VmControllerEvent; +use futures::StreamExt; +use std::io; +use std::io::IsTerminal; + +/// Target of the events logged here: the REPL's own, so that the headless +/// REPL logs controller events exactly as the interactive one does. +const REPL_TARGET: &str = "openvmm_entry::repl"; + +/// Runs the REPL without reading stdin. +/// +/// No command can arrive, so this handles only the controller events, as the +/// interactive loop of [`super::run_repl`] does, and returns the exit status of +/// the event that ends the run. +pub(super) async fn run( + vm_controller_events: &mut mesh::Receiver, +) -> anyhow::Result { + while let Some(event) = vm_controller_events.next().await { + if let Some(code) = controller_exit(event)? { + return Ok(code); + } + } + // As in the interactive loop, whose other event sources stay open, the + // controller closing its event channel does not end the run. + std::future::pending().await +} + +/// Maps a controller event to the exit status of the REPL, or to `None` when +/// the REPL keeps running. +fn controller_exit(event: VmControllerEvent) -> anyhow::Result> { + match event { + VmControllerEvent::WorkerStopped { error } => { + if let Some(err) = &error { + tracing::error!(target: REPL_TARGET, error = err.as_str(), "vm worker stopped"); + } + Ok(Some(i32::from(error.is_some()))) + } + VmControllerEvent::VncWorkerStopped { .. } => Ok(None), + VmControllerEvent::GuestHalt(reason) => { + tracing::info!(target: REPL_TARGET, reason = reason.as_str(), "guest halted"); + Ok(None) + } + VmControllerEvent::ExitRequested { code } => Ok(Some(code)), + VmControllerEvent::ExitFailed { error } => Err(anyhow::anyhow!(error)), + } +} + +/// Enables raw console mode for the console input of the stdio thread, unless +/// stdin is not a terminal. Returns whether raw mode was enabled. +pub(super) fn enable_raw_mode(stdin: &io::Stdin) -> io::Result { + let terminal = stdin.is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode()?; + } + Ok(terminal) +} + +/// Disables raw console mode if [`enable_raw_mode`] enabled it. +pub(super) fn disable_raw_mode(enabled: bool) -> io::Result<()> { + if enabled { + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::controller_exit; + use crate::repl::ReplResources; + use crate::repl::launch::ReplLaunch; + use crate::repl::run_repl; + use crate::vm_controller::VmControllerEvent; + use pal_async::DefaultDriver; + use pal_async::async_test; + use std::pin::pin; + use test_with_tracing::test; + + #[async_test] + async fn headless_repl_waits_for_controller_without_stdin(driver: DefaultDriver) { + let (vm_rpc, _vm_requests) = mesh::channel(); + let (vm_controller, _controller_requests) = mesh::channel(); + let (events, vm_controller_events) = mesh::channel(); + let resources = ReplResources { + vm_rpc, + vm_controller, + vm_controller_events, + scsi_rpc: None, + nvme_vtl2_rpc: None, + consomme_rpc: None, + shutdown_ic: None, + kvp_ic: None, + console_in: None, + has_vtl2: false, + launch: ReplLaunch { + restore_ready_pending: false, + stdin_enabled: false, + }, + }; + let mut repl = pin!(run_repl(&driver, resources)); + assert!(futures::poll!(repl.as_mut()).is_pending()); + events.send(VmControllerEvent::ExitRequested { code: 23 }); + assert_eq!(repl.await.unwrap(), 23); + } + + #[test] + fn repl_propagates_exit_failures_and_preserves_requested_statuses() { + let error = controller_exit(VmControllerEvent::ExitFailed { + error: "console output drain timed out".to_owned(), + }) + .unwrap_err(); + assert!(error.to_string().contains("console output drain timed out")); + for code in [0, 37] { + assert_eq!( + controller_exit(VmControllerEvent::ExitRequested { code }).unwrap(), + Some(code) + ); + } + } +} diff --git a/openvmm/openvmm_entry/src/repl/launch.rs b/openvmm/openvmm_entry/src/repl/launch.rs new file mode 100644 index 00000000000..1270298d85e --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/launch.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! How a VM launch configures the REPL beyond the resources it shares with it: +//! whether the REPL reads stdin ([`super::headless`] otherwise), and whether a +//! paused snapshot restore awaits its first resume ([`super::restore_ready`]). + +use crate::Options; + +/// How a VM launch configures the REPL. +pub(crate) struct ReplLaunch { + /// Whether a paused snapshot restore waits for its first successful + /// `resume`, which publishes its restore readiness event. + pub(crate) restore_ready_pending: bool, + /// Whether the REPL reads stdin. Otherwise, it runs headless. + pub(crate) stdin_enabled: bool, +} + +impl ReplLaunch { + /// Returns how a VM launched from the command line configures the REPL. + pub(crate) fn from_options(opt: &Options) -> Self { + Self { + restore_ready_pending: opt.paused && opt.restore_ready_path.is_some(), + stdin_enabled: !opt.microvm.microvm_control_auth_stdin, + } + } +} diff --git a/openvmm/openvmm_entry/src/repl/restore_ready.rs b/openvmm/openvmm_entry/src/repl/restore_ready.rs new file mode 100644 index 00000000000..11d990ed017 --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/restore_ready.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore readiness in the REPL. +//! +//! A VM restored with `--restore-snapshot --restore-ready-path` publishes its +//! restore readiness event, once, when it first starts. With `--paused`, that +//! first start is the REPL's first successful `resume`; until then, the +//! restore readiness is pending (`ReplLaunch::restore_ready_pending`). A +//! resume that fails while it is pending ends the REPL with an error, which +//! tears the restored VM down: the single-use readiness endpoint may already +//! be consumed, and a later resume would start the guest without the event. + +use mesh::error::RemoteError; + +/// Target of the event logged here: the REPL's own, next to its other resume +/// events. +const REPL_TARGET: &str = "openvmm_entry::repl"; + +/// Handles a failed `resume` command. The failure is logged; while the +/// restore readiness is `pending`, it also ends the REPL with an error. +pub(super) fn resume_failed(err: RemoteError, pending: bool) -> anyhow::Result<()> { + tracing::error!( + target: REPL_TARGET, + error = &err as &dyn std::error::Error, + "resume failed" + ); + if pending { + return Err(anyhow::Error::new(err).context("restore readiness resume failed")); + } + Ok(()) +} diff --git a/openvmm/openvmm_entry/src/serial_io.rs b/openvmm/openvmm_entry/src/serial_io.rs index 18dce9b0a9c..e820d0808f2 100644 --- a/openvmm/openvmm_entry/src/serial_io.rs +++ b/openvmm/openvmm_entry/src/serial_io.rs @@ -15,6 +15,9 @@ use vm_resource::IntoResource; use vm_resource::Resource; use vm_resource::kind::SerialBackendHandle; +pub(crate) mod connect; +pub mod microvm; + #[cfg(unix)] pub fn anonymous_serial_pair( driver: &(impl Driver + ?Sized), @@ -71,7 +74,6 @@ pub fn bind_serial(path: &Path) -> io::Result> { /// /// Unlike [`bind_serial`], which creates a new server, this function connects /// to a pipe or socket that already exists. -#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub fn connect_serial(path: &Path) -> io::Result> { #[cfg(windows)] { diff --git a/openvmm/openvmm_entry/src/serial_io/connect.rs b/openvmm/openvmm_entry/src/serial_io/connect.rs new file mode 100644 index 00000000000..9b9928fd7f9 --- /dev/null +++ b/openvmm/openvmm_entry/src/serial_io/connect.rs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serial endpoint helpers beyond the upstream ones: binding a listener without +//! removing an existing socket path, and connecting to an existing endpoint as +//! a client within a bounded time. + +use anyhow::Context; +use serial_socket::net::OpenSocketSerialConfig; +use std::io; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::path::Path; +use unix_socket::UnixListener; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::SerialBackendHandle; + +/// Binds a listener without removing an existing socket path. +pub(crate) fn bind_serial_without_cleanup( + path: &Path, +) -> io::Result> { + // `bind_serial` returns a named pipe before it would remove an existing + // socket path. + #[cfg(windows)] + { + if path.starts_with("//./pipe") { + return super::bind_serial(path); + } + } + + Ok(OpenSocketSerialConfig::from(UnixListener::bind(path)?).into_resource()) +} + +pub(crate) fn connect_serial_with_timeout( + path: &Path, + timeout: std::time::Duration, +) -> io::Result> { + let path = path.to_owned(); + let (send, recv) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("serial-connect".to_owned()) + .spawn(move || { + let _ = send.send(super::connect_serial(&path)); + })?; + match recv.recv_timeout(timeout) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("serial endpoint did not connect within {timeout:?}"), + )), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other( + "serial connection worker terminated without a result", + )), + } +} + +pub(crate) fn connect_tcp_serial( + addr: &SocketAddr, + timeout: std::time::Duration, +) -> anyhow::Result> { + let stream = TcpStream::connect_timeout(addr, timeout) + .with_context(|| format!("failed to connect to tcp address {addr}"))?; + Ok(OpenSocketSerialConfig::from(stream).into_resource()) +} diff --git a/openvmm/openvmm_entry/src/serial_io/microvm.rs b/openvmm/openvmm_entry/src/serial_io/microvm.rs new file mode 100644 index 00000000000..0cc8bddb283 --- /dev/null +++ b/openvmm/openvmm_entry/src/serial_io/microvm.rs @@ -0,0 +1,474 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Local serial endpoints and capability input of the microVM control console. + +#[cfg(target_os = "linux")] +use serial_socket::net::OpenSocketSerialConfig; +use std::fs::File; +use std::io; +#[cfg(any(unix, windows))] +use std::io::Read; +use std::path::Path; +#[cfg(target_os = "linux")] +use unix_socket::UnixListener; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::SerialBackendHandle; + +#[cfg(target_os = "linux")] +pub fn bind_control_serial(path: &Path) -> io::Result> { + Ok(OpenSocketSerialConfig::from(bind_control_listener(path)?).into_resource()) +} + +#[cfg(target_os = "linux")] +fn bind_control_listener(path: &Path) -> io::Result { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint has no parent directory", + ) + })?; + let parent_metadata = fs_err::symlink_metadata(parent)?; + if parent_metadata.file_type().is_symlink() + || !parent_metadata.is_dir() + || parent_metadata.uid() != pal::unix::effective_user_id() + || parent_metadata.mode() & 0o7777 != 0o700 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "control endpoint parent must be a non-symlink directory owned by OpenVMM with mode 0700", + )); + } + + let listener = UnixListener::bind(path)?; + let bound_metadata = fs_err::symlink_metadata(path)?; + let prepare_result = (|| { + fs_err::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + let socket_metadata = fs_err::symlink_metadata(path)?; + if !socket_metadata.file_type().is_socket() + || socket_metadata.dev() != bound_metadata.dev() + || socket_metadata.ino() != bound_metadata.ino() + || socket_metadata.uid() != parent_metadata.uid() + || socket_metadata.mode() & 0o7777 != 0o600 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "control endpoint must be an owned Unix socket with mode 0600", + )); + } + Ok(()) + })(); + if let Err(error) = prepare_result { + drop(listener); + if let Ok(current) = fs_err::symlink_metadata(path) + && current.file_type().is_socket() + && current.dev() == bound_metadata.dev() + && current.ino() == bound_metadata.ino() + { + let _ = fs_err::remove_file(path); + } + return Err(error); + } + Ok(listener) +} + +#[cfg(windows)] +pub fn bind_control_serial(path: &Path) -> io::Result> { + use serial_socket::windows::OpenWindowsPipeSerialConfig; + + Ok(OpenWindowsPipeSerialConfig::from(bind_control_pipe(path)?).into_resource()) +} + +#[cfg(windows)] +fn bind_control_pipe(path: &Path) -> io::Result { + use pal::windows::security::LocalSecurityDescriptor; + + const NAMED_PIPE_PREFIX: &str = "//./pipe/"; + + let normalized = path.to_string_lossy().replace('\\', "/"); + let name = normalized.strip_prefix(NAMED_PIPE_PREFIX).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint must name a Windows //./pipe/... endpoint", + ) + })?; + if name.is_empty() || name.contains('/') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint must contain one nonempty Windows named-pipe name", + )); + } + + let user_sid = pal::windows::security::user_sid::current_process_user_sid()?; + let descriptor = format!("D:P(A;;GA;;;SY)(A;;GA;;;{})", user_sid.to_string_sid()) + .parse::()?; + let pipe = pal::windows::pipe::peer::new_named_pipe_with_security( + path, + windows_sys::Win32::Foundation::GENERIC_READ + | windows_sys::Win32::Foundation::GENERIC_WRITE, + pal::windows::pipe::Disposition::Create, + pal::windows::pipe::PipeMode::Byte, + &descriptor, + )?; + Ok(pipe) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub fn bind_control_serial(_path: &Path) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure control-console local endpoints are only supported on Linux", + )) +} + +#[cfg(target_os = "linux")] +pub fn bind_host_control( + path: &Path, + driver: &pal_async::DefaultDriver, +) -> io::Result> { + Ok(Box::new(serial_socket::net::SocketSerialBackend::new( + Box::new(driver.clone()), + OpenSocketSerialConfig::from(bind_control_listener(path)?), + )?)) +} + +#[cfg(windows)] +pub fn bind_host_control( + path: &Path, + driver: &pal_async::DefaultDriver, +) -> io::Result> { + Ok(Box::new( + serial_socket::windows::WindowsPipeSerialBackend::new( + Box::new(driver.clone()), + serial_socket::windows::OpenWindowsPipeSerialConfig::from(bind_control_pipe(path)?), + )?, + )) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub fn bind_host_control( + _path: &Path, + _driver: &pal_async::DefaultDriver, +) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure host-control endpoints are only supported on Linux and Windows", + )) +} + +/// Consumes a one-way pipe containing exactly one nonzero 32-byte control capability. +#[cfg(any(unix, windows))] +pub fn read_control_capability(mut file: File) -> io::Result<[u8; 32]> { + const CONTROL_CAPABILITY_LEN: usize = 32; + + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + + if !file.metadata()?.file_type().is_fifo() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control authentication input is not a one-way pipe", + )); + } + pal::unix::pipe::set_nonblocking(&file, true)?; + } + #[cfg(windows)] + { + use pal::windows::pipe::PipeExt as _; + use windows_sys::Win32::System::Pipes::PIPE_NOWAIT; + + if !pal::windows::pipe::peer::is_pipe(&file) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control authentication input is not a one-way pipe", + )); + } + file.set_pipe_mode(PIPE_NOWAIT)?; + } + + let mut capability = [0u8; CONTROL_CAPABILITY_LEN]; + let mut offset = 0; + while offset != capability.len() { + match file.read(&mut capability[offset..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "control authentication payload has an invalid length", + )); + } + Ok(count) => offset += count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) if control_pipe_would_block(&error) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "control authentication writer was not closed", + )); + } + Err(error) if control_pipe_is_closed(&error) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "control authentication payload has an invalid length", + )); + } + Err(error) => return Err(error), + } + } + + let mut trailing = [0u8; 1]; + loop { + match file.read(&mut trailing) { + Ok(0) => break, + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control authentication payload has an invalid length", + )); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) if control_pipe_would_block(&error) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "control authentication writer was not closed", + )); + } + Err(error) if control_pipe_is_closed(&error) => break, + Err(error) => return Err(error), + } + } + if capability == [0; CONTROL_CAPABILITY_LEN] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control authentication capability must not be zero", + )); + } + Ok(capability) +} + +fn control_pipe_would_block(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_NO_DATA as i32) + } + #[cfg(not(windows))] + { + false + } +} + +fn control_pipe_is_closed(error: &io::Error) -> bool { + #[cfg(windows)] + { + matches!( + error.raw_os_error().map(|value| value as u32), + Some( + windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE + | windows_sys::Win32::Foundation::ERROR_PIPE_NOT_CONNECTED + ) + ) + } + #[cfg(not(windows))] + { + let _ = error; + false + } +} + +/// Reads the prepared authentication pipe without taking ownership of descriptor 0. +#[cfg(target_os = "linux")] +pub fn read_control_capability_from_stdin() -> io::Result<[u8; 32]> { + use std::os::fd::AsFd; + + let stdin = io::stdin(); + read_control_capability(File::from(stdin.as_fd().try_clone_to_owned()?)) +} + +/// Reads the prepared authentication pipe without taking ownership of handle 0. +#[cfg(windows)] +pub fn read_control_capability_from_stdin() -> io::Result<[u8; 32]> { + use std::os::windows::io::AsHandle as _; + + let stdin = io::stdin(); + read_control_capability(File::from(stdin.as_handle().try_clone_to_owned()?)) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::io::Write as _; + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use test_with_tracing::test; + + fn read_capability_payload(payload: &[u8], keep_writer_open: bool) -> io::Result<[u8; 32]> { + let (read, mut write) = pal::pipe_pair()?; + write.write_all(payload)?; + if !keep_writer_open { + drop(write); + } + read_control_capability(read) + } + + #[test] + fn control_capability_requires_exact_closed_pipe_payload() { + let mut capability = [0x5a; 32]; + capability[0] = 0; + assert_eq!( + read_capability_payload(&capability, false).unwrap(), + capability + ); + for length in [0, 1, 31] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], false) + .unwrap_err() + .kind(), + io::ErrorKind::UnexpectedEof + ); + } + for length in [33, 64] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], false) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + } + for length in [0, 31, 32] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], true) + .unwrap_err() + .kind(), + io::ErrorKind::TimedOut + ); + } + assert_eq!( + read_capability_payload(&[0; 32], false).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } + + #[test] + fn control_capability_rejects_non_pipe_input() { + assert_eq!( + read_control_capability(tempfile::tempfile().unwrap()) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn control_capability_stdin_child() { + let Some(expected) = std::env::var_os("OPENVMM_TEST_CONTROL_STDIN") else { + return; + }; + let result = read_control_capability_from_stdin(); + match expected.to_str().unwrap() { + "valid" => { + let mut capability = [0x5a; 32]; + capability[0] = 0; + assert_eq!(result.unwrap(), capability); + assert_eq!(io::stdin().read(&mut [0]).unwrap(), 0); + } + "open-writer" => assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut), + "non-pipe" => assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput), + _ => panic!("unexpected child-test scenario"), + } + } + + #[test] + fn control_capability_uses_prepared_child_stdin() { + use std::process::Command; + use std::process::Stdio; + use std::time::Duration; + use std::time::Instant; + + for scenario in ["valid", "open-writer", "non-pipe"] { + let (read, mut write) = pal::pipe_pair().unwrap(); + let mut capability = [0x5a; 32]; + capability[0] = 0; + write.write_all(&capability).unwrap(); + let _writer = (scenario == "open-writer").then_some(write); + let input = if scenario == "non-pipe" { + tempfile::tempfile().unwrap() + } else { + read + }; + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "serial_io::microvm::tests::control_capability_stdin_child", + "--nocapture", + ]) + .env("OPENVMM_TEST_CONTROL_STDIN", scenario) + .stdin(Stdio::from(input)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("stdin capability scenario {scenario} timed out: {output:?}"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "stdin scenario {scenario}: {output:?}" + ); + } + } + + #[test] + fn control_listener_has_private_permissions_and_exclusive_path() { + let directory = tempfile::tempdir().unwrap(); + fs_err::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.path().join("control.sock"); + + let listener = bind_control_serial(&path).unwrap(); + let metadata = fs_err::symlink_metadata(&path).unwrap(); + assert!(metadata.file_type().is_socket()); + assert_eq!(metadata.mode() & 0o777, 0o600); + assert_eq!(metadata.uid(), pal::unix::effective_user_id()); + assert!(bind_control_serial(&path).is_err()); + drop(listener); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use std::io::Write as _; + use test_with_tracing::test; + + #[test] + fn control_capability_requires_an_exact_closed_windows_pipe() { + let (read, mut write) = pal::windows::pipe::pair().unwrap(); + let mut capability = [0x5a; 32]; + capability[0] = 0; + write.write_all(&capability).unwrap(); + drop(write); + assert_eq!(read_control_capability(read).unwrap(), capability); + + let (read, mut write) = pal::windows::pipe::pair().unwrap(); + write.write_all(&capability[..31]).unwrap(); + drop(write); + assert_eq!( + read_control_capability(read).unwrap_err().kind(), + io::ErrorKind::UnexpectedEof + ); + } +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs new file mode 100644 index 00000000000..59760e19e6e --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore of a VM launched from the command line, and the restore +//! steps it shares with the management RPC endpoint. +//! +//! A `--restore-snapshot` launch opens one exact snapshot generation first +//! ([`SnapshotRestore::open`]); a microVM derives its restore-time +//! configuration from that generation's manifest. Just before the VM worker +//! launches, the opened generation is validated against the VM configuration, +//! claimed if it is a single-use resume snapshot, and its RAM is mapped +//! copy-on-write ([`SnapshotRestore::prepare`]). Its open artifact handles then +//! move to the worker, which keeps the generation pinned until VM teardown. +//! The `--restore-ready-path` endpoint is connected next +//! ([`restore_ready_sink`]); the worker writes the restore readiness event to +//! it when the restored VM first starts. +//! +//! With `OPENVMM_STARTUP_PROFILE` set, these steps record the `restore` phases +//! `artifact_open`, `artifact_prepare`, and `cow_section_create`, and the +//! launch records the `startup` milestone `worker_launch` +//! ([`worker_launched`]). + +mod prepare; +mod ready; + +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use prepare::prepare_snapshot_restore_for_config; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use ready::connect_restore_ready_sink; +pub(crate) use ready::restore_ready_sink; + +use crate::Options; +use crate::microvm::MicrovmLaunch; +use anyhow::Context; +use mesh::payload::message::ProtobufMessage; +use openvmm_defs::profile::ProfileSpan; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_helpers::snapshot::restore::OpenedSnapshot; + +/// The snapshot restore of a VM launched from the command line. +pub(crate) struct SnapshotRestore { + /// The opened snapshot generation, until [`Self::prepare`] takes it. + snapshot: Option, + /// The restore inputs of the VM worker, recorded by [`Self::prepare`]. + worker: WorkerRestore, +} + +/// Restore inputs of the VM worker beyond its guest RAM and saved state. +#[derive(Default)] +pub(crate) struct WorkerRestore { + /// Whether writes to the worker's guest RAM must remain private to this VM. + pub(crate) shared_memory_copy_on_write: bool, + /// Snapshot generation handles that must outlive the restored VM. + pub(crate) guards: Option, + /// The time ABI inputs of a restored microVM, validated by the + /// controller's restore preflight. + pub(crate) time: Option, +} + +impl SnapshotRestore { + /// Opens the `--restore-snapshot` generation, if any. + pub(crate) fn open(opt: &Options) -> anyhow::Result { + let artifact_open = ProfileSpan::start(); + let snapshot = opt + .restore_snapshot + .as_deref() + .map(OpenedSnapshot::open) + .transpose()?; + if opt.restore_snapshot.is_some() { + let counters = if openvmm_defs::profile::enabled() { + snapshot + .as_ref() + .and_then(|snapshot| snapshot.artifact_size_counters().ok()) + .map(|(logical_bytes, allocated_bytes)| { + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(logical_bytes), + allocated_bytes: Some(allocated_bytes), + ..Default::default() + } + }) + .unwrap_or_default() + } else { + Default::default() + }; + artifact_open.complete("restore", "artifact_open", counters); + } + Ok(Self { + snapshot, + worker: WorkerRestore::default(), + }) + } + + /// Returns the opened snapshot generation, until [`Self::prepare`] takes + /// it. + pub(crate) fn snapshot(&self) -> Option<&OpenedSnapshot> { + self.snapshot.as_ref() + } + + /// Validates the opened snapshot generation against the VM configuration + /// and prepares it for the VM worker. + /// + /// Returns the worker's copy-on-write guest RAM and saved state, and + /// records the worker's other restore inputs for [`Self::into_worker`]. + pub(crate) fn prepare( + &mut self, + opt: &Options, + microvm: &mut MicrovmLaunch, + expected_hypervisor: &str, + ) -> anyhow::Result<(SharedMemoryFd, ProtobufMessage)> { + let prepared = prepare::prepare_snapshot_restore( + self.snapshot + .take() + .context("snapshot restore is missing its opened generation")?, + opt, + microvm, + expected_hypervisor, + )?; + self.worker.shared_memory_copy_on_write = true; + self.worker.guards = Some(prepared.guards); + if let Some(time) = prepared.restore_time { + let restore_record = microvm + .take_restore_time_record() + .context("time ABI restore is missing its restore packet")?; + let packet_selected = microvm.take_restore_packet_selected(); + self.worker.time = Some(time.into_input(restore_record, packet_selected)); + } + Ok((prepared.shared_memory, prepared.saved_state)) + } + + /// Returns the restore inputs of the VM worker: the defaults of a VM that + /// is not restored, or those recorded by [`Self::prepare`]. + pub(crate) fn into_worker(self) -> WorkerRestore { + self.worker + } +} + +/// Records the `startup/worker_launch` profile milestone once the VM worker +/// has launched. +pub(crate) fn worker_launched(worker_launch: ProfileSpan) { + worker_launch.complete_milestone("startup", "worker_launch", Default::default()); +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs new file mode 100644 index 00000000000..3a79741f757 --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation of an opened snapshot generation against the VM configuration, +//! and preparation of the copy-on-write guest RAM and saved state it restores. + +use crate::Options; +use crate::microvm; +use anyhow::Context; + +/// An opened snapshot generation, validated against the VM configuration and +/// prepared for the VM worker. +pub(crate) struct PreparedSnapshotRestore { + pub(crate) shared_memory: openvmm_defs::worker::SharedMemoryFd, + pub(crate) guards: openvmm_defs::worker::SnapshotRestoreGuards, + pub(crate) saved_state: mesh::payload::message::ProtobufMessage, + /// The time ABI restore of a microVM. + pub(crate) restore_time: Option, +} + +/// Validate an opened snapshot generation against the current VM config. +/// Returns the shared memory handle, lifetime guards, and saved device state. +pub(super) fn prepare_snapshot_restore( + snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, + opt: &Options, + microvm: µvm::MicrovmLaunch, + expected_hypervisor: &str, +) -> anyhow::Result { + let base_memory_size = snapshot.manifest().memory_size_bytes; + let expected_microvm_contract = + microvm.expected_restore_contract(opt, snapshot.manifest(), expected_hypervisor)?; + let hooks = virt::time_abi::TimeAbiTestHooks::parse(&opt.microvm.x_time_abi_test_hook)?; + let time_abi = microvm::TimeAbiRestoreOptions { + cpu_profile: opt.microvm.cpu_profile.as_deref().unwrap_or("auto"), + hooks: &hooks, + }; + prepare_snapshot_restore_for_config( + snapshot, + base_memory_size, + opt.memory_size(), + opt.processors, + expected_microvm_contract.map(|contract| (contract, time_abi)), + ) +} + +/// Validates an opened snapshot generation against a VM configuration and +/// prepares it for the VM worker. A microVM restore passes the contract the +/// snapshot must match and the options of its time ABI restore. +pub(crate) fn prepare_snapshot_restore_for_config( + snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, + expected_memory_size: u64, + selected_memory_size: u64, + expected_vp_count: u32, + microvm: Option<( + microvm::ExpectedRestoreContract<'_>, + microvm::TimeAbiRestoreOptions<'_>, + )>, +) -> anyhow::Result { + let artifact_prepare = openvmm_defs::profile::ProfileSpan::start(); + let manifest = snapshot.manifest(); + // Validate manifest against current VM config. + openvmm_helpers::snapshot::validate_manifest( + manifest, + crate::GUEST_ARCH, + expected_memory_size, + expected_vp_count, + crate::system_page_size(), + )?; + let restore_time = microvm + .map(|(contract, time_abi)| { + microvm::validate_restore_contract( + manifest, + expected_memory_size, + expected_vp_count, + contract, + time_abi, + ) + }) + .transpose()?; + + // The manifest and state.bin inventories describe the same machine boundary. + // Require them to agree before worker and partition construction. + let state_msg: mesh::payload::message::ProtobufMessage = + mesh::payload::decode(snapshot.state_bytes()) + .context("failed to decode saved state from snapshot")?; + if let Some(contract) = &manifest.machine_contract { + let inventory_msg: mesh::payload::message::ProtobufMessage = + mesh::payload::decode(snapshot.state_bytes()) + .context("failed to decode saved state inventory from snapshot")?; + let saved_state: openvmm_defs::worker::SavedState = inventory_msg + .parse() + .context("failed to parse saved state inventory from snapshot")?; + anyhow::ensure!( + saved_state.inventory == contract.state_unit_names, + "snapshot manifest state-unit inventory does not match state.bin" + ); + } + + snapshot.claim_for_restore()?; + + artifact_prepare.complete( + "restore", + "artifact_prepare", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(selected_memory_size), + ..Default::default() + }, + ); + + // Create the private mapping from a duplicate of the exact opened handle. + // The original file and directory handles move to the worker and keep this + // generation pinned until VM teardown. + let cow_section_create = openvmm_defs::profile::ProfileSpan::start(); + let memory_file = snapshot.duplicate_memory_file_for_mapping(expected_memory_size)?; + let shared_memory = + openvmm_helpers::shared_memory::file_to_copy_on_write_memory_fd(memory_file)?; + cow_section_create.complete( + "restore", + "cow_section_create", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(expected_memory_size), + ..Default::default() + }, + ); + snapshot.validate_memory_generation(expected_memory_size)?; + let (_, _, guards) = snapshot.into_parts(); + + Ok(PreparedSnapshotRestore { + shared_memory, + guards, + saved_state: state_msg, + restore_time, + }) +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/ready.rs b/openvmm/openvmm_entry/src/snapshot_restore/ready.rs new file mode 100644 index 00000000000..41cad3e2518 --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/ready.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The restore readiness endpoint: a single-use, process-local sink to which +//! the VM worker writes its restore readiness event when the restored VM first +//! starts. + +use crate::Options; +use anyhow::Context; +use std::fs::File; +use std::io; +use std::path::Path; + +/// Connects the `--restore-ready-path` endpoint, if one is configured. +pub(crate) fn restore_ready_sink(opt: &Options) -> anyhow::Result> { + opt.restore_ready_path + .as_deref() + .map(connect_restore_ready_sink) + .transpose() + .context("failed to connect restore readiness endpoint") +} + +/// Connects a single-use restore-readiness event sink. +pub(crate) fn connect_restore_ready_sink(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::OwnedFd; + + let socket = unix_socket::UnixStream::connect(path)?; + Ok(File::from(OwnedFd::from(socket))) + } + + #[cfg(windows)] + { + const NAMED_PIPE_PREFIX: &str = "//./pipe/"; + + let normalized = path.to_string_lossy().replace('\\', "/"); + if !normalized.starts_with(NAMED_PIPE_PREFIX) || normalized.len() == NAMED_PIPE_PREFIX.len() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "restore readiness path must name a Windows //./pipe/... endpoint", + )); + } + std::fs::OpenOptions::new().write(true).open(path) + } +} diff --git a/openvmm/openvmm_entry/src/storage_builder.rs b/openvmm/openvmm_entry/src/storage_builder.rs index 454c256266f..08eb74621f5 100644 --- a/openvmm/openvmm_entry/src/storage_builder.rs +++ b/openvmm/openvmm_entry/src/storage_builder.rs @@ -37,6 +37,8 @@ use vtl2_settings_proto::Lun; use vtl2_settings_proto::StorageController; use vtl2_settings_proto::storage_controller; +pub(crate) mod microvm; + /// Namespace GUID for deriving deterministic GUIDs from controller names. /// This is hashed together with the name via SHA-256 to produce a UUIDv8. const OPENVMM_CONTROLLER_NS: Guid = guid::guid!("a3f1e2d4-5b6c-4a8d-9e0f-1234567890ab"); @@ -133,12 +135,14 @@ pub(super) struct StorageBuilder { underhill_scsi_luns: Vec, underhill_nvme_luns: Vec, vtl0_virtio_blk_disks: Vec, + microvm_image_slots: Option, openhcl_vtl: Option, } struct VirtioBlkDisk { disk: Resource, read_only: bool, + microvm: Option, } #[derive(Clone)] @@ -198,6 +202,7 @@ impl StorageBuilder { underhill_scsi_luns: Vec::new(), underhill_nvme_luns: Vec::new(), vtl0_virtio_blk_disks: Vec::new(), + microvm_image_slots: None, openhcl_vtl, } } @@ -513,7 +518,11 @@ impl StorageBuilder { if is_dvd { anyhow::bail!("dvd not supported with virtio-blk"); } - let vblk = VirtioBlkDisk { disk, read_only }; + let vblk = VirtioBlkDisk { + disk, + read_only, + microvm: None, + }; if let Some(port) = pcie_port { self.pcie_virtio_blk_disks.push((port, vblk)); } else { @@ -880,6 +889,7 @@ impl StorageBuilder { resources.nvme_vtl2_rpc = Some(send); } + self.build_microvm_sandbox_blocks(config, resources)?; for (i, vblk) in std::mem::take(&mut self.vtl0_virtio_blk_disks) .into_iter() .enumerate() diff --git a/openvmm/openvmm_entry/src/storage_builder/microvm.rs b/openvmm/openvmm_entry/src/storage_builder/microvm.rs new file mode 100644 index 00000000000..4b0e5440672 --- /dev/null +++ b/openvmm/openvmm_entry/src/storage_builder/microvm.rs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixed-role microVM sandbox block devices. + +use super::StorageBuilder; +use super::VirtioBlkDisk; +use crate::VmResources; +use crate::cli_args::DiskCliKind; +use crate::cli_args::microvm::SnapshotBlockIdentityCli; +use crate::disk_open; +use anyhow::Context; +use mesh::CellUpdater; +use openvmm_defs::config::Config; +use openvmm_defs::config::VirtioBus; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmSandboxBlockConfig; +use openvmm_defs::microvm::MicrovmSandboxBlockRole; +use std::time::Duration; +use virtio_resources::blk::VirtioBlkHandle; +use virtio_resources::blk::VirtioBlkImageSlotHandle; +use vm_resource::IntoResource; +use vm_resource::Resource; + +/// The fixed role of a microVM sandbox block device. +pub(super) struct SandboxBlock { + role: MicrovmSandboxBlockRole, + snapshot_source: Option, +} + +pub(crate) struct MicrovmSandboxBlockSource { + pub(crate) role: MicrovmSandboxBlockRole, + pub(crate) read_only: bool, + pub(crate) length: u64, + pub(crate) logical_block_size: u32, + pub(crate) physical_block_size: u32, + pub(crate) file: std::fs::File, +} + +pub(crate) fn capture_snapshot_block_contract( + sources: &[MicrovmSandboxBlockSource], + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + identity_mode: SnapshotBlockIdentityCli, + generation_id: Option<[u8; 16]>, + scratch_restore_mode: &str, +) -> anyhow::Result> { + sources + .iter() + .map(|source| { + if !source.read_only + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + source.file.sync_all().with_context(|| { + format!("failed to flush microVM {} block", source.role.as_str()) + })?; + } + let (identity_kind, identity, artifact) = if source.role + == MicrovmSandboxBlockRole::Scratch + && scratch_policy == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + { + ("fresh", Vec::new(), String::new()) + } else { + let (identity_kind, identity) = match identity_mode { + SnapshotBlockIdentityCli::Sha256 => ( + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256, + openvmm_helpers::snapshot::fs::file_sha256( + &source.file, + source.length, + &format!("microVM {} block", source.role.as_str()), + )?, + ), + SnapshotBlockIdentityCli::Generation => ( + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION, + generation_id + .context("generation identity requires a snapshot generation ID")? + .to_vec(), + ), + }; + let artifact = if source.role == MicrovmSandboxBlockRole::Scratch { + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() + } else { + String::new() + }; + (identity_kind, identity, artifact) + }; + Ok( + openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock { + role: source.role.as_str().to_owned(), + read_only: source.read_only, + length: source.length, + identity_kind: identity_kind.to_owned(), + identity, + artifact, + logical_block_size: source.logical_block_size, + physical_block_size: source.physical_block_size, + restore_mode: if source.role == MicrovmSandboxBlockRole::Scratch + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + scratch_restore_mode.to_owned() + } else { + String::new() + }, + }, + ) + }) + .collect() +} + +pub(crate) fn restore_snapshot_block_contract( + sources: &[MicrovmSandboxBlockSource], + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + saved_blocks: &[openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock], +) -> anyhow::Result> { + sources + .iter() + .map(|source| { + let saved = saved_blocks + .iter() + .find(|block| block.role == source.role.as_str()) + .with_context(|| { + format!( + "snapshot is missing the microVM {} block contract", + source.role.as_str() + ) + })?; + let identity = match saved.identity_kind.as_str() { + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + openvmm_helpers::snapshot::fs::file_sha256( + &source.file, + source.length, + &format!("microVM {} block", source.role.as_str()), + )? + } + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION + | "unbound" + | "fresh" => saved.identity.clone(), + other => anyhow::bail!( + "snapshot microVM {} block identity kind '{other}' is unsupported", + source.role.as_str() + ), + }; + Ok( + openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock { + role: source.role.as_str().to_owned(), + read_only: source.read_only, + length: source.length, + identity_kind: saved.identity_kind.clone(), + identity, + artifact: if source.role == MicrovmSandboxBlockRole::Scratch + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() + } else { + String::new() + }, + logical_block_size: source.logical_block_size, + physical_block_size: source.physical_block_size, + restore_mode: saved.restore_mode.clone(), + }, + ) + }) + .collect() +} + +impl StorageBuilder { + /// Declares the fixed image-slot capacity for the microVM profile. + pub fn add_microvm_image_slots( + &mut self, + image_slots: Option, + ) -> anyhow::Result<()> { + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } + anyhow::ensure!( + self.microvm_image_slots.is_none(), + "microVM image slots were configured more than once" + ); + self.microvm_image_slots = image_slots; + Ok(()) + } + + /// Adds a fixed-role microVM sandbox block device. + pub async fn add_microvm_sandbox_block( + &mut self, + role: MicrovmSandboxBlockRole, + kind: &DiskCliKind, + read_only: bool, + snapshot_capable: bool, + ) -> anyhow::Result<()> { + if !snapshot_capable { + self.vtl0_virtio_blk_disks.push(VirtioBlkDisk { + disk: disk_open(kind, read_only).await?, + read_only, + microvm: Some(SandboxBlock { + role, + snapshot_source: None, + }), + }); + return Ok(()); + } + let (kind, delay_ms) = match kind { + DiskCliKind::DelayDiskWrapper { delay_ms, disk } => (disk.as_ref(), Some(*delay_ms)), + kind => (kind, None), + }; + let DiskCliKind::File { + path, + create_with_len, + direct, + } = kind + else { + anyhow::bail!("snapshot-capable microVM sandbox blocks require a plain file backend"); + }; + anyhow::ensure!( + !direct, + "snapshot-capable microVM sandbox blocks do not support direct I/O" + ); + anyhow::ensure!( + !matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("vhd" | "vhdx" | "vmgs" | "iso") + ), + "snapshot-capable microVM sandbox blocks require an unformatted raw file backend" + ); + + let mut options = std::fs::OpenOptions::new(); + options + .read(true) + .write(!read_only || create_with_len.is_some()); + if create_with_len.is_some() { + options.create(true).truncate(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); + } + let file = options + .open(path) + .with_context(|| format!("failed to open sandbox block {}", path.display()))?; + if let Some(length) = create_with_len { + file.set_len(*length) + .with_context(|| format!("failed to size sandbox block {}", path.display()))?; + } + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect sandbox block {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "microVM sandbox block is not a regular file: {}", + path.display() + ); + let length = metadata.len(); + anyhow::ensure!( + length != 0 && length % 512 == 0, + "microVM sandbox block length must be nonzero and 512-byte aligned" + ); + let logical_block_size: u32 = 512; + #[cfg(target_os = "linux")] + let physical_block_size = { + use std::os::unix::fs::MetadataExt as _; + u32::try_from(metadata.blksize()) + .context("sandbox block filesystem block size does not fit in u32")? + }; + #[cfg(windows)] + let physical_block_size: u32 = 4096; + #[cfg(not(any(target_os = "linux", windows)))] + let physical_block_size = logical_block_size; + anyhow::ensure!( + physical_block_size >= logical_block_size && physical_block_size.is_power_of_two(), + "microVM sandbox block has unsupported physical block geometry" + ); + let disk_file = file + .try_clone() + .context("failed to duplicate microVM sandbox block handle")?; + #[cfg(target_os = "linux")] + let disk = Resource::new(disk_backend_resources::BlockDeviceDiskHandle { file: disk_file }); + #[cfg(not(target_os = "linux"))] + let disk = Resource::new(disk_backend_resources::FileDiskHandle(disk_file)); + let disk = if let Some(delay_ms) = delay_ms { + Resource::new(disk_backend_resources::DelayDiskHandle { + disk, + delay: CellUpdater::new(Duration::from_millis(delay_ms)).cell(), + }) + } else { + disk + }; + self.vtl0_virtio_blk_disks.push(VirtioBlkDisk { + disk, + read_only, + microvm: Some(SandboxBlock { + role, + snapshot_source: Some(MicrovmSandboxBlockSource { + role, + read_only, + length, + logical_block_size, + physical_block_size, + file, + }), + }), + }); + Ok(()) + } + + /// Adds the sandbox block devices of a microVM at their fixed virtio-mmio + /// slots, in role order. + pub(super) fn build_microvm_sandbox_blocks( + &mut self, + config: &mut Config, + resources: &mut VmResources, + ) -> anyhow::Result<()> { + if config.machine_profile != MachineProfile::Microvm { + return Ok(()); + } + let mut vtl0_virtio_blk_disks = std::mem::take(&mut self.vtl0_virtio_blk_disks); + anyhow::ensure!( + vtl0_virtio_blk_disks + .iter() + .all(|disk| disk.microvm.is_some()), + "microVM requires roles for every virtio-blk device" + ); + vtl0_virtio_blk_disks.sort_by_key(|disk| disk.microvm.as_ref().map(|block| block.role)); + for mut vblk in vtl0_virtio_blk_disks { + if let Some(block) = vblk.microvm.take() { + config + .microvm + .sandbox_blocks + .push(MicrovmSandboxBlockConfig { + role: block.role, + read_only: vblk.read_only, + }); + if let Some(source) = block.snapshot_source { + resources.microvm.sandbox_block_sources.push(source); + } + } + config.virtio_devices.push(( + VirtioBus::Mmio, + VirtioBlkHandle { + disk: vblk.disk, + read_only: vblk.read_only, + } + .into_resource(), + )); + } + config.microvm.image_slots = self.microvm_image_slots; + resources.microvm.image_slot_requests.clear(); + if let Some(image_slots) = self.microvm_image_slots { + for index in 0..openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY { + let (requests, receiver) = mesh::channel(); + let active = index < image_slots.active_count; + config.virtio_devices.push(( + VirtioBus::Mmio, + VirtioBlkImageSlotHandle { + active, + requests: receiver, + } + .into_resource(), + )); + resources + .microvm + .image_slot_requests + .push(active.then_some(requests)); + } + } + Ok(()) + } +} diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs new file mode 100644 index 00000000000..02527322165 --- /dev/null +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -0,0 +1,1860 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM-specific support for the management RPC endpoint. + +use super::VmLifecycle; +use super::grpc_error; +use crate::cli_args::SerialConfigCli; +use crate::serial_io::connect::bind_serial_without_cleanup; +use crate::vm_controller::MicrovmController; +use crate::vm_controller::VmControllerEvent; +use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; +use memory_range::MemoryRange; +use openvmm_defs::config::ArchTopologyConfig; +use openvmm_defs::config::Config; +use openvmm_defs::config::LoadMode; +use openvmm_defs::config::MemoryConfig; +use openvmm_defs::config::NumaNode; +use openvmm_defs::config::NumaTopology; +use openvmm_defs::config::VpAssignment; +use openvmm_defs::config::X86TopologyConfig; +use openvmm_defs::microvm::MICROVM_ABI_VERSION_2; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::build_microvm_command_line; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_ttrpc_vmservice as vmservice; +use serial_core::resources::DisconnectedSerialBackendHandle; +use std::fs::File; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vm_manifest_builder::BaseChipsetType; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; +use vmotherboard::ChipsetDeviceHandle; + +type SerialPorts = [Option>; 4]; + +struct AuthoritativeRestore { + path: PathBuf, + snapshot: Option, + base_memory_size: u64, + selected_memory_size: u64, + restore_memory_target_requested: bool, + restore_memory_ranges: Vec, + vp_count: u32, + machine_contract: openvmm_helpers::snapshot::microvm::SnapshotMachineContract, +} + +struct RestoredFilesystem { + config: openvmm_defs::microvm::MicrovmFilesystemConfig, + root_path: String, + attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +/// Formats a create request without its host-control capability, which +/// authenticates a host-only endpoint and must never reach logs. +pub(super) struct RedactedCreateVmRequest<'a>(pub(super) &'a vmservice::CreateVmRequest); + +impl std::fmt::Debug for RedactedCreateVmRequest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut request = self.0.clone(); + let redacted = request + .microvm_snapshot + .as_mut() + .is_some_and(|config| !std::mem::take(&mut config.host_control_capability).is_empty()); + std::fmt::Debug::fmt(&request, f)?; + if redacted { + f.write_str(" (host_control_capability redacted)")?; + } + Ok(()) + } +} + +/// MicroVM state accumulated while a management-RPC VM is being created. +pub(super) struct CreateVm { + active: bool, + machine_profile: MachineProfile, + processor_count: u32, + authoritative_restore: Option, + restore_console_config: Option, + restore_filesystem_config: Option, + restored_filesystem: Option, + snapshot_destination: Option, + restore_online_vp_count: Option, + image_slots: Option, + restore_active_image_slot_count: Option, + host_control_path: Option, + host_control_capability: Option<[u8; 32]>, + restore_gate_timeout: Duration, + snapshot_quiesce_timeout: Duration, + memory_capacity_bytes: u64, + restore_ready_path: String, + source_hypervisor: String, + shared_memory: Option, + snapshot_restore_guards: Option, + saved_state: Option, + /// The time ABI restore validated by the restore preflight. + restore_time: Option, + snapshot_notify: Option>, + snapshot_ready: Option>, + snapshot_requests: Option>, + portb: Option>, + portb_has_output: bool, + resources: crate::microvm::MicrovmResources, + filesystem_slot: bool, + filesystems: Vec, + effective_command_line: Option, + snapshot_memory_file: Option, + snapshot_memory_handle: Option, + snapshot_memory_path: Option, + memory_capacity: Option, +} + +impl CreateVm { + /// Parses the microVM portion of a create request and returns the effective + /// VM configuration. + pub(super) fn new( + request: vmservice::CreateVmRequest, + ) -> anyhow::Result<(Self, vmservice::VmConfig)> { + let vmservice::CreateVmRequest { + config: requested_config, + microvm_snapshot, + .. + } = request; + let vmservice::MicrovmSnapshotConfig { + destination_path, + restore_path, + restore_entropy, + quiesce_timeout_ms, + restore_ready_path, + restore_processor_count, + restore_gate_timeout_ms, + memory_capacity_bytes, + restore_memory_bytes, + image_slots, + image_slot_boot_count, + restore_image_slot_count, + host_control_path, + host_control_capability, + } = microvm_snapshot.unwrap_or_default(); + let restore_online_vp_count = + (restore_processor_count != 0).then_some(restore_processor_count); + let image_slot_boot_count = u8::try_from(image_slot_boot_count) + .context("image_slot_boot_count does not fit in u8")?; + anyhow::ensure!( + image_slots || image_slot_boot_count == 0, + "image_slot_boot_count requires image_slots" + ); + let restore_active_image_slot_count = (restore_image_slot_count != 0) + .then(|| u8::try_from(restore_image_slot_count)) + .transpose() + .context("restore_image_slot_count does not fit in u8")?; + let mut image_slots = + image_slots.then_some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: if image_slot_boot_count == 0 { + 1 + } else { + image_slot_boot_count + }, + active_count: if image_slot_boot_count == 0 { + 1 + } else { + image_slot_boot_count + }, + }); + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } + let host_control_path = + (!host_control_path.is_empty()).then(|| PathBuf::from(host_control_path)); + let host_control_capability = + if host_control_capability.is_empty() { + None + } else { + Some(host_control_capability.try_into().map_err(|_| { + anyhow::anyhow!("host_control_capability must be exactly 32 bytes") + })?) + }; + let restore_gate_timeout = Duration::from_millis(if restore_gate_timeout_ms == 0 { + 60_000 + } else { + restore_gate_timeout_ms + }); + let resolve_path = |value: String| -> anyhow::Result> { + if value.is_empty() { + return Ok(None); + } + let path = PathBuf::from(value); + if path.is_absolute() { + Ok(Some(path)) + } else { + Ok(Some( + std::env::current_dir() + .context("failed to resolve current directory")? + .join(path), + )) + } + }; + let snapshot_destination = resolve_path(destination_path)?; + let restore_path = resolve_path(restore_path)?; + anyhow::ensure!( + snapshot_destination.is_none() || restore_path.is_none(), + "snapshot capture and restore paths are mutually exclusive" + ); + anyhow::ensure!( + !restore_entropy || restore_path.is_some(), + "restore_entropy requires restore_path" + ); + anyhow::ensure!( + restore_online_vp_count.is_none() || restore_path.is_some(), + "restore_processor_count requires restore_path" + ); + anyhow::ensure!( + restore_active_image_slot_count.is_none() || restore_path.is_some(), + "restore_image_slot_count requires restore_path" + ); + anyhow::ensure!( + image_slots.is_none() || restore_path.is_none(), + "image_slots configures cold boot and cannot be combined with restore_path" + ); + anyhow::ensure!( + host_control_path.is_some() == host_control_capability.is_some(), + "host_control_path and host_control_capability must be supplied together" + ); + anyhow::ensure!( + image_slots.is_none() || host_control_path.is_some(), + "image slots require host control" + ); + anyhow::ensure!( + restore_gate_timeout_ms == 0 || restore_path.is_some(), + "restore_gate_timeout_ms requires restore_path" + ); + anyhow::ensure!( + memory_capacity_bytes == 0 || snapshot_destination.is_some(), + "memory_capacity_bytes requires destination_path" + ); + anyhow::ensure!( + restore_memory_bytes == 0 || restore_path.is_some(), + "restore_memory_bytes requires restore_path" + ); + anyhow::ensure!( + restore_ready_path.is_empty() || restore_path.is_some(), + "restore_ready_path requires restore_path" + ); + anyhow::ensure!( + quiesce_timeout_ms == 0 || snapshot_destination.is_some(), + "quiesce_timeout_ms requires destination_path" + ); + let snapshot_quiesce_timeout = Duration::from_millis(if quiesce_timeout_ms == 0 { + 5_000 + } else { + quiesce_timeout_ms + }); + + let authoritative_restore = if let Some(path) = restore_path { + let snapshot = openvmm_helpers::snapshot::restore::OpenedSnapshot::open(&path)?; + let manifest = snapshot.manifest(); + openvmm_helpers::snapshot::validate_manifest( + manifest, + crate::GUEST_ARCH, + manifest.memory_size_bytes, + manifest.vp_count, + crate::system_page_size(), + )?; + let machine_contract = + openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; + anyhow::ensure!( + machine_contract.machine_profile == "microvm", + "snapshot machine profile is not microvm" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract( + machine_contract, + )?; + validate_restore_console_attachments(&machine_contract.attachments)?; + anyhow::ensure!( + machine_contract.microvm_sandbox_blocks.is_empty(), + "TTRPC restore does not support microVM sandbox-block snapshots" + ); + let base_memory_size = manifest.memory_size_bytes; + let restore_memory_target_requested = restore_memory_bytes != 0; + let selected_memory_size = if restore_memory_target_requested { + restore_memory_bytes + } else { + base_memory_size + }; + let restore_memory_ranges = if restore_memory_target_requested { + openvmm_helpers::snapshot::microvm::validate_restore_memory_target( + manifest, + selected_memory_size, + )? + } else { + Vec::new() + }; + let vp_count = manifest.vp_count; + if let Some(restore_online_vp_count) = restore_online_vp_count { + openvmm_helpers::snapshot::microvm::validate_restore_online_vp_count( + manifest, + restore_online_vp_count, + )?; + } + if machine_contract.microvm_image_slot_capacity != 0 { + let active_count = restore_active_image_slot_count + .unwrap_or(machine_contract.boot_active_image_slot_count); + openvmm_helpers::snapshot::microvm::validate_restore_active_image_slot_count( + manifest, + active_count, + )?; + anyhow::ensure!( + host_control_path.is_some(), + "restoring image slots requires host control" + ); + image_slots = Some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: machine_contract.boot_active_image_slot_count, + active_count, + }); + } else { + anyhow::ensure!( + restore_active_image_slot_count.is_none(), + "snapshot does not declare image slots" + ); + } + let machine_contract = machine_contract.clone(); + Some(AuthoritativeRestore { + path, + snapshot: Some(snapshot), + base_memory_size, + selected_memory_size, + restore_memory_target_requested, + restore_memory_ranges, + vp_count, + machine_contract, + }) + } else { + None + }; + + let mut restore_console_config = None; + let mut restore_filesystem_config = None; + let req_config = if let Some(restore) = &authoritative_restore { + let restore_profile = + ttrpc_machine_profile(restore.machine_contract.microvm_abi_version)?; + let requested = requested_config.unwrap_or_else(|| vmservice::VmConfig { + machine_profile: restore_profile as i32, + ..Default::default() + }); + anyhow::ensure!( + requested.machine_profile == restore_profile as i32, + "microVM snapshot restore requires the matching microVM ABI profile" + ); + anyhow::ensure!( + requested.memory_config.is_none() + && requested.boot_config.is_none() + && requested.windows_options.is_none() + && requested.hvsocket_config.is_none() + && requested.numa_config.is_none() + && requested.pcie.is_none(), + "restore configuration may contain only processor identity, serial attachments, and guest power actions" + ); + if let Some(processors) = &requested.processor_config { + anyhow::ensure!( + processors.processor_count == restore.vp_count + && processors.processor_weight == 0 + && processors.processor_limit == 0, + "restore processor count does not match the snapshot" + ); + } + if let Some(devices) = &requested.devices_config { + anyhow::ensure!( + devices.scsi_disks.is_empty() + && devices.vpmem_disks.is_empty() + && devices.nic_config.is_empty() + && devices.windows_device.is_empty() + && devices.virtiofs_config.len() <= 1, + "restore devices_config may contain only one virtio-fs and one virtio-console attachment" + ); + restore_console_config = devices.virtio_console.clone(); + restore_filesystem_config = devices.virtiofs_config.first().cloned(); + } + vmservice::VmConfig { + serial_config: requested.serial_config, + guest_power_actions: requested.guest_power_actions, + machine_profile: restore_profile as i32, + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + Default::default(), + )), + ..Default::default() + } + } else { + requested_config.context("missing configuration")? + }; + + let requested_profile = + vmservice::vm_config::MachineProfile::from_i32(req_config.machine_profile) + .with_context(|| { + format!("unknown machine profile {}", req_config.machine_profile) + })?; + let machine_profile = openvmm_machine_profile(requested_profile); + let active = machine_profile == MachineProfile::Microvm; + let processor_count = authoritative_restore + .as_ref() + .map(|restore| restore.vp_count) + .or_else(|| { + req_config + .processor_config + .as_ref() + .map(|config| config.processor_count) + }) + .unwrap_or(1); + anyhow::ensure!( + active || (snapshot_destination.is_none() && authoritative_restore.is_none()), + "microVM snapshot options require the microVM machine profile" + ); + + Ok(( + Self { + active, + machine_profile, + processor_count, + authoritative_restore, + restore_console_config, + restore_filesystem_config, + restored_filesystem: None, + snapshot_destination, + restore_online_vp_count, + image_slots, + restore_active_image_slot_count, + host_control_path, + host_control_capability, + restore_gate_timeout, + snapshot_quiesce_timeout, + memory_capacity_bytes, + restore_ready_path, + source_hypervisor: String::new(), + shared_memory: None, + snapshot_restore_guards: None, + saved_state: None, + restore_time: None, + snapshot_notify: None, + snapshot_ready: None, + snapshot_requests: None, + portb: None, + portb_has_output: false, + resources: Default::default(), + filesystem_slot: active, + filesystems: Vec::new(), + effective_command_line: None, + snapshot_memory_file: None, + snapshot_memory_handle: None, + snapshot_memory_path: None, + memory_capacity: None, + }, + req_config, + )) + } + + /// Completes restore preparation after the hypervisor backend has been + /// selected. + pub(super) fn prepare_restore(&mut self, source_hypervisor: String) -> anyhow::Result<()> { + self.source_hypervisor = source_hypervisor; + if let Some(restore) = &self.authoritative_restore { + anyhow::ensure!( + restore.machine_contract.source_hypervisor == self.source_hypervisor, + "snapshot source hypervisor '{}' does not match destination '{}'", + restore.machine_contract.source_hypervisor, + self.source_hypervisor + ); + self.filesystem_slot = + crate::microvm::microvm_filesystem_slot_from_snapshot(&restore.machine_contract)?; + } + + self.restored_filesystem = if let Some(restore) = &self.authoritative_restore { + // The management API describes at most one virtio-fs attachment. + anyhow::ensure!( + restore + .machine_contract + .microvm_additional_filesystems + .is_empty(), + "ttrpc restore does not yet expose more than one microVM filesystem" + ); + let first_slot = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0]; + let saved_policy = restore.machine_contract.microvm_filesystem.as_ref(); + let saved_attachment = restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == first_slot.stable_id); + anyhow::ensure!( + saved_policy.is_some() == saved_attachment.is_some() + && (restore.machine_contract.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION + || self.filesystem_slot == saved_policy.is_some()), + "snapshot microVM filesystem slot, policy, and attachment inventories disagree" + ); + match (saved_policy, saved_attachment) { + (Some(saved_policy), Some(saved_attachment)) => { + let requested = self.restore_filesystem_config.as_ref().context( + "snapshot restore requires a fresh virtiofs_config attachment for fs:microvm0", + )?; + let config = crate::microvm::microvm_filesystem_from_snapshot(saved_policy)?; + anyhow::ensure!( + requested.tag == "microvm" + && requested.guest_mount_target == config.guest_mount_target + && requested.read_write != config.access.is_read_only() + && !requested.root_path.is_empty(), + "restore-time virtiofs_config does not match the snapshot policy" + ); + let (root_path, attachment) = crate::microvm::microvm_filesystem_attachment( + Path::new(&requested.root_path), + first_slot, + )?; + anyhow::ensure!( + !saved_policy.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + anyhow::ensure!( + root_path == saved_policy.canonical_host_path, + "restore-time filesystem canonical host path does not match the snapshot contract" + ); + anyhow::ensure!( + &attachment == saved_attachment, + "restore-time filesystem root identity does not match the snapshot attachment" + ); + Some(RestoredFilesystem { + config, + root_path, + attachment, + }) + } + (None, None) => { + if let Some(requested) = self.restore_filesystem_config.as_ref() { + anyhow::ensure!( + restore.machine_contract.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, + "snapshot does not support restore-time microVM filesystem attachment" + ); + anyhow::ensure!( + requested.tag == "microvm" && !requested.root_path.is_empty(), + "restore-time virtiofs_config does not match the fixed microVM slot" + ); + let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + requested.guest_mount_target.clone(), + if requested.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + let (root_path, attachment) = + crate::microvm::microvm_filesystem_attachment( + Path::new(&requested.root_path), + first_slot, + )?; + Some(RestoredFilesystem { + config, + root_path, + attachment, + }) + } else { + None + } + } + _ => anyhow::bail!( + "snapshot microVM filesystem policy and attachment inventories disagree" + ), + } + } else { + None + }; + + if let Some(restore) = &mut self.authoritative_restore { + anyhow::ensure!( + restore.machine_contract.microvm_network.is_none(), + "ttrpc restore does not yet expose microVM network attachments" + ); + // The management endpoint restores with the snapshot's CPU + // profile and no time ABI test hooks. + let hooks = virt::time_abi::TimeAbiTestHooks::default(); + let prepared = crate::snapshot_restore::prepare_snapshot_restore_for_config( + restore + .snapshot + .take() + .context("snapshot restore is missing its opened generation")?, + restore.base_memory_size, + restore.selected_memory_size, + restore.vp_count, + Some(( + ( + &self.source_hypervisor, + &restore.machine_contract.effective_command_line, + None, + self.restored_filesystem + .iter() + .map(|filesystem| { + ( + &filesystem.config, + Path::new(&filesystem.root_path), + &filesystem.attachment, + ) + }) + .collect(), + restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"), + None, + restore.machine_contract.microvm_sandbox_blocks.clone(), + ), + crate::microvm::TimeAbiRestoreOptions { + cpu_profile: "auto", + hooks: &hooks, + }, + )), + )?; + let restore_time = prepared + .restore_time + .context("microVM snapshot is missing its time ABI records")?; + self.shared_memory = Some(prepared.shared_memory); + self.snapshot_restore_guards = Some(prepared.guards); + self.saved_state = Some(prepared.saved_state); + self.restore_time = Some(restore_time); + } + Ok(()) + } + + /// Takes the process-local restore readiness endpoint path. + pub(super) fn take_restore_ready_path(&mut self) -> String { + std::mem::take(&mut self.restore_ready_path) + } + + /// Creates snapshot coordination channels and validates the effective + /// microVM request. + pub(super) fn finish_prepare( + &mut self, + req_config: &vmservice::VmConfig, + ) -> anyhow::Result<()> { + if self.active { + let (notify, requests) = mesh::channel(); + self.snapshot_notify = Some(notify); + self.resources.snapshot_requests = Some(requests); + let (ready, requests) = mesh::channel(); + self.snapshot_ready = Some(ready); + self.snapshot_requests = Some(requests); + } + + if self.active { + anyhow::ensure!( + cfg!(guest_arch = "x86_64"), + "microVM requires an x86-64 guest" + ); + if self.authoritative_restore.is_none() { + anyhow::ensure!( + matches!( + req_config.boot_config.as_ref(), + Some(vmservice::vm_config::BootConfig::DirectBoot(_)) + ), + "the microVM profile requires direct_boot" + ); + } + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(self.processor_count), + "microVM does not support {} vCPUs", + self.processor_count + ); + anyhow::ensure!( + req_config.numa_config.is_none(), + "microVM does not support custom NUMA topology" + ); + anyhow::ensure!(req_config.pcie.is_none(), "microVM does not support PCIe"); + anyhow::ensure!( + req_config.iommufds.is_empty(), + "microVM ABI version 1 does not support iommufd contexts" + ); + anyhow::ensure!( + req_config.hvsocket_config.is_none(), + "microVM does not support hvsocket" + ); + + let serial_ports = req_config + .serial_config + .iter() + .flat_map(|config| &config.ports) + .collect::>(); + anyhow::ensure!( + serial_ports.len() <= 1 && serial_ports.iter().all(|port| port.port == 0), + "microVM accepts only serial port 0 as its portb endpoint" + ); + if let Some(devices) = &req_config.devices_config { + anyhow::ensure!( + devices.scsi_disks.is_empty() + && devices.vpmem_disks.is_empty() + && devices.nic_config.is_empty() + && devices.windows_device.is_empty() + && devices.virtiofs_config.len() <= 1, + "microVM supports only one fixed virtio-fs and one fixed virtio-console device" + ); + if let Some(filesystem) = devices.virtiofs_config.first() { + anyhow::ensure!( + !filesystem.read_only, + "microVM virtio-fs uses read_write rather than read_only" + ); + anyhow::ensure!( + filesystem.tag == "microvm" && !filesystem.root_path.is_empty(), + "microVM virtio-fs requires tag 'microvm' and a host root path" + ); + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target.clone(), + if filesystem.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + } + if let Some(console) = &devices.virtio_console { + anyhow::ensure!( + !console.socket_path.is_empty(), + "microVM virtio-console requires a socket path" + ); + } + } + } + Ok(()) + } + + /// Returns whether the microVM machine profile is active. + pub(super) fn is_active(&self) -> bool { + self.active + } + + /// Returns the effective virtual processor count. + pub(super) fn processor_count(&self) -> u32 { + self.processor_count + } + + /// Returns whether a cold boot requested its fixed virtio-console. + pub(super) fn has_requested_console(&self, config: &vmservice::VmConfig) -> bool { + self.active + && self.authoritative_restore.is_none() + && config + .devices_config + .as_ref() + .and_then(|devices| devices.virtio_console.as_ref()) + .is_some() + } + + /// Creates the inert kernel handle used by an authoritative restore. + pub(super) fn restore_kernel(&self) -> anyhow::Result> { + if self.authoritative_restore.is_some() { + return Ok(Some( + tempfile::tempfile().context("failed to create inert restore kernel handle")?, + )); + } + Ok(None) + } + + /// Builds the effective direct-boot kernel command line. + pub(super) fn command_line( + &self, + requested: String, + has_requested_console: bool, + ) -> anyhow::Result { + if let Some(restore) = &self.authoritative_restore { + return Ok(restore.machine_contract.effective_command_line.clone()); + } + if self.active { + let mut cmdline = build_microvm_command_line(&[requested], has_requested_console)?; + openvmm_defs::microvm::append_microvm_processor_limit( + &mut cmdline, + self.processor_count, + )?; + Ok(cmdline) + } else { + Ok(requested) + } + } + + /// Returns the direct-boot mode selected by the machine profile. + pub(super) fn direct_boot_mode(&self) -> openvmm_defs::config::LinuxDirectBootMode { + if self.active { + openvmm_defs::config::LinuxDirectBootMode::MpTable + } else { + openvmm_defs::config::LinuxDirectBootMode::Acpi + } + } + + /// Returns the base chipset selected by the machine profile. + pub(super) fn base_chipset_type(&self) -> BaseChipsetType { + if self.active { + BaseChipsetType::Microvm + } else { + BaseChipsetType::HyperVGen2LinuxDirect + } + } + + /// Removes the microVM portb endpoint from the standard serial-port array. + pub(super) fn configure_serial_ports( + &mut self, + mut ports: SerialPorts, + ) -> anyhow::Result> { + if !self.active { + return Ok(Some(ports)); + } + self.portb_has_output = ports[0].is_some(); + if ports.iter().skip(1).any(Option::is_some) { + anyhow::bail!("microVM accepts only serial port 0 as its portb endpoint"); + } + self.portb = Some( + ports[0] + .take() + .unwrap_or_else(|| DisconnectedSerialBackendHandle.into_resource()), + ); + Ok(None) + } + + /// Adds the fixed microVM chipset devices to the manifest. + pub(super) fn add_chipset_devices( + &mut self, + devices: &mut Vec, + ) -> anyhow::Result<()> { + let Some(io) = self.portb.take() else { + return Ok(()); + }; + let output_drain = self.portb_has_output.then(|| { + let (drain, requests) = crate::microvm::output::MicrovmOutputDrain::new(None); + self.resources.output_drain = Some(drain); + requests + }); + let gate_required = self.restore_gate_required(); + let (generation_id, restore) = match &self.restore_time { + Some(restore_time) => { + let restore = self + .authoritative_restore + .as_ref() + .context("time ABI restore is missing its snapshot")?; + let (generation_id, base) = crate::microvm::restore_packet_base( + restore_time.contract(), + self.restore_online_vp_count, + self.restore_active_image_slot_count, + restore.restore_memory_target_requested, + &restore.restore_memory_ranges, + gate_required, + )?; + ( + generation_id, + Some(self.resources.time_abi_restore_packet(base)), + ) + } + None => (crate::microvm::fresh_microvm_generation_id()?, None), + }; + let time_abi = crate::microvm::time_abi_chipset( + devices, + &virt::time_abi::TimeAbiTestHooks::default(), + restore, + ); + devices.extend([ + ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { + io, + generation_id, + output_drain, + time_abi, + } + .into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: MicrovmSnapshotRequestHandle { + notify: self.snapshot_notify.take(), + input_gate_timeout: self.snapshot_quiesce_timeout, + } + .into_resource(), + }, + ]); + Ok(()) + } + + /// Returns the snapshot-selected NUMA topology for an authoritative + /// restore. + pub(super) fn restore_numa(&self) -> Option<(NumaTopology, u64)> { + self.authoritative_restore.as_ref().map(|restore| { + let mem_size = restore.selected_memory_size; + let numa = NumaTopology { + nodes: vec![NumaNode { + mem: Some(MemoryConfig { + mem_size, + prefetch_memory: false, + private_memory: false, + transparent_hugepages: true, + hugepages: false, + hugepage_size: None, + host_numa_node: None, + }), + vps: VpAssignment::FromTopology, + }], + distances: vec![], + }; + (numa, mem_size) + }) + } + + /// Applies machine-profile-specific values to an otherwise standard VM + /// configuration. + pub(super) fn apply_config(&self, config: &mut Config) { + config.machine_profile = self.machine_profile; + if self.active { + config.processor_topology.vps_per_socket = Some(self.processor_count); + config.processor_topology.enable_smt = Some(false); + config.processor_topology.arch = Some(ArchTopologyConfig::X86(X86TopologyConfig { + apic_id_offset: 0, + x2apic: openvmm_defs::config::X2ApicConfig::Unsupported, + })); + config.hypervisor.with_hv = false; + config.vmbus = None; + // A restore uses the snapshot's CPU profile and the next + // generation counter; a cold boot selects the host's profile. + let (cpu_profile, generation) = match &self.restore_time { + Some(restore_time) => ( + restore_time.cpu_profile().id.clone(), + restore_time.contract().capture_generation.saturating_add(1), + ), + None => ("auto".to_owned(), 0), + }; + config.microvm.time_abi = Some(openvmm_defs::time_abi::TimeAbiParameters { + cpu_profile, + generation, + hooks: Default::default(), + }); + } + config.microvm.filesystem_bootstrap = self + .authoritative_restore + .as_ref() + .is_some_and(|restore| restore.machine_contract.microvm_filesystem.is_some()); + config.microvm.memory_capacity = self + .authoritative_restore + .as_ref() + .and_then(|restore| { + (restore.machine_contract.memory_expansion_version != 0) + .then_some(restore.machine_contract.memory_capacity_bytes) + }) + .or((self.memory_capacity_bytes != 0).then_some(self.memory_capacity_bytes)); + config.microvm.snapshot_memory_ranges = self + .authoritative_restore + .as_ref() + .filter(|restore| restore.machine_contract.memory_expansion_version != 0) + .map(|restore| { + restore + .machine_contract + .memory_ranges + .iter() + .map(|range| MemoryRange::new(range.gpa_start..range.gpa_start + range.length)) + .collect() + }) + .unwrap_or_default(); + config.microvm.restore_memory_ranges = self + .authoritative_restore + .as_ref() + .map(|restore| { + restore + .restore_memory_ranges + .iter() + .map(|range| MemoryRange::new(range.gpa_start..range.gpa_start + range.length)) + .collect() + }) + .unwrap_or_default(); + } + + /// Adds devices reconstructed from an authoritative snapshot contract. + pub(super) fn add_restored_devices(&mut self, config: &mut Config) -> anyhow::Result<()> { + if let Some(filesystem) = self.restored_filesystem.take() { + self.resources.filesystem_root_paths = vec![PathBuf::from(&filesystem.root_path)]; + config.microvm.filesystems = vec![filesystem.config.clone()]; + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::HostFs { + root_path: filesystem.root_path, + mount_options: String::new(), + }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: "fs:microvm0".to_owned(), + root_identity: filesystem.attachment.identity.clone(), + read_only: filesystem.config.access.is_read_only(), + // The snapshot's access policy holds after a restore. + denied_paths: filesystem.config.denied_paths.clone(), + allowed_paths: filesystem.config.allowed_paths.clone(), + writable_paths: filesystem.config.writable_paths.clone(), + caller_identity: filesystem.config.owner.is_caller(), + }, + } + .into_resource(), + )); + self.resources.filesystem_attachments = vec![filesystem.attachment]; + } + + let Some(restore) = &self.authoritative_restore else { + return Ok(()); + }; + let has_console = restore + .machine_contract + .devices + .iter() + .any(|device| device.stable_id == "console:microvm-virtio0"); + let attachment = restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"); + anyhow::ensure!( + has_console == attachment.is_some(), + "snapshot microVM console device and attachment inventories disagree" + ); + if let Some(attachment) = attachment { + let requested_attachment = self.restore_console_config.as_ref().map(|console| { + if console.connect { + SerialConfigCli::ConnectPipe(PathBuf::from(&console.socket_path)) + } else { + SerialConfigCli::Pipe(PathBuf::from(&console.socket_path)) + } + }); + let (endpoint_config, resource_attachment, snapshot_attachment) = + crate::microvm::microvm_console_attachment_from_snapshot( + attachment, + requested_attachment.as_ref(), + )?; + crate::microvm::validate_microvm_console_attachment_namespace( + &snapshot_attachment, + &restore.path, + )?; + let (backend, disconnect_policy) = match endpoint_config { + SerialConfigCli::Pipe(path) => { + let backend = bind_serial_without_cleanup(&path).with_context(|| { + format!( + "failed to recreate virtio console listener: {}", + path.display() + ) + })?; + self.resources.console_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path)?; + (backend, VirtioConsoleDisconnectPolicy::Retain) + } + SerialConfigCli::Tcp(address) => ( + crate::serial_io::bind_tcp_serial(&address)?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::ConnectPipe(path) => ( + crate::serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { + format!( + "failed to reconnect virtio console client: {}", + path.display() + ) + })?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::ConnectTcp(address) => ( + crate::serial_io::connect::connect_tcp_serial( + &address, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + )?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::None => ( + DisconnectedSerialBackendHandle.into_resource(), + VirtioConsoleDisconnectPolicy::Discard, + ), + _ => unreachable!("saved microVM console was validated as an attachment"), + }; + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy, + attachment: Some(resource_attachment), + } + .into_resource(), + )); + self.resources.console_attachment = Some(snapshot_attachment); + } + Ok(()) + } + + /// Consumes microVM-only devices so the caller can retain the unchanged + /// standard-device construction path. + pub(super) fn take_devices( + &mut self, + config: &mut Config, + devices: &mut vmservice::DevicesConfig, + ) -> anyhow::Result<()> { + if !self.active { + return Ok(()); + } + for filesystem in std::mem::take(&mut devices.virtiofs_config) { + self.add_filesystem(config, filesystem)?; + } + if let Some(console) = devices.virtio_console.take() { + self.add_console(config, console)?; + } + Ok(()) + } + + fn add_filesystem( + &mut self, + config: &mut Config, + filesystem: vmservice::VirtioFs, + ) -> anyhow::Result<()> { + anyhow::ensure!( + config.microvm.filesystems.is_empty() + && filesystem.tag == "microvm" + && !filesystem.root_path.is_empty(), + "microVM permits one virtio-fs attachment with tag 'microvm'" + ); + let filesystem_config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target, + if filesystem.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + let (root_path, attachment) = crate::microvm::microvm_filesystem_attachment( + Path::new(&filesystem.root_path), + &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0], + )?; + self.resources.filesystem_root_paths = vec![PathBuf::from(&root_path)]; + let resource = virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::HostFs { + root_path, + mount_options: String::new(), + }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: "fs:microvm0".to_owned(), + root_identity: attachment.identity.clone(), + read_only: filesystem_config.access.is_read_only(), + denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), + caller_identity: filesystem_config.owner.is_caller(), + }, + } + .into_resource(); + if self.snapshot_destination.is_some() { + tracing::warn!( + stable_id = "fs:microvm0", + access_mode = filesystem_config.access.as_str(), + "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" + ); + } + config.microvm.filesystems = vec![filesystem_config]; + config.microvm.filesystem_bootstrap = true; + self.resources.filesystem_attachments = vec![attachment]; + config + .virtio_devices + .push((openvmm_defs::config::VirtioBus::Mmio, resource)); + Ok(()) + } + + fn add_console( + &mut self, + config: &mut Config, + console: vmservice::VirtioConsoleConfig, + ) -> anyhow::Result<()> { + if console.socket_path.is_empty() { + return Ok(()); + } + let endpoint_config = if console.connect { + SerialConfigCli::ConnectPipe(PathBuf::from(&console.socket_path)) + } else { + SerialConfigCli::Pipe(PathBuf::from(&console.socket_path)) + }; + let (endpoint_config, attachment, snapshot_attachment) = + crate::microvm::microvm_console_attachment_from_cli(&endpoint_config)?; + if let Some(snapshot_dir) = &self.snapshot_destination { + crate::microvm::validate_microvm_console_attachment_namespace( + &snapshot_attachment, + snapshot_dir, + )?; + } + let backend = match endpoint_config { + SerialConfigCli::Pipe(path) => { + let backend = bind_serial_without_cleanup(&path).with_context(|| { + format!("failed to bind virtio console socket: {}", path.display()) + })?; + self.resources.console_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path)?; + backend + } + SerialConfigCli::ConnectPipe(path) => { + crate::serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { + format!( + "failed to connect virtio console socket: {}", + path.display() + ) + })? + } + _ => unreachable!("path input produced a non-path attachment"), + }; + self.resources.console_attachment = Some(snapshot_attachment); + let resource: Resource = + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Retain, + attachment: Some(attachment), + } + .into_resource(); + config + .virtio_devices + .push((openvmm_defs::config::VirtioBus::Mmio, resource)); + Ok(()) + } + + /// Finalizes the fixed virtio-mmio device inventory and discovery command + /// line. + pub(super) fn finish_devices( + &mut self, + config: &mut Config, + driver: &pal_async::DefaultDriver, + ) -> anyhow::Result<()> { + if self.filesystem_slot + && !config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtiofs") + { + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::Dormant, + profile: virtio_resources::fs::microvm::VirtioFsProfile::MicrovmDormant { + stable_id: "fs:microvm0".to_owned(), + }, + } + .into_resource(), + )); + } + config.microvm.image_slots = self.image_slots; + if let Some(image_slots) = self.image_slots { + self.resources.image_slot_requests.clear(); + for index in 0..openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY { + let (requests, receiver) = mesh::channel(); + let active = index < image_slots.active_count; + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::blk::VirtioBlkImageSlotHandle { + active, + requests: receiver, + } + .into_resource(), + )); + self.resources + .image_slot_requests + .push(active.then_some(requests)); + } + } + if let (Some(path), Some(capability)) = ( + self.host_control_path.as_deref(), + self.host_control_capability, + ) { + let authentication = + crate::microvm::microvm_control_authentication_from_capability(capability, 5000)?; + let mut server = + crate::microvm::MicrovmHostControlServer::bind(path, driver, authentication)?; + server.set_image_slots( + self.resources.image_slot_requests.clone(), + self.resources.image_slot_transition.clone(), + ); + self.resources.host_control = + Some(std::sync::Arc::new(futures::lock::Mutex::new(server))); + self.resources.host_control_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path.to_owned())?; + } + + if self.active && self.authoritative_restore.is_none() { + let has_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + let LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } = &mut config.load_mode + else { + unreachable!("microVM was validated with direct_boot"); + }; + openvmm_defs::microvm::append_microvm_virtio_discovery( + cmdline, + None, + self.filesystem_slot, + &config.microvm.filesystems, + has_console, + config.virtio_devices.iter().any(|(_, device)| { + device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID + }), + &config.microvm.sandbox_blocks, + config.microvm.image_slots, + )?; + } + Ok(()) + } + + /// Validates the finished configuration and prepares snapshot RAM state. + pub(super) fn prepare_launch( + &mut self, + config: &Config, + config_mem_size: u64, + ) -> anyhow::Result<()> { + openvmm_defs::microvm::validate_machine_config(config, None)?; + self.effective_command_line = match &config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => Some(cmdline.clone()), + _ => None, + }; + self.filesystems.clone_from(&config.microvm.filesystems); + for root_path in &self.resources.filesystem_root_paths { + crate::microvm::validate_microvm_filesystem_private_storage( + root_path, + self.snapshot_destination.as_deref(), + self.authoritative_restore + .as_ref() + .map(|restore| restore.path.as_path()), + None, + )?; + } + self.snapshot_memory_file = if let Some(destination) = &self.snapshot_destination { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent_metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!("failed to inspect snapshot parent {}", parent.display()) + })?; + anyhow::ensure!( + parent_metadata.file_type().is_dir(), + "snapshot parent is not a directory: {}", + parent.display() + ); + anyhow::ensure!( + fs_err::symlink_metadata(destination) + .is_err_and(|error| error.kind() == io::ErrorKind::NotFound), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let file = tempfile::Builder::new() + .prefix(".openvmm-microvm-memory-") + .tempfile_in(parent) + .context("failed to create snapshot memory backing")?; + openvmm_helpers::snapshot::fs::initialize_snapshot_memory_backing_file( + file.as_file(), + config_mem_size, + ) + .context("failed to initialize snapshot memory backing")?; + Some(file) + } else { + None + }; + self.snapshot_memory_path = self + .snapshot_memory_file + .as_ref() + .map(|file| file.path().to_owned()); + self.snapshot_memory_handle = self + .snapshot_memory_file + .as_ref() + .map(|file| { + file.reopen() + .context("failed to duplicate automatic snapshot RAM handle") + }) + .transpose()?; + self.memory_capacity = config.microvm.memory_capacity; + Ok(()) + } + + /// Takes the microVM-derived VM worker launch fields. + pub(super) fn worker_fields(&mut self) -> anyhow::Result { + if self.shared_memory.is_none() + && let Some(file) = &self.snapshot_memory_handle + { + let file = file + .try_clone() + .context("failed to duplicate snapshot RAM handle for worker")?; + self.shared_memory = Some(openvmm_helpers::shared_memory::file_to_shared_memory_fd( + file, + )?); + } + let restore_gate_timeout = self + .restore_gate_required() + .then_some(self.restore_gate_timeout); + let restore_time = self + .restore_time + .take() + .map(|restore_time| -> anyhow::Result<_> { + let restore_record = self + .resources + .restore_time_record + .take() + .context("time ABI restore is missing its restore packet")?; + Ok(restore_time.into_input( + restore_record, + self.resources.restore_packet_selected.take(), + )) + }) + .transpose()?; + Ok(WorkerFields { + saved_state: self.saved_state.take(), + shared_memory: self.shared_memory.take(), + shared_memory_copy_on_write: self.authoritative_restore.is_some(), + snapshot_restore_guards: self.snapshot_restore_guards.take(), + snapshot_boundary_requests: self.resources.snapshot_requests.take(), + snapshot_ready: self.snapshot_ready.take(), + restore_time, + restore_gate_timeout, + restore_vp_count: self.restore_online_vp_count, + }) + } + + /// Returns whether a restore arms the post-restore input gate, which the + /// guest acknowledges through port `0x605`: with a restore-time VP, RAM, + /// or image-slot target. + fn restore_gate_required(&self) -> bool { + self.restore_online_vp_count.is_some() + || self.restore_active_image_slot_count.is_some() + || self + .authoritative_restore + .as_ref() + .is_some_and(|restore| !restore.restore_memory_ranges.is_empty()) + } + + /// Converts the remaining state into VM-controller launch fields. + pub(super) fn into_controller(self) -> ControllerFields { + ControllerFields { + memory_backing_file: self.snapshot_memory_path, + microvm: MicrovmController { + active: self.active, + snapshot_memory_handle: self.snapshot_memory_handle, + memory_capacity: self.memory_capacity, + snapshot_requests: self.snapshot_requests, + snapshot_destination: self.snapshot_destination, + snapshot_tier: None, + snapshot_block_identity: Default::default(), + snapshot_generation_id: None, + snapshot_scratch_restore_mode: Default::default(), + snapshot_quiesce_timeout: self.snapshot_quiesce_timeout, + source_hypervisor: self.source_hypervisor, + effective_command_line: self.effective_command_line, + resources: self.resources, + network: None, + filesystem_slot: self.filesystem_slot, + filesystems: self.filesystems, + image_slots: self.image_slots, + snapshot_memory_file: self.snapshot_memory_file, + _private_scratch_dir: None, + }, + } + } +} + +/// MicroVM-derived values passed to the VM worker. +pub(super) struct WorkerFields { + pub(super) saved_state: Option, + pub(super) shared_memory: Option, + pub(super) shared_memory_copy_on_write: bool, + pub(super) snapshot_restore_guards: Option, + pub(super) snapshot_boundary_requests: Option>, + pub(super) snapshot_ready: Option>, + pub(super) restore_time: Option, + pub(super) restore_gate_timeout: Option, + pub(super) restore_vp_count: Option, +} + +/// MicroVM-derived values passed to the VM controller. +pub(super) struct ControllerFields { + pub(super) memory_backing_file: Option, + pub(super) microvm: MicrovmController, +} + +/// Handles controller events emitted by the microVM guest-exit path. +pub(super) fn handle_exit_event( + event: VmControllerEvent, + lifecycle: &mut VmLifecycle, + wait_vm_response: &mut Option<( + mesh::CancelContext, + mesh::OneshotSender>, + )>, +) -> anyhow::Result { + match event { + VmControllerEvent::ExitRequested { code } => { + let reason = format!("guest exited with status {code}"); + tracing::info!(code, "guest halted with process status"); + *lifecycle = VmLifecycle::Halted(reason.clone()); + if code == 0 { + if let Some((_, response)) = wait_vm_response.take() { + response.send(Ok(())); + } + Ok(true) + } else { + if let Some((_, response)) = wait_vm_response.take() { + response.send(Err(grpc_error(anyhow::anyhow!(reason.clone())))); + } + Err(anyhow::anyhow!(reason)) + } + } + VmControllerEvent::ExitFailed { error } => { + *lifecycle = VmLifecycle::Halted(error.clone()); + if let Some((_, response)) = wait_vm_response.take() { + response.send(Err(grpc_error(anyhow::anyhow!(error.clone())))); + } + Err(anyhow::anyhow!(error)) + } + _ => unreachable!("only microVM exit events are delegated"), + } +} + +fn openvmm_machine_profile(profile: vmservice::vm_config::MachineProfile) -> MachineProfile { + match profile { + vmservice::vm_config::MachineProfile::Standard => MachineProfile::Standard, + vmservice::vm_config::MachineProfile::Microvm => MachineProfile::Microvm, + } +} + +fn ttrpc_machine_profile(abi_version: u32) -> anyhow::Result { + anyhow::ensure!( + abi_version == MICROVM_ABI_VERSION_2, + "unsupported microVM ABI version {abi_version}; this OpenVMM supports version {MICROVM_ABI_VERSION_2}" + ); + Ok(vmservice::vm_config::MachineProfile::Microvm) +} + +fn validate_restore_console_attachments( + attachments: &[openvmm_helpers::snapshot::microvm::SnapshotAttachment], +) -> anyhow::Result<()> { + anyhow::ensure!( + !attachments.iter().any(|attachment| { + attachment.stable_id == crate::microvm::MICROVM_CONTROL_CONSOLE_STABLE_ID + || attachment.kind == crate::microvm::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND + }), + "OpenVMM management RPC cannot restore control-console snapshots before authenticated broker activation" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::*; + use super::*; + use openvmm_defs::microvm::MachineProfile as OpenvmmMachineProfile; + use test_with_tracing::test; + + #[test] + fn rpc_service_reports_guest_exit_status() { + DefaultPool::run_with(async |driver| { + for (event, expected_error) in [ + (VmControllerEvent::ExitRequested { code: 0 }, None), + ( + VmControllerEvent::ExitRequested { code: 1 }, + Some("guest exited with status 1"), + ), + ( + VmControllerEvent::ExitFailed { + error: "console output drain timed out".to_owned(), + }, + Some("console output drain timed out"), + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let listener = UnixListener::bind(directory.path().join("rpc")).unwrap(); + let (events, event_recv) = mesh::channel(); + let (worker_send, worker_recv) = mesh::channel(); + let (response, received) = mesh::oneshot(); + let mut service = VmService { + driver: driver.clone(), + vm: None, + vm_controller: None, + vm_controller_events: Some(event_recv), + controller_task: None, + wait_vm_response: Some((mesh::CancelContext::new(), response)), + lifecycle: VmLifecycle::Running, + restore_ready_pending: false, + rpc_tasks: Vec::new(), + transport: ResolvedTransport::Auto, + registry: FdRegistry::default(), + }; + events.send(event); + let result = mesh::CancelContext::new() + .with_timeout(Duration::from_secs(5)) + .until_cancelled(service.run(listener, worker_recv)) + .await + .expect("guest exit must stop the RPC service"); + drop(worker_send); + let response = received.await.unwrap(); + if let Some(expected_error) = expected_error { + assert!(result.unwrap_err().to_string().contains(expected_error)); + assert!(response.unwrap_err().message.contains(expected_error)); + } else { + result.unwrap(); + response.unwrap(); + } + assert!(matches!(service.lifecycle, VmLifecycle::Halted(_))); + } + }); + } + + #[test] + fn ttrpc_virtio_fs_preserves_microvm_wire_fields() { + let encoded = b"\x1a\x06/share\x20\x01"; + let filesystem: vmservice::VirtioFs = mesh::payload::decode(encoded).unwrap(); + assert_eq!(filesystem.guest_mount_target, "/share"); + assert!(filesystem.read_write); + assert!(!filesystem.read_only); + assert_eq!(mesh::payload::encode(filesystem), encoded); + } + + #[test] + fn ttrpc_virtio_fs_read_only_uses_a_distinct_wire_field() { + let filesystem = vmservice::VirtioFs { + read_only: true, + ..Default::default() + }; + assert_eq!(mesh::payload::encode(filesystem), [0x28, 0x01]); + let decoded: vmservice::VirtioFs = mesh::payload::decode(&[0x28, 0x01]).unwrap(); + assert!(decoded.read_only); + assert!(!decoded.read_write); + assert!(decoded.guest_mount_target.is_empty()); + assert!(mesh::payload::decode::(&[0x18, 0x01]).is_err()); + } + + #[test] + fn ttrpc_vm_config_uses_non_conflicting_microvm_wire_fields() { + let encoded = b"\x88\x01\x02"; + let config: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert!(config.boot_config.is_none()); + assert_eq!( + config.machine_profile, + vmservice::vm_config::MachineProfile::Microvm as i32 + ); + assert!(config.crash_dump_path.is_none()); + assert_eq!(mesh::payload::encode(config), encoded); + assert!(mesh::payload::decode::(b"\x72\x00\x78\x02").is_err()); + } + + #[test] + fn ttrpc_crash_dump_uses_a_distinct_wire_field() { + let config = vmservice::VmConfig { + crash_dump_path: Some("dump".to_owned()), + ..Default::default() + }; + let encoded = b"\x82\x01\x04dump"; + assert_eq!(mesh::payload::encode(config), encoded); + let decoded: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert_eq!(decoded.crash_dump_path.as_deref(), Some("dump")); + assert!(decoded.boot_config.is_none()); + } + + #[test] + fn ttrpc_platform_flags_avoid_fork_wire_fields() { + let config = vmservice::VmConfig { + disable_vmbus: true, + disable_hv: true, + ..Default::default() + }; + let encoded = b"\xa0\x01\x01\xa8\x01\x01"; + assert_eq!(mesh::payload::encode(config), encoded); + let decoded: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert!(decoded.disable_vmbus); + assert!(decoded.disable_hv); + assert!(decoded.crash_dump_path.is_none()); + assert_eq!( + decoded.machine_profile, + vmservice::vm_config::MachineProfile::Standard as i32 + ); + assert!(mesh::payload::decode::(b"\x80\x01\x01").is_err()); + } + + #[test] + fn ttrpc_standard_virtio_fs_preserves_access_mode() { + for read_only in [false, true] { + let handle = build_virtio_fs(vmservice::VirtioFs { + tag: "share".to_owned(), + root_path: "host-root".to_owned(), + read_only, + ..Default::default() + }) + .unwrap(); + assert!(matches!( + handle.profile, + virtio_resources::fs::microvm::VirtioFsProfile::Standard + )); + let virtio_resources::fs::VirtioFsBackend::HostFs { + root_path, + mount_options, + } = handle.fs + else { + panic!("standard virtio-fs must use HostFs"); + }; + assert_eq!(root_path, "host-root"); + assert_eq!(mount_options, if read_only { "ro" } else { "" }); + } + } + + #[test] + fn ttrpc_standard_virtio_fs_rejects_microvm_mount_fields() { + for (guest_mount_target, read_write) in [("/share", false), ("", true)] { + let error = build_virtio_fs(vmservice::VirtioFs { + tag: "share".to_owned(), + root_path: "host-root".to_owned(), + guest_mount_target: guest_mount_target.to_owned(), + read_write, + ..Default::default() + }) + .err() + .expect("microVM mount fields must be rejected"); + assert!( + error + .to_string() + .contains("standard-machine virtio-fs does not accept microVM mount fields") + ); + } + } + + #[test] + fn management_restore_rejects_control_console_attachments_explicitly() { + let boot = openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: crate::microvm::MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: crate::microvm::MICROVM_CONSOLE_ATTACHMENT_KIND.to_owned(), + required: false, + reconnect_policy: "discard-while-disconnected".to_owned(), + identity_kind: "disconnected".to_owned(), + identity: b"discard".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + }; + validate_restore_console_attachments(&[]).unwrap(); + validate_restore_console_attachments(std::slice::from_ref(&boot)).unwrap(); + + let mut control = boot.clone(); + control.stable_id = crate::microvm::MICROVM_CONTROL_CONSOLE_STABLE_ID.to_owned(); + control.kind = crate::microvm::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND.to_owned(); + for policy in ["discard-while-disconnected", "recreate-listener"] { + control.reconnect_policy = policy.to_owned(); + let error = + validate_restore_console_attachments(&[boot.clone(), control.clone()]).unwrap_err(); + assert!( + error + .to_string() + .contains("management RPC cannot restore control-console snapshots") + ); + assert!( + error + .to_string() + .contains("before authenticated broker activation") + ); + } + } + + #[test] + fn ttrpc_microvm_profile_preserves_wire_identity_and_processor_policy() { + assert_eq!( + vmservice::vm_config::MachineProfile::Microvm as i32, + MICROVM_ABI_VERSION_2 as i32 + ); + assert_eq!( + openvmm_machine_profile(vmservice::vm_config::MachineProfile::Microvm), + OpenvmmMachineProfile::Microvm + ); + assert_eq!( + ttrpc_machine_profile(MICROVM_ABI_VERSION_2).unwrap(), + vmservice::vm_config::MachineProfile::Microvm + ); + assert!(vmservice::vm_config::MachineProfile::from_i32(1).is_none()); + assert!(ttrpc_machine_profile(1).is_err()); + + for processor_count in [1, 2, 4, 8] { + assert!(openvmm_defs::microvm::microvm_processor_count_supported( + processor_count + )); + } + for processor_count in [0, 3, 5, 16] { + assert!(!openvmm_defs::microvm::microvm_processor_count_supported( + processor_count + )); + } + } + + #[test] + fn create_request_debug_redacts_the_host_control_capability() { + let request = vmservice::CreateVmRequest { + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + host_control_path: "host-control".to_owned(), + host_control_capability: vec![0x5a; 32], + ..Default::default() + }), + ..Default::default() + }; + let capability = format!("{:?}", vec![0x5a_u8; 32]); + assert!(format!("{request:?}").contains(&capability)); + let redacted = format!("{:?}", RedactedCreateVmRequest(&request)); + assert!(!redacted.contains(&capability)); + assert!(redacted.contains("host-control")); + assert!(redacted.contains("host_control_capability redacted")); + + let request = vmservice::CreateVmRequest::default(); + assert_eq!( + format!("{:?}", RedactedCreateVmRequest(&request)), + format!("{request:?}") + ); + } + + #[test] + fn image_slot_boot_count_requires_image_slots() { + let error = CreateVm::new(vmservice::CreateVmRequest { + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + image_slot_boot_count: 2, + ..Default::default() + }), + ..Default::default() + }) + .err() + .expect("a boot count without image slots must be rejected"); + assert!( + error + .to_string() + .contains("image_slot_boot_count requires image_slots") + ); + } +} diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index ba14b0397f6..24ced839b03 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -8,6 +8,7 @@ // The fd-passing protocol relies on `SCM_RIGHTS` and so exists only on unix. #[cfg(unix)] mod fd_passing; +mod microvm; #[cfg(unix)] use fd_passing::FdRegistry; @@ -90,6 +91,8 @@ use pal_async::task::Task; use scsidisk_resources::SimpleScsiDiskHandle; use std::fs::File; use std::future::Future; +use std::io; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -98,6 +101,7 @@ use storvsp_resources::ScsiControllerRequest; use storvsp_resources::ScsiDeviceAndPath; use unix_socket::UnixListener; use virtio_resources::VirtioPciDeviceHandle; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use vm_manifest_builder::VmManifestBuilder; use vm_resource::IntoResource; use vm_resource::Resource; @@ -373,6 +377,7 @@ impl Worker for TtrpcWorker { controller_task: None, wait_vm_response: None, lifecycle: VmLifecycle::Uninitialized, + restore_ready_pending: false, rpc_tasks: Vec::new(), transport: self.transport, registry: FdRegistry::default(), @@ -412,6 +417,7 @@ impl VmService { } }); + let mut exit_error = None; let quit = loop { // Take the controller events receiver out of self so it can be // polled in the select without borrowing self. @@ -489,9 +495,14 @@ impl VmService { ); break false; } - Action::ControllerEvent(Some(event)) => { - self.handle_controller_event(event); - } + Action::ControllerEvent(Some(event)) => match self.handle_controller_event(event) { + Ok(true) => break true, + Ok(false) => {} + Err(error) => { + exit_error = Some(error); + break true; + } + }, Action::ControllerEvent(None) => {} // handled above Action::WaitVmCancelled(reason) => { tracing::debug!("WaitVm client cancelled"); @@ -523,7 +534,11 @@ impl VmService { let _ = Arc::try_unwrap(vm).ok().expect("no more VM references"); } drop(cancel_send); - server_task.await + server_task.await?; + match exit_error { + Some(error) => Err(error), + None => Ok(()), + } } fn start_rpc( @@ -676,6 +691,7 @@ struct VmService { controller_task: Option>, wait_vm_response: Option<(mesh::CancelContext, mesh::OneshotSender>)>, lifecycle: VmLifecycle, + restore_ready_pending: bool, rpc_tasks: Vec>, transport: ResolvedTransport, /// Registry of file descriptors passed in over the fd-passing protocol, @@ -713,7 +729,12 @@ enum HandleAction { impl VmService { async fn handle(&mut self, ctx: mesh::CancelContext, request: vmservice::Vm) -> HandleAction { - tracing::debug!(?request, "request"); + if let vmservice::Vm::CreateVm(create, _) = &request { + let request = microvm::RedactedCreateVmRequest(create); + tracing::debug!(?request, "create VM request"); + } else { + tracing::debug!(?request, "request"); + } match request { vmservice::Vm::CreateVm(request, response) => { response.send(map_grpc(self.create_vm(request).await)) @@ -844,14 +865,31 @@ impl VmService { } async fn create_vm(&mut self, request: vmservice::CreateVmRequest) -> anyhow::Result<()> { - let mut req_config = request.config.context("missing configuration")?; - if self.vm.is_some() { bail!("VM already created"); } + let (mut microvm, mut req_config) = microvm::CreateVm::new(request)?; validate_platform_config(&req_config)?; + let hypervisor = if microvm.is_active() { + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? + } else { + openvmm_helpers::hypervisor::choose_hypervisor()? + }; + let source_hypervisor = hypervisor.id().to_owned(); + microvm.prepare_restore(source_hypervisor)?; + let restore_ready_path = microvm.take_restore_ready_path(); + let restore_ready_sink = if restore_ready_path.is_empty() { + None + } else { + Some( + crate::snapshot_restore::connect_restore_ready_sink(Path::new(&restore_ready_path)) + .context("failed to connect restore readiness endpoint")?, + ) + }; + let restore_ready_required = restore_ready_sink.is_some(); + microvm.finish_prepare(&req_config)?; let iommufds = IommufdContexts::new(std::mem::take(&mut req_config.iommufds))?; // Snapshot the fd registry so tap NIC backends can resolve descriptors @@ -873,6 +911,7 @@ impl VmService { } let any_serial_configured = ports.iter().any(|port| port.is_some()); let com1_configured = ports[0].is_some(); + let has_requested_microvm_console = microvm.has_requested_console(&req_config); #[cfg(guest_arch = "aarch64")] let arch = vm_manifest_builder::MachineArch::Aarch64; @@ -910,23 +949,33 @@ impl VmService { if isolation.is_some() { bail!("VM-service SNP isolation currently supports only IGVM boot"); } - let kernel = File::open(boot.kernel_path).context("failed to open kernel")?; + let kernel = if let Some(kernel) = microvm.restore_kernel()? { + kernel + } else { + File::open(boot.kernel_path).context("failed to open kernel")? + }; let initrd = if boot.initrd_path.is_empty() { None } else { Some(File::open(boot.initrd_path).context("failed to open initrd")?) }; + let cmdline = + microvm.command_line(boot.kernel_cmdline, has_requested_microvm_console)?; ( LoadMode::Linux { kernel, initrd, - cmdline: boot.kernel_cmdline, - enable_serial: true, + cmdline, + enable_serial: !microvm.is_active(), isolation: openvmm_defs::config::LinuxIsolationConfig::None, - boot_mode: openvmm_defs::config::LinuxDirectBootMode::Acpi, - smbios, + boot_mode: microvm.direct_boot_mode(), + smbios: if microvm.is_active() { + Box::default() + } else { + smbios + }, }, - vm_manifest_builder::BaseChipsetType::HyperVGen2LinuxDirect, + microvm.base_chipset_type(), None, None, ) @@ -1042,8 +1091,11 @@ impl VmService { } }; - let mut chipset_builder = - VmManifestBuilder::new(base_chipset_type, arch).with_serial(ports); + let ports = microvm.configure_serial_ports(ports)?; + let mut chipset_builder = VmManifestBuilder::new(base_chipset_type, arch); + if let Some(ports) = ports { + chipset_builder = chipset_builder.with_serial(ports); + } if req_config.disable_vmbus { chipset_builder = chipset_builder.without_vmbus(); } @@ -1064,15 +1116,18 @@ impl VmService { )); } let layout_config = chipset_builder.layout_config(); - let chipset = chipset_builder + let mut chipset = chipset_builder .build() .context("failed to build vm configuration")?; + microvm.add_chipset_devices(&mut chipset.chipset_devices)?; // Build the NUMA topology. A `MemoryConfig` and an explicit // `NumaConfig` are mutually exclusive (mirrors the CLI `--memory` vs // `--numa` conflict). `config_mem_size` is the total guest memory // reported to the `VmController`. - let (numa, config_mem_size) = if let Some(numa_config) = req_config.numa_config.take() { + let (numa, config_mem_size) = if let Some(restored) = microvm.restore_numa() { + restored + } else if let Some(numa_config) = req_config.numa_config.take() { if req_config.memory_config.is_some() { bail!("memory_config and numa_config are mutually exclusive"); } @@ -1103,11 +1158,7 @@ impl VmService { (numa, mem_size) }; - let config_proc_count = req_config - .processor_config - .as_ref() - .map(|c| c.processor_count) - .unwrap_or(1); + let config_proc_count = microvm.processor_count(); let arch = parse_arch_topology_overrides(req_config.processor_config.as_ref())?; // Build the PCIe topology (root complexes, switches, and the devices @@ -1120,6 +1171,8 @@ impl VmService { let mut config = Config { // TODO: devices, other stuff + machine_profile: openvmm_defs::microvm::MachineProfile::Standard, + microvm: Default::default(), load_mode, ide_disks: vec![], floppy_disks: vec![], @@ -1164,6 +1217,7 @@ impl VmService { layout: layout_config, rtc_delta_milliseconds: 0, }; + microvm.apply_config(&mut config); let guest_power_actions = { use vmservice::vm_config::GuestPowerAction as ProtoAction; @@ -1188,7 +1242,9 @@ impl VmService { let mut scsi_rpc = None; let mut consomme_rpc = None; - if let Some(devices_config) = req_config.devices_config { + microvm.add_restored_devices(&mut config)?; + if let Some(mut devices_config) = req_config.devices_config { + microvm.take_devices(&mut config, &mut devices_config)?; if !devices_config.scsi_disks.is_empty() { let mut devices = Vec::new(); for disk in devices_config.scsi_disks { @@ -1257,7 +1313,12 @@ impl VmService { ) })?; let resource: Resource = - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource(); + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Discard, + attachment: None, + } + .into_resource(); if cfg!(windows) || cfg!(target_os = "macos") { config.vpci_devices.push(VpciDeviceConfig { vtl: DeviceVtl::Vtl0, @@ -1272,6 +1333,8 @@ impl VmService { } } + microvm.finish_devices(&mut config, &self.driver)?; + if let Some(hvsocket_config) = req_config.hvsocket_config { let vmbus = config .vmbus @@ -1284,6 +1347,9 @@ impl VmService { vmbus.vsock_path = Some(hvsocket_config.path); } + microvm.prepare_launch(&config, config_mem_size)?; + let worker_fields = microvm.worker_fields()?; + let (send, recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); @@ -1298,10 +1364,18 @@ impl VmService { .launch_worker( VM_WORKER, VmWorkerParameters { - hypervisor: openvmm_helpers::hypervisor::choose_hypervisor()?, + hypervisor, cfg: config, - saved_state: None, - shared_memory: None, + saved_state: worker_fields.saved_state, + shared_memory: worker_fields.shared_memory, + shared_memory_copy_on_write: worker_fields.shared_memory_copy_on_write, + snapshot_restore_guards: worker_fields.snapshot_restore_guards, + snapshot_boundary_requests: worker_fields.snapshot_boundary_requests, + snapshot_ready: worker_fields.snapshot_ready, + restore_time: worker_fields.restore_time, + restore_ready_sink, + restore_gate_timeout: worker_fields.restore_gate_timeout, + restore_vp_count: worker_fields.restore_vp_count, rpc: recv, notify: notify_send, }, @@ -1314,6 +1388,7 @@ impl VmService { // Create channels for VmController. let (vm_controller_send, vm_controller_recv) = mesh::channel(); let (event_send, event_recv) = mesh::channel(); + let controller_fields = microvm.into_controller(); // Build VmController with no paravisor-specific fields. let controller = VmController { @@ -1327,19 +1402,21 @@ impl VmService { vm_rpc: send.clone(), paravisor_diag: None, igvm_path, - memory_backing_file: None, + memory_backing_file: controller_fields.memory_backing_file, memory, processors, log_file: None, crash_dump_path: req_config.crash_dump_path.map(Into::into), + microvm: controller_fields.microvm, guest_power_actions, }; // Spawn the controller task. - let controller_task = self.driver.spawn( - "vm-controller", - controller.run(vm_controller_recv, event_send, notify_recv), - ); + let controller_task = self.driver.spawn("vm-controller", async move { + let _ = controller + .run(vm_controller_recv, event_send, notify_recv) + .await; + }); self.vm_controller = Some(vm_controller_send); self.vm_controller_events = Some(event_recv); @@ -1350,6 +1427,7 @@ impl VmService { worker_rpc: send, iommufds: Arc::new(iommufds), })); + self.restore_ready_pending = restore_ready_required; self.lifecycle = VmLifecycle::Paused; Ok(()) } @@ -1368,6 +1446,7 @@ impl VmService { task.await; } self.vm_controller_events.take(); + self.restore_ready_pending = false; self.lifecycle = VmLifecycle::Uninitialized; if let Some((_, response)) = self.wait_vm_response.take() { response.send(Err(grpc_error(anyhow!("VM torn down")))); @@ -1433,31 +1512,45 @@ impl VmService { async fn resume_vm(&mut self) -> anyhow::Result<()> { let vm = self.vm.clone().context("VM not created yet")?; - vm.worker_rpc - .call(VmRpc::Resume, ()) - .await - .map(drop) - .context("resume failed")?; + let resumed = match vm.worker_rpc.call_failable(VmRpc::Resume, ()).await { + Ok(resumed) => resumed, + Err(error) => { + if self.restore_ready_pending { + self.teardown_vm() + .await + .context("failed to tear down VM after restore readiness failure")?; + } + return Err(error).context("resume failed"); + } + }; + anyhow::ensure!( + resumed, + "VM did not resume; a state unit failed to start or the VM was already running" + ); + self.restore_ready_pending = false; if !matches!(self.lifecycle, VmLifecycle::Halted(_)) { self.lifecycle = VmLifecycle::Running; } Ok(()) } - fn handle_controller_event(&mut self, event: VmControllerEvent) { - match event { + fn handle_controller_event(&mut self, event: VmControllerEvent) -> anyhow::Result { + Ok(match event { VmControllerEvent::GuestHalt(reason) => { tracing::info!(%reason, "guest halted (via controller)"); self.lifecycle = VmLifecycle::Halted(reason); if let Some((_, response)) = self.wait_vm_response.take() { response.send(Ok(())); } + false } - VmControllerEvent::ExitRequested { code } => { - // The protocol has no `exit` power action, so this should not - // occur in ttrpc/grpc mode; log rather than exiting the server - // out from under its clients. - tracing::warn!(code, "unexpected exit request in server mode"); + event @ (VmControllerEvent::ExitRequested { .. } + | VmControllerEvent::ExitFailed { .. }) => { + return microvm::handle_exit_event( + event, + &mut self.lifecycle, + &mut self.wait_vm_response, + ); } VmControllerEvent::WorkerStopped { error } => { if let Some(err) = &error { @@ -1478,13 +1571,15 @@ impl VmService { self.vm.take(); self.vm_controller.take(); self.lifecycle = VmLifecycle::Uninitialized; + false } VmControllerEvent::VncWorkerStopped { error } => { if let Some(err) = &error { tracing::error!(error = %err, "VNC worker stopped unexpectedly"); } + false } - } + }) } fn add_pcie_device( @@ -1675,7 +1770,7 @@ impl VmService { fn open_socket_backend( connect: bool, ) -> ( - fn(&std::path::Path) -> std::io::Result>, + fn(&Path) -> io::Result>, &'static str, ) { if connect { @@ -1819,12 +1914,16 @@ fn parse_nic_config( } else { Some(consomme.cidr) }, + static_ipv4: None, ports: consomme .ports .into_iter() .map(parse_port_config) .collect::>()?, recv, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(), _ => anyhow::bail!("unsupported backend"), @@ -2177,7 +2276,7 @@ fn build_vfio_device( if host_pci_address.contains('/') || host_pci_address.contains("..") { anyhow::bail!("PCI address must not contain path separators"); } - let sysfs_path = std::path::Path::new("/sys/bus/pci/devices").join(&host_pci_address); + let sysfs_path = Path::new("/sys/bus/pci/devices").join(&host_pci_address); if let Some(iommu_id) = iommufd_id { let iommufd = iommufds.get(&iommu_id)?; let vfio_dev_dir = sysfs_path.join("vfio-dev"); @@ -2191,7 +2290,7 @@ fn build_vfio_device( .next() .context("no vfio-dev entry found")? .context("failed to read vfio-dev entry")?; - let dev_path = std::path::Path::new("/dev/vfio/devices").join(entry.file_name()); + let dev_path = Path::new("/dev/vfio/devices").join(entry.file_name()); let cdev = File::options() .read(true) .write(true) @@ -2328,6 +2427,10 @@ async fn build_virtio_device( .parse::() .context("invalid mac address")?, endpoint, + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource() } @@ -2348,7 +2451,12 @@ async fn build_virtio_device( } Kind::Console(vmservice::VirtioConsole { backend }) => { let backend = build_serial_backend(backend.context("missing console backend")?)?; - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource() + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Discard, + attachment: None, + } + .into_resource() } Kind::VhostUser(vhost_user) => build_vhost_user_device(vhost_user)?, Kind::Fs(config) => build_virtio_fs(config)?.into_resource(), @@ -2362,8 +2470,14 @@ fn build_virtio_fs( tag, root_path, read_only, + guest_mount_target, + read_write, } = config; const VIRTIO_FS_TAG_LEN: usize = 36; + anyhow::ensure!( + guest_mount_target.is_empty() && !read_write, + "standard-machine virtio-fs does not accept microVM mount fields" + ); anyhow::ensure!(!tag.is_empty(), "virtio-fs tag must not be empty"); anyhow::ensure!( !tag.contains('\0'), @@ -2391,6 +2505,7 @@ fn build_virtio_fs( String::new() }, }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, }) } @@ -2422,11 +2537,15 @@ fn build_nic_backend( Kind::Consomme(vmservice::ConsommeBackend { cidr, ports }) => { net_backend_resources::consomme::ConsommeHandle { cidr: (!cidr.is_empty()).then_some(cidr), + static_ipv4: None, ports: ports .into_iter() .map(parse_port_config) .collect::>()?, recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource() } diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index 2e87654b9aa..89d3abd8fe1 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -18,6 +18,7 @@ use mesh::rpc::Rpc; use mesh::rpc::RpcSend; use mesh_worker::WorkerEvent; use mesh_worker::WorkerHandle; +use microvm::GuestSnapshotAction; use openvmm_defs::rpc::VmRpc; use std::path::Path; use std::path::PathBuf; @@ -26,6 +27,12 @@ use std::sync::Arc; use std::time::Instant; use vmm_core_defs::HaltReason; +mod microvm; + +pub(crate) use microvm::MicrovmController; +pub(crate) use microvm::MicrovmTeardownError; +pub(crate) use microvm::MicrovmTeardownStatus; + /// Inspection target: host-side workers or the paravisor. #[derive(Clone, Copy, mesh::MeshPayload)] pub enum InspectTarget { @@ -107,6 +114,8 @@ pub enum VmControllerEvent { /// The controller requests that the process exit with this code, because the /// guest drove a power event the user opted into exiting on. ExitRequested { code: i32 }, + /// A guest-requested process exit failed and must terminate the runner. + ExitFailed { error: String }, } /// Owns exclusive VM resources and services RPCs from the REPL. @@ -126,6 +135,7 @@ pub struct VmController { pub(crate) processors: u32, pub(crate) log_file: Option, pub(crate) crash_dump_path: Option, + pub(crate) microvm: MicrovmController, pub(crate) guest_power_actions: GuestPowerActions, } @@ -158,7 +168,9 @@ impl Default for GuestPowerActions { /// Decide what to do for a guest halt, given the per-event actions. fn action_for(reason: &HaltReason, actions: &GuestPowerActions) -> GuestPowerAction { match reason { - HaltReason::PowerOff | HaltReason::Hibernate => actions.shutdown, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } | HaltReason::Hibernate => { + actions.shutdown + } HaltReason::Reset => actions.reset, HaltReason::TripleFault { .. } => actions.crash, HaltReason::Watchdog => actions.watchdog, @@ -175,17 +187,25 @@ impl VmController { mut rpc_recv: mesh::Receiver, event_send: mesh::Sender, mut notify_recv: mesh::Receiver, - ) { + ) -> MicrovmTeardownStatus { enum Event { Rpc(VmControllerRpc), RpcClosed, Worker(WorkerEvent), VncWorker(WorkerEvent), Halt(HaltReason), + SnapshotRequest(chipset_resources::microvm::MicrovmSnapshotScratchPolicy), + HostControl(anyhow::Result<()>), } let mut quit = false; let mut rpc_closed = false; + // The host-control service persists across iterations: rebuilding it + // whenever another event wins would cancel a session mid-request. + let mut host_control = Box::pin( + futures::stream::iter(self.microvm.resources.host_control.clone()) + .then(|server| async move { server.lock().await.run().await }), + ); loop { let event = { let rpc = pin!(async { @@ -203,8 +223,20 @@ impl VmController { .flatten() .map(Event::VncWorker); let halt = (&mut notify_recv).map(Event::Halt); - - (rpc.into_stream(), vm, vnc, halt) + let snapshot_request = + futures::stream::iter(self.microvm.snapshot_requests.as_mut()) + .flatten() + .map(Event::SnapshotRequest); + let host_control = (&mut host_control).map(Event::HostControl); + + ( + rpc.into_stream(), + vm, + vnc, + halt, + snapshot_request, + host_control, + ) .merge() .next() .await @@ -230,7 +262,9 @@ impl VmController { } else { tracing::error!("vm worker unexpectedly stopped"); } - event_send.send(VmControllerEvent::WorkerStopped { error: None }); + event_send.send(VmControllerEvent::WorkerStopped { + error: (!quit).then(|| "VM worker unexpectedly stopped".to_owned()), + }); break; } WorkerEvent::Failed(err) => { @@ -276,6 +310,13 @@ impl VmController { }, Event::Halt(reason) => { tracing::info!(?reason, "guest halted"); + if let HaltReason::PowerOffWithStatus { code } = reason { + self.request_exit(i32::from(code), &event_send).await; + return MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }; + } // On a guest crash, write a `.vmrs` dump (if configured) // before applying the crash action, since a `Reset` action // would wipe the guest state we want to capture. @@ -302,10 +343,11 @@ impl VmController { // are parked, so don't stop it here; signal the runner to // exit instead. tracing::info!(exit_code = code, "requesting exit on guest halt"); - event_send.send(VmControllerEvent::ExitRequested { - code: i32::from(code), - }); - return; + self.request_exit(i32::from(code), &event_send).await; + return MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }; } GuestPowerAction::Reset => { // Reboot the VM in place. @@ -324,18 +366,44 @@ impl VmController { } } } + Event::SnapshotRequest(scratch_policy) => { + let action = self.handle_guest_snapshot_request(scratch_policy).await; + if let GuestSnapshotAction::Terminate { exit_code } = action { + event_send.send(VmControllerEvent::ExitRequested { code: exit_code }); + break; + } + } + Event::HostControl(result) => { + let error = result + .err() + .unwrap_or_else(|| anyhow::anyhow!("host-control service stopped")); + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM host-control service failed" + ); + self.vm_worker.stop(); + quit = true; + event_send.send(VmControllerEvent::ExitFailed { + error: format!("microVM host-control service failed: {error:#}"), + }); + } } } // Ensure all workers are cleaned up before shutting down the mesh. self.vm_worker.stop(); - if let Err(err) = self.vm_worker.join().await { - tracing::error!( - error = err.as_ref() as &dyn std::error::Error, - "vm worker join failed" - ); - } + let vm_worker_stopped = match self.vm_worker.join().await { + Ok(()) => true, + Err(err) => { + tracing::error!( + error = err.as_ref() as &dyn std::error::Error, + "vm worker join failed" + ); + false + } + }; + let mut auxiliary_workers_stopped = true; if let Some(mut vnc) = self.vnc_worker.take() { vnc.stop(); if let Err(err) = vnc.join().await { @@ -343,6 +411,7 @@ impl VmController { error = err.as_ref() as &dyn std::error::Error, "vnc worker join failed" ); + auxiliary_workers_stopped = false; } } @@ -353,10 +422,15 @@ impl VmController { error = err.as_ref() as &dyn std::error::Error, "gdb worker join failed" ); + auxiliary_workers_stopped = false; } } self.mesh.shutdown().await; + MicrovmTeardownStatus { + vm_worker_stopped, + auxiliary_workers_stopped, + } } async fn handle_rpc(&mut self, rpc: VmControllerRpc, quit: &mut bool) { @@ -418,6 +492,9 @@ impl VmController { } async fn handle_restart(&mut self) -> anyhow::Result<()> { + if self.microvm.active { + anyhow::bail!("worker restart is unavailable for microVM"); + } let vm_host = self .mesh .make_host("vm", self.log_file.clone()) @@ -460,6 +537,9 @@ impl VmController { } async fn handle_save_snapshot(&self, dir: &Path) -> anyhow::Result<()> { + if self.microvm.active { + anyhow::bail!("disk snapshots are unavailable for microVM"); + } let memory_file_path = self .memory_backing_file .as_ref() @@ -496,6 +576,7 @@ impl VmController { vp_count: self.processors, page_size: crate::system_page_size(), architecture: crate::GUEST_ARCH.to_string(), + ..Default::default() }; // Write snapshot directory. diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs new file mode 100644 index 00000000000..938062388ef --- /dev/null +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -0,0 +1,684 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM snapshot capture, guest exit, and teardown handling of the VM controller. + +use super::VmController; +use super::VmControllerEvent; +use crate::microvm::MicrovmResources; +use anyhow::Context; +use mesh::rpc::RpcSend; +use openvmm_defs::rpc::SnapshotQuiesceError; +use openvmm_defs::rpc::VmRpc; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +/// MicroVM state owned by the VM controller. +#[derive(Default)] +pub(crate) struct MicrovmController { + /// Whether the microVM machine profile is selected. + pub(crate) active: bool, + /// Exact open handle of the file backing guest RAM for snapshot capture. + pub(crate) snapshot_memory_handle: Option, + /// Immutable RAM capacity reserved in captured snapshots. + pub(crate) memory_capacity: Option, + /// Guest-requested snapshot boundaries released by the VM worker. + pub(crate) snapshot_requests: + Option>, + /// Directory receiving a guest-requested snapshot. + pub(crate) snapshot_destination: Option, + /// Sandbox capture tier of a guest-requested snapshot. + pub(crate) snapshot_tier: Option, + /// Sandbox-block identity policy recorded in a captured snapshot. + pub(crate) snapshot_block_identity: crate::cli_args::microvm::SnapshotBlockIdentityCli, + /// Caller-authenticated immutable storage generation. + pub(crate) snapshot_generation_id: Option<[u8; 16]>, + /// Restore-time materialization policy recorded for paired scratch. + pub(crate) snapshot_scratch_restore_mode: + crate::cli_args::microvm::SnapshotScratchRestoreModeCli, + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. + pub(crate) snapshot_quiesce_timeout: Duration, + /// Hypervisor backend recorded in captured machine contracts. + pub(crate) source_hypervisor: String, + /// Effective kernel command line of the MP-table boot. + pub(crate) effective_command_line: Option, + /// Host attachments and cleanup guards of the microVM devices. + pub(crate) resources: MicrovmResources, + /// Static identity of the microVM virtio-net device. + pub(crate) network: Option, + /// Whether the first microVM virtio-fs slot is present. + pub(crate) filesystem_slot: bool, + /// Guest-visible policies of the active microVM filesystems, in virtio-fs + /// slot order. + pub(crate) filesystems: Vec, + /// Fixed image-slot activation contract. + pub(crate) image_slots: Option, + /// Automatic RAM backing created for snapshot capture. + pub(crate) snapshot_memory_file: Option, + /// Private copy of a paired scratch image, kept alive for the VM lifetime. + pub(crate) _private_scratch_dir: Option, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct MicrovmTeardownStatus { + pub(crate) vm_worker_stopped: bool, + pub(crate) auxiliary_workers_stopped: bool, +} + +impl MicrovmTeardownStatus { + pub(crate) fn complete(self) -> bool { + self.vm_worker_stopped && self.auxiliary_workers_stopped + } + + /// Fails a run whose workers did not stop cleanly when `enforce` is set. + pub(crate) fn enforce(self, enforce: bool, result: anyhow::Result) -> anyhow::Result { + if enforce && !self.complete() { + let teardown_error = MicrovmTeardownError(self); + return match result { + Ok(_) => Err(teardown_error.into()), + Err(error) => Err(error.context(teardown_error)), + }; + } + result + } +} + +#[derive(Debug)] +pub(crate) struct MicrovmTeardownError(pub(crate) MicrovmTeardownStatus); + +impl std::fmt::Display for MicrovmTeardownError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("one or more microVM workers failed to stop cleanly") + } +} + +impl std::error::Error for MicrovmTeardownError {} + +pub(super) enum GuestSnapshotAction { + Continue, + Terminate { exit_code: i32 }, +} + +struct ImageSlotTransitionGuard(Arc); + +impl Drop for ImageSlotTransitionGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +async fn ensure_image_slot_channels_empty( + slots: &[Option>], +) -> anyhow::Result<()> { + for (index, requests) in slots.iter().enumerate() { + let Some(requests) = requests else { + continue; + }; + let state = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .with_context(|| format!("image slot {index} did not answer"))?; + anyhow::ensure!( + state.identity.is_none(), + "image slot {index} is bound to '{}'", + state.identity.as_deref().unwrap_or_default() + ); + } + Ok(()) +} + +async fn guest_exit_event( + code: i32, + drain: Option, +) -> VmControllerEvent { + if let Some(drain) = drain + && let Err(error) = drain.drain().await + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "failed to drain microVM console output before exit" + ); + return VmControllerEvent::ExitFailed { + error: format!("failed to drain microVM console output: {error:#}"), + }; + } + VmControllerEvent::ExitRequested { code } +} + +impl VmController { + pub(super) async fn request_exit( + &mut self, + code: i32, + events: &mesh::Sender, + ) { + events.send(guest_exit_event(code, self.microvm.resources.output_drain.take()).await); + } + + pub(super) async fn handle_guest_snapshot_request( + &mut self, + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + ) -> GuestSnapshotAction { + let _image_slot_guard = if self.microvm.image_slots.is_some() { + let transition = self.microvm.resources.image_slot_transition.clone(); + if transition + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + tracelimit::warn_ratelimited!( + "ignoring microVM snapshot request because an image-slot transition is active" + ); + return self.release_snapshot_boundary_without_capture().await; + } + Some(ImageSlotTransitionGuard(transition)) + } else { + None + }; + let Some(destination) = self.microvm.snapshot_destination.clone() else { + tracelimit::warn_ratelimited!( + "ignoring microVM snapshot request because no destination is configured" + ); + return self.release_snapshot_boundary_without_capture().await; + }; + if let Err(error) = self.ensure_image_slots_empty().await { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot preflight rejected bound image slots; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } + + let preflight = (|| -> anyhow::Result<()> { + anyhow::ensure!( + self.microvm.active, + "guest-requested snapshot capture requires the microVM profile" + ); + anyhow::ensure!( + matches!( + self.microvm.source_hypervisor.as_str(), + "kvm" | "mshv" | "whp" + ), + "microVM snapshot source backend must be KVM, MSHV, or WHP" + ); + anyhow::ensure!( + self.microvm.resources.sandbox_block_sources.is_empty() + || (self.microvm.resources.sandbox_block_sources.len() >= 2 + && self + .microvm + .resources + .sandbox_block_sources + .last() + .is_some_and(|source| { + source.role + == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + })), + "microVM snapshot requires either no blocks or at least one lower layer and scratch" + ); + if self.microvm.resources.sandbox_block_sources.is_empty() { + anyhow::ensure!( + self.microvm.snapshot_tier.is_none(), + "blockless microVM snapshot capture does not use a tier" + ); + } else { + let tier = self + .microvm + .snapshot_tier + .context("microVM sandbox snapshot capture requires a tier")?; + let paired_scratch = scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired; + anyhow::ensure!( + paired_scratch == tier.requires_paired_scratch(), + "snapshot tier '{}' requires {} scratch capture", + tier.manifest_name(), + if tier.requires_paired_scratch() { + "paired" + } else { + "fresh" + } + ); + } + anyhow::ensure!( + fs_err::symlink_metadata(&destination) + .is_err_and(|error| { error.kind() == std::io::ErrorKind::NotFound }), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let memory_path = self + .memory_backing_file + .clone() + .context("microVM snapshot capture requires file-backed RAM")?; + let memory_file = self + .microvm + .snapshot_memory_handle + .as_ref() + .context("microVM snapshot capture lost its exact RAM handle")?; + anyhow::ensure!( + memory_file + .metadata() + .context("failed to inspect snapshot RAM handle")? + .len() + == self.memory, + "snapshot RAM handle size does not match the VM" + ); + if let Some(file) = &self.microvm.snapshot_memory_file { + anyhow::ensure!( + file.path() == memory_path, + "automatic snapshot memory backing path changed unexpectedly" + ); + } + anyhow::ensure!( + self.microvm.effective_command_line.is_some(), + "microVM snapshot capture requires an effective command line" + ); + Ok(()) + })(); + if let Err(error) = preflight { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot preflight failed; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } + + // The source terminates once the snapshot commits, so deliver the + // console output the guest wrote before its request while the endpoint + // is still open. Bytes that miss the bound stay in the snapshot. + if let Some(drain) = &self.microvm.resources.output_drain { + let output_flush = openvmm_defs::profile::ProfileSpan::start(); + match drain.flush(self.microvm.snapshot_quiesce_timeout).await { + Ok(()) => output_flush.complete("capture", "output_flush", Default::default()), + Err(error) => tracelimit::warn_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to flush microVM console output before snapshot; undelivered bytes stay in the snapshot" + ), + } + } + + let response = match self + .vm_rpc + .call( + VmRpc::QuiesceForSnapshot, + self.microvm.snapshot_quiesce_timeout, + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(SnapshotQuiesceError::Rejected(error))) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "microVM snapshot request was rejected; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } + Ok(Err(SnapshotQuiesceError::RollbackSafe(error))) => { + return self.rollback_failed_guest_snapshot(error.into()).await; + } + Ok(Err(SnapshotQuiesceError::Uncertain(error))) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "microVM snapshot quiesce left uncertain state; terminating source" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + Err(error) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "lost VM worker during snapshot quiesce; terminating source" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + }; + let command_line = response.effective_command_line; + + let result = (|| -> anyhow::Result<()> { + let network = self + .microvm + .network + .as_ref() + .zip(self.microvm.resources.egress_policy.as_ref()) + .zip(self.microvm.resources.network_attachment.clone()) + .map(|((network, policy), attachment)| (network, policy, attachment)); + let filesystems = self + .microvm + .filesystems + .iter() + .zip(&self.microvm.resources.filesystem_root_paths) + .zip(&self.microvm.resources.filesystem_attachments) + .map(|((filesystem, root_path), attachment)| { + (filesystem, root_path.as_path(), attachment.clone()) + }) + .collect(); + let mut blocks = crate::storage_builder::microvm::capture_snapshot_block_contract( + &self.microvm.resources.sandbox_block_sources, + scratch_policy, + self.microvm.snapshot_block_identity, + self.microvm.snapshot_generation_id, + self.microvm.snapshot_scratch_restore_mode.manifest_name(), + )?; + if self.microvm.snapshot_tier + == Some(crate::cli_args::microvm::SnapshotTierCli::Platform) + { + for block in blocks.iter_mut().filter(|block| block.read_only) { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + } + let machine_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_image_slots( + &self.microvm.source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + network, + self.microvm.filesystem_slot, + filesystems, + self.microvm.resources.console_attachment.clone(), + self.microvm.resources.control_console_attachment.clone(), + blocks, + self.microvm.image_slots, + self.processors, + self.memory, + self.microvm.memory_capacity, + response.state_unit_names, + response.time.time, + response.time.cpu_profile, + )?; + let manifest = openvmm_helpers::snapshot::SnapshotManifest { + version: openvmm_helpers::snapshot::MANIFEST_VERSION, + created_at: std::time::SystemTime::now().into(), + openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), + memory_size_bytes: self.memory, + vp_count: self.processors, + page_size: crate::system_page_size(), + architecture: crate::GUEST_ARCH.to_owned(), + state_size_bytes: 0, + machine_contract: Some(machine_contract), + format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: + openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE + .to_owned(), + snapshot_tier: self + .microvm + .snapshot_tier + .map(crate::cli_args::microvm::SnapshotTierCli::manifest_name) + .unwrap_or_default() + .to_owned(), + restore_policy: self + .microvm + .snapshot_tier + .map(crate::cli_args::microvm::SnapshotTierCli::restore_policy) + .unwrap_or_default() + .to_owned(), + consumed_config_sections: match self.microvm.snapshot_tier { + Some(crate::cli_args::microvm::SnapshotTierCli::Platform) => { + openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_INVARIANTS + } + Some( + crate::cli_args::microvm::SnapshotTierCli::WorkloadStart + | crate::cli_args::microvm::SnapshotTierCli::InstanceCheckpoint, + ) => { + openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_INVARIANTS + | openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_IMAGE_BINDING + | openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_SANDBOX + } + None => 0, + }, + }; + let saved_state_bytes = mesh::payload::encode(response.saved_state); + let memory_file = self + .microvm + .snapshot_memory_handle + .as_ref() + .context("microVM snapshot capture lost its exact RAM handle")?; + let memory_handle_flush = openvmm_defs::profile::ProfileSpan::start(); + memory_file + .sync_all() + .context("failed to flush snapshot RAM handle")?; + memory_handle_flush.complete( + "capture", + "memory_handle_flush", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(self.memory), + ..Default::default() + }, + ); + let scratch_file = (!self.microvm.resources.sandbox_block_sources.is_empty() + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired) + .then(|| { + self.microvm + .resources + .sandbox_block_sources + .iter() + .find(|source| { + source.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }) + .map(|source| &source.file) + .context("paired snapshot lost its scratch backing handle") + }) + .transpose()?; + let publication = openvmm_defs::profile::ProfileSpan::start(); + let write_result = if self.microvm.snapshot_memory_file.is_some() { + openvmm_helpers::snapshot::publish::write_snapshot_from_owned_memory_and_scratch_files( + &destination, + &manifest, + &saved_state_bytes, + memory_file, + scratch_file, + ) + } else { + openvmm_helpers::snapshot::publish::write_snapshot_from_memory_and_scratch_files( + &destination, + &manifest, + &saved_state_bytes, + memory_file, + scratch_file, + ) + }; + if write_result.is_ok() { + publication.complete_milestone( + "capture", + "publication", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(self.memory), + ..Default::default() + }, + ); + } + write_result.map_err(anyhow::Error::new) + })(); + + match result { + Ok(()) => { + if let Some(cleanup) = self.microvm.resources.console_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source console socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + if let Some(cleanup) = self.microvm.resources.control_console_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source control console socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source host-control socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + tracing::info!( + path = %destination.display(), + "microVM snapshot committed; terminating source process" + ); + GuestSnapshotAction::Terminate { exit_code: 0 } + } + Err(error) => { + let write_error = + error.downcast_ref::(); + if write_error.is_some_and(|error| error.is_committed()) { + if let Some(cleanup) = self.microvm.resources.console_socket_cleanup.take() + && let Err(cleanup_error) = cleanup.remove_if_owned() + { + tracing::error!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "committed snapshot console socket could not be removed" + ); + } + if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "committed snapshot host-control socket could not be removed" + ); + } + if let Some(cleanup) = + self.microvm.resources.control_console_socket_cleanup.take() + && let Err(cleanup_error) = cleanup.remove_if_owned() + { + tracing::error!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "committed snapshot control console socket could not be removed" + ); + } + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + path = %destination.display(), + "snapshot committed but final durability reporting failed; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } else if write_error.is_some_and(|error| !error.is_rollback_safe()) { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot failed before commit but automatic RAM alias cleanup is uncertain; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } else { + self.rollback_failed_guest_snapshot(error).await + } + } + } + } + + async fn ensure_image_slots_empty(&self) -> anyhow::Result<()> { + ensure_image_slot_channels_empty(&self.microvm.resources.image_slot_requests).await + } + + async fn rollback_failed_guest_snapshot( + &mut self, + error: anyhow::Error, + ) -> GuestSnapshotAction { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot failed before commit; attempting rollback" + ); + match self + .vm_rpc + .call_failable( + VmRpc::ResumeAfterFailedSnapshot, + self.microvm.snapshot_quiesce_timeout, + ) + .await + { + Ok(()) => { + tracing::info!("microVM snapshot rollback succeeded; guest resumed"); + GuestSnapshotAction::Continue + } + Err(rollback_error) => { + tracing::error!( + error = &rollback_error as &dyn std::error::Error, + "microVM snapshot rollback failed; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } + } + } + + async fn release_snapshot_boundary_without_capture(&mut self) -> GuestSnapshotAction { + match self + .vm_rpc + .call_failable(VmRpc::ReleaseSnapshotBoundary, ()) + .await + { + Ok(()) => GuestSnapshotAction::Continue, + Err(error) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "failed to release microVM snapshot boundary; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::microvm::output::MicrovmOutputDrain; + use futures::executor::block_on; + use test_with_tracing::test; + + #[test] + fn successful_drain_preserves_guest_exit_status() { + block_on(async { + for code in [0, 37] { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let (event, ()) = futures::join!(guest_exit_event(code, Some(drain)), async { + requests.recv().await.unwrap().complete(Ok(())); + }); + assert!(matches!( + event, + VmControllerEvent::ExitRequested { code: actual } if actual == code + )); + } + }); + } + + #[test] + fn failed_drain_requests_process_exit_not_worker_stopped() { + let (drain, requests) = MicrovmOutputDrain::new(None); + drop(requests); + let event = block_on(guest_exit_event(0, Some(drain))); + assert!(matches!( + event, + VmControllerEvent::ExitFailed { error } + if error.contains("failed to drain microVM console output") + )); + } + + #[test] + fn bound_image_slot_rejects_capture_preflight() { + block_on(async { + let (requests, mut receiver) = mesh::channel(); + let slots = vec![Some(requests)]; + let (result, ()) = futures::join!(ensure_image_slot_channels_empty(&slots), async { + match receiver.recv().await.unwrap() { + virtio_resources::blk::ImageSlotRequest::Query(rpc) => { + rpc.handle_sync(|()| virtio_resources::blk::ImageSlotState { + identity: Some("image-a".to_owned()), + capacity_sectors: 8, + }); + } + virtio_resources::blk::ImageSlotRequest::Bind(_) => { + panic!("capture preflight must only query image slots") + } + } + }); + assert!(result.unwrap_err().to_string().contains("is bound")); + }); + } +} diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index 60c3a6e4c93..43615f4d69f 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -7,28 +7,42 @@ edition.workspace = true rust-version.workspace = true [dependencies] +cpu_profile.workspace = true disk_backend_resources.workspace = true disk_vhd1.workspace = true disklayer_vhdx.workspace = true get_resources.workspace = true hypervisor_resources.workspace = true +net_backend_resources.workspace = true openvmm_defs.workspace = true +pal.workspace = true +virt.workspace = true vm_resource.workspace = true mesh.workspace = true anyhow.workspace = true fs-err.workspace = true +sha2.workspace = true +thiserror.workspace = true tracing.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["dir", "fs"] } + [target.'cfg(windows)'.dependencies] disk_vhdmp.workspace = true sparse_mmap.workspace = true +[target.'cfg(windows)'.dependencies.windows-sys] +workspace = true +features = ["Win32_Storage_FileSystem"] + [dev-dependencies] +sparse_mmap.workspace = true tempfile.workspace = true [lints] diff --git a/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs new file mode 100644 index 00000000000..9e8e79bede2 --- /dev/null +++ b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Measures the host-side snapshot foundations used by microVM phase 2. + +use anyhow::Context; +use openvmm_helpers::snapshot::MANIFEST_VERSION; +use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::publish::write_snapshot; +use openvmm_helpers::snapshot::restore::read_snapshot_with_memory; +use sparse_mmap::SparseMapping; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; +use std::time::Instant; + +const RESULT_PREFIX: &str = "OPENVMM_PHASE2_RESULT="; +const STATE_SIZE_BYTES: usize = 64 * 1024; + +struct Options { + warmups: usize, + runs: usize, + memory_mib: usize, + restore_worker: Option, +} + +impl Options { + fn parse() -> anyhow::Result { + let mut options = Self { + warmups: 3, + runs: 11, + memory_mib: 128, + restore_worker: None, + }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--restore-worker" => { + options.restore_worker = Some(PathBuf::from( + args.next().context("missing value for --restore-worker")?, + )); + continue; + } + "--help" | "-h" => { + println!( + "usage: phase2_snapshot_bench [--warmups N] [--runs N] \ + [--memory-mib N]" + ); + std::process::exit(0); + } + "--warmups" | "--runs" | "--memory-mib" => {} + _ => anyhow::bail!("unknown argument: {arg}"), + } + let value = args + .next() + .with_context(|| format!("missing value for {arg}"))? + .parse::() + .with_context(|| format!("invalid value for {arg}"))?; + anyhow::ensure!(value > 0, "{arg} must be greater than zero"); + match arg.as_str() { + "--warmups" => options.warmups = value, + "--runs" => options.runs = value, + "--memory-mib" => options.memory_mib = value, + _ => unreachable!(), + } + } + Ok(options) + } +} + +#[derive(Default)] +struct Samples { + snapshot_publish_ms: Vec, + restore_prepare_ms: Vec, + snapshot_verify_ms: Vec, + cow_map_ms: Vec, + cow_dirty_all_ms: Vec, + repeat_restore_prepare_ms: Vec, + new_process_restore_prepare_ms: Vec, + repeat_verify_ms: Vec, + repeat_cow_map_ms: Vec, +} + +struct Iteration { + snapshot_publish_ms: f64, + restore_prepare_ms: f64, + snapshot_verify_ms: f64, + cow_map_ms: f64, + cow_dirty_all_ms: f64, + repeat_restore_prepare_ms: f64, + new_process_restore_prepare_ms: f64, + repeat_verify_ms: f64, + repeat_cow_map_ms: f64, +} + +impl Samples { + fn push(&mut self, sample: Iteration) { + self.snapshot_publish_ms.push(sample.snapshot_publish_ms); + self.restore_prepare_ms.push(sample.restore_prepare_ms); + self.snapshot_verify_ms.push(sample.snapshot_verify_ms); + self.cow_map_ms.push(sample.cow_map_ms); + self.cow_dirty_all_ms.push(sample.cow_dirty_all_ms); + self.repeat_restore_prepare_ms + .push(sample.repeat_restore_prepare_ms); + self.new_process_restore_prepare_ms + .push(sample.new_process_restore_prepare_ms); + self.repeat_verify_ms.push(sample.repeat_verify_ms); + self.repeat_cow_map_ms.push(sample.repeat_cow_map_ms); + } +} + +fn elapsed_ms(started: Instant) -> f64 { + started.elapsed().as_secs_f64() * 1000.0 +} + +fn create_memory_source(path: &Path, size: u64) -> anyhow::Result<()> { + let mut file = fs_err::File::create(path).context("failed to create memory source")?; + file.set_len(size).context("failed to size memory source")?; + const INITIALIZE_CHUNK_BYTES: usize = 1024 * 1024; + let chunk = vec![0x6d_u8; INITIALIZE_CHUNK_BYTES]; + let mut remaining = size; + while remaining > 0 { + let count = usize::try_from(remaining.min(INITIALIZE_CHUNK_BYTES as u64)).unwrap(); + file.write_all(&chunk[..count]) + .context("failed to initialize memory source")?; + remaining -= count as u64; + } + file.seek(SeekFrom::Start(0)) + .context("failed to seek in memory source")?; + file.write_all(&[0x5a]) + .context("failed to seed memory source")?; + file.seek(SeekFrom::End(-1)) + .context("failed to seek in memory source")?; + file.write_all(&[0xa5]) + .context("failed to seed end of memory source")?; + file.sync_all().context("failed to flush memory source")?; + Ok(()) +} + +fn map_copy_on_write( + memory_file: std::fs::File, + memory_size: usize, +) -> anyhow::Result { + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&memory_file, false) + .context("failed to create copy-on-write memory handle")?; + let mapping = SparseMapping::new(memory_size).context("failed to reserve memory mapping")?; + mapping + .map_file_copy_on_write(0, memory_size, &mappable, 0, true) + .context("failed to map snapshot memory copy-on-write")?; + Ok(mapping) +} + +fn validate_original(mapping: &SparseMapping, memory_size: usize) -> anyhow::Result<()> { + let mut byte = [0_u8; 1]; + mapping.read_at(0, &mut byte)?; + anyhow::ensure!(byte == [0x5a], "snapshot first byte changed"); + mapping.read_at(memory_size - 1, &mut byte)?; + anyhow::ensure!(byte == [0xa5], "snapshot last byte changed"); + Ok(()) +} + +fn run_iteration( + root: &Path, + iteration: usize, + executable: &Path, + memory_path: &Path, + memory_size: usize, + state: &[u8], +) -> anyhow::Result { + let snapshot_path = root.join(format!("snapshot-{iteration}")); + let manifest = SnapshotManifest { + version: MANIFEST_VERSION, + created_at: std::time::SystemTime::now().into(), + openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), + memory_size_bytes: memory_size as u64, + vp_count: 1, + page_size: SparseMapping::page_size() as u32, + architecture: "x86_64".to_owned(), + state_size_bytes: 0, + machine_contract: None, + format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE.to_owned(), + snapshot_tier: String::new(), + restore_policy: String::new(), + consumed_config_sections: 0, + }; + + let started = Instant::now(); + write_snapshot(&snapshot_path, &manifest, state, memory_path)?; + let snapshot_publish_ms = elapsed_ms(started); + + let restore_started = Instant::now(); + let started = Instant::now(); + let (_, restored_state, memory_file) = + read_snapshot_with_memory(&snapshot_path, memory_size as u64)?; + let snapshot_verify_ms = elapsed_ms(started); + anyhow::ensure!(restored_state == state, "saved state changed"); + + let started = Instant::now(); + let mapping = map_copy_on_write(memory_file, memory_size)?; + let cow_map_ms = elapsed_ms(started); + validate_original(&mapping, memory_size)?; + let restore_prepare_ms = elapsed_ms(restore_started); + + let started = Instant::now(); + mapping.fill_at(0, 0x3c, memory_size)?; + let cow_dirty_all_ms = elapsed_ms(started); + drop(mapping); + + let repeat_restore_started = Instant::now(); + let started = Instant::now(); + let (_, restored_state, memory_file) = + read_snapshot_with_memory(&snapshot_path, memory_size as u64)?; + let repeat_verify_ms = elapsed_ms(started); + anyhow::ensure!( + restored_state == state, + "saved state changed after COW dirtying" + ); + + let started = Instant::now(); + let mapping = map_copy_on_write(memory_file, memory_size)?; + let repeat_cow_map_ms = elapsed_ms(started); + validate_original(&mapping, memory_size)?; + let repeat_restore_prepare_ms = elapsed_ms(repeat_restore_started); + drop(mapping); + + let started = Instant::now(); + let status = Command::new(executable) + .arg("--restore-worker") + .arg(&snapshot_path) + .arg("--memory-mib") + .arg((memory_size / (1024 * 1024)).to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .context("failed to launch restore-preparation worker")?; + anyhow::ensure!(status.success(), "restore-preparation worker failed"); + let new_process_restore_prepare_ms = elapsed_ms(started); + + fs_err::remove_dir_all(&snapshot_path).context("failed to remove benchmark snapshot")?; + Ok(Iteration { + snapshot_publish_ms, + restore_prepare_ms, + snapshot_verify_ms, + cow_map_ms, + cow_dirty_all_ms, + repeat_restore_prepare_ms, + new_process_restore_prepare_ms, + repeat_verify_ms, + repeat_cow_map_ms, + }) +} + +fn median(samples: &[f64]) -> f64 { + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + let middle = sorted.len() / 2; + if sorted.len().is_multiple_of(2) { + (sorted[middle - 1] + sorted[middle]) / 2.0 + } else { + sorted[middle] + } +} + +fn nearest_rank_percentile(samples: &[f64], percentile: usize) -> f64 { + assert!(!samples.is_empty()); + assert!((1..=100).contains(&percentile)); + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + let index = (percentile * sorted.len()).div_ceil(100) - 1; + sorted[index] +} + +fn metric_json(name: &str, samples: &[f64]) -> String { + let values = samples + .iter() + .map(|sample| format!("{sample:.6}")) + .collect::>() + .join(","); + format!( + "\"{name}\":{{\"samples_ms\":[{values}],\"p50_ms\":{:.6},\ + \"p95_ms\":{:.6},\"min_ms\":{:.6},\"max_ms\":{:.6}}}", + median(samples), + nearest_rank_percentile(samples, 95), + samples.iter().copied().fold(f64::INFINITY, f64::min), + samples.iter().copied().fold(f64::NEG_INFINITY, f64::max), + ) +} + +fn print_result(options: &Options, samples: &Samples) { + let metrics = [ + metric_json("snapshot_publish", &samples.snapshot_publish_ms), + metric_json("restore_prepare", &samples.restore_prepare_ms), + metric_json("snapshot_verify", &samples.snapshot_verify_ms), + metric_json("cow_map", &samples.cow_map_ms), + metric_json("cow_dirty_all", &samples.cow_dirty_all_ms), + metric_json("repeat_restore_prepare", &samples.repeat_restore_prepare_ms), + metric_json( + "new_process_restore_prepare", + &samples.new_process_restore_prepare_ms, + ), + metric_json("repeat_verify", &samples.repeat_verify_ms), + metric_json("repeat_cow_map", &samples.repeat_cow_map_ms), + ]; + println!( + "{RESULT_PREFIX}{{\"memory_mib\":{},\"state_bytes\":{},\ + \"artifact_unchanged\":true,\"metrics\":{{{}}}}}", + options.memory_mib, + STATE_SIZE_BYTES, + metrics.join(",") + ); +} + +fn main() -> anyhow::Result<()> { + let options = Options::parse()?; + let memory_size = options + .memory_mib + .checked_mul(1024 * 1024) + .context("memory size overflow")?; + anyhow::ensure!( + memory_size >= SparseMapping::page_size(), + "memory size must be at least one host page" + ); + + if let Some(snapshot_path) = &options.restore_worker { + let (_, _, memory_file) = read_snapshot_with_memory(snapshot_path, memory_size as u64)?; + let mapping = map_copy_on_write(memory_file, memory_size)?; + validate_original(&mapping, memory_size)?; + mapping.write_at(0, &[0x3c])?; + return Ok(()); + } + + let root = tempfile::tempdir().context("failed to create benchmark directory")?; + let executable = std::env::current_exe().context("failed to locate benchmark executable")?; + let memory_path = root.path().join("memory-source.bin"); + create_memory_source(&memory_path, memory_size as u64)?; + let state = vec![0xc3_u8; STATE_SIZE_BYTES]; + + for index in 0..options.warmups { + let sample = run_iteration( + root.path(), + index, + &executable, + &memory_path, + memory_size, + &state, + )?; + println!( + "warmup {}/{}: publish={:.3} ms restore-prep={:.3} ms COW-dirty={:.3} ms", + index + 1, + options.warmups, + sample.snapshot_publish_ms, + sample.restore_prepare_ms, + sample.cow_dirty_all_ms, + ); + } + + let mut samples = Samples::default(); + for index in 0..options.runs { + let sample = run_iteration( + root.path(), + options.warmups + index, + &executable, + &memory_path, + memory_size, + &state, + )?; + println!( + "sample {}/{}: publish={:.3} ms restore-prep={:.3} ms verify={:.3} ms \ + map={:.3} ms dirty={:.3} ms repeat-restore-prep={:.3} ms \ + new-process-restore-prep={:.3} ms", + index + 1, + options.runs, + sample.snapshot_publish_ms, + sample.restore_prepare_ms, + sample.snapshot_verify_ms, + sample.cow_map_ms, + sample.cow_dirty_all_ms, + sample.repeat_restore_prepare_ms, + sample.new_process_restore_prepare_ms, + ); + samples.push(sample); + } + print_result(&options, &samples); + Ok(()) +} diff --git a/openvmm/openvmm_helpers/src/hypervisor.rs b/openvmm/openvmm_helpers/src/hypervisor.rs index 31d909eca43..8f24f3ec18e 100644 --- a/openvmm/openvmm_helpers/src/hypervisor.rs +++ b/openvmm/openvmm_helpers/src/hypervisor.rs @@ -4,9 +4,14 @@ //! Hypervisor resource construction and auto-detection for OpenVMM entry //! points. +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; use hypervisor_resources::HypervisorKind; use vm_resource::Resource; +pub mod microvm; + /// Returns a [`Resource`] for the first available hypervisor /// backend. /// @@ -54,3 +59,75 @@ pub fn hypervisor_resource(spec: &str) -> anyhow::Result) -> anyhow::Result { + fingerprint_backend(spec)?.cpu_fingerprint() +} + +/// A hypervisor backend selected for a host CPU fingerprint and its checks. +pub struct FingerprintBackend<'a> { + probe: &'static dyn hypervisor_resources::HypervisorProbe, + params: Vec<(&'a str, &'a str)>, +} + +/// Selects the hypervisor backend for a host CPU fingerprint. +/// +/// `spec` selects the backend as for [`hypervisor_resource`]. Without it, the +/// first available backend is used, as for [`choose_hypervisor`]. +pub fn fingerprint_backend(spec: Option<&str>) -> anyhow::Result> { + match spec { + Some(spec) => { + let (name, params) = parse_hypervisor_spec(spec)?; + let probe = hypervisor_resources::probe_by_name(name) + .ok_or_else(|| anyhow::anyhow!("unknown hypervisor: {name}"))?; + Ok(FingerprintBackend { probe, params }) + } + None => { + for probe in hypervisor_resources::probes() { + if probe.try_new_resource()?.is_some() { + return Ok(FingerprintBackend { + probe, + params: Vec::new(), + }); + } + } + anyhow::bail!("no hypervisor available"); + } + } +} + +impl FingerprintBackend<'_> { + /// Returns the guest CPU surface that the backend supports on this host. + pub fn cpu_fingerprint(&self) -> anyhow::Result { + self.probe.cpu_fingerprint(&self.params) + } + + /// Returns what VP 0 of a probe partition configured from `profile` + /// reads at the host's CPUID entries outside the profile's tables, or + /// `None` if the backend has no such probe; see + /// [`HypervisorProbe::profile_unlisted_cpuid`](hypervisor_resources::HypervisorProbe::profile_unlisted_cpuid). + pub fn profile_unlisted_cpuid( + &self, + profile: &CpuProfile, + ) -> anyhow::Result>> { + self.probe.profile_unlisted_cpuid(&self.params, profile) + } +} + +#[cfg(test)] +mod tests { + use super::cpu_fingerprint; + + #[test] + fn fingerprint_rejects_unknown_hypervisor() { + let error = cpu_fingerprint(Some("nosuch")).unwrap_err(); + assert_eq!(error.to_string(), "unknown hypervisor: nosuch"); + let error = cpu_fingerprint(Some(":x")).unwrap_err(); + assert_eq!(error.to_string(), "empty hypervisor name in spec: :x"); + } +} diff --git a/openvmm/openvmm_helpers/src/hypervisor/microvm.rs b/openvmm/openvmm_helpers/src/hypervisor/microvm.rs new file mode 100644 index 00000000000..f4105325df9 --- /dev/null +++ b/openvmm/openvmm_helpers/src/hypervisor/microvm.rs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Hypervisor selection for the microVM machine profile. + +use super::choose_hypervisor; +use hypervisor_resources::HypervisorKind; +use vm_resource::Resource; + +/// Returns the native backend supported by the microVM profile. +pub fn choose_microvm_hypervisor() -> anyhow::Result> { + #[cfg(any(target_os = "linux", windows))] + return choose_hypervisor(); + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("the microVM profile requires a Linux KVM/MSHV or Windows WHP host"); +} diff --git a/openvmm/openvmm_helpers/src/shared_memory.rs b/openvmm/openvmm_helpers/src/shared_memory.rs index e85043fa5db..fdf79315a5f 100644 --- a/openvmm/openvmm_helpers/src/shared_memory.rs +++ b/openvmm/openvmm_helpers/src/shared_memory.rs @@ -15,17 +15,29 @@ pub fn open_memory_backing_file( path: &std::path::Path, size: u64, ) -> anyhow::Result { - let file = fs_err::OpenOptions::new() + file_to_shared_memory_fd(open_memory_backing_file_handle(path, size)?) +} + +/// Opens and sizes a file backing guest RAM without converting its handle. +/// +/// Snapshot capture uses this to give the worker a duplicate while retaining +/// the exact same underlying file for publication. +pub fn open_memory_backing_file_handle( + path: &std::path::Path, + size: u64, +) -> anyhow::Result { + let file = std::fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) - .open(path)?; + .open(path) + .with_context(|| format!("failed to open memory backing file {}", path.display()))?; let existing_len = file.metadata()?.len(); if existing_len == 0 { - file.set_len(size) - .context("failed to set memory backing file size")?; + crate::snapshot::fs::initialize_snapshot_memory_backing_file(&file, size) + .context("failed to initialize snapshot memory backing file")?; } else if existing_len != size { anyhow::bail!( "memory backing file {} has size {} bytes, expected {} bytes", @@ -35,7 +47,7 @@ pub fn open_memory_backing_file( ); } - file_to_shared_memory_fd(file.into()) + Ok(file) } /// Convert a `std::fs::File` to the platform-appropriate shared memory handle. @@ -52,3 +64,18 @@ pub fn file_to_shared_memory_fd(file: std::fs::File) -> anyhow::Result anyhow::Result { + #[cfg(unix)] + { + use std::os::unix::io::OwnedFd; + Ok(OwnedFd::from(file)) + } + #[cfg(windows)] + { + Ok(sparse_mmap::new_mappable_from_file_copy_on_write( + &file, false, + )?) + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index cabd96f0fbe..b87d052b5a1 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -3,13 +3,21 @@ //! Snapshot manifest types and I/O functions for saving/restoring VM snapshots. -use anyhow::Context; use mesh::payload::Protobuf; use mesh::payload::Timestamp; -use std::path::Path; -/// Current manifest format version. Bump when making incompatible changes. -pub const MANIFEST_VERSION: u32 = 1; +pub mod format; +pub mod fs; +pub mod microvm; +pub mod publish; +pub mod restore; +pub mod time; + +pub use publish::write_snapshot; + +/// The manifest format version, the only one this OpenVMM reads or writes. +/// Bump when making incompatible changes. +pub const MANIFEST_VERSION: u32 = 6; /// Manifest describing a VM snapshot. #[derive(Clone, Protobuf)] @@ -36,86 +44,32 @@ pub struct SnapshotManifest { /// Architecture string ("x86_64" or "aarch64"). #[mesh(7)] pub architecture: String, -} - -/// Write a snapshot to the given directory. -/// -/// The directory is created if it does not exist. The snapshot consists of: -/// - `manifest.bin` — protobuf-encoded [`SnapshotManifest`] -/// - `state.bin` — raw device saved-state bytes -/// - `memory.bin` — hard link to the memory backing file -pub fn write_snapshot( - dir: &Path, - manifest: &SnapshotManifest, - saved_state_bytes: &[u8], - memory_file_path: &Path, -) -> anyhow::Result<()> { - fs_err::create_dir_all(dir)?; - - // Write manifest. - let manifest_bytes = mesh::payload::encode(manifest.clone()); - fs_err::write(dir.join("manifest.bin"), &manifest_bytes)?; - - // Write device state. - fs_err::write(dir.join("state.bin"), saved_state_bytes)?; - - // Handle memory.bin: hard-link from the backing file. - let memory_bin_path = dir.join("memory.bin"); - let canonical_source = fs_err::canonicalize(memory_file_path)?; - - // Check whether source and target are already the same file (e.g., - // the user pointed --memory-backing-file at /memory.bin directly). - let needs_link = if memory_bin_path.exists() { - let canonical_target = fs_err::canonicalize(&memory_bin_path)?; - if canonical_source == canonical_target { - false - } else { - // Different file at the target path — remove it so the hard - // link can be created. - fs_err::remove_file(&memory_bin_path)?; - true - } - } else { - true - }; - - if needs_link { - if let Err(err) = std::fs::hard_link(&canonical_source, &memory_bin_path) { - if err.kind() == std::io::ErrorKind::CrossesDevices { - anyhow::bail!( - "memory backing file ({}) must be on the same filesystem as the snapshot \ - directory ({}); consider placing the backing file inside the snapshot \ - directory", - memory_file_path.display(), - dir.display(), - ); - } - return Err(err).with_context(|| { - format!( - "failed to hard-link {} -> {}", - canonical_source.display(), - memory_bin_path.display() - ) - }); - } - } - - Ok(()) -} - -/// Read a snapshot from the given directory. -/// -/// Returns the decoded manifest and the raw saved-state bytes. -/// The caller is responsible for opening `memory.bin` separately. -pub fn read_snapshot(dir: &Path) -> anyhow::Result<(SnapshotManifest, Vec)> { - let manifest_bytes = - fs_err::read(dir.join("manifest.bin")).context("failed to read manifest.bin")?; - let manifest: SnapshotManifest = - mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; - - let state_bytes = fs_err::read(dir.join("state.bin")).context("failed to read state.bin")?; - - Ok((manifest, state_bytes)) + /// Length of `state.bin` in bytes. + #[mesh(8)] + pub state_size_bytes: u64, + // Fields 9 and 10 held the artifact digests of manifest version 2. They + // are retired, and their numbers are never reused. + /// Authoritative machine composition for versioned machine profiles. + #[mesh(11)] + pub machine_contract: Option, + /// Snapshot format magic. + #[mesh(12)] + pub format_magic: Vec, + /// Version of the serialized VM saved-state schema. + #[mesh(13)] + pub saved_state_schema_version: u32, + /// Fully qualified protobuf root type stored in `state.bin`. + #[mesh(14)] + pub saved_state_root_type: String, + /// Sandbox capture tier. Empty for blockless microVM snapshots. + #[mesh(15)] + pub snapshot_tier: String, + /// `clone` for reusable artifacts or `resume` for single-use artifacts. + #[mesh(16)] + pub restore_policy: String, + /// Bitmask of configuration sections consumed before capture. + #[mesh(17)] + pub consumed_config_sections: u32, } /// Validate that a snapshot manifest is compatible with the running VM config. @@ -130,13 +84,8 @@ pub fn validate_manifest( expected_vp_count: u32, expected_page_size: u32, ) -> anyhow::Result<()> { - if manifest.version != MANIFEST_VERSION { - anyhow::bail!( - "snapshot manifest version {} is not supported (expected {})", - manifest.version, - MANIFEST_VERSION, - ); - } + format::validate_manifest_header(manifest)?; + format::validate_manifest_contents(manifest)?; if manifest.architecture != expected_arch { anyhow::bail!( @@ -175,10 +124,11 @@ pub fn validate_manifest( #[cfg(test)] mod tests { + use super::restore::read_snapshot; use super::*; /// Helper: build a test manifest with sensible defaults. - fn test_manifest() -> SnapshotManifest { + pub(super) fn test_manifest() -> SnapshotManifest { SnapshotManifest { version: MANIFEST_VERSION, created_at: Timestamp { @@ -190,6 +140,7 @@ mod tests { vp_count: 2, page_size: 4096, architecture: "x86_64".to_string(), + ..Default::default() } } @@ -200,62 +151,30 @@ mod tests { // Create a fake memory backing file in the same directory (same fs). let mem_path = dir.path().join("memory.bin"); - std::fs::write(&mem_path, b"FAKEMEM").unwrap(); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); let manifest = test_manifest(); let state = b"saved-state-data"; write_snapshot(&snap_dir, &manifest, state, &mem_path).unwrap(); - let (read_manifest, read_state) = read_snapshot(&snap_dir).unwrap(); + let (read_manifest, read_state) = read_snapshot(&snap_dir, 1024).unwrap(); assert_eq!(read_manifest.version, manifest.version); assert_eq!(read_manifest.memory_size_bytes, manifest.memory_size_bytes); assert_eq!(read_manifest.vp_count, manifest.vp_count); assert_eq!(read_manifest.architecture, manifest.architecture); + assert_eq!(read_manifest.state_size_bytes, state.len() as u64); assert_eq!(read_state, state); // memory.bin should exist in the snapshot directory. assert!(snap_dir.join("memory.bin").exists()); } - #[test] - fn write_snapshot_creates_dir() { - let dir = tempfile::tempdir().unwrap(); - let snap_dir = dir.path().join("a").join("b").join("c"); - - let mem_path = dir.path().join("memory.bin"); - std::fs::write(&mem_path, b"MEM").unwrap(); - - write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); - - assert!(snap_dir.join("manifest.bin").exists()); - assert!(snap_dir.join("state.bin").exists()); - assert!(snap_dir.join("memory.bin").exists()); - } - - #[test] - fn write_snapshot_same_memory_path() { - // When the memory backing file IS /memory.bin, the function - // should detect the collision and skip the hard-link. - let dir = tempfile::tempdir().unwrap(); - let snap_dir = dir.path().join("snap"); - std::fs::create_dir_all(&snap_dir).unwrap(); - - let mem_path = snap_dir.join("memory.bin"); - std::fs::write(&mem_path, b"SAMEFILE").unwrap(); - - // Should succeed without error. - write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); - - // The file content should be unchanged. - assert_eq!(std::fs::read(&mem_path).unwrap(), b"SAMEFILE"); - } - #[test] fn read_snapshot_missing_file() { let dir = tempfile::tempdir().unwrap(); // No files written — read should fail. - let result = read_snapshot(dir.path()); + let result = read_snapshot(dir.path(), 1024); assert!(result.is_err()); } diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs new file mode 100644 index 00000000000..5693a41bb42 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot format identifiers and manifest format validation: format magic +//! and version, saved-state schema, capture tiers, restore policies, +//! configuration sections, artifact names, and size limits. + +use super::MANIFEST_VERSION; +use super::SnapshotManifest; +use super::microvm; +use mesh::payload::Timestamp; +use sha2::Digest; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; + +/// Magic identifying the OpenVMM snapshot manifest format. +pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V6\0"; +/// Saved-state schema version used by the VM worker envelope. +pub const SAVED_STATE_SCHEMA_VERSION: u32 = 1; +/// Protobuf root type stored in `state.bin`. +pub const SAVED_STATE_ROOT_TYPE: &str = "openvmm.SavedState"; +/// Fleet-wide snapshot captured before image and sandbox configuration is consumed. +pub const SNAPSHOT_TIER_PLATFORM: &str = "platform"; +/// Tenant-scoped snapshot captured at a warm workload handoff point. +pub const SNAPSHOT_TIER_WORKLOAD_START: &str = "workload-start"; +/// Single-instance checkpoint captured during a live workload. +pub const SNAPSHOT_TIER_INSTANCE_CHECKPOINT: &str = "instance-checkpoint"; +/// Reusable restore policy for snapshots that create independent instances. +pub const SNAPSHOT_RESTORE_POLICY_CLONE: &str = "clone"; +/// Single-use restore policy for snapshots that continue one instance. +pub const SNAPSHOT_RESTORE_POLICY_RESUME: &str = "resume"; +/// The invariant configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_INVARIANTS: u32 = 1 << 0; +/// The image-binding configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_IMAGE_BINDING: u32 = 1 << 1; +/// The per-sandbox configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_SANDBOX: u32 = 1 << 2; +pub(super) const SNAPSHOT_CONFIG_ALL: u32 = + SNAPSHOT_CONFIG_INVARIANTS | SNAPSHOT_CONFIG_IMAGE_BINDING | SNAPSHOT_CONFIG_SANDBOX; + +pub(super) const MANIFEST_FILE_NAME: &str = "manifest.bin"; +pub(super) const STATE_FILE_NAME: &str = "state.bin"; +pub(super) const MEMORY_FILE_NAME: &str = "memory.bin"; +pub(super) const RESUME_CLAIM_FILE_NAME: &str = "resume.claim"; +/// Fixed snapshot-relative name of a paired scratch image. +pub const SCRATCH_FILE_NAME: &str = "scratch.img"; +pub(super) const MAX_MANIFEST_SIZE_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_SAVED_STATE_SIZE_BYTES: u64 = 256 * 1024 * 1024; +pub(super) const SHA256_SIZE: usize = 32; + +/// An empty manifest in the current snapshot format. +/// +/// Capture code sets the identity fields (version, creation time, OpenVMM +/// version, and VM shape) explicitly and takes the rest from this default: +/// the current format magic, saved-state schema, and root type, with every +/// other field empty. +impl Default for SnapshotManifest { + fn default() -> Self { + Self { + version: MANIFEST_VERSION, + created_at: Timestamp { + seconds: 0, + nanos: 0, + }, + openvmm_version: String::new(), + memory_size_bytes: 0, + vp_count: 0, + page_size: 0, + architecture: String::new(), + state_size_bytes: 0, + machine_contract: None, + format_magic: SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: SAVED_STATE_ROOT_TYPE.to_owned(), + snapshot_tier: String::new(), + restore_policy: String::new(), + consumed_config_sections: 0, + } + } +} + +pub(super) fn validate_sha256(digest: &[u8], description: &str) -> anyhow::Result<()> { + anyhow::ensure!( + digest.len() == SHA256_SIZE, + "{description} SHA-256 digest has invalid length {}", + digest.len(), + ); + Ok(()) +} + +pub(super) fn verify_digest( + bytes: &[u8], + expected: &[u8], + description: &str, +) -> anyhow::Result<()> { + let actual: [u8; 32] = sha2::Sha256::digest(bytes).into(); + anyhow::ensure!( + actual.as_slice() == expected, + "{description} SHA-256 digest mismatch", + ); + Ok(()) +} + +/// Validates the manifest's format identity: version 6, the matching magic, +/// and the saved-state schema. Any other version or magic is +/// `E_SNAPSHOT_VERSION`, which asks for a recapture. +pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::Result<()> { + if manifest.version != MANIFEST_VERSION { + return Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + format!( + "snapshot manifest version {} is not supported (expected {MANIFEST_VERSION}); recapture the snapshot", + manifest.version + ), + ) + .into()); + } + if manifest.format_magic != SNAPSHOT_FORMAT_MAGIC { + return Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + "snapshot format magic is invalid; recapture the snapshot", + ) + .into()); + } + anyhow::ensure!( + manifest.saved_state_schema_version == SAVED_STATE_SCHEMA_VERSION, + "snapshot saved-state schema version {} is unsupported", + manifest.saved_state_schema_version + ); + anyhow::ensure!( + manifest.saved_state_root_type == SAVED_STATE_ROOT_TYPE, + "snapshot saved-state root type '{}' is unsupported", + manifest.saved_state_root_type + ); + Ok(()) +} + +/// Validates the manifest's machine contract and capture tier. A microVM +/// machine contract must carry valid time ABI records; a manifest without a +/// machine contract, a regular VM's, has none. +pub(super) fn validate_manifest_contents(manifest: &SnapshotManifest) -> anyhow::Result<()> { + // The time records come first, so a clock parameter on a platform + // snapshot's command line reports `E_CMDLINE_CLOCK_TOKEN`. + if let Some(contract) = &manifest.machine_contract { + super::time::validate_time_abi_contract(contract)?; + } + microvm::validate_snapshot_tier(manifest) +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_manifest; + use super::super::validate_manifest; + use super::*; + + #[test] + fn other_versions_are_rejected_with_e_snapshot_version() { + for version in [0, 1, 2, 3, 4, 5, 7, u32::MAX] { + let mut manifest = test_manifest(); + manifest.version = version; + let error = format!( + "{:#}", + validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err() + ); + assert!(error.contains("[E_SNAPSHOT_VERSION]"), "{error}"); + assert!(error.contains("recapture the snapshot"), "{error}"); + } + } + + #[test] + fn validate_manifest_wrong_magic() { + for magic in [&b"NOT_OPENVMM"[..], b"OPENVMM_SNAPSHOT_V5\0"] { + let mut manifest = test_manifest(); + manifest.format_magic = magic.to_vec(); + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("[E_SNAPSHOT_VERSION]"), "{err}"); + assert!(err.to_string().contains("format magic"), "{err}"); + } + } + + #[test] + fn validate_manifest_wrong_saved_state_schema() { + let mut manifest = test_manifest(); + manifest.saved_state_schema_version += 1; + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("schema version")); + } + + #[test] + fn validate_manifest_wrong_saved_state_root() { + let mut manifest = test_manifest(); + manifest.saved_state_root_type = "other.SavedState".to_owned(); + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("root type")); + } + + #[test] + fn retired_digest_fields_are_ignored_when_decoding() { + // A manifest encoded with fields 9 and 10 still decodes; the version + // check rejects every manifest that set them. + #[derive(mesh::payload::Protobuf)] + #[mesh(package = "openvmm.snapshot")] + struct WithDigests { + #[mesh(1)] + version: u32, + #[mesh(9)] + state_sha256: Vec, + #[mesh(10)] + memory_sha256: Vec, + } + let bytes = mesh::payload::encode(WithDigests { + version: 2, + state_sha256: vec![0xa5; SHA256_SIZE], + memory_sha256: vec![0x5a; SHA256_SIZE], + }); + let manifest: SnapshotManifest = mesh::payload::decode(&bytes).unwrap(); + assert_eq!(manifest.version, 2); + let error = validate_manifest_header(&manifest).unwrap_err(); + assert!( + error.to_string().contains("[E_SNAPSHOT_VERSION]"), + "{error}" + ); + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs new file mode 100644 index 00000000000..024b8356239 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -0,0 +1,1273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! File-system helpers for snapshot artifacts, including their +//! platform-specific implementations: private files and directories, +//! no-replace renames and directory flushes, directory-relative artifact +//! access and file generations, exact-file hard links, sparse-aware clone and +//! copy with allocation accounting, and SHA-256 over file handles. + +use super::format::validate_sha256; +use anyhow::Context; +use sha2::Digest; +use std::io::Read; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +const COPY_BUFFER_SIZE: usize = 1024 * 1024; + +pub(super) fn snapshot_parent(dir: &Path) -> &Path { + match dir.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +pub(super) fn path_exists(path: &Path) -> anyhow::Result { + match fs_err::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())), + } +} + +pub(super) fn ensure_path_absent(path: &Path, description: &str) -> anyhow::Result<()> { + anyhow::ensure!( + !path_exists(path)?, + "{description} already exists: {}", + path.display(), + ); + Ok(()) +} + +pub(super) fn validate_directory(path: &Path, description: &str) -> anyhow::Result<()> { + let metadata = fs_err::symlink_metadata(path) + .with_context(|| format!("failed to inspect {description} {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_dir(), + "{description} is not a directory: {}", + path.display(), + ); + Ok(()) +} + +pub(super) fn create_private_directory(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + let descriptor: pal::windows::security::LocalSecurityDescriptor = + "D:P(A;;FA;;;SY)(A;;FA;;;OW)".parse()?; + pal::windows::security::create::create_directory_with_security(path, &descriptor) + } + #[cfg(not(windows))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + #[cfg(not(windows))] + return builder.create(path); +} + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub(super) fn rename_no_replace(source: &Path, destination: &Path) -> anyhow::Result<()> { + let source_parent = snapshot_parent(source); + let destination_parent = snapshot_parent(destination); + anyhow::ensure!( + source_parent == destination_parent, + "snapshot staging and destination directories have different parents", + ); + let source_name = source + .file_name() + .context("snapshot staging path must name a directory")?; + let destination_name = destination + .file_name() + .context("snapshot destination must name a directory")?; + let parent = std::fs::File::open(destination_parent).with_context(|| { + format!( + "failed to open snapshot parent directory {}", + destination_parent.display() + ) + })?; + nix::fcntl::renameat2( + &parent, + source_name, + &parent, + destination_name, + nix::fcntl::RenameFlags::RENAME_NOREPLACE, + )?; + Ok(()) +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub(super) fn rename_no_replace(source: &Path, destination: &Path) -> anyhow::Result<()> { + fs_err::rename(source, destination)?; + Ok(()) +} + +fn create_file(path: &Path, description: &str) -> anyhow::Result { + #[cfg(windows)] + { + let descriptor: pal::windows::security::LocalSecurityDescriptor = + "D:P(A;;FA;;;SY)(A;;FA;;;OW)".parse()?; + pal::windows::security::create::create_file_with_security(path, &descriptor) + .with_context(|| format!("failed to create {description} at {}", path.display())) + } + #[cfg(not(windows))] + let mut options = fs_err::OpenOptions::new(); + #[cfg(not(windows))] + options.write(true).create_new(true); + #[cfg(unix)] + { + use fs_err::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(windows))] + return options + .open(path) + .map(Into::into) + .with_context(|| format!("failed to create {description} at {}", path.display())); +} + +pub(super) fn write_bytes(path: &Path, bytes: &[u8], description: &str) -> anyhow::Result<()> { + let mut file = create_file(path, description)?; + file.write_all(bytes) + .with_context(|| format!("failed to write {description}"))?; + file.sync_all() + .with_context(|| format!("failed to flush {description}"))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +pub(super) fn create_hard_link_from_handle( + source: &std::fs::File, + directory: &std::fs::File, + name: &str, +) -> std::io::Result<&'static str> { + use nix::fcntl::AT_FDCWD; + use nix::fcntl::AtFlags; + use std::os::fd::AsRawFd; + + match nix::unistd::linkat( + source, + Path::new(""), + directory, + name, + AtFlags::AT_EMPTY_PATH, + ) { + Ok(()) => return Ok("linkat-empty-path"), + Err(error) + if matches!( + error, + nix::errno::Errno::EPERM | nix::errno::Errno::EINVAL | nix::errno::Errno::ENOENT + ) => + { + tracing::debug!( + error = &nix_error(error) as &dyn std::error::Error, + "AT_EMPTY_PATH hard link is unavailable" + ); + } + Err(error) => return Err(nix_error(error)), + } + + let source_path = PathBuf::from(format!("/proc/self/fd/{}", source.as_raw_fd())); + // Following is required to link the descriptor's target rather than the + // procfs symlink itself. The descriptor remains live, and the caller proves + // the resulting device/inode against `source` before publication. + nix::unistd::linkat( + AT_FDCWD, + &source_path, + directory, + name, + AtFlags::AT_SYMLINK_FOLLOW, + ) + .map(|()| "linkat-proc-fd") + .map_err(nix_error) +} + +#[cfg(windows)] +pub(super) fn create_hard_link_from_handle( + source: &std::fs::File, + directory: &std::fs::File, + name: &str, +) -> std::io::Result<&'static str> { + pal::windows::fs::relative::hard_link_relative(source, directory, std::ffi::OsStr::new(name))?; + Ok("file-link-information") +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn create_hard_link_from_handle( + _source: &std::fs::File, + _directory: &std::fs::File, + _name: &str, +) -> std::io::Result<&'static str> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "exact-file hard links are unsupported on this platform", + )) +} + +#[cfg(target_os = "linux")] +pub(super) fn hard_link_is_unsupported(error: &std::io::Error) -> bool { + matches!( + error.raw_os_error(), + Some( + libc::EACCES + | libc::EMLINK + | libc::EINVAL + | libc::ELOOP + | libc::ENOENT + | libc::ENOSYS + | libc::EOPNOTSUPP + | libc::EPERM + | libc::EXDEV + ) + ) +} + +#[cfg(windows)] +pub(super) fn hard_link_is_unsupported(error: &std::io::Error) -> bool { + use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; + use windows_sys::Win32::Foundation::ERROR_FILE_SYSTEM_LIMITATION; + use windows_sys::Win32::Foundation::ERROR_INVALID_FUNCTION; + use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; + use windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE; + use windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED; + use windows_sys::Win32::Foundation::ERROR_PRIVILEGE_NOT_HELD; + use windows_sys::Win32::Foundation::ERROR_TOO_MANY_LINKS; + + matches!( + error.raw_os_error(), + Some(raw) if matches!( + raw as u32, + ERROR_ACCESS_DENIED + | ERROR_FILE_SYSTEM_LIMITATION + | ERROR_INVALID_FUNCTION + | ERROR_INVALID_PARAMETER + | ERROR_NOT_SAME_DEVICE + | ERROR_NOT_SUPPORTED + | ERROR_PRIVILEGE_NOT_HELD + | ERROR_TOO_MANY_LINKS + ) + ) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn hard_link_is_unsupported(_error: &std::io::Error) -> bool { + true +} + +pub(super) fn verify_hard_link_identity( + source: &std::fs::File, + linked: &std::fs::File, + expected_length: u64, +) -> anyhow::Result<()> { + let source_metadata = source + .metadata() + .context("failed to re-inspect automatic snapshot RAM handle")?; + let linked_metadata = linked + .metadata() + .context("failed to inspect linked snapshot memory")?; + anyhow::ensure!( + source_metadata.file_type().is_file() && linked_metadata.file_type().is_file(), + "automatic snapshot RAM hard link does not resolve to regular files" + ); + anyhow::ensure!( + source_metadata.len() == expected_length && linked_metadata.len() == expected_length, + "automatic snapshot RAM hard-link EOF does not match manifest ({expected_length} bytes)" + ); + + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + anyhow::ensure!( + source_metadata.dev() == linked_metadata.dev() + && source_metadata.ino() == linked_metadata.ino(), + "automatic snapshot RAM hard link has the wrong device or inode" + ); + } + #[cfg(windows)] + { + let source_identity = pal::windows::fs::relative::file_identity(source) + .context("failed to query automatic snapshot RAM identity")?; + let linked_identity = pal::windows::fs::relative::file_identity(linked) + .context("failed to query linked snapshot RAM identity")?; + anyhow::ensure!( + source_identity == linked_identity && source_identity.end_of_file == expected_length, + "automatic snapshot RAM hard link has the wrong file identity or EOF" + ); + } + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("exact-file hard-link identity checks are unsupported on this platform"); + + Ok(()) +} + +/// Sizes a newly created snapshot RAM backing. +/// +/// Windows leaves the file non-sparse to avoid slow copy-on-write faults from +/// sparse files. Other supported platforms retain sparse allocation. +pub fn initialize_snapshot_memory_backing_file( + file: &std::fs::File, + size: u64, +) -> anyhow::Result { + let metadata = file + .metadata() + .context("failed to inspect new snapshot memory backing")?; + anyhow::ensure!( + metadata.file_type().is_file(), + "snapshot memory backing handle is not a regular file" + ); + anyhow::ensure!( + metadata.len() == 0, + "new snapshot memory backing is not empty" + ); + + size_empty_file(file, size, "snapshot memory backing")?; + let allocated_bytes = allocated_file_bytes(file, size) + .context("failed to inspect snapshot memory backing allocation")?; + #[cfg(not(windows))] + anyhow::ensure!( + allocated_bytes == 0, + "snapshot memory backing allocated {allocated_bytes} bytes while sizing to {size} bytes" + ); + tracing::info!( + logical_bytes = size, + allocated_bytes, + "initialized snapshot RAM backing" + ); + Ok(allocated_bytes) +} + +pub(super) fn copy_exact( + source_file: &std::fs::File, + destination_path: &Path, + expected_length: u64, + source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + let source_metadata = source_file + .metadata() + .with_context(|| format!("failed to inspect {source_description}"))?; + anyhow::ensure!( + source_metadata.file_type().is_file(), + "{source_description} handle is not a regular file" + ); + let source_length = source_metadata.len(); + anyhow::ensure!( + source_length == expected_length, + "{source_description} size ({source_length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + let destination = create_file(destination_path, destination_description)?; + let method = clone_or_copy(source_file, &destination, expected_length) + .with_context(|| format!("failed to copy {source_description}"))?; + + anyhow::ensure!( + source_file + .metadata() + .with_context(|| format!("failed to re-inspect {source_description}"))? + .len() + == expected_length, + "{source_description} changed length while it was being copied", + ); + anyhow::ensure!( + destination + .metadata() + .with_context(|| format!("failed to inspect {destination_description}"))? + .len() + == expected_length, + "{destination_description} length does not match {source_description}", + ); + + destination + .sync_all() + .with_context(|| format!("failed to flush {destination_description}"))?; + let source_allocated_bytes = allocated_file_bytes(source_file, expected_length).ok(); + let allocated_bytes = allocated_file_bytes(&destination, expected_length).ok(); + tracing::info!( + method, + logical_bytes = expected_length, + ?source_allocated_bytes, + ?allocated_bytes, + "published independent snapshot artifact" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +pub(super) fn reflink_exact( + source_file: &std::fs::File, + destination_path: &Path, + expected_length: u64, + source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + let source_metadata = source_file + .metadata() + .with_context(|| format!("failed to inspect {source_description}"))?; + anyhow::ensure!( + source_metadata.file_type().is_file() && source_metadata.len() == expected_length, + "{source_description} does not match the expected regular-file generation" + ); + let destination = create_file(destination_path, destination_description)?; + pal::unix::fs::reflink(source_file, &destination).with_context(|| { + format!("{destination_description} requires filesystem reflink support") + })?; + + anyhow::ensure!( + destination + .metadata() + .with_context(|| format!("failed to inspect {destination_description}"))? + .len() + == expected_length, + "{destination_description} length does not match {source_description}", + ); + destination + .sync_all() + .with_context(|| format!("failed to flush {destination_description}"))?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn reflink_exact( + _source_file: &std::fs::File, + _destination_path: &Path, + _expected_length: u64, + _source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + anyhow::bail!("{destination_description} copy-on-write cloning is unsupported on this platform") +} + +fn size_empty_file(file: &std::fs::File, length: u64, description: &str) -> anyhow::Result<()> { + file.set_len(0) + .with_context(|| format!("failed to reset {description}"))?; + file.set_len(length) + .with_context(|| format!("failed to size {description}"))?; + anyhow::ensure!( + file.metadata() + .with_context(|| format!("failed to inspect {description}"))? + .len() + == length, + "{description} has the wrong logical length after sizing" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + let clone_error = match pal::unix::fs::reflink(source, destination) { + Ok(()) => return Ok("ficlone"), + Err(error) => error, + }; + tracing::debug!( + error = &clone_error as &dyn std::error::Error, + "FICLONE unavailable" + ); + size_empty_file(destination, length, "snapshot clone destination")?; + match linux_allocated_ranges(source, length) { + Ok(ranges) => { + copy_allocated_ranges(source, destination, length, &ranges)?; + Ok("seek-data-hole") + } + Err(error) => { + tracing::debug!( + error = &error as &dyn std::error::Error, + "SEEK_DATA/SEEK_HOLE unavailable" + ); + copy_nonzero_data(source, destination, 0, length)?; + Ok("zero-scan") + } + } +} + +#[cfg(windows)] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + let mut source = source + .try_clone() + .context("failed to duplicate snapshot memory source handle")?; + source + .seek(SeekFrom::Start(0)) + .context("failed to rewind snapshot memory source")?; + let mut destination = destination + .try_clone() + .context("failed to duplicate snapshot memory destination handle")?; + destination + .seek(SeekFrom::Start(0)) + .context("failed to rewind snapshot memory destination")?; + + let limit = length + .checked_add(1) + .context("snapshot memory length cannot be bounded")?; + let mut source = Read::by_ref(&mut source).take(limit); + let mut total = 0_u64; + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + loop { + let count = source + .read(&mut buffer) + .context("failed to read snapshot memory source")?; + if count == 0 { + break; + } + destination + .write_all(&buffer[..count]) + .context("failed to write dense snapshot memory destination")?; + total = total + .checked_add(count as u64) + .context("snapshot memory length overflowed u64")?; + } + anyhow::ensure!( + total == length, + "snapshot memory changed while it was copied (expected {length} bytes, copied {total} bytes)", + ); + Ok("dense-copy") +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + size_empty_file(destination, length, "snapshot clone destination")?; + copy_nonzero_data(source, destination, 0, length)?; + Ok("zero-scan") +} + +#[cfg(target_os = "linux")] +fn copy_allocated_ranges( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, + ranges: &[(u64, u64)], +) -> anyhow::Result<()> { + let mut cursor = 0_u64; + for &(offset, range_length) in ranges { + let end = offset + .checked_add(range_length) + .context("allocated range overflowed u64")?; + anyhow::ensure!( + offset >= cursor && range_length != 0 && end <= length, + "invalid allocated range for sparse copy" + ); + copy_nonzero_data(source, destination, offset, range_length)?; + cursor = end; + } + Ok(()) +} + +#[cfg(not(windows))] +fn copy_nonzero_data( + source: &std::fs::File, + destination: &std::fs::File, + offset: u64, + length: u64, +) -> anyhow::Result<()> { + let end = offset + .checked_add(length) + .context("snapshot copy range overflowed u64")?; + let mut cursor = offset; + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + while cursor < end { + let count = usize::try_from((end - cursor).min(buffer.len() as u64)).unwrap(); + let read = read_file_at(source, &mut buffer[..count], cursor) + .context("failed to read snapshot source")?; + anyhow::ensure!( + read != 0, + "snapshot source changed while it was being copied" + ); + if buffer[..read].iter().any(|byte| *byte != 0) { + write_file_all_at(destination, &buffer[..read], cursor) + .context("failed to write snapshot destination")?; + } + cursor = cursor + .checked_add(read as u64) + .context("snapshot copy offset overflowed u64")?; + } + Ok(()) +} + +#[cfg(not(windows))] +fn read_file_at(file: &std::fs::File, buffer: &mut [u8], offset: u64) -> std::io::Result { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_at(file, buffer, offset) + } + #[cfg(windows)] + { + std::os::windows::fs::FileExt::seek_read(file, buffer, offset) + } +} + +#[cfg(not(windows))] +fn write_file_all_at( + file: &std::fs::File, + mut bytes: &[u8], + mut offset: u64, +) -> std::io::Result<()> { + while !bytes.is_empty() { + #[cfg(unix)] + let written = std::os::unix::fs::FileExt::write_at(file, bytes, offset)?; + #[cfg(windows)] + let written = std::os::windows::fs::FileExt::seek_write(file, bytes, offset)?; + if written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "failed to write snapshot destination", + )); + } + bytes = &bytes[written..]; + offset = offset + .checked_add(written as u64) + .ok_or_else(|| std::io::Error::other("snapshot write offset overflowed u64"))?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn linux_allocated_ranges(file: &std::fs::File, length: u64) -> std::io::Result> { + use std::os::fd::AsRawFd; + + let extent_file = std::fs::File::open(format!("/proc/self/fd/{}", file.as_raw_fd()))?; + let mut ranges = Vec::new(); + let mut cursor = 0_u64; + while cursor < length { + let Some(data) = pal::unix::fs::seek_data(&extent_file, cursor)? else { + break; + }; + if data >= length { + break; + } + let hole = pal::unix::fs::seek_hole(&extent_file, data)?.unwrap_or(length); + if hole <= data || hole > length { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "filesystem returned an invalid sparse extent", + )); + } + ranges.push((data, hole - data)); + cursor = hole; + } + Ok(ranges) +} + +#[cfg(target_os = "linux")] +pub(super) fn allocated_file_bytes(file: &std::fs::File, _length: u64) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + file.metadata()? + .blocks() + .checked_mul(512) + .context("allocated byte count overflowed u64") +} + +#[cfg(windows)] +pub(super) fn allocated_file_bytes(file: &std::fs::File, length: u64) -> anyhow::Result { + match pal::windows::fs::sparse::allocated_ranges(file, length) { + Ok(ranges) => ranges + .into_iter() + .try_fold(0_u64, |total, (_, range_length)| { + total + .checked_add(range_length) + .context("allocated byte count overflowed u64") + }), + Err(error) => { + tracing::debug!( + error = &error as &dyn std::error::Error, + "allocated-range accounting is unavailable" + ); + pal::windows::fs::sparse::allocation_size(file).map_err(anyhow::Error::new) + } + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn allocated_file_bytes(file: &std::fs::File, _length: u64) -> anyhow::Result { + Ok(file.metadata()?.len()) +} + +pub(super) struct OpenedSnapshotDirectory { + pub(super) file: std::fs::File, + path: PathBuf, +} + +impl OpenedSnapshotDirectory { + pub(super) fn open(path: &Path) -> anyhow::Result { + Self::open_impl(path, false) + } + + pub(super) fn open_for_publication(path: &Path) -> anyhow::Result { + Self::open_impl(path, true) + } + + fn open_impl(path: &Path, allow_changes: bool) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; + + options + .share_mode(if allow_changes { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } else { + FILE_SHARE_READ + }) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + } + #[cfg(not(windows))] + let _ = allow_changes; + let file = options + .open(path) + .with_context(|| format!("failed to open snapshot directory {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect snapshot directory {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_dir(), + "snapshot path is not a directory: {}", + path.display(), + ); + reject_windows_reparse_point(&metadata, "snapshot directory", path)?; + Ok(Self { + file, + path: path.to_owned(), + }) + } + + pub(super) fn open_regular_file( + &self, + name: &str, + description: &str, + ) -> anyhow::Result { + #[cfg(target_os = "linux")] + let file = { + use nix::fcntl::OFlag; + use nix::sys::stat::Mode; + + let fd = nix::fcntl::openat( + &self.file, + name, + OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, + Mode::empty(), + ) + .map_err(nix_error) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + std::fs::File::from(fd) + }; + #[cfg(windows)] + let file = pal::windows::fs::relative::open_relative_read_only( + &self.file, + std::ffi::OsStr::new(name), + ) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + #[cfg(not(any(target_os = "linux", windows)))] + let file = open_regular_file_impl(&self.path.join(name), description)?; + + validate_opened_regular_file(&file, description, &self.display_path(name))?; + Ok(file) + } + + pub(super) fn open_regular_file_for_identity( + &self, + name: &str, + description: &str, + ) -> anyhow::Result { + #[cfg(windows)] + let file = pal::windows::fs::relative::open_relative_for_identity( + &self.file, + std::ffi::OsStr::new(name), + ) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + #[cfg(not(windows))] + let file = self.open_regular_file(name, description)?; + + validate_opened_regular_file(&file, description, &self.display_path(name))?; + Ok(file) + } + + pub(super) fn open_file_with_length( + &self, + name: &str, + expected_length: u64, + artifact_name: &str, + ) -> anyhow::Result { + let file = self.open_regular_file(name, artifact_name)?; + let length = opened_file_generation(&file, artifact_name)?.length(); + anyhow::ensure!( + length == expected_length, + "{artifact_name} size ({length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + Ok(file) + } + + pub(super) fn entry_names(&self) -> anyhow::Result> { + #[cfg(target_os = "linux")] + { + use std::os::fd::OwnedFd; + use std::os::unix::ffi::OsStrExt; + + let directory: OwnedFd = self + .file + .try_clone() + .context("failed to duplicate snapshot directory handle")? + .into(); + let mut directory = nix::dir::Dir::from_fd(directory).map_err(nix_error)?; + let mut names = Vec::new(); + for entry in directory.iter() { + let entry = entry.map_err(nix_error)?; + let name = std::ffi::OsStr::from_bytes(entry.file_name().to_bytes()); + if name != "." && name != ".." { + names.push(name.to_owned()); + } + } + Ok(names) + } + #[cfg(windows)] + { + pal::windows::fs::relative::directory_entry_names(&self.file) + .context("failed to enumerate opened snapshot directory") + } + #[cfg(not(any(target_os = "linux", windows)))] + { + fs_err::read_dir(&self.path) + .with_context(|| { + format!( + "failed to enumerate snapshot directory {}", + self.path.display() + ) + })? + .map(|entry| { + entry + .context("failed to inspect snapshot directory entry") + .map(|entry| entry.file_name()) + }) + .collect() + } + } + + pub(super) fn create_new_file(&self, name: &str) -> std::io::Result { + #[cfg(target_os = "linux")] + { + use nix::fcntl::OFlag; + use nix::sys::stat::Mode; + + nix::fcntl::openat( + &self.file, + name, + OFlag::O_WRONLY + | OFlag::O_CREAT + | OFlag::O_EXCL + | OFlag::O_CLOEXEC + | OFlag::O_NOFOLLOW, + Mode::S_IRUSR | Mode::S_IWUSR, + ) + .map(std::fs::File::from) + .map_err(nix_error) + } + #[cfg(windows)] + { + pal::windows::fs::relative::create_relative_new(&self.file, std::ffi::OsStr::new(name)) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(self.path.join(name)) + } + } + + pub(super) fn sync(&self) -> anyhow::Result<()> { + #[cfg(unix)] + { + self.file.sync_all().context("failed to flush directory") + } + #[cfg(not(unix))] + { + Ok(()) + } + } + + pub(super) fn display_path(&self, name: impl AsRef) -> PathBuf { + self.path.join(name) + } + + pub(super) fn into_file(self) -> std::fs::File { + self.file + } +} + +#[cfg(target_os = "linux")] +fn nix_error(error: nix::errno::Errno) -> std::io::Error { + std::io::Error::from_raw_os_error(error as i32) +} + +pub(super) fn open_regular_file(path: &Path, description: &str) -> anyhow::Result { + open_regular_file_impl(path, description) +} + +fn open_regular_file_impl(path: &Path, description: &str) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + + let file = options + .open(path) + .with_context(|| format!("failed to open {description} at {}", path.display()))?; + validate_opened_regular_file(&file, description, path)?; + Ok(file) +} + +fn validate_opened_regular_file( + file: &std::fs::File, + description: &str, + path: &Path, +) -> anyhow::Result<()> { + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "{description} is not a regular file: {}", + path.display(), + ); + reject_windows_reparse_point(&metadata, description, path) +} + +#[cfg(windows)] +fn reject_windows_reparse_point( + metadata: &std::fs::Metadata, + description: &str, + path: &Path, +) -> anyhow::Result<()> { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + + anyhow::ensure!( + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0, + "{description} is a reparse point: {}", + path.display(), + ); + Ok(()) +} + +#[cfg(not(windows))] +fn reject_windows_reparse_point( + _metadata: &std::fs::Metadata, + _description: &str, + _path: &Path, +) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + device: u64, + inode: u64, + length: u64, + modified_seconds: i64, + modified_nanoseconds: i64, + changed_seconds: i64, + changed_nanoseconds: i64, +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + volume_serial_number: u64, + file_id: [u8; 16], + length: u64, +} + +#[cfg(not(any(target_os = "linux", windows)))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + length: u64, +} + +impl OpenedFileGeneration { + pub(super) fn length(self) -> u64 { + self.length + } +} + +#[cfg(target_os = "linux")] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + Ok(OpenedFileGeneration { + device: metadata.dev(), + inode: metadata.ino(), + length: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + let identity = pal::windows::fs::relative::file_identity(file) + .with_context(|| format!("failed to query {description} FILE_ID_INFO and EOF"))?; + Ok(OpenedFileGeneration { + volume_serial_number: identity.volume_serial_number, + file_id: identity.file_id, + length: identity.end_of_file, + }) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + Ok(OpenedFileGeneration { + length: metadata.len(), + }) +} + +pub(super) fn read_bounded_open_file( + file: &std::fs::File, + maximum_size: u64, + description: &str, +) -> anyhow::Result> { + let generation = opened_file_generation(file, description)?; + let length = generation.length(); + anyhow::ensure!( + length <= maximum_size, + "{description} is {length} bytes, exceeding the maximum of {maximum_size} bytes", + ); + let capacity = usize::try_from(length).context("artifact length does not fit in usize")?; + let mut bytes = Vec::with_capacity(capacity); + let mut reader = file + .try_clone() + .with_context(|| format!("failed to duplicate {description} handle"))?; + reader + .seek(SeekFrom::Start(0)) + .with_context(|| format!("failed to rewind {description}"))?; + reader + .take(maximum_size + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {description}"))?; + anyhow::ensure!( + bytes.len() as u64 == length && opened_file_generation(file, description)? == generation, + "{description} changed while it was being read", + ); + Ok(bytes) +} + +/// Computes SHA-256 over an exact-length regular file handle. +pub fn file_sha256( + file: &std::fs::File, + expected_length: u64, + description: &str, +) -> anyhow::Result> { + let mut file = file + .try_clone() + .with_context(|| format!("failed to duplicate {description} handle"))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("failed to rewind {description}"))?; + let actual_length = file + .metadata() + .with_context(|| format!("failed to inspect {description}"))? + .len(); + anyhow::ensure!( + actual_length == expected_length, + "{description} size ({actual_length} bytes) doesn't match expected ({expected_length} bytes)" + ); + let mut digest = sha2::Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + let mut total = 0_u64; + loop { + let count = file + .read(&mut buffer) + .with_context(|| format!("failed to read {description}"))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + total = total + .checked_add(count as u64) + .context("file length overflowed u64 while hashing")?; + anyhow::ensure!( + total <= expected_length, + "{description} grew while it was being hashed" + ); + } + anyhow::ensure!( + total == expected_length, + "{description} changed while it was being hashed" + ); + Ok(digest.finalize().to_vec()) +} + +pub(super) fn verify_file_digest( + file: &std::fs::File, + expected_length: u64, + expected_digest: &[u8], + description: &str, +) -> anyhow::Result<()> { + validate_sha256(expected_digest, description)?; + anyhow::ensure!( + file_sha256(file, expected_length, description)? == expected_digest, + "{description} SHA-256 digest mismatch" + ); + Ok(()) +} + +pub(super) fn open_file_with_length( + path: &Path, + expected_length: u64, + artifact_name: &str, +) -> anyhow::Result { + let file = open_regular_file(path, artifact_name)?; + let length = opened_file_generation(&file, artifact_name)?.length(); + anyhow::ensure!( + length == expected_length, + "{artifact_name} size ({length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + Ok(file) +} + +#[cfg(unix)] +pub(super) fn sync_directory(path: &Path) -> anyhow::Result<()> { + fs_err::File::open(path) + .with_context(|| format!("failed to open directory {} for flush", path.display()))? + .sync_all() + .with_context(|| format!("failed to flush directory {}", path.display())) +} + +#[cfg(not(unix))] +pub(super) fn sync_directory(_path: &Path) -> anyhow::Result<()> { + // Windows has no portable directory-flush equivalent. Artifact handles are + // flushed before the same-volume directory rename, which remains the + // publication boundary; crash durability of that rename is filesystem-defined. + Ok(()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn sparse_copy_fallbacks_preserve_extents_and_clone_independence() { + const MEMORY_SIZE: u64 = 16 * 1024 * 1024; + const EXTENT_SIZE: u64 = 4096; + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("source.bin"); + let mut source = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(source_path) + .unwrap(); + initialize_snapshot_memory_backing_file(&source, MEMORY_SIZE).unwrap(); + source.write_all(b"head").unwrap(); + source.seek(SeekFrom::End(-4)).unwrap(); + source.write_all(b"tail").unwrap(); + source.sync_all().unwrap(); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + let tail_offset = expected.len() - 4; + expected[..4].copy_from_slice(b"head"); + expected[tail_offset..].copy_from_slice(b"tail"); + + let allocated_ranges = [(0, EXTENT_SIZE), (MEMORY_SIZE - EXTENT_SIZE, EXTENT_SIZE)]; + for fallback in ["allocated-ranges", "zero-scan"] { + let path = dir.path().join(format!("{fallback}.bin")); + let destination = create_file(&path, fallback).unwrap(); + size_empty_file(&destination, MEMORY_SIZE, fallback).unwrap(); + match fallback { + "allocated-ranges" => { + copy_allocated_ranges(&source, &destination, MEMORY_SIZE, &allocated_ranges) + .unwrap() + } + "zero-scan" => copy_nonzero_data(&source, &destination, 0, MEMORY_SIZE).unwrap(), + _ => unreachable!(), + } + destination.sync_all().unwrap(); + let allocated = allocated_file_bytes(&destination, MEMORY_SIZE).unwrap(); + assert!( + allocated < MEMORY_SIZE / 4, + "{fallback} allocated {allocated} bytes for a {MEMORY_SIZE}-byte file", + ); + } + + source.seek(SeekFrom::Start(0)).unwrap(); + source.write_all(b"xxxx").unwrap(); + source.sync_all().unwrap(); + for fallback in ["allocated-ranges", "zero-scan"] { + assert_eq!( + std::fs::read(dir.path().join(format!("{fallback}.bin"))).unwrap(), + expected, + "{fallback}" + ); + } + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs new file mode 100644 index 00000000000..53168bc9bff --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -0,0 +1,4295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM snapshot machine-contract types, construction, and validation. + +use super::SnapshotManifest; +use super::format::SCRATCH_FILE_NAME; +#[cfg(test)] +use super::format::SHA256_SIZE; +use super::format::SNAPSHOT_CONFIG_ALL; +use super::format::SNAPSHOT_CONFIG_INVARIANTS; +use super::format::SNAPSHOT_RESTORE_POLICY_CLONE; +use super::format::SNAPSHOT_RESTORE_POLICY_RESUME; +use super::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT; +use super::format::SNAPSHOT_TIER_PLATFORM; +use super::format::SNAPSHOT_TIER_WORKLOAD_START; +use super::format::validate_sha256; +use super::format::verify_digest; +use anyhow::Context; +use mesh::payload::Protobuf; +use sha2::Digest; +use std::collections::HashSet; +use std::path::Path; + +/// Capability version for the always-present dormant microVM virtio-fs slot. +pub const MICROVM_FILESYSTEM_SLOT_VERSION: u32 = 1; +/// Linux-direct MP-table boot layout with shared interrupt status. +pub const MICROVM_BOOT_LAYOUT_VERSION: u32 = 2; +/// Contract version for one-shot restore-time microVM memory expansion. +pub const MICROVM_MEMORY_EXPANSION_VERSION: u32 = 1; +/// Linux memory-block granularity used by the x86-64 microVM guest. +pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = + openvmm_defs::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES; +/// Snapshot contract name for shared-status edge interrupts. +pub const MICROVM_SHARED_STATUS_INTERRUPT_MODE: &str = "edge-shared-status"; +/// Clock policy applied when a snapshot is restored. +pub const ADVANCE_BY_HOST_DOWNTIME: &str = "advance_by_host_downtime"; +/// Whole-file SHA-256 sandbox-block identity. +pub const SNAPSHOT_BLOCK_IDENTITY_SHA256: &str = "sha256"; +/// Caller-authenticated immutable storage generation. +pub const SNAPSHOT_BLOCK_IDENTITY_GENERATION: &str = "generation"; +/// Independent private scratch materialization. +pub const SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY: &str = "private-copy"; +/// Filesystem copy-on-write scratch materialization. +pub const SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE: &str = "copy-on-write"; +/// Direct scratch attachment after a single-use resume claim. +pub const SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED: &str = "direct-claimed"; +/// Byte length of a storage generation identity. +pub const SNAPSHOT_GENERATION_ID_SIZE: usize = 16; +const MAX_MEMORY_RANGES: usize = 128; +const MAX_DEVICES: usize = 64; +const MAX_DEVICE_RANGES: usize = 16; +const MAX_STATE_UNITS: usize = 512; +const MAX_ATTACHMENTS: usize = 64; +const MAX_ATTACHMENT_IDENTITY_BYTES: usize = 4096; +const MAX_COMMAND_LINE_BYTES: usize = 64 * 1024; + +/// A guest RAM range and its corresponding offset in `memory.bin`. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMemoryRange { + /// Guest physical base address. + #[mesh(1)] + pub gpa_start: u64, + /// Range length in bytes. + #[mesh(2)] + pub length: u64, + /// Byte offset in `memory.bin`. + #[mesh(3)] + pub file_offset: u64, +} + +/// A restore-attachable guest RAM range that is absent from `memory.bin`. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMemoryExpansionRange { + /// Guest physical base address. + #[mesh(1)] + pub gpa_start: u64, + /// Range length in bytes. + #[mesh(2)] + pub length: u64, +} + +/// Processor topology that must be reproduced during restore. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotProcessorTopology { + /// Socket count. + #[mesh(1)] + pub sockets: u32, + /// Dies per socket. + #[mesh(2)] + pub dies_per_socket: u32, + /// Cores per die. + #[mesh(3)] + pub cores_per_die: u32, + /// Threads per core. + #[mesh(4)] + pub threads_per_core: u32, + /// APIC IDs in virtual-processor order. + #[mesh(5)] + pub apic_ids: Vec, +} + +/// An address range owned by a saved device. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotDeviceRange { + /// Address space (`pmio` or `mmio`). + #[mesh(1)] + pub address_space: String, + /// Range base address. + #[mesh(2)] + pub start: u64, + /// Range length in bytes. + #[mesh(3)] + pub length: u64, +} + +/// Guest-visible device identity and placement. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotDevice { + /// Stable device identity. + #[mesh(1)] + pub stable_id: String, + /// State-unit identity, including units with no mutable state. + #[mesh(2)] + pub state_unit_name: String, + /// Device kind. + #[mesh(3)] + pub kind: String, + /// Stable device order. + #[mesh(4)] + pub order: u32, + /// PMIO or MMIO ranges. + #[mesh(5)] + pub ranges: Vec, + /// Interrupt number, or `None` for devices without an interrupt. + #[mesh(6)] + pub irq: Option, + /// Transport kind, empty for non-transport devices. + #[mesh(7)] + pub transport: String, + /// Effective feature banks in bank order. + #[mesh(8)] + pub feature_banks: Vec, + /// Queue count. + #[mesh(9)] + pub queue_count: u32, + /// Maximum queue sizes in queue order. + #[mesh(10)] + pub queue_max_sizes: Vec, +} + +/// Stable identity for a host resource required by a device. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotAttachment { + /// Stable attachment ID. + #[mesh(1)] + pub stable_id: String, + /// Attachment kind. + #[mesh(2)] + pub kind: String, + /// Whether restore requires the attachment. + #[mesh(3)] + pub required: bool, + /// Reconnection policy. + #[mesh(4)] + pub reconnect_policy: String, + /// Immutable identity kind. + #[mesh(5)] + pub identity_kind: String, + /// Provider identity or SHA-256 bytes. + #[mesh(6)] + pub identity: Vec, + /// Attachment length when applicable. + #[mesh(7)] + pub length: u64, + /// Bounded reconnect timeout. Zero for non-client policies. + #[mesh(8)] + pub reconnect_timeout_ms: u64, +} + +/// Canonical guest-visible identity of the microVM network. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmNetwork { + /// Required cross-platform host-network implementation contract. + #[mesh(9)] + pub profile: String, + /// Guest IPv4 address in network byte order. + #[mesh(1)] + pub guest_ipv4: u32, + /// IPv4 subnet prefix length. + #[mesh(2)] + pub prefix_length: u32, + /// Derived gateway IPv4 address in network byte order. + #[mesh(3)] + pub gateway_ipv4: u32, + /// Deterministic guest MAC address. + #[mesh(4)] + pub guest_mac: Vec, + /// Deterministic gateway MAC address. + #[mesh(5)] + pub gateway_mac: Vec, + /// Canonical run-scoped egress policy mode. + #[mesh(6)] + pub egress_policy_mode: String, + /// SHA-256 of the canonical run-scoped egress policy. + #[mesh(7)] + pub egress_policy_sha256: Vec, + /// Whether restore must supply the same policy contract. + #[mesh(8)] + pub egress_policy_required: bool, + /// Version of the canonical egress-policy digest encoding. + #[mesh(10)] + pub egress_policy_encoding_version: u32, +} + +/// Canonical guest-visible policy of the microVM filesystem. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmFilesystem { + /// Absolute guest mount target. + #[mesh(1)] + pub guest_mount_target: String, + /// Snapshot-authoritative `ro` or `rw` access mode. + #[mesh(2)] + pub access_mode: String, + /// Host attachment policy. + #[mesh(3)] + pub restore_mode: String, + /// Fixed virtio-fs tag. + #[mesh(4)] + pub tag: String, + /// Number of high-priority queues. + #[mesh(5)] + pub high_priority_queue_count: u32, + /// Number of request queues. + #[mesh(6)] + pub request_queue_count: u32, + /// DAX/shared-memory window size. + #[mesh(7)] + pub shared_memory_size: u64, + /// Whether all opened files use direct I/O. + #[mesh(8)] + pub direct_io: bool, + /// Guest entry-cache lifetime in nanoseconds. + #[mesh(9)] + pub entry_cache_timeout_ns: u64, + /// Guest attribute-cache lifetime in nanoseconds. + #[mesh(10)] + pub attribute_cache_timeout_ns: u64, + /// Canonical absolute host export path. + #[mesh(11)] + pub canonical_host_path: String, + /// Canonical host-relative paths hidden by the virtio-fs server. + #[mesh(12)] + pub denied_paths: Vec, + /// Host identity of guest operations: empty for the VMM, which is also + /// what snapshots that predate this field used, or `caller`. + #[mesh(13)] + pub owner_mode: String, + /// Canonical host-relative paths inside denied paths that the virtio-fs + /// server exposes again. Snapshots that predate this field have none. + #[mesh(14)] + pub allowed_paths: Vec, + /// Canonical host-relative paths that are the only parts of a read-write + /// filesystem that the guest can modify, or none when all of it is + /// writable, as in snapshots that predate this field. + #[mesh(15)] + pub writable_paths: Vec, +} + +/// Authoritative identity and snapshot policy for a microVM sandbox block. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmSandboxBlock { + /// Stable role (`distro`, `runtime`, `custom`, or `scratch`). + #[mesh(1)] + pub role: String, + /// Whether the guest sees the device as read-only. + #[mesh(2)] + pub read_only: bool, + /// Logical device length in bytes. + #[mesh(3)] + pub length: u64, + /// Kind of immutable identity carried in `identity`. + #[mesh(4)] + pub identity_kind: String, + /// Immutable layer identity or paired-scratch digest. + #[mesh(5)] + pub identity: Vec, + /// Fixed snapshot-relative artifact name; empty for external layers. + #[mesh(6)] + pub artifact: String, + /// Guest-visible logical block size in bytes. + #[mesh(7)] + pub logical_block_size: u32, + /// Guest-visible physical block size in bytes. + #[mesh(8)] + pub physical_block_size: u32, + /// Restore-time materialization policy for paired scratch. + #[mesh(9)] + pub restore_mode: String, +} + +impl SnapshotMicrovmFilesystem { + fn new( + config: &openvmm_defs::microvm::MicrovmFilesystemConfig, + canonical_host_path: &str, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, + ) -> Self { + Self { + guest_mount_target: config.guest_mount_target.clone(), + access_mode: config.access.as_str().to_owned(), + restore_mode: "live-revalidate".to_owned(), + tag: slot.tag.to_owned(), + high_priority_queue_count: 1, + request_queue_count: 1, + shared_memory_size: 0, + direct_io: true, + entry_cache_timeout_ns: 0, + attribute_cache_timeout_ns: 0, + canonical_host_path: canonical_host_path.to_owned(), + denied_paths: config.denied_paths.clone(), + owner_mode: match config.owner { + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm => String::new(), + openvmm_defs::microvm::MicrovmFilesystemOwner::Caller => { + openvmm_defs::microvm::MicrovmFilesystemOwner::Caller + .as_str() + .to_owned() + } + }, + allowed_paths: config.allowed_paths.clone(), + writable_paths: config.writable_paths.clone(), + } + } +} + +/// Parses the host identity of guest operations recorded in a snapshot +/// filesystem policy. +pub fn snapshot_microvm_filesystem_owner( + owner_mode: &str, +) -> anyhow::Result { + match owner_mode { + "" => Ok(openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm), + "caller" => Ok(openvmm_defs::microvm::MicrovmFilesystemOwner::Caller), + mode => anyhow::bail!("snapshot filesystem owner mode '{mode}' is unsupported"), + } +} + +impl SnapshotMicrovmNetwork { + fn new( + config: &openvmm_defs::microvm::MicrovmNetworkConfig, + egress_policy: &net_backend_resources::egress::EgressPolicy, + ) -> Self { + Self { + profile: config.profile.as_str().to_owned(), + guest_ipv4: u32::from(config.guest_ipv4), + prefix_length: u32::from(config.prefix_length), + gateway_ipv4: u32::from(config.derived_gateway_ipv4), + guest_mac: config.guest_mac.to_bytes().to_vec(), + gateway_mac: config.gateway_mac.to_bytes().to_vec(), + egress_policy_mode: egress_policy.mode_name().to_owned(), + egress_policy_sha256: sha2::Sha256::digest(egress_policy.canonical_bytes()).to_vec(), + egress_policy_required: egress_policy.is_active(), + egress_policy_encoding_version: + net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION, + } + } +} + +/// Validates a restore-time policy against the snapshot's canonical contract. +pub fn validate_microvm_network_policy( + saved: &SnapshotMicrovmNetwork, + policy: &net_backend_resources::egress::EgressPolicy, +) -> anyhow::Result<()> { + let encoding_version = match saved.egress_policy_encoding_version { + 0 => 1, + version => version, + }; + let canonical = policy + .canonical_bytes_for_version(encoding_version) + .with_context(|| { + format!("snapshot egress policy encoding version {encoding_version} is unsupported") + })?; + let digest = sha2::Sha256::digest(canonical); + anyhow::ensure!( + saved.egress_policy_mode == policy.mode_name() + && saved.egress_policy_sha256 == digest.as_slice() + && saved.egress_policy_required == policy.is_active(), + "restore-time egress policy does not match the snapshot contract" + ); + Ok(()) +} + +/// Machine composition that becomes authoritative after capture. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMachineContract { + /// Machine profile name. + #[mesh(1)] + pub machine_profile: String, + /// microVM ABI version. + #[mesh(2)] + pub microvm_abi_version: u32, + /// Source hypervisor kind. + #[mesh(3)] + pub source_hypervisor: String, + /// Effective kernel command line. + #[mesh(4)] + pub effective_command_line: String, + /// SHA-256 of the effective command line. + #[mesh(5)] + pub effective_command_line_sha256: Vec, + /// Guest RAM ranges in stable order. + #[mesh(6)] + pub memory_ranges: Vec, + /// Processor topology. + #[mesh(7)] + pub topology: SnapshotProcessorTopology, + /// Guest-visible devices in stable order. + #[mesh(8)] + pub devices: Vec, + /// Complete state-unit inventory in stable order. + #[mesh(9)] + pub state_unit_names: Vec, + /// Required host attachments. + #[mesh(10)] + pub attachments: Vec, + // Fields 11 to 15, 17, and 20 held the clock fields of manifest versions + // 2 to 5. They are retired, and their numbers are never reused. + /// Version of the fixed cold-boot and memory layout. + #[mesh(16)] + pub boot_layout_version: u32, + /// Static identity of the optional microVM virtio-net device. + #[mesh(18)] + pub microvm_network: Option, + /// Guest-visible policy of the optional microVM virtio-fs device in the + /// first slot. + #[mesh(19)] + pub microvm_filesystem: Option, + /// Fixed-role sandbox blocks in guest-visible order. + #[mesh(21)] + pub microvm_sandbox_blocks: Vec, + /// Version of the reserved restore-attachable microVM virtio-fs slot. + #[mesh(22)] + pub microvm_filesystem_slot_version: u32, + /// Virtual processors online at boot, or zero when restore activation is disabled. + #[mesh(23)] + pub boot_online_vp_count: u32, + /// Virtio interrupt-delivery mode. + #[mesh(24)] + pub virtio_interrupt_mode: String, + /// Guest-physical base of the shared interrupt-status page, or zero when absent. + #[mesh(25)] + pub virtio_shared_status_page_gpa: u64, + /// Size of the shared interrupt-status page, or zero when absent. + #[mesh(26)] + pub virtio_shared_status_page_size: u64, + /// Restore-time memory expansion capability version, or zero when absent. + #[mesh(27)] + pub memory_expansion_version: u32, + /// Immutable maximum guest RAM size for restore-time expansion. + #[mesh(28)] + pub memory_capacity_bytes: u64, + /// Required alignment of every restore-time memory target and range. + #[mesh(29)] + pub memory_block_size_bytes: u64, + /// Canonical capacity ranges absent from the captured boot memory map. + #[mesh(30)] + pub memory_expansion_ranges: Vec, + /// The NVX time ABI time contract; required. + #[mesh(31)] + pub time: Option, + /// The NVX time ABI CPU profile record; required. + #[mesh(32)] + pub cpu_profile: Option, + /// Guest-visible policies of the virtio-fs slots after the first, in slot + /// order. Each of these slots exists only with its filesystem attached, + /// which requires a filesystem in the first slot. + #[mesh(33)] + pub microvm_additional_filesystems: Vec, + /// Fixed image-slot capacity when declared, or zero without image slots. + #[mesh(34)] + pub microvm_image_slot_capacity: u32, + /// Image slots active on the captured cold boot, or zero without slots. + #[mesh(35)] + pub boot_active_image_slot_count: u8, +} + +impl SnapshotMachineContract { + /// Sets the effective command line and its digest together. + pub fn set_effective_command_line(&mut self, command_line: String) { + self.effective_command_line_sha256 = sha2::Sha256::digest(command_line.as_bytes()).to_vec(); + self.effective_command_line = command_line; + } + + /// Returns the policies of the attached microVM filesystems, in + /// virtio-fs slot order. + pub fn microvm_filesystems(&self) -> impl Iterator { + self.microvm_filesystem + .iter() + .chain(&self.microvm_additional_filesystems) + } +} + +fn microvm_snapshot_topology(processor_count: u32) -> anyhow::Result { + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(processor_count), + "microVM does not support {processor_count} vCPUs" + ); + Ok(SnapshotProcessorTopology { + sockets: 1, + dies_per_socket: 1, + cores_per_die: processor_count, + threads_per_core: 1, + apic_ids: (0..processor_count).collect(), + }) +} + +fn microvm_boot_online_vp_count( + vp_capacity: u32, + effective_command_line: &str, +) -> anyhow::Result { + let mut boot_online_vp_count = None; + for token in effective_command_line.split_ascii_whitespace() { + let Some(value) = token.strip_prefix("maxcpus=") else { + continue; + }; + anyhow::ensure!( + boot_online_vp_count.is_none(), + "microVM command line contains multiple maxcpus values" + ); + let count = value + .parse::() + .context("microVM maxcpus value is invalid")?; + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(count), + "microVM does not support a boot-online count of {count}" + ); + anyhow::ensure!( + count <= vp_capacity, + "microVM boot-online count {count} exceeds VP capacity {vp_capacity}" + ); + boot_online_vp_count = Some(count); + } + Ok(boot_online_vp_count.unwrap_or(0)) +} + +fn validate_microvm_image_slot_contract(contract: &SnapshotMachineContract) -> anyhow::Result<()> { + let capacity = u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY); + match contract.microvm_image_slot_capacity { + 0 => anyhow::ensure!( + contract.boot_active_image_slot_count == 0, + "snapshot without image slots has a boot-active image-slot count" + ), + declared if declared == capacity => { + anyhow::ensure!( + (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY) + .contains(&contract.boot_active_image_slot_count), + "snapshot boot-active image-slot count is invalid" + ); + anyhow::ensure!( + contract.microvm_additional_filesystems.is_empty(), + "snapshot image slots cannot coexist with the second virtio-fs slot" + ); + } + declared => anyhow::bail!( + "snapshot image-slot capacity {declared} is unsupported; expected 0 or {capacity}" + ), + } + let mut tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("microvm_image_slots=")); + if contract.microvm_image_slot_capacity == 0 { + anyhow::ensure!( + tokens.next().is_none(), + "snapshot without image slots has a discovery token" + ); + } else { + let discovery = format!("microvm_image_slots={capacity}"); + anyhow::ensure!( + tokens.next() == Some(discovery.as_str()) && tokens.next().is_none(), + "snapshot image-slot discovery token does not match the declared capacity" + ); + } + Ok(()) +} + +/// Validates a restore-time online VP target against an opt-in snapshot contract. +pub fn validate_restore_online_vp_count( + manifest: &SnapshotManifest, + restore_online_vp_count: u32, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_supported_microvm_contract(contract)?; + validate_microvm_image_slot_contract(contract)?; + anyhow::ensure!( + contract.boot_online_vp_count != 0, + "snapshot does not declare restore-time VP activation support" + ); + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(restore_online_vp_count), + "restore-online VP count {restore_online_vp_count} is not supported" + ); + anyhow::ensure!( + restore_online_vp_count >= contract.boot_online_vp_count, + "restore-online VP count {restore_online_vp_count} is below boot-online count {}", + contract.boot_online_vp_count + ); + anyhow::ensure!( + restore_online_vp_count <= manifest.vp_count, + "restore-online VP count {restore_online_vp_count} exceeds VP capacity {}", + manifest.vp_count + ); + Ok(()) +} + +/// Validates a restore-time active image-slot target against the snapshot. +pub fn validate_restore_active_image_slot_count( + manifest: &SnapshotManifest, + restore_active_image_slot_count: u8, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_supported_microvm_contract(contract)?; + validate_microvm_image_slot_contract(contract)?; + anyhow::ensure!( + contract.microvm_image_slot_capacity + == u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY), + "snapshot does not declare restore-time image-slot activation support" + ); + anyhow::ensure!( + restore_active_image_slot_count >= contract.boot_active_image_slot_count, + "restore image-slot count {restore_active_image_slot_count} is below boot-active count {}", + contract.boot_active_image_slot_count + ); + anyhow::ensure!( + u32::from(restore_active_image_slot_count) <= contract.microvm_image_slot_capacity, + "restore image-slot count {restore_active_image_slot_count} exceeds capacity {}", + contract.microvm_image_slot_capacity + ); + Ok(()) +} + +/// Validates and selects the expansion ranges for a restore-time RAM target. +pub fn validate_restore_memory_target( + manifest: &SnapshotManifest, + restore_memory_size: u64, +) -> anyhow::Result> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_machine_contract_shape(contract, manifest.memory_size_bytes, manifest.vp_count)?; + anyhow::ensure!( + contract.memory_expansion_version == MICROVM_MEMORY_EXPANSION_VERSION, + "snapshot does not declare restore-time memory expansion support" + ); + anyhow::ensure!( + restore_memory_size >= manifest.memory_size_bytes, + "restore memory target {restore_memory_size} is below snapshot RAM {}", + manifest.memory_size_bytes + ); + anyhow::ensure!( + restore_memory_size <= contract.memory_capacity_bytes, + "restore memory target {restore_memory_size} exceeds RAM capacity {}", + contract.memory_capacity_bytes + ); + anyhow::ensure!( + restore_memory_size.is_multiple_of(contract.memory_block_size_bytes), + "restore memory target {restore_memory_size} is not aligned to the {}-byte memory block size", + contract.memory_block_size_bytes + ); + memory_expansion_prefix( + &contract.memory_expansion_ranges, + restore_memory_size - manifest.memory_size_bytes, + ) +} + +fn canonical_microvm_memory_ranges(memory_size: u64) -> anyhow::Result> { + const LOW_RAM_END: u64 = 3 * 1024 * 1024 * 1024; + const HIGH_RAM_START: u64 = 4 * 1024 * 1024 * 1024; + anyhow::ensure!(memory_size != 0, "microVM RAM size must be nonzero"); + let low_length = memory_size.min(LOW_RAM_END); + let mut ranges = vec![SnapshotMemoryRange { + gpa_start: 0, + length: low_length, + file_offset: 0, + }]; + if memory_size > LOW_RAM_END { + let high_length = memory_size - LOW_RAM_END; + HIGH_RAM_START + .checked_add(high_length) + .context("microVM RAM layout overflows GPA space")?; + ranges.push(SnapshotMemoryRange { + gpa_start: HIGH_RAM_START, + length: high_length, + file_offset: LOW_RAM_END, + }); + } + Ok(ranges) +} + +fn canonical_memory_expansion_ranges( + memory_size: u64, + memory_capacity: u64, +) -> anyhow::Result> { + anyhow::ensure!( + memory_capacity >= memory_size, + "RAM capacity {memory_capacity} is below snapshot RAM {memory_size}" + ); + let mut ranges = Vec::new(); + for capacity_range in canonical_microvm_memory_ranges(memory_capacity)? { + let logical_start = capacity_range.file_offset.max(memory_size); + let logical_end = capacity_range + .file_offset + .checked_add(capacity_range.length) + .context("microVM capacity range overflows")?; + if logical_start >= logical_end { + continue; + } + ranges.push(SnapshotMemoryExpansionRange { + gpa_start: capacity_range.gpa_start + logical_start - capacity_range.file_offset, + length: logical_end - logical_start, + }); + } + Ok(ranges) +} + +fn memory_expansion_prefix( + ranges: &[SnapshotMemoryExpansionRange], + size: u64, +) -> anyhow::Result> { + let mut remaining = size; + let mut prefix = Vec::new(); + for range in ranges { + if remaining == 0 { + break; + } + let length = range.length.min(remaining); + prefix.push(SnapshotMemoryExpansionRange { + gpa_start: range.gpa_start, + length, + }); + remaining -= length; + } + anyhow::ensure!( + remaining == 0, + "memory expansion ranges do not cover target" + ); + Ok(prefix) +} + +/// Returns the snapshot device of a fixed virtio-fs slot. +fn microvm_filesystem_device( + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, + order: usize, +) -> SnapshotDevice { + SnapshotDevice { + stable_id: slot.stable_id.to_owned(), + state_unit_name: format!("virtiofs-{}", slot.mmio_base), + kind: "virtio-fs".to_owned(), + order: order as u32, + ranges: vec![SnapshotDeviceRange { + address_space: "mmio".to_owned(), + start: slot.mmio_base, + length: openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + }], + irq: Some(slot.irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![ + openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES as u32, + (openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES >> 32) as u32, + ], + queue_count: 2, + queue_max_sizes: vec![256, 256], + } +} + +/// Validates the live attachment of the filesystem in `slot` and returns its +/// snapshot policy. +fn microvm_filesystem_policy( + source_hypervisor: &str, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, + filesystem: &openvmm_defs::microvm::MicrovmFilesystemConfig, + canonical_host_path: &Path, + attachment: &SnapshotAttachment, +) -> anyhow::Result { + let canonical_host_path = canonical_host_path + .to_str() + .context("microVM filesystem canonical host path is not valid UTF-8")?; + anyhow::ensure!( + !canonical_host_path.is_empty(), + "microVM filesystem canonical host path is empty" + ); + anyhow::ensure!( + attachment.stable_id == slot.stable_id + && attachment.kind == "virtio-fs" + && attachment.required + && attachment.reconnect_policy == "live-revalidate" + && match source_hypervisor { + "kvm" | "mshv" => attachment.identity_kind == "unix-device-inode-v1", + "whp" => attachment.identity_kind == "windows-volume-file-id-v1", + _ => false, + } + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0 + && attachment.reconnect_timeout_ms == 0, + "microVM filesystem attachment has an unsupported live-revalidation policy" + ); + Ok(SnapshotMicrovmFilesystem::new( + filesystem, + canonical_host_path, + slot, + )) +} + +/// Builds the authoritative microVM machine contract, with the time ABI +/// records `time` and `cpu_profile`. +/// +/// `filesystem_slot` reserves the first virtio-fs slot, and `filesystems` +/// occupy the virtio-fs slots in order. +pub fn microvm_machine_contract( + source_hypervisor: &str, + boot_layout_version: u32, + effective_command_line: String, + network: Option<( + &openvmm_defs::microvm::MicrovmNetworkConfig, + &net_backend_resources::egress::EgressPolicy, + SnapshotAttachment, + )>, + filesystem_slot: bool, + filesystems: Vec<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + console_attachment: Option, + control_console_attachment: Option, + sandbox_blocks: Vec, + processor_count: u32, + memory_size: u64, + memory_capacity: Option, + state_unit_names: Vec, + time: openvmm_defs::time_abi::SnapshotTimeContract, + cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, +) -> anyhow::Result { + microvm_machine_contract_with_image_slots( + source_hypervisor, + boot_layout_version, + effective_command_line, + network, + filesystem_slot, + filesystems, + console_attachment, + control_console_attachment, + sandbox_blocks, + None, + processor_count, + memory_size, + memory_capacity, + state_unit_names, + time, + cpu_profile, + ) +} + +/// Builds the authoritative microVM ABI 2 machine contract with optional +/// image slots, which use the second virtio-fs slot's window and interrupt. +pub fn microvm_machine_contract_with_image_slots( + source_hypervisor: &str, + boot_layout_version: u32, + effective_command_line: String, + network: Option<( + &openvmm_defs::microvm::MicrovmNetworkConfig, + &net_backend_resources::egress::EgressPolicy, + SnapshotAttachment, + )>, + filesystem_slot: bool, + filesystems: Vec<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + console_attachment: Option, + control_console_attachment: Option, + sandbox_blocks: Vec, + image_slots: Option, + processor_count: u32, + memory_size: u64, + memory_capacity: Option, + state_unit_names: Vec, + time: openvmm_defs::time_abi::SnapshotTimeContract, + cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, +) -> anyhow::Result { + anyhow::ensure!( + matches!(source_hypervisor, "kvm" | "mshv" | "whp"), + "microVM snapshots require the KVM, MSHV, or WHP hypervisor" + ); + let topology = microvm_snapshot_topology(processor_count)?; + let boot_online_vp_count = + microvm_boot_online_vp_count(processor_count, &effective_command_line)?; + + let memory_ranges = canonical_microvm_memory_ranges(memory_size)?; + let ( + memory_expansion_version, + memory_capacity_bytes, + memory_block_size_bytes, + memory_expansion_ranges, + ) = if let Some(memory_capacity) = memory_capacity { + anyhow::ensure!( + memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM {memory_size} is not aligned to the {MICROVM_MEMORY_BLOCK_SIZE_BYTES}-byte memory block size" + ); + anyhow::ensure!( + memory_capacity.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "RAM capacity {memory_capacity} is not aligned to the {MICROVM_MEMORY_BLOCK_SIZE_BYTES}-byte memory block size" + ); + ( + MICROVM_MEMORY_EXPANSION_VERSION, + memory_capacity, + MICROVM_MEMORY_BLOCK_SIZE_BYTES, + canonical_memory_expansion_ranges(memory_size, memory_capacity)?, + ) + } else { + (0, 0, 0, Vec::new()) + }; + + let device = |stable_id: &str, + state_unit_name: &str, + kind: &str, + ranges: Vec, + irq: Option, + order: u32| SnapshotDevice { + stable_id: stable_id.to_owned(), + state_unit_name: state_unit_name.to_owned(), + kind: kind.to_owned(), + order, + ranges, + irq, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }; + let pmio = |start, length| SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start, + length, + }; + let mmio = |start, length| SnapshotDeviceRange { + address_space: "mmio".to_owned(), + start, + length, + }; + + let mut devices = vec![ + device("partition", "partition", "partition", Vec::new(), None, 0), + device("vp0", "partition", "vcpu", Vec::new(), None, 1), + device("vmtime", "vmtime", "clock", Vec::new(), None, 2), + device( + "pic", + "pic", + "pic", + vec![pmio(0x20, 2), pmio(0xa0, 2)], + None, + 3, + ), + device( + "ioapic", + "ioapic", + "ioapic", + vec![mmio(0xfec0_0000, 0x1000)], + None, + 4, + ), + device( + "lapic", + "partition", + "lapic", + vec![mmio(0xfee0_0000, 0x1000)], + None, + 5, + ), + device("pit", "pit", "pit", vec![pmio(0x40, 4)], Some(0), 6), + device("rtc", "rtc", "rtc", vec![pmio(0x70, 2)], Some(8), 7), + device( + "microvm-portb", + "microvm-portb", + "portb", + vec![pmio(0xe9, 2)], + None, + 8, + ), + device( + "microvm-shutdown", + "microvm-shutdown", + "shutdown", + vec![pmio(0x604, 1)], + None, + 9, + ), + device( + "microvm-snapshot-request", + "microvm-snapshot-request", + "snapshot-request", + vec![pmio(0x605, 1)], + None, + 10, + ), + ]; + let mut attachments = Vec::new(); + let microvm_network = if let Some((network, egress_policy, attachment)) = network { + let policy_is_valid = matches!(source_hypervisor, "kvm" | "mshv" | "whp") + && attachment.reconnect_policy == "recreate-endpoint" + && !attachment.required + && attachment.identity_kind == "user-mode-nat" + && attachment.identity == b"consomme"; + anyhow::ensure!( + attachment.stable_id == "net:microvm0" + && attachment.kind == "virtio-net" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0 + && attachment.reconnect_timeout_ms == 0, + "microVM network attachment has an unsupported endpoint policy" + ); + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor))?; + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{irq}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains(discovery.as_str()) + && network + .command_line_fragment() + .split_ascii_whitespace() + .all(|token| tokens.contains(token)), + "microVM network command line does not match its saved identity" + ); + devices.push(SnapshotDevice { + stable_id: "net:microvm0".to_owned(), + state_unit_name: format!( + "virtio-net-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE + ), + kind: "virtio-net".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![ + openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES as u32, + (openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES >> 32) as u32, + ], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + Some(SnapshotMicrovmNetwork::new(network, egress_policy)) + } else { + None + }; + let filesystem_slot_count = + openvmm_defs::microvm::microvm_filesystem_slot_count(filesystem_slot, filesystems.len())?; + let filesystem_slots = + &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[..filesystem_slot_count]; + openvmm_defs::microvm::validate_microvm_filesystems( + &filesystems + .iter() + .map(|(filesystem, _, _)| (*filesystem).clone()) + .collect::>(), + )?; + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + for (index, slot) in openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .enumerate() + { + let discovery = slot.discovery_token(); + let expected = usize::from(index < filesystem_slot_count); + anyhow::ensure!( + tokens.iter().filter(|token| **token == discovery).count() == expected, + "microVM virtio-fs slot {} must be discovered {expected} time(s) by the effective command line", + slot.stable_id + ); + } + // The bootstrap triplets of the attached filesystems, exactly and in slot + // order. + let bootstrap = tokens + .iter() + .copied() + .filter(|token| { + ["virtfs_dir=", "virtfs_tag=", "virtfs_mode="] + .iter() + .any(|prefix| token.starts_with(prefix)) + }) + .collect::>(); + let expected_bootstrap = filesystems + .iter() + .zip(filesystem_slots) + .map(|((filesystem, _, _), slot)| filesystem.command_line_fragment(slot)) + .collect::>(); + anyhow::ensure!( + bootstrap + == expected_bootstrap + .iter() + .flat_map(|fragment| fragment.split_ascii_whitespace()) + .collect::>(), + "microVM filesystem command line does not match its saved policy" + ); + if let Some(slot) = filesystem_slots.first() { + devices.push(microvm_filesystem_device(slot, devices.len())); + } + let mut filesystem_policies = Vec::with_capacity(filesystems.len()); + let mut filesystem_attachments = Vec::with_capacity(filesystems.len()); + for ((filesystem, canonical_host_path, attachment), slot) in + filesystems.into_iter().zip(filesystem_slots) + { + filesystem_policies.push(microvm_filesystem_policy( + source_hypervisor, + slot, + filesystem, + canonical_host_path, + &attachment, + )?); + filesystem_attachments.push(attachment); + } + let mut filesystem_policies = filesystem_policies.into_iter(); + let mut filesystem_attachments = filesystem_attachments.into_iter(); + let microvm_filesystem = filesystem_policies.next(); + attachments.extend(filesystem_attachments.next()); + if let Some(attachment) = console_attachment { + let policy_is_valid = match attachment.reconnect_policy.as_str() { + "recreate-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" | "tcp" + ) + } + "reconnect-client" => { + attachment.required + && attachment.reconnect_timeout_ms + == openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" | "tcp" + ) + } + "require-inherited-attachment" => { + attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "provider" + && attachment.identity == b"console" + } + "discard-while-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } + _ => false, + }; + anyhow::ensure!( + attachment.stable_id == "console:microvm-virtio0" + && attachment.kind == "virtio-console" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0, + "microVM console attachment has an unsupported reconnect policy" + ); + devices.push(SnapshotDevice { + stable_id: "console:microvm-virtio0".to_owned(), + state_unit_name: format!( + "virtio-console-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE + ), + kind: "virtio-console".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![0x3000_0001, 0x0000_0003], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + } + + for block in &sandbox_blocks { + let role = match block.role.as_str() { + "distro" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + "runtime" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Runtime, + "custom" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Custom, + "scratch" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + role => anyhow::bail!("snapshot sandbox block role '{role}' is unsupported"), + }; + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + role.mmio_base(), + role.irq(), + ); + anyhow::ensure!( + effective_command_line + .split_ascii_whitespace() + .any(|token| token == discovery), + "microVM sandbox block '{}' is missing from the effective command line", + block.role + ); + let features = openvmm_defs::microvm::microvm_sandbox_block_features(role); + devices.push(SnapshotDevice { + stable_id: format!("blk:sandbox:{}", role.as_str()), + state_unit_name: format!("virtio-blk-{}", role.mmio_base()), + kind: "virtio-blk".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + role.mmio_base(), + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(role.irq()), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![features as u32, (features >> 32) as u32], + queue_count: 1, + queue_max_sizes: vec![256], + }); + } + if let Some(attachment) = control_console_attachment { + let policy_is_valid = match attachment.reconnect_policy.as_str() { + "broker-authenticated-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" + ) + } + "broker-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } + _ => false, + }; + anyhow::ensure!( + attachment.stable_id == "console:microvm-control0" + && attachment.kind == "virtio-control-console" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0, + "microVM control console attachment has an unsupported reconnect policy" + ); + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains(discovery.as_str()) + && tokens.contains(openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE), + "microVM control console command line does not match its saved identity" + ); + devices.push(SnapshotDevice { + stable_id: "console:microvm-control0".to_owned(), + state_unit_name: format!( + "virtio-control-console-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE + ), + kind: "virtio-control-console".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![0x3000_0001, 0x0000_0003], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + } + for slot in filesystem_slots.iter().skip(1) { + devices.push(microvm_filesystem_device(slot, devices.len())); + } + attachments.extend(filesystem_attachments); + if let Some(image_slots) = image_slots { + image_slots.validate()?; + anyhow::ensure!( + filesystem_slots.len() <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains( + format!( + "microvm_image_slots={}", + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ) + .as_str() + ), + "microVM image-slot discovery token is missing" + ); + for (index, (base, irq)) in openvmm_defs::microvm::MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .zip(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_IRQS) + .enumerate() + { + let discovery = format!( + "virtio_mmio.device={:#x}@{base:#x}:{irq}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + ); + anyhow::ensure!( + tokens.contains(discovery.as_str()), + "microVM image slot {index} is missing from the effective command line" + ); + let features = openvmm_defs::microvm::microvm_image_slot_features(); + devices.push(SnapshotDevice { + stable_id: openvmm_defs::microvm::microvm_image_slot_name(index as u8)?, + state_unit_name: format!("virtio-blk-image-slot-{base}"), + kind: "virtio-blk-image-slot".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio(*base, openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN)], + irq: Some(irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![features as u32, (features >> 32) as u32], + queue_count: 1, + queue_max_sizes: vec![256], + }); + } + } + + let mut contract = SnapshotMachineContract { + machine_profile: "microvm".to_owned(), + microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + source_hypervisor: source_hypervisor.to_owned(), + effective_command_line: String::new(), + effective_command_line_sha256: Vec::new(), + memory_ranges, + topology, + devices, + state_unit_names, + attachments, + boot_layout_version, + microvm_network, + microvm_filesystem, + microvm_additional_filesystems: filesystem_policies.collect(), + microvm_sandbox_blocks: sandbox_blocks, + microvm_filesystem_slot_version: if filesystem_slot { + MICROVM_FILESYSTEM_SLOT_VERSION + } else { + 0 + }, + boot_online_vp_count, + virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), + virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, + virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + memory_expansion_version, + memory_capacity_bytes, + memory_block_size_bytes, + memory_expansion_ranges, + time: Some(time), + cpu_profile: Some(cpu_profile), + microvm_image_slot_capacity: image_slots + .map(|_| openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into()) + .unwrap_or(0), + boot_active_image_slot_count: image_slots.map_or(0, |slots| slots.boot_count), + }; + contract.set_effective_command_line(effective_command_line); + validate_machine_contract_shape(&contract, memory_size, processor_count)?; + Ok(contract) +} + +/// Returns whether restore must hold external device input until guest repair completes. +pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { + manifest.machine_contract.as_ref().is_some_and(|contract| { + contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + }) && !manifest.snapshot_tier.is_empty() +} + +pub(super) fn paired_scratch_block( + manifest: &SnapshotManifest, +) -> Option<&SnapshotMicrovmSandboxBlock> { + manifest + .machine_contract + .as_ref()? + .microvm_sandbox_blocks + .iter() + .find(|block| block.artifact == SCRATCH_FILE_NAME) +} + +/// Rejects a snapshot contract that does not use the supported persisted microVM identities. +pub fn validate_supported_microvm_contract( + contract: &SnapshotMachineContract, +) -> anyhow::Result<()> { + anyhow::ensure!( + contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + "snapshot microVM ABI version {} is unsupported; this OpenVMM supports version {}", + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + ); + anyhow::ensure!( + contract.boot_layout_version == MICROVM_BOOT_LAYOUT_VERSION, + "snapshot boot layout version {} is unsupported; this OpenVMM supports version {}", + contract.boot_layout_version, + MICROVM_BOOT_LAYOUT_VERSION, + ); + Ok(()) +} + +/// Validate and exactly compare an authoritative microVM machine contract. +pub fn validate_microvm_machine_contract( + manifest: &SnapshotManifest, + expected: &SnapshotMachineContract, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_machine_contract_shape(contract, manifest.memory_size_bytes, manifest.vp_count)?; + + anyhow::ensure!( + contract.machine_profile == "microvm", + "snapshot machine profile '{}' is not microvm", + contract.machine_profile, + ); + anyhow::ensure!( + contract.machine_profile == expected.machine_profile, + "snapshot machine profile doesn't match the requested machine" + ); + anyhow::ensure!( + contract.microvm_abi_version == expected.microvm_abi_version, + "snapshot microVM ABI version {} doesn't match expected {}", + contract.microvm_abi_version, + expected.microvm_abi_version, + ); + anyhow::ensure!( + contract.microvm_filesystem_slot_version == expected.microvm_filesystem_slot_version, + "snapshot microVM filesystem slot capability doesn't match the requested machine" + ); + anyhow::ensure!( + contract.microvm_image_slot_capacity == expected.microvm_image_slot_capacity + && contract.boot_active_image_slot_count == expected.boot_active_image_slot_count, + "snapshot microVM image-slot contract doesn't match the requested machine" + ); + anyhow::ensure!( + contract.source_hypervisor == expected.source_hypervisor, + "snapshot source hypervisor '{}' doesn't match expected '{}'", + contract.source_hypervisor, + expected.source_hypervisor, + ); + anyhow::ensure!( + contract.boot_layout_version == expected.boot_layout_version, + "snapshot boot layout version doesn't match the requested machine" + ); + anyhow::ensure!( + contract.virtio_interrupt_mode == expected.virtio_interrupt_mode + && contract.virtio_shared_status_page_gpa == expected.virtio_shared_status_page_gpa + && contract.virtio_shared_status_page_size == expected.virtio_shared_status_page_size, + "snapshot virtio interrupt contract doesn't match the requested machine" + ); + anyhow::ensure!( + contract.effective_command_line == expected.effective_command_line, + "snapshot effective command line doesn't match the requested machine" + ); + anyhow::ensure!( + contract.memory_ranges == expected.memory_ranges, + "snapshot RAM layout doesn't match the requested machine" + ); + anyhow::ensure!( + contract.memory_expansion_version == expected.memory_expansion_version + && contract.memory_capacity_bytes == expected.memory_capacity_bytes + && contract.memory_block_size_bytes == expected.memory_block_size_bytes + && contract.memory_expansion_ranges == expected.memory_expansion_ranges, + "snapshot RAM capacity contract doesn't match the requested machine" + ); + anyhow::ensure!( + contract.topology == expected.topology, + "snapshot processor topology doesn't match the requested machine" + ); + anyhow::ensure!( + contract.boot_online_vp_count == 0 + || contract.boot_online_vp_count == expected.boot_online_vp_count, + "snapshot boot-online VP count doesn't match the requested machine" + ); + anyhow::ensure!( + contract.devices == expected.devices, + "snapshot device inventory, order, or configuration doesn't match the requested machine" + ); + anyhow::ensure!( + contract.state_unit_names == expected.state_unit_names, + "snapshot state-unit inventory or order doesn't match the requested machine" + ); + anyhow::ensure!( + contract.attachments == expected.attachments, + "snapshot attachment inventory doesn't match the supplied attachments" + ); + anyhow::ensure!( + contract.microvm_network == expected.microvm_network, + "snapshot static network identity doesn't match the requested machine" + ); + anyhow::ensure!( + contract.microvm_filesystem == expected.microvm_filesystem + && contract.microvm_additional_filesystems == expected.microvm_additional_filesystems, + "snapshot filesystem policy doesn't match the requested machine" + ); + anyhow::ensure!( + { + let mut expected_blocks = expected.microvm_sandbox_blocks.clone(); + if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { + for block in &mut expected_blocks { + if block.read_only { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + } + } + contract.microvm_sandbox_blocks == expected_blocks + }, + "snapshot sandbox block topology or identity doesn't match the requested machine" + ); + // The time ABI records are not compared: the restore preflight checks + // them against this host's rates and CPU profile. + Ok(()) +} + +pub(super) fn validate_machine_contract_shape( + contract: &SnapshotMachineContract, + memory_size: u64, + vp_count: u32, +) -> anyhow::Result<()> { + validate_supported_microvm_contract(contract)?; + validate_microvm_image_slot_contract(contract)?; + anyhow::ensure!( + contract.virtio_interrupt_mode == MICROVM_SHARED_STATUS_INTERRUPT_MODE + && contract.virtio_shared_status_page_gpa + == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + && contract.virtio_shared_status_page_size + == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + "snapshot microVM shared-status interrupt contract is invalid" + ); + anyhow::ensure!( + contract.effective_command_line.len() < MAX_COMMAND_LINE_BYTES, + "snapshot command line exceeds the 64-KiB limit" + ); + anyhow::ensure!( + !contract.effective_command_line.contains('\0'), + "snapshot command line contains an embedded NUL" + ); + validate_sha256( + &contract.effective_command_line_sha256, + "effective command line", + )?; + verify_digest( + contract.effective_command_line.as_bytes(), + &contract.effective_command_line_sha256, + "effective command line", + )?; + super::time::validate_time_abi_contract(contract)?; + + anyhow::ensure!( + !contract.memory_ranges.is_empty() && contract.memory_ranges.len() <= MAX_MEMORY_RANGES, + "snapshot RAM range count is invalid" + ); + let mut total_memory = 0_u64; + for (index, range) in contract.memory_ranges.iter().enumerate() { + anyhow::ensure!(range.length != 0, "snapshot RAM range {index} is empty"); + let gpa_end = range + .gpa_start + .checked_add(range.length) + .with_context(|| format!("snapshot RAM range {index} overflows GPA space"))?; + let file_end = range + .file_offset + .checked_add(range.length) + .with_context(|| format!("snapshot RAM range {index} overflows file space"))?; + anyhow::ensure!( + file_end <= memory_size, + "snapshot RAM range {index} exceeds memory.bin" + ); + total_memory = total_memory + .checked_add(range.length) + .context("snapshot total RAM size overflows")?; + for previous in &contract.memory_ranges[..index] { + let previous_gpa_end = previous.gpa_start + previous.length; + let previous_file_end = previous.file_offset + previous.length; + anyhow::ensure!( + gpa_end <= previous.gpa_start || range.gpa_start >= previous_gpa_end, + "snapshot RAM ranges overlap in GPA space" + ); + anyhow::ensure!( + file_end <= previous.file_offset || range.file_offset >= previous_file_end, + "snapshot RAM ranges overlap in memory.bin" + ); + } + } + anyhow::ensure!( + total_memory == memory_size, + "snapshot RAM ranges cover {total_memory} bytes, expected {memory_size}" + ); + match contract.memory_expansion_version { + 0 => anyhow::ensure!( + contract.memory_capacity_bytes == 0 + && contract.memory_block_size_bytes == 0 + && contract.memory_expansion_ranges.is_empty(), + "legacy snapshot has an unexpected RAM capacity contract" + ), + MICROVM_MEMORY_EXPANSION_VERSION => { + anyhow::ensure!( + memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM is not memory-block aligned" + ); + anyhow::ensure!( + contract.memory_block_size_bytes == MICROVM_MEMORY_BLOCK_SIZE_BYTES, + "snapshot memory block size {} is unsupported", + contract.memory_block_size_bytes + ); + anyhow::ensure!( + contract.memory_capacity_bytes >= memory_size + && contract + .memory_capacity_bytes + .is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM capacity is invalid" + ); + anyhow::ensure!( + contract.memory_ranges == canonical_microvm_memory_ranges(memory_size)?, + "snapshot base RAM ranges are not canonical" + ); + anyhow::ensure!( + contract.memory_expansion_ranges + == canonical_memory_expansion_ranges( + memory_size, + contract.memory_capacity_bytes, + )?, + "snapshot memory expansion ranges are not canonical" + ); + } + version => { + anyhow::bail!("snapshot memory expansion contract version {version} is unsupported") + } + } + + let topology = &contract.topology; + let topology_vp_count = u64::from(topology.sockets) + .checked_mul(u64::from(topology.dies_per_socket)) + .and_then(|count| count.checked_mul(u64::from(topology.cores_per_die))) + .and_then(|count| count.checked_mul(u64::from(topology.threads_per_core))) + .context("snapshot processor topology overflows")?; + anyhow::ensure!( + topology_vp_count == u64::from(vp_count) && topology.apic_ids.len() == vp_count as usize, + "snapshot processor topology doesn't describe {vp_count} virtual processors" + ); + ensure_unique(&topology.apic_ids, "APIC ID")?; + anyhow::ensure!( + *topology == microvm_snapshot_topology(vp_count)?, + "snapshot processor topology is not canonical for the microVM" + ); + if contract.boot_online_vp_count != 0 { + anyhow::ensure!( + contract.boot_online_vp_count + == microvm_boot_online_vp_count(vp_count, &contract.effective_command_line,)?, + "snapshot boot-online VP count does not match the effective command line" + ); + } + + if let Some(network) = &contract.microvm_network { + anyhow::ensure!( + network.profile == openvmm_defs::microvm::MicrovmNetworkProfile::Portable.as_str(), + "snapshot microVM network profile '{}' is unsupported", + network.profile + ); + let prefix_length = u8::try_from(network.prefix_length) + .context("snapshot network prefix does not fit in u8")?; + let parsed = format!( + "{}/{}", + std::net::Ipv4Addr::from(network.guest_ipv4), + prefix_length + ) + .parse::() + .context("snapshot static network identity is invalid")?; + anyhow::ensure!( + network.gateway_ipv4 == u32::from(parsed.derived_gateway_ipv4) + && network.guest_mac == parsed.guest_mac.to_bytes() + && network.gateway_mac == parsed.gateway_mac.to_bytes(), + "snapshot static network identity is not canonical" + ); + let valid_policy_requirement = match network.egress_policy_mode.as_str() { + "allow-all" => !network.egress_policy_required, + "deny-all" | "allow-list" | "block-list" | "endpoint" => network.egress_policy_required, + "rules" => true, + _ => false, + }; + anyhow::ensure!( + valid_policy_requirement, + "snapshot egress policy requirement is invalid" + ); + anyhow::ensure!( + matches!( + network.egress_policy_encoding_version, + 0 | 1 | 2 | net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION + ), + "snapshot egress policy encoding version {} is unsupported", + network.egress_policy_encoding_version + ); + validate_sha256(&network.egress_policy_sha256, "egress policy")?; + } + let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; + anyhow::ensure!( + contract.microvm_additional_filesystems.is_empty() || contract.microvm_filesystem.is_some(), + "snapshot microVM filesystem slots after the first require a filesystem in the first slot" + ); + anyhow::ensure!( + contract.microvm_additional_filesystems.len() < slots.len(), + "snapshot has more microVM filesystems than virtio-fs slots" + ); + let mut filesystems = Vec::with_capacity(slots.len()); + for (filesystem, slot) in contract.microvm_filesystems().zip(slots) { + anyhow::ensure!( + !filesystem.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + let access = match filesystem.access_mode.as_str() { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("snapshot filesystem access mode '{mode}' is unsupported"), + }; + let parsed = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target.clone(), + access, + ) + .and_then(|config| { + config.with_access_policy( + filesystem.denied_paths.clone(), + filesystem.allowed_paths.clone(), + filesystem.writable_paths.clone(), + ) + }) + .context("snapshot filesystem policy is invalid")? + .with_owner(snapshot_microvm_filesystem_owner(&filesystem.owner_mode)?); + anyhow::ensure!( + *filesystem + == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path, slot), + "snapshot filesystem policy is not canonical" + ); + filesystems.push(parsed); + } + openvmm_defs::microvm::validate_microvm_filesystems(&filesystems) + .context("snapshot filesystem policies are invalid")?; + + let has_filesystem_device = contract + .devices + .iter() + .any(|device| device.stable_id == slots[0].stable_id); + let has_filesystem_attachment = contract + .attachments + .iter() + .any(|attachment| attachment.stable_id == slots[0].stable_id); + match contract.microvm_filesystem_slot_version { + 0 => anyhow::ensure!( + has_filesystem_device == contract.microvm_filesystem.is_some() + && has_filesystem_attachment == contract.microvm_filesystem.is_some(), + "legacy snapshot microVM filesystem device, policy, and attachment inventories disagree" + ), + MICROVM_FILESYSTEM_SLOT_VERSION => anyhow::ensure!( + has_filesystem_device + && has_filesystem_attachment == contract.microvm_filesystem.is_some(), + "snapshot reserved microVM filesystem slot, policy, and attachment inventories disagree" + ), + version => anyhow::bail!( + "snapshot microVM filesystem slot capability version {version} is unsupported" + ), + } + // A slot after the first exists only with its filesystem attached. + for (index, slot) in slots.iter().enumerate().skip(1) { + let attached = contract.microvm_additional_filesystems.len() >= index; + let has_device = contract + .devices + .iter() + .any(|device| device.stable_id == slot.stable_id); + let has_attachment = contract + .attachments + .iter() + .any(|attachment| attachment.stable_id == slot.stable_id); + anyhow::ensure!( + has_device == attached && has_attachment == attached, + "snapshot microVM filesystem slot {} device, policy, and attachment inventories disagree", + slot.stable_id + ); + } + + if !contract.microvm_sandbox_blocks.is_empty() { + anyhow::ensure!( + contract.microvm_sandbox_blocks.len() >= 2 + && contract.microvm_sandbox_blocks.len() <= 4, + "microVM snapshot must contain one to three layers and scratch" + ); + let mut previous_role = None; + for block in &contract.microvm_sandbox_blocks { + let role = match block.role.as_str() { + "distro" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + "runtime" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Runtime, + "custom" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Custom, + "scratch" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + role => anyhow::bail!("snapshot sandbox block role '{role}' is unsupported"), + }; + anyhow::ensure!( + previous_role.is_none_or(|previous| previous < role), + "snapshot sandbox block roles are duplicated or out of order" + ); + previous_role = Some(role); + anyhow::ensure!( + block.read_only == role.is_read_only(), + "snapshot sandbox block '{}' has an invalid access mode", + block.role + ); + anyhow::ensure!( + block.length != 0 && block.length % 512 == 0, + "snapshot sandbox block '{}' has invalid geometry", + block.role + ); + anyhow::ensure!( + block.logical_block_size >= 512 + && block.logical_block_size.is_power_of_two() + && block.physical_block_size >= block.logical_block_size + && block.physical_block_size.is_power_of_two() + && block.length % u64::from(block.logical_block_size) == 0, + "snapshot sandbox block '{}' has invalid block geometry", + block.role + ); + if role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch { + anyhow::ensure!( + block.artifact.is_empty() || block.artifact == SCRATCH_FILE_NAME, + "snapshot scratch artifact name is invalid" + ); + if block.artifact.is_empty() { + anyhow::ensure!( + block.identity_kind == "fresh" && block.identity.is_empty(), + "snapshot fresh scratch policy is invalid" + ); + } else { + anyhow::ensure!( + matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 | SNAPSHOT_BLOCK_IDENTITY_GENERATION + ), + "snapshot paired scratch has an unsupported identity kind" + ); + match block.identity_kind.as_str() { + SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + validate_sha256(&block.identity, "scratch block")?; + } + SNAPSHOT_BLOCK_IDENTITY_GENERATION => { + anyhow::ensure!( + block.identity.len() == SNAPSHOT_GENERATION_ID_SIZE + && block.identity.iter().any(|byte| *byte != 0), + "snapshot scratch generation identity is invalid" + ); + } + _ => unreachable!(), + } + } + } else { + anyhow::ensure!( + block.artifact.is_empty() + && matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 + | SNAPSHOT_BLOCK_IDENTITY_GENERATION + | "unbound" + ), + "snapshot read-only layer '{}' has an invalid identity policy", + block.role + ); + match block.identity_kind.as_str() { + SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + validate_sha256(&block.identity, &format!("{} block", block.role))?; + } + SNAPSHOT_BLOCK_IDENTITY_GENERATION => { + anyhow::ensure!( + block.identity.len() == SNAPSHOT_GENERATION_ID_SIZE + && block.identity.iter().any(|byte| *byte != 0), + "snapshot layer '{}' generation identity is invalid", + block.role + ); + } + "unbound" => { + anyhow::ensure!( + block.identity.is_empty(), + "snapshot unbound layer '{}' carries an identity", + block.role + ); + } + _ => unreachable!(), + } + } + if role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + && !block.artifact.is_empty() + { + anyhow::ensure!( + matches!( + block.restore_mode.as_str(), + "" | SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + | SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE + | SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED + ), + "snapshot scratch restore mode '{}' is unsupported", + block.restore_mode + ); + } else { + anyhow::ensure!( + block.restore_mode.is_empty(), + "snapshot block '{}' unexpectedly carries a restore mode", + block.role + ); + } + } + anyhow::ensure!( + previous_role == Some(openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch), + "microVM snapshot is missing its scratch role" + ); + let bound_blocks = contract + .microvm_sandbox_blocks + .iter() + .filter(|block| { + matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 | SNAPSHOT_BLOCK_IDENTITY_GENERATION + ) + }) + .collect::>(); + if let Some(first) = bound_blocks.first() { + anyhow::ensure!( + bound_blocks + .iter() + .all(|block| block.identity_kind == first.identity_kind), + "snapshot sandbox blocks use mixed identity policies" + ); + if first.identity_kind == SNAPSHOT_BLOCK_IDENTITY_GENERATION { + anyhow::ensure!( + bound_blocks + .iter() + .all(|block| block.identity == first.identity), + "snapshot sandbox blocks do not share one storage generation" + ); + } + } + } + + anyhow::ensure!( + contract.devices.len() <= MAX_DEVICES, + "snapshot device inventory is too large" + ); + let mut device_ids = HashSet::new(); + for (index, device) in contract.devices.iter().enumerate() { + anyhow::ensure!( + device.order == index as u32, + "snapshot device order is not canonical" + ); + anyhow::ensure!( + !device.stable_id.is_empty() && device_ids.insert(device.stable_id.as_str()), + "snapshot contains an empty or duplicate device ID" + ); + anyhow::ensure!( + !device.state_unit_name.is_empty(), + "snapshot device '{}' has no state-unit name", + device.stable_id, + ); + anyhow::ensure!( + device.ranges.len() <= MAX_DEVICE_RANGES, + "snapshot device '{}' has too many address ranges", + device.stable_id, + ); + for range in &device.ranges { + anyhow::ensure!( + matches!(range.address_space.as_str(), "pmio" | "mmio") && range.length != 0, + "snapshot device '{}' has an invalid address range", + device.stable_id, + ); + range.start.checked_add(range.length).with_context(|| { + format!("snapshot device '{}' range overflows", device.stable_id) + })?; + } + anyhow::ensure!( + device.queue_count as usize == device.queue_max_sizes.len(), + "snapshot device '{}' queue inventory is inconsistent", + device.stable_id, + ); + } + + anyhow::ensure!( + !contract.state_unit_names.is_empty() && contract.state_unit_names.len() <= MAX_STATE_UNITS, + "snapshot state-unit inventory size is invalid" + ); + ensure_unique(&contract.state_unit_names, "state-unit name")?; + let state_units = contract + .state_unit_names + .iter() + .map(String::as_str) + .collect::>(); + for device in &contract.devices { + anyhow::ensure!( + state_units.contains(device.state_unit_name.as_str()), + "snapshot device '{}' references an unknown state unit", + device.stable_id, + ); + } + + anyhow::ensure!( + contract.attachments.len() <= MAX_ATTACHMENTS, + "snapshot attachment inventory is too large" + ); + let mut attachment_ids = HashSet::new(); + for attachment in &contract.attachments { + anyhow::ensure!( + !attachment.stable_id.is_empty() + && attachment_ids.insert(attachment.stable_id.as_str()), + "snapshot contains an empty or duplicate attachment ID" + ); + anyhow::ensure!( + !attachment.kind.is_empty() + && !attachment.reconnect_policy.is_empty() + && !attachment.identity_kind.is_empty() + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES, + "snapshot attachment '{}' has an incomplete identity", + attachment.stable_id, + ); + anyhow::ensure!( + match attachment.reconnect_policy.as_str() { + "recreate-listener" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } + "reconnect-client" => { + attachment.required && attachment.reconnect_timeout_ms != 0 + } + "require-inherited-attachment" => { + attachment.required && attachment.reconnect_timeout_ms == 0 + } + "discard-while-disconnected" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } + "broker-authenticated-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" + ) + } + "broker-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } + "recreate-endpoint" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } + "live-revalidate" => { + attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.length == 0 + } + _ => false, + }, + "snapshot attachment '{}' has an invalid reconnect policy", + attachment.stable_id, + ); + } + Ok(()) +} + +pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Result<()> { + let Some(contract) = manifest.machine_contract.as_ref() else { + anyhow::ensure!( + manifest.snapshot_tier.is_empty() + && manifest.restore_policy.is_empty() + && manifest.consumed_config_sections == 0, + "snapshot tier metadata requires a microVM machine contract" + ); + return Ok(()); + }; + if contract.microvm_sandbox_blocks.is_empty() { + anyhow::ensure!( + manifest.snapshot_tier.is_empty() + && manifest.restore_policy.is_empty() + && manifest.consumed_config_sections == 0, + "snapshot tier metadata requires microVM sandbox blocks" + ); + return Ok(()); + } + + let paired_scratch = paired_scratch_block(manifest).is_some(); + let expected_consumed_sections = match manifest.snapshot_tier.as_str() { + SNAPSHOT_TIER_PLATFORM => SNAPSHOT_CONFIG_INVARIANTS, + SNAPSHOT_TIER_WORKLOAD_START | SNAPSHOT_TIER_INSTANCE_CHECKPOINT => SNAPSHOT_CONFIG_ALL, + _ => 0, + }; + let valid = manifest.consumed_config_sections == expected_consumed_sections + && matches!( + ( + manifest.snapshot_tier.as_str(), + manifest.restore_policy.as_str(), + paired_scratch, + ), + (SNAPSHOT_TIER_PLATFORM, SNAPSHOT_RESTORE_POLICY_CLONE, false) + | ( + SNAPSHOT_TIER_WORKLOAD_START, + SNAPSHOT_RESTORE_POLICY_CLONE, + true + ) + | ( + SNAPSHOT_TIER_INSTANCE_CHECKPOINT, + SNAPSHOT_RESTORE_POLICY_RESUME, + true + ) + ); + anyhow::ensure!( + valid, + "snapshot tier '{}', restore policy '{}', and scratch policy are not a canonical microVM combination", + manifest.snapshot_tier, + manifest.restore_policy, + ); + let expected_tier_token = format!("nvx_snapshot_tier={}", manifest.snapshot_tier); + let tier_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("nvx_snapshot_tier=")) + .collect::>(); + anyhow::ensure!( + tier_tokens == [expected_tier_token.as_str()], + "snapshot tier '{}' does not match its saved host policy", + manifest.snapshot_tier, + ); + let layers_are_unbound = contract + .microvm_sandbox_blocks + .iter() + .filter(|block| block.read_only) + .all(|block| block.identity_kind == "unbound" && block.identity.is_empty()); + anyhow::ensure!( + layers_are_unbound == (manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM), + "snapshot layer identity binding does not match tier '{}'", + manifest.snapshot_tier, + ); + if let Some(scratch) = paired_scratch_block(manifest) { + match scratch.restore_mode.as_str() { + "" | SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY => {} + SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE => anyhow::ensure!( + manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_CLONE, + "copy-on-write scratch materialization requires clone restore policy" + ), + SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED => anyhow::ensure!( + manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_RESUME, + "direct-claimed scratch materialization requires resume restore policy" + ), + _ => unreachable!("machine-contract shape validation rejects unknown restore modes"), + } + } + if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { + let control_tty_count = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| *token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE) + .count(); + anyhow::ensure!( + control_tty_count <= 1, + "platform snapshot command line contains duplicate control tty configuration" + ); + let processor_limit_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("nr_cpus=")) + .collect::>(); + anyhow::ensure!( + processor_limit_tokens.len() <= 1, + "platform snapshot command line contains duplicate processor capacity" + ); + if let Some(processor_limit) = processor_limit_tokens.first() { + let expected = format!("nr_cpus={}", contract.topology.apic_ids.len()); + anyhow::ensure!( + **processor_limit == expected, + "platform snapshot command line processor capacity does not match its machine contract" + ); + } + anyhow::ensure!( + contract + .effective_command_line + .split_ascii_whitespace() + .all(platform_command_line_token_is_invariant), + "platform snapshot command line contains tenant or unsupported configuration" + ); + } + Ok(()) +} + +fn platform_command_line_token_is_invariant(token: &str) -> bool { + matches!( + token, + "earlycon=xe9" + | "console=hvc0" + | "console=hvc1" + | "reboot=t" + | "panic=-1" + | "nvx_sandbox=1" + | "nvx_config=0xd0010000,65536" + | "nvx_snapshot_tier=platform" + | "microvm_image_slots=4" + ) || token.starts_with("nr_cpus=") + || token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE + || [ + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + ] + .iter() + .any(|prefix| token.starts_with(prefix)) +} + +fn ensure_unique(values: &[T], description: &str) -> anyhow::Result<()> +where + T: Eq + std::hash::Hash, +{ + let mut unique = HashSet::with_capacity(values.len()); + anyhow::ensure!( + values.iter().all(|value| unique.insert(value)), + "snapshot contains a duplicate {description}" + ); + Ok(()) +} + +#[cfg(test)] +const TEST_DISTRO_IDENTITY_BYTE: u8 = 0x11; + +#[cfg(test)] +pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { + let mut manifest = super::tests::test_manifest(); + let mut contract = test_machine_contract(); + contract.microvm_sandbox_blocks = vec![ + SnapshotMicrovmSandboxBlock { + role: "distro".to_owned(), + read_only: true, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![TEST_DISTRO_IDENTITY_BYTE; SHA256_SIZE], + artifact: String::new(), + logical_block_size: 512, + physical_block_size: 4096, + restore_mode: String::new(), + }, + SnapshotMicrovmSandboxBlock { + role: "scratch".to_owned(), + read_only: false, + length: scratch.len() as u64, + identity_kind: "sha256".to_owned(), + identity: sha2::Sha256::digest(scratch).to_vec(), + artifact: SCRATCH_FILE_NAME.to_owned(), + logical_block_size: 512, + physical_block_size: 4096, + restore_mode: SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY.to_owned(), + }, + ]; + contract.set_effective_command_line("console=hvc0 nvx_snapshot_tier=workload-start".to_owned()); + manifest.machine_contract = Some(contract); + manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_ALL; + manifest +} + +#[cfg(test)] +pub(super) fn test_machine_contract() -> SnapshotMachineContract { + let mut contract = SnapshotMachineContract { + machine_profile: "microvm".to_owned(), + microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + source_hypervisor: "whp".to_owned(), + effective_command_line: String::new(), + effective_command_line_sha256: Vec::new(), + memory_ranges: vec![SnapshotMemoryRange { + gpa_start: 0, + length: 1024, + file_offset: 0, + }], + topology: SnapshotProcessorTopology { + sockets: 1, + dies_per_socket: 1, + cores_per_die: 2, + threads_per_core: 1, + apic_ids: vec![0, 1], + }, + devices: vec![ + SnapshotDevice { + stable_id: "portb".to_owned(), + state_unit_name: "portb".to_owned(), + kind: "portb".to_owned(), + order: 0, + ranges: vec![SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start: 0xe9, + length: 2, + }], + irq: None, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }, + SnapshotDevice { + stable_id: "shutdown".to_owned(), + state_unit_name: "shutdown".to_owned(), + kind: "shutdown".to_owned(), + order: 1, + ranges: vec![SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start: 0x604, + length: 1, + }], + irq: None, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }, + ], + state_unit_names: vec!["portb".to_owned(), "shutdown".to_owned()], + attachments: Vec::new(), + boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, + microvm_network: None, + microvm_filesystem: None, + microvm_additional_filesystems: Vec::new(), + microvm_sandbox_blocks: Vec::new(), + microvm_filesystem_slot_version: 0, + boot_online_vp_count: 0, + virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), + virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, + virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + memory_expansion_version: 0, + memory_capacity_bytes: 0, + memory_block_size_bytes: 0, + memory_expansion_ranges: Vec::new(), + time: Some(super::time::tests::test_time_contract()), + cpu_profile: Some(super::time::tests::test_cpu_profile()), + microvm_image_slot_capacity: 0, + boot_active_image_slot_count: 0, + }; + contract.set_effective_command_line("console=hvc0".to_owned()); + contract +} + +#[cfg(test)] +mod tests { + use super::super::format::validate_manifest_contents; + use super::super::tests::test_manifest; + use super::*; + + const TEST_SCRATCH_IDENTITY_BYTE: u8 = 0x22; + + fn virtio_state_unit_name(kind: &str, mmio_base: u64) -> String { + format!("{kind}-{mmio_base}") + } + + #[test] + fn restore_online_vp_count_is_bounded_by_template() { + assert_eq!( + microvm_boot_online_vp_count(8, "console=hvc0 maxcpus=1").unwrap(), + 1 + ); + assert_eq!(microvm_boot_online_vp_count(8, "console=hvc0").unwrap(), 0); + assert!(microvm_boot_online_vp_count(8, "maxcpus=3").is_err()); + + let mut manifest = test_manifest(); + manifest.vp_count = 8; + let mut contract = test_machine_contract(); + contract.boot_online_vp_count = 2; + manifest.machine_contract = Some(contract); + + for target in [2, 4, 8] { + validate_restore_online_vp_count(&manifest, target).unwrap(); + } + + for target in [1, 3, 16] { + assert!(validate_restore_online_vp_count(&manifest, target).is_err()); + } + + manifest.vp_count = 4; + let error = validate_restore_online_vp_count(&manifest, 8).unwrap_err(); + assert!(error.to_string().contains("exceeds VP capacity 4")); + manifest.vp_count = 8; + + manifest + .machine_contract + .as_mut() + .unwrap() + .boot_online_vp_count = 0; + let error = validate_restore_online_vp_count(&manifest, 8).unwrap_err(); + assert!(error.to_string().contains("does not declare")); + } + + #[test] + fn restore_image_slot_count_is_bounded_by_template() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.microvm_image_slot_capacity = + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into(); + contract.boot_active_image_slot_count = 2; + contract.set_effective_command_line("console=hvc0 microvm_image_slots=4".to_owned()); + assert_eq!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ); + manifest.machine_contract = Some(contract); + + for target in [2, 3, 4] { + validate_restore_active_image_slot_count(&manifest, target).unwrap(); + } + for target in [0, 1, 5] { + assert!(validate_restore_active_image_slot_count(&manifest, target).is_err()); + } + } + + #[test] + fn generated_image_slots_use_abi_two() { + use openvmm_defs::microvm::{ + MICROVM_BASE_COMMAND_LINE, MICROVM_IMAGE_SLOT_MMIO_BASES, MicrovmImageSlotsConfig, + append_microvm_virtio_discovery, + }; + + for image_slots in [ + None, + Some(MicrovmImageSlotsConfig { + boot_count: 1, + active_count: 1, + }), + ] { + let mut command_line = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut command_line, + None, + false, + &[], + false, + false, + &[], + image_slots, + ) + .unwrap(); + let mut state_unit_names = [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + ] + .map(str::to_owned) + .to_vec(); + if image_slots.is_some() { + state_unit_names.extend( + MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .map(|base| format!("virtio-blk-image-slot-{base}")), + ); + } + + let contract = microvm_machine_contract_with_image_slots( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + false, + Vec::new(), + None, + None, + Vec::new(), + image_slots, + 1, + 1024, + None, + state_unit_names, + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap(); + assert_eq!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ); + assert_eq!( + contract.microvm_image_slot_capacity, + if image_slots.is_some() { 4 } else { 0 } + ); + assert_eq!( + contract.boot_active_image_slot_count, + image_slots.map_or(0, |slots| slots.boot_count) + ); + assert_eq!( + contract + .devices + .iter() + .filter(|device| device.kind == "virtio-blk-image-slot") + .count(), + if image_slots.is_some() { 4 } else { 0 } + ); + } + } + + #[test] + fn abi_two_image_slots_require_canonical_metadata() { + let mut contract = test_machine_contract(); + validate_microvm_image_slot_contract(&contract).unwrap(); + + contract.boot_active_image_slot_count = 1; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.microvm_image_slot_capacity = + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into(); + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.set_effective_command_line("console=hvc0 microvm_image_slots=4".to_owned()); + validate_microvm_image_slot_contract(&contract).unwrap(); + assert!(platform_command_line_token_is_invariant( + "microvm_image_slots=4" + )); + assert!(!platform_command_line_token_is_invariant( + "microvm_image_slots=3" + )); + + contract.boot_active_image_slot_count = 0; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.boot_active_image_slot_count = 5; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.boot_active_image_slot_count = 1; + contract.set_effective_command_line( + "console=hvc0 microvm_image_slots=4 microvm_image_slots=4".to_owned(), + ); + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.microvm_image_slot_capacity = 0; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + } + + fn microvm_console_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-virtio0".to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "tcp".to_owned(), + identity: b"127.0.0.1:5555".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn microvm_control_console_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-control0".to_owned(), + kind: "virtio-control-console".to_owned(), + required: false, + reconnect_policy: "broker-disconnected".to_owned(), + identity_kind: "disconnected".to_owned(), + identity: b"discard".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn microvm_control_console_listener_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-control0".to_owned(), + kind: "virtio-control-console".to_owned(), + required: false, + reconnect_policy: "broker-authenticated-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: b"/run/nvx/control.sock".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn microvm_network_attachment(source_hypervisor: &str) -> SnapshotAttachment { + assert!(matches!(source_hypervisor, "kvm" | "mshv" | "whp")); + SnapshotAttachment { + stable_id: "net:microvm0".to_owned(), + kind: "virtio-net".to_owned(), + required: false, + reconnect_policy: "recreate-endpoint".to_owned(), + identity_kind: "user-mode-nat".to_owned(), + identity: b"consomme".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn microvm_filesystem_attachment(source_hypervisor: &str) -> SnapshotAttachment { + microvm_slot_attachment(source_hypervisor, 0) + } + + fn microvm_slot_attachment(source_hypervisor: &str, slot: usize) -> SnapshotAttachment { + SnapshotAttachment { + stable_id: openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[slot] + .stable_id + .to_owned(), + kind: "virtio-fs".to_owned(), + required: true, + reconnect_policy: "live-revalidate".to_owned(), + identity_kind: match source_hypervisor { + "kvm" | "mshv" => "unix-device-inode-v1", + "whp" => "windows-volume-file-id-v1", + _ => unreachable!(), + } + .to_owned(), + identity: format!("root-object-v1-{slot}").into_bytes(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn generated_network_contract(source_hypervisor: &str) -> SnapshotMachineContract { + generated_network_contract_with_mode( + source_hypervisor, + net_backend_resources::egress::EgressPolicyMode::AllowList(vec![ + "192.0.2.0/24".parse().unwrap(), + ]), + ) + } + + fn generated_network_contract_with_mode( + source_hypervisor: &str, + mode: net_backend_resources::egress::EgressPolicyMode, + ) -> SnapshotMachineContract { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let egress_policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + mode, + ) + .unwrap(); + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); + let command_line = format!( + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0000000:{irq} {}", + network.command_line_fragment_with_dns(egress_policy.allows_gateway_dns()) + ); + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + Some(( + &network, + &egress_policy, + microvm_network_attachment(source_hypervisor), + )), + false, + Vec::new(), + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-net-3489660928", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn generated_console_contract() -> SnapshotMachineContract { + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" + .to_owned(), + None, + false, + Vec::new(), + Some(microvm_console_attachment()), + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-console-3489669120", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn generated_control_console_contract_with_attachment( + control_console_attachment: SnapshotAttachment, + ) -> SnapshotMachineContract { + let command_line = format!( + "{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + {}", + openvmm_defs::microvm::MICROVM_CONSOLE_COMMAND_LINE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], + openvmm_defs::microvm::MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE, + ); + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + false, + Vec::new(), + Some(microvm_console_attachment()), + Some(control_console_attachment), + vec![ + SnapshotMicrovmSandboxBlock { + role: "distro".to_owned(), + read_only: true, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![0x11; SHA256_SIZE], + artifact: String::new(), + logical_block_size: 512, + physical_block_size: 4096, + restore_mode: String::new(), + }, + SnapshotMicrovmSandboxBlock { + role: "scratch".to_owned(), + read_only: false, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![TEST_SCRATCH_IDENTITY_BYTE; SHA256_SIZE], + artifact: SCRATCH_FILE_NAME.to_owned(), + logical_block_size: 512, + physical_block_size: 4096, + restore_mode: SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY.to_owned(), + }, + ], + 1, + 1024, + None, + vec![ + "partition".to_owned(), + "vmtime".to_owned(), + "pic".to_owned(), + "ioapic".to_owned(), + "pit".to_owned(), + "rtc".to_owned(), + "microvm-portb".to_owned(), + "microvm-shutdown".to_owned(), + "microvm-snapshot-request".to_owned(), + virtio_state_unit_name( + "virtio-console", + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + ), + virtio_state_unit_name( + "virtio-blk", + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_MMIO_BASE, + ), + virtio_state_unit_name( + "virtio-blk", + openvmm_defs::microvm::MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], + ), + virtio_state_unit_name( + "virtio-control-console", + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + ), + ], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn generated_control_console_contract() -> SnapshotMachineContract { + generated_control_console_contract_with_attachment(microvm_control_console_attachment()) + } + + fn generated_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + generated_filesystem_contract_with_owner( + source_hypervisor, + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, + ) + } + + fn generated_filesystem_contract_with_owner( + source_hypervisor: &str, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + ) -> SnapshotMachineContract { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) + .unwrap() + .with_owner(owner); + generated_filesystem_contract_with_config(source_hypervisor, &filesystem) + } + + fn generated_filesystem_contract_with_config( + source_hypervisor: &str, + filesystem: &openvmm_defs::microvm::MicrovmFilesystemConfig, + ) -> SnapshotMachineContract { + let command_line = format!( + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", + filesystem.command_line_fragment(&openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0]) + ); + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + true, + vec![( + filesystem, + Path::new(if cfg!(windows) { + r"C:\microvm-share" + } else { + "/microvm-share" + }), + microvm_filesystem_attachment(source_hypervisor), + )], + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn generated_dormant_filesystem_contract() -> SnapshotMachineContract { + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6" + .to_owned(), + None, + true, + Vec::new(), + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn two_filesystems() -> [openvmm_defs::microvm::MicrovmFilesystemConfig; 2] { + [ + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/workspace".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) + .unwrap(), + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(), + ] + } + + fn microvm_share_path(slot: usize) -> &'static Path { + Path::new(match (cfg!(windows), slot) { + (true, 0) => r"C:\workspace", + (true, _) => r"C:\toolcache", + (false, 0) => "/workspace", + (false, _) => "/toolcache", + }) + } + + fn filesystems_contract( + source_hypervisor: &str, + command_line: String, + filesystem_slot: bool, + filesystems: Vec<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + ) -> anyhow::Result { + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + filesystem_slot, + filesystems, + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + "virtiofs-3489693696", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + } + + fn slot_filesystems<'a>( + source_hypervisor: &str, + filesystems: &'a [openvmm_defs::microvm::MicrovmFilesystemConfig], + ) -> Vec<( + &'a openvmm_defs::microvm::MicrovmFilesystemConfig, + &'static Path, + SnapshotAttachment, + )> { + filesystems + .iter() + .enumerate() + .map(|(slot, filesystem)| { + ( + filesystem, + microvm_share_path(slot), + microvm_slot_attachment(source_hypervisor, slot), + ) + }) + .collect() + } + + fn two_filesystem_command_line() -> String { + filesystems_command_line(&two_filesystems()) + } + + fn filesystems_command_line( + filesystems: &[openvmm_defs::microvm::MicrovmFilesystemConfig], + ) -> String { + let mut command_line = + openvmm_defs::microvm::build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + true, + filesystems, + false, + false, + &[], + None, + ) + .unwrap(); + command_line + } + + fn generated_two_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + let filesystems = two_filesystems(); + filesystems_contract( + source_hypervisor, + two_filesystem_command_line(), + true, + slot_filesystems(source_hypervisor, &filesystems), + ) + .unwrap() + } + + fn two_filesystem_manifest(contract: &SnapshotMachineContract) -> SnapshotManifest { + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + } + + #[test] + fn generated_microvm_two_filesystem_contract_has_fixed_abi() { + for source_hypervisor in ["kvm", "mshv", "whp"] { + let contract = generated_two_filesystem_contract(source_hypervisor); + let first = contract + .devices + .iter() + .find(|device| device.stable_id == "fs:microvm0") + .unwrap(); + assert_eq!(first.state_unit_name, "virtiofs-3489665024"); + assert_eq!(first.ranges[0].start, 0xd000_1000); + assert_eq!(first.irq, Some(6)); + // The second slot follows every other device, in address order. + let second = contract.devices.last().unwrap(); + assert_eq!(second.stable_id, "fs:microvm1"); + assert_eq!(second.state_unit_name, "virtiofs-3489693696"); + assert_eq!(second.ranges[0].start, 0xd000_8000); + assert_eq!(second.ranges[0].length, 0x1000); + assert_eq!(second.irq, Some(13)); + assert_eq!(second.transport, "virtio-mmio"); + assert_eq!(second.feature_banks, first.feature_banks); + assert_eq!(second.queue_max_sizes, first.queue_max_sizes); + assert_eq!( + contract.attachments, + [ + microvm_slot_attachment(source_hypervisor, 0), + microvm_slot_attachment(source_hypervisor, 1), + ] + ); + + let workspace = contract.microvm_filesystem.as_ref().unwrap(); + assert_eq!(workspace.guest_mount_target, "/workspace"); + assert_eq!(workspace.access_mode, "rw"); + assert_eq!(workspace.tag, "microvm"); + assert_eq!(workspace.denied_paths, ["secrets"]); + let [toolcache] = contract.microvm_additional_filesystems.as_slice() else { + panic!("the second share is missing from the contract"); + }; + assert_eq!(toolcache.guest_mount_target, "/opt/hostedtoolcache"); + assert_eq!(toolcache.access_mode, "ro"); + assert_eq!(toolcache.tag, "microvm1"); + assert!(toolcache.denied_paths.is_empty()); + assert_eq!( + toolcache.canonical_host_path, + microvm_share_path(1).to_str().unwrap() + ); + + validate_microvm_machine_contract(&two_filesystem_manifest(&contract), &contract) + .unwrap(); + } + } + + #[test] + fn validate_microvm_contract_rejects_a_missing_or_changed_second_share() { + let contract = generated_two_filesystem_contract("kvm"); + let manifest = two_filesystem_manifest(&contract); + let filesystems = two_filesystems(); + + // A machine with only the first share has another command line and + // device inventory, so it can't restore the two-share snapshot. + let only_first = filesystems_contract( + "kvm", + filesystems_command_line(&filesystems[..1]), + true, + slot_filesystems("kvm", &filesystems[..1]), + ) + .unwrap(); + let error = validate_microvm_machine_contract(&manifest, &only_first).unwrap_err(); + assert!( + error + .to_string() + .contains("doesn't match the requested machine") + ); + + let mut writable = filesystems.clone(); + writable[1] = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .unwrap(); + let error = filesystems_contract( + "kvm", + contract.effective_command_line.clone(), + true, + slot_filesystems("kvm", &writable), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not match its saved policy") + ); + + let mut replaced = contract.clone(); + replaced.attachments[1].identity = b"another-root".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &replaced).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + + let mut moved = contract.clone(); + moved.microvm_additional_filesystems[0].canonical_host_path = "/moved".to_owned(); + let error = validate_microvm_machine_contract(&manifest, &moved).unwrap_err(); + assert!(error.to_string().contains("filesystem policy")); + } + + #[test] + fn microvm_contract_shape_rejects_an_inconsistent_second_slot() { + let contract = generated_two_filesystem_contract("whp"); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + + let mut missing_device = contract.clone(); + missing_device.devices.pop(); + let mut missing_attachment = contract.clone(); + missing_attachment.attachments.pop(); + let mut missing_policy = contract.clone(); + missing_policy.microvm_additional_filesystems.clear(); + for invalid in [missing_device, missing_attachment, missing_policy] { + let error = validate_machine_contract_shape(&invalid, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("fs:microvm1"), "{error:#}"); + } + + let mut without_first = contract.clone(); + without_first.microvm_filesystem = None; + let error = validate_machine_contract_shape(&without_first, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("first slot")); + + let mut first_tag = contract.clone(); + first_tag.microvm_additional_filesystems[0].tag = "microvm".to_owned(); + let error = validate_machine_contract_shape(&first_tag, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + + let mut nested = contract.clone(); + nested.microvm_additional_filesystems[0].guest_mount_target = "/workspace/cache".to_owned(); + let error = validate_machine_contract_shape(&nested, 1024, 1).unwrap_err(); + assert!(format!("{error:#}").contains("overlap")); + + let mut too_many = contract.clone(); + too_many + .microvm_additional_filesystems + .push(too_many.microvm_additional_filesystems[0].clone()); + let error = validate_machine_contract_shape(&too_many, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("more microVM filesystems")); + } + + #[test] + fn microvm_contract_builder_rejects_misplaced_second_share() { + let filesystems = two_filesystems(); + let command_line = two_filesystem_command_line(); + + // Shares occupy the slots after the reserved first slot. + assert!( + filesystems_contract( + "kvm", + command_line.clone(), + false, + slot_filesystems("kvm", &filesystems), + ) + .is_err() + ); + + let mut swapped = slot_filesystems("kvm", &filesystems); + swapped[0].2 = microvm_slot_attachment("kvm", 1); + swapped[1].2 = microvm_slot_attachment("kvm", 0); + let error = filesystems_contract("kvm", command_line.clone(), true, swapped).unwrap_err(); + assert!(error.to_string().contains("live-revalidation policy")); + + let undiscovered = command_line.replace(" virtio_mmio.device=0x1000@0xd0008000:13", ""); + let error = filesystems_contract( + "kvm", + undiscovered, + true, + slot_filesystems("kvm", &filesystems), + ) + .unwrap_err(); + assert!(error.to_string().contains("fs:microvm1")); + + // The contract requires each slot's discovery token once and the + // bootstrap triplets exactly, in slot order. + let first = "virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw"; + let second = "virtfs_dir=/opt/hostedtoolcache virtfs_tag=microvm1 virtfs_mode=ro"; + assert!(command_line.ends_with(&format!("{first} {second}"))); + for invalid in [ + command_line.replace(&format!("{first} {second}"), &format!("{second} {first}")), + format!("{command_line} {second}"), + format!("{command_line} virtfs_mode=ro"), + command_line.replace( + " virtio_mmio.device=0x1000@0xd0008000:13", + " virtio_mmio.device=0x1000@0xd0008000:13 virtio_mmio.device=0x1000@0xd0008000:13", + ), + ] { + assert!( + filesystems_contract("kvm", invalid, true, slot_filesystems("kvm", &filesystems)) + .is_err() + ); + } + let error = filesystems_contract( + "kvm", + command_line.clone(), + true, + slot_filesystems("kvm", &filesystems[..1]), + ) + .unwrap_err(); + assert!(error.to_string().contains("fs:microvm1"), "{error:#}"); + } + + #[test] + fn generated_microvm_console_contract_has_fixed_abi() { + let contract = generated_console_contract(); + let console = contract.devices.last().unwrap(); + assert_eq!(console.stable_id, "console:microvm-virtio0"); + assert_eq!(console.state_unit_name, "virtio-console-3489669120"); + assert_eq!(console.ranges[0].start, 0xd000_2000); + assert_eq!(console.ranges[0].length, 0x1000); + assert_eq!(console.irq, Some(7)); + assert_eq!(console.transport, "virtio-mmio"); + assert_eq!(console.feature_banks, [0x3000_0001, 0x0000_0003]); + assert_eq!(console.queue_max_sizes, [256, 256]); + assert_eq!(contract.attachments, [microvm_console_attachment()]); + } + + #[test] + fn generated_microvm_control_console_contract_has_distinct_fixed_abi() { + let contract = generated_control_console_contract(); + let boot = contract + .devices + .iter() + .find(|device| device.stable_id == "console:microvm-virtio0") + .unwrap(); + let control = contract + .devices + .iter() + .find(|device| device.stable_id == "console:microvm-control0") + .unwrap(); + assert_eq!(boot.stable_id, "console:microvm-virtio0"); + assert_eq!(control.stable_id, "console:microvm-control0"); + assert_eq!(control.state_unit_name, "virtio-control-console-3489689600"); + assert_eq!(control.ranges[0].start, 0xd000_7000); + assert_eq!(control.ranges[0].length, 0x1000); + assert_eq!(control.irq, Some(3)); + assert_eq!(control.transport, "virtio-mmio"); + assert_eq!(control.feature_banks, boot.feature_banks); + assert_eq!(control.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [ + microvm_console_attachment(), + microvm_control_console_attachment() + ] + ); + } + + #[test] + fn generated_microvm_control_console_contract_accepts_broker_listener() { + let attachment = microvm_control_console_listener_attachment(); + let contract = generated_control_console_contract_with_attachment(attachment.clone()); + assert_eq!(contract.attachments[1], attachment); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + } + + #[test] + fn generated_microvm_network_contract_has_backend_specific_fixed_abi() { + for (source_hypervisor, irq) in [("kvm", 10), ("mshv", 10), ("whp", 5)] { + let contract = generated_network_contract(source_hypervisor); + let network_device = contract.devices.last().unwrap(); + assert_eq!(network_device.stable_id, "net:microvm0"); + assert_eq!(network_device.state_unit_name, "virtio-net-3489660928"); + assert_eq!(network_device.ranges[0].start, 0xd000_0000); + assert_eq!(network_device.ranges[0].length, 0x1000); + assert_eq!(network_device.irq, Some(irq)); + assert_eq!(network_device.transport, "virtio-mmio"); + assert_eq!(network_device.feature_banks, [0x20, 0x1]); + assert_eq!(network_device.queue_count, 2); + assert_eq!(network_device.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [microvm_network_attachment(source_hypervisor)] + ); + + let network = contract.microvm_network.unwrap(); + assert_eq!(network.profile, "portable"); + assert_eq!( + network.guest_ipv4, + u32::from(std::net::Ipv4Addr::new(10, 0, 0, 2)) + ); + assert_eq!(network.prefix_length, 24); + assert_eq!( + network.gateway_ipv4, + u32::from(std::net::Ipv4Addr::new(10, 0, 0, 1)) + ); + assert_eq!(network.guest_mac, [0x52, 0x54, 0, 0, 0, 2]); + assert_eq!(network.gateway_mac, [0x52, 0x54, 0, 0, 0, 1]); + assert_eq!(network.egress_policy_mode, "allow-list"); + assert_eq!(network.egress_policy_sha256.len(), 32); + assert!(network.egress_policy_required); + assert_eq!( + network.egress_policy_encoding_version, + net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION + ); + } + } + + #[test] + fn generated_microvm_network_contract_accepts_deny_all_egress() { + let contract = generated_network_contract_with_mode( + "whp", + net_backend_resources::egress::EgressPolicyMode::DenyAll, + ); + + let network = contract.microvm_network.as_ref().unwrap(); + assert_eq!(network.egress_policy_mode, "deny-all"); + assert!(network.egress_policy_required); + assert!(!contract.effective_command_line.contains("virtnet_dns=")); + validate_supported_microvm_contract(&contract).unwrap(); + } + + #[test] + fn legacy_network_policy_digest_remains_valid() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + net_backend_resources::egress::EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap(); + let mut saved = SnapshotMicrovmNetwork::new(&network, &policy); + saved.egress_policy_encoding_version = 0; + saved.egress_policy_sha256 = + sha2::Sha256::digest(policy.canonical_bytes_for_version(1).unwrap()).to_vec(); + + validate_microvm_network_policy(&saved, &policy).unwrap(); + saved.egress_policy_encoding_version = u32::MAX; + assert!(validate_microvm_network_policy(&saved, &policy).is_err()); + } + + #[test] + fn generated_microvm_filesystem_contract_has_fixed_abi() { + for source_hypervisor in ["kvm", "mshv", "whp"] { + let contract = generated_filesystem_contract(source_hypervisor); + let filesystem_device = contract.devices.last().unwrap(); + assert_eq!(filesystem_device.stable_id, "fs:microvm0"); + assert_eq!(filesystem_device.state_unit_name, "virtiofs-3489665024"); + assert_eq!(filesystem_device.ranges[0].start, 0xd000_1000); + assert_eq!(filesystem_device.ranges[0].length, 0x1000); + assert_eq!(filesystem_device.irq, Some(6)); + assert_eq!(filesystem_device.transport, "virtio-mmio"); + assert_eq!(filesystem_device.feature_banks, [0x3000_0000, 0x0000_0003]); + assert_eq!(filesystem_device.queue_count, 2); + assert_eq!(filesystem_device.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [microvm_filesystem_attachment(source_hypervisor)] + ); + + let filesystem = contract.microvm_filesystem.unwrap(); + assert_eq!(filesystem.guest_mount_target, "/mnt/share"); + assert_eq!(filesystem.access_mode, "ro"); + assert_eq!( + filesystem.canonical_host_path, + if cfg!(windows) { + r"C:\microvm-share" + } else { + "/microvm-share" + } + ); + assert_eq!(filesystem.restore_mode, "live-revalidate"); + assert_eq!(filesystem.tag, "microvm"); + assert_eq!(filesystem.high_priority_queue_count, 1); + assert_eq!(filesystem.request_queue_count, 1); + assert_eq!(filesystem.shared_memory_size, 0); + assert!(filesystem.direct_io); + assert_eq!(filesystem.entry_cache_timeout_ns, 0); + assert_eq!(filesystem.attribute_cache_timeout_ns, 0); + } + } + + #[test] + fn generated_dormant_microvm_filesystem_contract_reserves_fixed_slot() { + let contract = generated_dormant_filesystem_contract(); + assert_eq!( + contract.microvm_filesystem_slot_version, + MICROVM_FILESYSTEM_SLOT_VERSION + ); + assert!(contract.microvm_filesystem.is_none()); + assert!(contract.attachments.is_empty()); + let filesystem = contract.devices.last().unwrap(); + assert_eq!(filesystem.stable_id, "fs:microvm0"); + assert_eq!(filesystem.state_unit_name, "virtiofs-3489665024"); + assert_eq!(filesystem.ranges[0].start, 0xd000_1000); + assert_eq!(filesystem.irq, Some(6)); + } + + #[test] + fn dormant_microvm_filesystem_capability_requires_fixed_device() { + let mut contract = generated_dormant_filesystem_contract(); + contract.devices.pop(); + assert!(validate_machine_contract_shape(&contract, 1024, 1).is_err()); + } + + #[test] + fn microvm_filesystem_owner_mode_is_canonical() { + use openvmm_defs::microvm::MicrovmFilesystemOwner; + assert_eq!( + snapshot_microvm_filesystem_owner("").unwrap(), + MicrovmFilesystemOwner::Vmm + ); + assert_eq!( + snapshot_microvm_filesystem_owner("caller").unwrap(), + MicrovmFilesystemOwner::Caller + ); + for mode in ["vmm", "root", "Caller"] { + assert!(snapshot_microvm_filesystem_owner(mode).is_err()); + } + // VMM ownership keeps the encoding of snapshots that predate the field. + for (owner, mode) in [ + (MicrovmFilesystemOwner::Vmm, ""), + (MicrovmFilesystemOwner::Caller, "caller"), + ] { + let contract = generated_filesystem_contract_with_owner("kvm", owner); + assert_eq!( + contract.microvm_filesystem.as_ref().unwrap().owner_mode, + mode + ); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + } + let mut contract = generated_filesystem_contract("kvm"); + contract.microvm_filesystem.as_mut().unwrap().owner_mode = "vmm".to_owned(); + let error = validate_machine_contract_shape(&contract, 1024, 1).unwrap_err(); + assert!( + error + .to_string() + .contains("owner mode 'vmm' is unsupported") + ); + } + + #[test] + fn microvm_filesystem_access_policy_is_recorded_and_validated() { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .and_then(|config| { + config.with_access_policy( + vec!["logs".to_owned()], + vec!["logs/payloads".to_owned()], + vec!["out".to_owned()], + ) + }) + .unwrap(); + let contract = generated_filesystem_contract_with_config("kvm", &filesystem); + let saved = contract.microvm_filesystem.as_ref().unwrap(); + assert_eq!(saved.denied_paths, ["logs"]); + assert_eq!(saved.allowed_paths, ["logs/payloads"]); + assert_eq!(saved.writable_paths, ["out"]); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + + // A policy that the configuration would reject is not a valid + // contract either. + let tampers: [fn(&mut SnapshotMicrovmFilesystem); 2] = [ + |filesystem| filesystem.allowed_paths = vec!["payloads".to_owned()], + |filesystem| filesystem.writable_paths = vec!["logs/secret".to_owned()], + ]; + for tamper in tampers { + let mut contract = contract.clone(); + tamper(contract.microvm_filesystem.as_mut().unwrap()); + let error = validate_machine_contract_shape(&contract, 1024, 1).unwrap_err(); + assert!( + format!("{error:#}").contains("snapshot filesystem policy is invalid"), + "{error:#}" + ); + } + } + + #[test] + fn validate_microvm_filesystem_contract_rejects_policy_change() { + let contract = generated_filesystem_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_filesystem + .as_mut() + .unwrap() + .request_queue_count = 2; + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + + #[test] + fn validate_microvm_network_contract_rejects_noncanonical_identity() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .guest_mac[5] = 3; + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + + #[test] + fn validate_microvm_network_contract_rejects_unsupported_profile() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .profile = "other".to_owned(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("profile")); + } + + #[test] + fn validate_microvm_network_contract_rejects_legacy_missing_profile() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .profile + .clear(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("profile '' is unsupported")); + } + + #[test] + fn validate_microvm_console_contract_rejects_attachment_change() { + let contract = generated_console_contract(); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.attachments[0].identity = b"127.0.0.1:6666".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + } + + #[test] + fn validate_microvm_control_console_contract_rejects_attachment_change() { + let contract = generated_control_console_contract(); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected + .attachments + .iter_mut() + .find(|attachment| attachment.stable_id == "console:microvm-control0") + .unwrap() + .identity = b"unexpected".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + } + + #[test] + fn validate_microvm_machine_contract_ok() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + validate_microvm_machine_contract(&manifest, &contract).unwrap(); + } + + #[test] + fn validate_microvm_machine_contract_rejects_abi() { + let mut manifest = test_manifest(); + let expected = test_machine_contract(); + for version in [1, 3] { + let mut contract = expected.clone(); + contract.microvm_abi_version = version; + manifest.machine_contract = Some(contract); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!( + err.to_string() + .contains(&format!("ABI version {version} is unsupported")) + ); + } + } + + #[test] + fn validate_microvm_machine_contract_rejects_backend() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.source_hypervisor = "kvm".to_owned(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("source hypervisor")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_command_line() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.set_effective_command_line("console=other".to_owned()); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("command line")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_unsupported_boot_layout() { + let mut manifest = test_manifest(); + let expected = test_machine_contract(); + let mut contract = expected.clone(); + contract.boot_layout_version = 1; + manifest.machine_contract = Some(contract); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!( + err.to_string() + .contains("boot layout version 1 is unsupported") + ); + } + + #[test] + fn microvm_snapshot_topology_is_canonical() { + for processor_count in [1, 2, 4, 8] { + let topology = microvm_snapshot_topology(processor_count).unwrap(); + assert_eq!(topology.sockets, 1); + assert_eq!(topology.dies_per_socket, 1); + assert_eq!(topology.cores_per_die, processor_count); + assert_eq!(topology.threads_per_core, 1); + assert_eq!(topology.apic_ids, (0..processor_count).collect::>()); + assert_eq!(MICROVM_BOOT_LAYOUT_VERSION, 2); + } + + for processor_count in [0, 3, 5, 16] { + assert!(microvm_snapshot_topology(processor_count).is_err()); + } + } + + #[test] + fn microvm_snapshot_identifies_shared_status_page() { + let contract = test_machine_contract(); + let mut manifest = test_manifest(); + manifest.machine_contract = Some(contract.clone()); + + validate_microvm_machine_contract(&manifest, &contract).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .virtio_shared_status_page_gpa += 0x1000; + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!( + error + .to_string() + .contains("shared-status interrupt contract") + ); + } + + #[test] + fn validate_microvm_snapshot_rejects_noncanonical_apic_ids() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.topology.apic_ids = vec![0, 2]; + manifest.machine_contract = Some(contract.clone()); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_topology() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.topology.apic_ids.swap(0, 1); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("processor topology")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_device_order() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.devices.reverse(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("device inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_removed_device() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.devices.pop(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("device inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_state_unit_order() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.state_unit_names.reverse(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("state-unit inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_duplicate_state_unit() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.state_unit_names.push("portb".to_owned()); + manifest.machine_contract = Some(contract.clone()); + let err = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(err.to_string().contains("duplicate state-unit name")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_overlapping_ram() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.memory_ranges = vec![ + SnapshotMemoryRange { + gpa_start: 0, + length: 768, + file_offset: 0, + }, + SnapshotMemoryRange { + gpa_start: 512, + length: 256, + file_offset: 768, + }, + ]; + manifest.machine_contract = Some(contract.clone()); + let err = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(err.to_string().contains("overlap in GPA space")); + } + + fn expandable_memory_manifest(base_memory: u64, capacity: u64) -> SnapshotManifest { + let mut manifest = test_manifest(); + manifest.memory_size_bytes = base_memory; + let mut contract = test_machine_contract(); + contract.memory_ranges = canonical_microvm_memory_ranges(base_memory).unwrap(); + contract.memory_expansion_version = MICROVM_MEMORY_EXPANSION_VERSION; + contract.memory_capacity_bytes = capacity; + contract.memory_block_size_bytes = MICROVM_MEMORY_BLOCK_SIZE_BYTES; + contract.memory_expansion_ranges = + canonical_memory_expansion_ranges(base_memory, capacity).unwrap(); + manifest.machine_contract = Some(contract); + manifest + } + + #[test] + fn restore_memory_targets_select_canonical_prefixes() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + let manifest = expandable_memory_manifest(512 * MB, 2 * GB); + + assert_eq!( + validate_restore_memory_target(&manifest, 512 * MB).unwrap(), + [] + ); + assert_eq!( + validate_restore_memory_target(&manifest, GB).unwrap(), + [SnapshotMemoryExpansionRange { + gpa_start: 512 * MB, + length: 512 * MB, + }] + ); + assert_eq!( + validate_restore_memory_target(&manifest, 2 * GB).unwrap(), + [SnapshotMemoryExpansionRange { + gpa_start: 512 * MB, + length: 1536 * MB, + }] + ); + } + + #[test] + fn restore_memory_target_validation_rejects_invalid_requests() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + let manifest = expandable_memory_manifest(512 * MB, 2 * GB); + + assert!( + validate_restore_memory_target(&manifest, 384 * MB) + .unwrap_err() + .to_string() + .contains("below snapshot RAM") + ); + assert!( + validate_restore_memory_target(&manifest, 640 * MB + 1) + .unwrap_err() + .to_string() + .contains("not aligned") + ); + assert!( + validate_restore_memory_target(&manifest, 2 * GB + 128 * MB) + .unwrap_err() + .to_string() + .contains("exceeds RAM capacity") + ); + } + + #[test] + fn restore_memory_contract_rejects_malformed_and_legacy_snapshots() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + + let mut overlap = expandable_memory_manifest(512 * MB, 2 * GB); + overlap + .machine_contract + .as_mut() + .unwrap() + .memory_expansion_ranges[0] + .gpa_start = 256 * MB; + assert!( + validate_restore_memory_target(&overlap, GB) + .unwrap_err() + .to_string() + .contains("not canonical") + ); + + let mut version = expandable_memory_manifest(512 * MB, 2 * GB); + version + .machine_contract + .as_mut() + .unwrap() + .memory_expansion_version += 1; + assert!( + validate_restore_memory_target(&version, GB) + .unwrap_err() + .to_string() + .contains("unsupported") + ); + + let mut legacy = test_manifest(); + legacy.machine_contract = Some(test_machine_contract()); + assert!( + validate_restore_memory_target(&legacy, 1024) + .unwrap_err() + .to_string() + .contains("does not declare") + ); + } + + #[test] + fn memory_capacity_contract_rejects_alignment_and_overflow() { + const MB: u64 = 1024 * 1024; + assert!(canonical_memory_expansion_ranges(512 * MB, 512 * MB - 1).is_err()); + assert!( + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "console=hvc0".to_owned(), + None, + false, + Vec::new(), + None, + None, + Vec::new(), + 1, + 512 * MB, + Some(512 * MB + 1), + Vec::new(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .is_err() + ); + assert!(canonical_microvm_memory_ranges(u64::MAX).is_err()); + } + + fn canonical_worker_platform_command_line(processor_count: u32) -> String { + let mut command_line = openvmm_defs::microvm::build_microvm_control_command_line( + &[ + "nvx_sandbox=1".to_owned(), + "nvx_config=0xd0010000,65536".to_owned(), + ], + true, + ) + .unwrap(); + openvmm_defs::microvm::append_microvm_processor_limit(&mut command_line, processor_count) + .unwrap(); + command_line.push_str(" nvx_snapshot_tier=platform"); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + false, + &[], + true, + true, + &[ + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + None, + ) + .unwrap(); + command_line + } + + fn make_platform_snapshot(manifest: &mut SnapshotManifest) { + manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; + let contract = manifest.machine_contract.as_mut().unwrap(); + for block in contract + .microvm_sandbox_blocks + .iter_mut() + .filter(|block| block.read_only) + { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + let scratch = contract.microvm_sandbox_blocks.last_mut().unwrap(); + scratch.identity_kind = "fresh".to_owned(); + scratch.identity.clear(); + scratch.artifact.clear(); + let processor_count = u32::try_from(contract.topology.apic_ids.len()).unwrap(); + contract + .set_effective_command_line(canonical_worker_platform_command_line(processor_count)); + } + + #[test] + fn abi_v2_snapshot_tier_contract_is_canonical() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + validate_manifest_contents(&manifest).unwrap(); + + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_ALL; + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + validate_manifest_contents(&manifest).unwrap(); + + manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + assert!(validate_manifest_contents(&manifest).is_err()); + + manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; + assert!(validate_manifest_contents(&manifest).is_err()); + + make_platform_snapshot(&mut manifest); + validate_manifest_contents(&manifest).unwrap(); + } + + #[test] + fn generation_identity_and_scratch_restore_policy_are_canonical() { + let mut manifest = paired_scratch_manifest(&[0x5a; 512]); + let generation = vec![0x42; SNAPSHOT_GENERATION_ID_SIZE]; + for block in &mut manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + { + block.identity_kind = SNAPSHOT_BLOCK_IDENTITY_GENERATION.to_owned(); + block.identity.clone_from(&generation); + } + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .last_mut() + .unwrap() + .restore_mode = SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE.to_owned(); + validate_manifest_contents(&manifest).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .last_mut() + .unwrap() + .restore_mode = SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED.to_owned(); + assert!(validate_manifest_contents(&manifest).is_err()); + + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + validate_manifest_contents(&manifest).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks[0] + .identity[0] ^= 1; + assert!( + validate_machine_contract_shape( + manifest.machine_contract.as_ref().unwrap(), + manifest.memory_size_bytes, + manifest.vp_count, + ) + .is_err() + ); + } + + #[test] + fn platform_snapshot_rejects_tenant_command_line() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line(contract.effective_command_line.replace( + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_entrypoint=/tenant", + )); + + assert!( + validate_manifest_contents(&manifest) + .unwrap_err() + .to_string() + .contains("contains tenant or unsupported configuration") + ); + + make_platform_snapshot(&mut manifest); + validate_manifest_contents(&manifest).unwrap(); + + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace("nvx_config=0xd0010000,65536", "nvx_config=tenant-data"), + ); + assert!( + validate_manifest_contents(&manifest) + .unwrap_err() + .to_string() + .contains("contains tenant or unsupported configuration") + ); + } + + #[test] + fn platform_snapshot_rejects_mismatched_processor_limit() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace("nr_cpus=2", "nr_cpus=1"), + ); + + assert!( + validate_manifest_contents(&manifest) + .unwrap_err() + .to_string() + .contains("processor capacity") + ); + } + + #[test] + fn platform_snapshot_accepts_worker_effective_canonical_command_line() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + + assert_eq!( + manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 \ + nvx_sandbox=1 nvx_config=0xd0010000,65536 nr_cpus=2 \ + nvx_snapshot_tier=platform \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0003000:4 \ + virtio_mmio.device=0x1000@0xd0006000:11 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + nvx_control_tty=hvc2" + ); + validate_manifest_contents(&manifest).unwrap(); + } + + #[test] + fn platform_snapshot_carries_no_clock_token() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + validate_manifest_contents(&manifest).unwrap(); + let canonical = manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line + .clone(); + + for token in ["tsc_early_khz=2100000", "lapic_timer_hz=200000000"] { + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line(format!("{canonical} {token}")); + let err = validate_manifest_contents(&manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + assert!(!platform_command_line_token_is_invariant(token)); + } + } + + #[test] + fn platform_snapshot_rejects_invalid_control_tokens() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let canonical = manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line + .clone(); + + for invalid_control in [ + "nvx_control_tty=hvc9", + "nvx_control_tty=hvc2 nvx_control_tty=hvc2", + ] { + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + canonical.replace("nvx_control_tty=hvc2", invalid_control), + ); + assert!(validate_manifest_contents(&manifest).is_err()); + } + } + + #[test] + fn abi_v2_contract_rejects_scratch_without_a_lower_layer() { + let scratch = vec![0x5a_u8; 1024]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .remove(0); + let contract = manifest.machine_contract.clone().unwrap(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!( + error + .to_string() + .contains("one to three layers and scratch") + ); + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs new file mode 100644 index 00000000000..029c4eabfa8 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -0,0 +1,1011 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot publication. Artifacts are written and flushed in a private +//! staging directory next to the destination, which is then renamed into +//! place in one operation; failures roll back and clean up the staging +//! directory. + +use super::SnapshotManifest; +use super::format::MANIFEST_FILE_NAME; +use super::format::MAX_MANIFEST_SIZE_BYTES; +use super::format::MAX_SAVED_STATE_SIZE_BYTES; +use super::format::MEMORY_FILE_NAME; +use super::format::SCRATCH_FILE_NAME; +use super::format::STATE_FILE_NAME; +use super::format::validate_manifest_contents; +use super::format::validate_manifest_header; +use super::fs::OpenedSnapshotDirectory; +use super::fs::allocated_file_bytes; +use super::fs::copy_exact; +use super::fs::create_hard_link_from_handle; +use super::fs::create_private_directory; +use super::fs::ensure_path_absent; +use super::fs::hard_link_is_unsupported; +use super::fs::open_file_with_length; +use super::fs::open_regular_file; +use super::fs::path_exists; +use super::fs::rename_no_replace; +use super::fs::snapshot_parent; +use super::fs::sync_directory; +use super::fs::validate_directory; +use super::fs::verify_hard_link_identity; +use super::fs::write_bytes; +use super::microvm; +use anyhow::Context; +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Error publishing a snapshot. +#[derive(Debug, thiserror::Error)] +pub enum SnapshotWriteError { + /// Nothing was published at the final destination. + #[error(transparent)] + BeforeCommit(#[from] anyhow::Error), + /// Publication did not commit, but an alias to live source RAM may remain. + /// + /// Callers must terminate rather than resume the source. The reported + /// private staging path may require operator cleanup after termination. + #[error( + "snapshot failed before commit at {path:?}, and automatic RAM staging alias cleanup is uncertain: publication error: {error:#}; cleanup error: {cleanup_error:#}" + )] + CleanupUncertain { + /// Staging directory that may retain an alias to live source RAM. + path: PathBuf, + /// Original publication failure. + error: anyhow::Error, + /// Failure removing or proving absence of the staging alias. + #[source] + cleanup_error: anyhow::Error, + }, + /// The directory rename committed, but its parent could not be flushed. + #[error("snapshot committed to {path:?}, but flushing its parent directory failed: {error:#}")] + Committed { + /// Final committed snapshot path. + path: PathBuf, + /// Parent-directory flush failure. + #[source] + error: anyhow::Error, + }, +} + +impl SnapshotWriteError { + /// Returns whether the final snapshot directory has been published. + pub fn is_committed(&self) -> bool { + matches!(self, Self::Committed { .. }) + } + + /// Returns whether a quiesced source may safely roll back and resume. + pub fn is_rollback_safe(&self) -> bool { + matches!(self, Self::BeforeCommit(_)) + } +} + +/// Write a snapshot to the given directory. +/// +/// The final directory must not already exist. The snapshot consists of: +/// - `manifest.bin` — protobuf-encoded [`SnapshotManifest`] +/// - `state.bin` — raw device saved-state bytes +/// - `memory.bin` — private copy of the memory backing file +/// +/// The files are written and flushed in a unique sibling staging directory. +/// The completed staging directory is then renamed to `dir` in one operation. +pub fn write_snapshot( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file_path: &Path, +) -> Result<(), SnapshotWriteError> { + let memory_file = open_regular_file(memory_file_path, "memory backing file")?; + write_snapshot_from_memory_file(dir, manifest, saved_state_bytes, &memory_file) +} + +/// Writes a snapshot from the exact open file handle backing guest RAM. +/// +/// Capture callers should prefer this over [`write_snapshot`] so replacing the +/// backing pathname cannot substitute different bytes after VM construction. +pub fn write_snapshot_from_memory_file( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, +) -> Result<(), SnapshotWriteError> { + write_snapshot_from_memory_and_scratch_files( + dir, + manifest, + saved_state_bytes, + memory_file, + None, + ) +} + +/// Writes a snapshot with an optional scratch image paired to the VM state. +pub fn write_snapshot_from_memory_and_scratch_files( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, +) -> Result<(), SnapshotWriteError> { + write_snapshot_with_memory_publication( + dir, + manifest, + saved_state_bytes, + memory_file, + scratch_file, + MemoryPublication::IndependentCopy, + ) +} + +/// Writes a snapshot by promoting OpenVMM-owned guest RAM as an exact file. +/// +/// The caller must use this only for automatic backing whose lifetime it owns, +/// and must stop all writers before calling. It must terminate the source after +/// success or [`SnapshotWriteError::Committed`]. A +/// [`SnapshotWriteError::BeforeCommit`] proves that staging was removed and the +/// source may resume. [`SnapshotWriteError::CleanupUncertain`] requires source +/// termination because a private staging alias may remain. Unsupported hard +/// links fall back to an independent sparse-aware copy. +pub fn write_snapshot_from_owned_memory_and_scratch_files( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, +) -> Result<(), SnapshotWriteError> { + write_snapshot_with_memory_publication( + dir, + manifest, + saved_state_bytes, + memory_file, + scratch_file, + MemoryPublication::OwnedExactFile, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MemoryPublication { + IndependentCopy, + OwnedExactFile, +} + +fn write_snapshot_with_memory_publication( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, + memory_publication: MemoryPublication, +) -> Result<(), SnapshotWriteError> { + let mut staging = stage_snapshot( + dir, + manifest, + saved_state_bytes, + memory_file, + scratch_file, + memory_publication, + )?; + let commit = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = + ensure_path_absent(dir, "snapshot destination").and_then(|()| staging.publish(dir)) + { + return Err(staging.rollback(error)); + } + commit.complete("capture", "publication_commit", Default::default()); + + let parent = snapshot_parent(dir); + let parent_sync = openvmm_defs::profile::ProfileSpan::start(); + sync_directory(parent).map_err(|error| SnapshotWriteError::Committed { + path: dir.to_owned(), + error, + })?; + parent_sync.complete("capture", "publication_parent_sync", Default::default()); + Ok(()) +} + +fn stage_snapshot( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, + memory_publication: MemoryPublication, +) -> Result { + validate_manifest_header(manifest)?; + validate_manifest_contents(manifest)?; + if let Some(contract) = &manifest.machine_contract { + microvm::validate_machine_contract_shape( + contract, + manifest.memory_size_bytes, + manifest.vp_count, + )?; + } + + if u64::try_from(saved_state_bytes.len()).unwrap_or(u64::MAX) > MAX_SAVED_STATE_SIZE_BYTES { + return Err(anyhow::anyhow!( + "saved state exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" + ) + .into()); + } + + let parent = snapshot_parent(dir); + validate_directory(parent, "snapshot parent directory")?; + ensure_path_absent(dir, "snapshot destination")?; + + let mut staging = StagingDirectory::create(parent, dir)?; + let state_path = staging.path().join(STATE_FILE_NAME); + let memory_path = staging.path().join(MEMORY_FILE_NAME); + let manifest_path = staging.path().join(MANIFEST_FILE_NAME); + + let result = (|| -> anyhow::Result<()> { + let state_write = openvmm_defs::profile::ProfileSpan::start(); + write_bytes(&state_path, saved_state_bytes, "saved state")?; + state_write.complete( + "capture", + "publication_state", + profile_path_counters(&state_path, saved_state_bytes.len() as u64), + ); + if memory_publication == MemoryPublication::IndependentCopy { + let memory_publish = openvmm_defs::profile::ProfileSpan::start(); + copy_exact( + memory_file, + &memory_path, + manifest.memory_size_bytes, + "memory backing file", + "snapshot memory", + )?; + memory_publish.complete( + "capture", + "publication_memory", + profile_path_counters(&memory_path, manifest.memory_size_bytes), + ); + } + match (microvm::paired_scratch_block(manifest), scratch_file) { + (Some(scratch), Some(scratch_file)) => { + let scratch_path = staging.path().join(SCRATCH_FILE_NAME); + let scratch_publish = openvmm_defs::profile::ProfileSpan::start(); + copy_exact( + scratch_file, + &scratch_path, + scratch.length, + "scratch backing file", + "snapshot scratch", + )?; + super::restore::verify_sandbox_block_identity( + &open_file_with_length(&scratch_path, scratch.length, SCRATCH_FILE_NAME)?, + scratch, + "scratch.img", + )?; + scratch_publish.complete( + "capture", + "publication_scratch", + profile_path_counters(&scratch_path, scratch.length), + ); + } + (Some(_), None) => anyhow::bail!("snapshot contract requires a paired scratch image"), + (None, Some(_)) => { + anyhow::bail!("snapshot contract does not declare a scratch image") + } + (None, None) => {} + } + + let mut published_manifest = manifest.clone(); + published_manifest.state_size_bytes = saved_state_bytes.len() as u64; + + let manifest_bytes = mesh::payload::encode(published_manifest); + anyhow::ensure!( + manifest_bytes.len() as u64 <= MAX_MANIFEST_SIZE_BYTES, + "snapshot manifest exceeds the maximum size of {MAX_MANIFEST_SIZE_BYTES} bytes", + ); + let manifest_write = openvmm_defs::profile::ProfileSpan::start(); + write_bytes(&manifest_path, &manifest_bytes, "snapshot manifest")?; + manifest_write.complete( + "capture", + "publication_manifest", + profile_path_counters(&manifest_path, manifest_bytes.len() as u64), + ); + + if memory_publication == MemoryPublication::OwnedExactFile { + let memory_publish = openvmm_defs::profile::ProfileSpan::start(); + publish_owned_memory_file(&mut staging, memory_file, manifest.memory_size_bytes)?; + memory_publish.complete( + "capture", + "publication_memory", + profile_path_counters(&memory_path, manifest.memory_size_bytes), + ); + } + + let staging_sync = openvmm_defs::profile::ProfileSpan::start(); + sync_directory(staging.path())?; + staging_sync.complete("capture", "publication_staging_sync", Default::default()); + Ok(()) + })(); + match result { + Ok(()) => Ok(staging), + Err(error) => Err(staging.rollback(error)), + } +} + +struct StagingDirectory { + path: Option, + source_memory_alias: bool, + #[cfg(test)] + inject_cleanup_failure: bool, +} + +impl StagingDirectory { + fn create(parent: &Path, destination: &Path) -> anyhow::Result { + let destination_name = destination + .file_name() + .context("snapshot destination must name a directory")? + .to_string_lossy(); + let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); + + for attempt in 0..100_u32 { + let path = parent.join(format!( + ".{destination_name}.staging-{}-{sequence}-{attempt}", + std::process::id() + )); + match create_private_directory(&path) { + Ok(()) => { + return Ok(Self { + path: Some(path), + source_memory_alias: false, + #[cfg(test)] + inject_cleanup_failure: false, + }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| { + format!("failed to create staging directory {}", path.display()) + }); + } + } + } + + anyhow::bail!( + "failed to allocate a unique snapshot staging directory in {}", + parent.display() + ) + } + + fn path(&self) -> &Path { + self.path.as_deref().expect("staging path is present") + } + + fn publish(&mut self, destination: &Path) -> anyhow::Result<()> { + let staging_path = self.path(); + rename_no_replace(staging_path, destination).with_context(|| { + format!( + "failed to publish snapshot {} to {}", + staging_path.display(), + destination.display() + ) + })?; + self.path = None; + Ok(()) + } + + fn mark_source_memory_alias(&mut self) { + self.source_memory_alias = true; + } + + fn rollback(mut self, error: anyhow::Error) -> SnapshotWriteError { + if self.source_memory_alias + && let Err(cleanup_error) = self.remove_staging_directory() + { + return SnapshotWriteError::CleanupUncertain { + path: self + .path + .clone() + .expect("unpublished staging path is present"), + error, + cleanup_error, + }; + } + + if let Err(cleanup_error) = self.remove_staging_directory() { + tracing::warn!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "failed to remove independent snapshot staging artifacts" + ); + } + SnapshotWriteError::BeforeCommit(error) + } + + fn remove_staging_directory(&mut self) -> anyhow::Result<()> { + #[cfg(test)] + if self.inject_cleanup_failure { + anyhow::bail!("injected snapshot staging cleanup failure"); + } + let Some(path) = self.path.as_ref() else { + return Ok(()); + }; + let parent = snapshot_parent(path).to_owned(); + match fs_err::remove_dir_all(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to remove staging directory {}", path.display()) + }); + } + } + anyhow::ensure!( + !path_exists(path)?, + "snapshot staging directory still exists after removal: {}", + path.display() + ); + sync_directory(&parent).context("failed to flush snapshot staging cleanup")?; + self.path = None; + self.source_memory_alias = false; + Ok(()) + } +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + if let Some(path) = &self.path { + if let Err(error) = fs_err::remove_dir_all(path) { + tracing::error!( + error = &error as &dyn std::error::Error, + source_memory_alias = self.source_memory_alias, + path = %path.display(), + "failed to remove dropped snapshot staging directory" + ); + } + } + } +} + +fn profile_path_counters( + path: &Path, + logical_bytes: u64, +) -> openvmm_defs::profile::ProfileCounters { + if !openvmm_defs::profile::enabled() { + return Default::default(); + } + let allocated_bytes = std::fs::File::open(path) + .ok() + .and_then(|file| allocated_file_bytes(&file, logical_bytes).ok()); + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(logical_bytes), + allocated_bytes, + ..Default::default() + } +} + +fn publish_owned_memory_file( + staging: &mut StagingDirectory, + source: &std::fs::File, + expected_length: u64, +) -> anyhow::Result<()> { + let source_metadata = source + .metadata() + .context("failed to inspect automatic snapshot RAM handle")?; + anyhow::ensure!( + source_metadata.file_type().is_file(), + "automatic snapshot RAM handle is not a regular file" + ); + anyhow::ensure!( + source_metadata.len() == expected_length, + "automatic snapshot RAM size ({} bytes) doesn't match manifest ({expected_length} bytes)", + source_metadata.len() + ); + + // The VM worker has stopped all writers before this call. Flush through + // the exact handle immediately before linking the same file generation. + source + .sync_all() + .context("failed to flush automatic snapshot RAM handle")?; + + let memory_path = staging.path().join(MEMORY_FILE_NAME); + let directory = OpenedSnapshotDirectory::open_for_publication(staging.path())?; + // From this point, any failure is treated as though the link may have been + // installed until the complete staging directory is proven absent. + staging.mark_source_memory_alias(); + let method = match create_hard_link_from_handle(source, &directory.file, MEMORY_FILE_NAME) { + Ok(method) => method, + Err(error) if hard_link_is_unsupported(&error) => { + tracing::info!( + error = &error as &dyn std::error::Error, + "exact-file snapshot RAM publication is unavailable; using independent copy" + ); + drop(directory); + return copy_exact( + source, + &memory_path, + expected_length, + "automatic snapshot RAM handle", + "snapshot memory", + ); + } + Err(error) => { + return Err(error).context("failed to create automatic RAM staging hard link"); + } + }; + let linked = + directory.open_regular_file_for_identity(MEMORY_FILE_NAME, "linked snapshot memory")?; + verify_hard_link_identity(source, &linked, expected_length)?; + tracing::info!( + method, + logical_bytes = expected_length, + "published exact snapshot memory artifact" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::format::SHA256_SIZE; + use super::super::fs::file_sha256; + use super::super::fs::initialize_snapshot_memory_backing_file; + use super::super::microvm::paired_scratch_manifest; + use super::super::restore::open_paired_scratch_file; + use super::super::restore::read_snapshot_manifest; + use super::super::tests::test_manifest; + use super::*; + use std::io::Seek; + use std::io::SeekFrom; + use std::io::Write; + + #[test] + fn paired_scratch_is_published_and_verified() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + let scratch = vec![0x5a_u8; 1024]; + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, &scratch).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let manifest = paired_scratch_manifest(&scratch); + + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let read_manifest = read_snapshot_manifest(&snap_dir).unwrap(); + let verified = open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap() + .unwrap(); + assert_eq!( + file_sha256(&verified, 1024, SCRATCH_FILE_NAME).unwrap(), + manifest.machine_contract.unwrap().microvm_sandbox_blocks[1].identity + ); + drop(verified); + + let published = snap_dir.join(SCRATCH_FILE_NAME); + std::fs::write(&published, vec![0xa5_u8; 1024]).unwrap(); + assert!( + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap_err() + .to_string() + .contains("digest mismatch") + ); + + std::fs::write(&published, vec![0_u8; 512]).unwrap(); + assert!( + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap_err() + .to_string() + .contains("doesn't match manifest") + ); + + std::fs::remove_file(&published).unwrap(); + let error = match read_snapshot_manifest(&snap_dir) { + Ok(_) => panic!("snapshot without scratch.img unexpectedly validated"), + Err(error) => error, + }; + assert!(error.to_string().contains("incomplete")); + } + + #[test] + fn generation_identity_uses_structure_without_rehashing_scratch() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let mut manifest = paired_scratch_manifest(&[0x5a; 1024]); + for block in &mut manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + { + block.identity_kind = microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION.to_owned(); + block.identity = vec![0x42; microvm::SNAPSHOT_GENERATION_ID_SIZE]; + } + + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let read_manifest = read_snapshot_manifest(&snap_dir).unwrap(); + std::fs::write(snap_dir.join(SCRATCH_FILE_NAME), vec![0xa5_u8; 1024]).unwrap(); + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap() + .unwrap(); + } + + #[test] + fn paired_scratch_digest_mismatch_is_not_published() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + let scratch = vec![0x5a_u8; 1024]; + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, &scratch).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let mut manifest = paired_scratch_manifest(&scratch); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks[1] + .identity = vec![0xff; SHA256_SIZE]; + + let error = write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap_err(); + assert!(error.to_string().contains("digest mismatch")); + assert!(!snap_dir.exists()); + } + + #[test] + fn write_snapshot_rejects_missing_parent() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("a").join("b").join("c"); + + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(!err.is_committed()); + assert!(err.to_string().contains("snapshot parent directory")); + assert!(!snap_dir.exists()); + } + + #[test] + fn write_snapshot_copies_memory() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, b"SAMEFILE").unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 8; + write_snapshot(&snap_dir, &manifest, b"state", &mem_path).unwrap(); + std::fs::write(&mem_path, b"MODIFIED").unwrap(); + + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + b"SAMEFILE" + ); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn owned_memory_snapshot_publishes_exact_file() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + + write_snapshot_from_owned_memory_and_scratch_files( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + None, + ) + .unwrap(); + + let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); + verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn owned_memory_link_uses_exact_handle_after_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + let moved_path = dir.path().join("mapped-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + std::fs::rename(&memory_path, &moved_path).unwrap(); + std::fs::write(&memory_path, vec![0xa5_u8; 1024]).unwrap(); + + write_snapshot_from_owned_memory_and_scratch_files( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + None, + ) + .unwrap(); + + let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); + verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + vec![0x5a_u8; 1024] + ); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn injected_failure_removes_live_memory_staging_alias() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + let staging = stage_snapshot( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + None, + MemoryPublication::OwnedExactFile, + ) + .unwrap(); + assert!(staging.source_memory_alias); + let staging_path = staging.path().to_owned(); + + let error = staging.rollback(anyhow::anyhow!("injected failure after memory link")); + + assert!(error.is_rollback_safe()); + assert!(!staging_path.exists()); + assert!(!snap_dir.exists()); + std::fs::write(&memory_path, vec![0xa5_u8; 1024]).unwrap(); + } + + #[test] + fn uncertain_live_alias_cleanup_is_not_rollback_safe() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mut staging = StagingDirectory::create(dir.path(), &snap_dir).unwrap(); + staging.mark_source_memory_alias(); + staging.inject_cleanup_failure = true; + + let error = staging.rollback(anyhow::anyhow!("injected publication failure")); + + assert!(!error.is_committed()); + assert!(!error.is_rollback_safe()); + assert!(matches!(error, SnapshotWriteError::CleanupUncertain { .. })); + } + + #[cfg(target_os = "linux")] + #[test] + fn write_snapshot_preserves_sparse_memory_and_clone_independence() { + const MEMORY_SIZE: u64 = 16 * 1024 * 1024; + let dir = tempfile::tempdir().unwrap(); + for (case, sparse) in [("zero", true), ("mixed", true), ("dense", false)] { + let case_dir = dir.path().join(case); + std::fs::create_dir(&case_dir).unwrap(); + let snap_dir = case_dir.join("snap"); + let mem_path = case_dir.join("memory.bin"); + let mut memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&mem_path) + .unwrap(); + assert_eq!( + initialize_snapshot_memory_backing_file(&memory_file, MEMORY_SIZE).unwrap(), + 0 + ); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + match case { + "zero" => {} + "mixed" => { + let tail_offset = expected.len() - 4; + expected[..4].copy_from_slice(b"head"); + expected[tail_offset..].copy_from_slice(b"tail"); + memory_file.write_all(b"head").unwrap(); + memory_file.seek(SeekFrom::End(-4)).unwrap(); + memory_file.write_all(b"tail").unwrap(); + } + "dense" => { + expected.fill(0x5a); + memory_file.seek(SeekFrom::Start(0)).unwrap(); + memory_file.write_all(&expected).unwrap(); + } + _ => unreachable!(), + } + memory_file.sync_all().unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = MEMORY_SIZE; + write_snapshot_from_memory_file(&snap_dir, &manifest, b"state", &memory_file).unwrap(); + + memory_file.seek(SeekFrom::Start(0)).unwrap(); + memory_file.write_all(b"xxxx").unwrap(); + memory_file.sync_all().unwrap(); + + let published_path = snap_dir.join(MEMORY_FILE_NAME); + let published = std::fs::File::open(&published_path).unwrap(); + assert_eq!(published.metadata().unwrap().len(), MEMORY_SIZE); + if sparse { + let allocated = allocated_file_bytes(&published, MEMORY_SIZE).unwrap(); + assert!( + allocated < MEMORY_SIZE / 4, + "{case} snapshot allocated {allocated} bytes for a {MEMORY_SIZE}-byte file", + ); + } + assert_eq!(std::fs::read(published_path).unwrap(), expected, "{case}"); + } + } + + #[cfg(windows)] + #[test] + fn write_snapshot_uses_dense_memory_files_on_windows() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x200; + const MEMORY_SIZE: u64 = 4 * 1024 * 1024; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + let mut memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(mem_path) + .unwrap(); + initialize_snapshot_memory_backing_file(&memory_file, MEMORY_SIZE).unwrap(); + assert_eq!( + memory_file.metadata().unwrap().file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0 + ); + + memory_file.write_all(b"head").unwrap(); + memory_file.seek(SeekFrom::End(-4)).unwrap(); + memory_file.write_all(b"tail").unwrap(); + memory_file.sync_all().unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = MEMORY_SIZE; + write_snapshot_from_memory_file(&snap_dir, &manifest, b"state", &memory_file).unwrap(); + + let published_path = snap_dir.join(MEMORY_FILE_NAME); + let published = std::fs::File::open(&published_path).unwrap(); + assert_eq!( + published.metadata().unwrap().file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0 + ); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + expected[..4].copy_from_slice(b"head"); + let tail_offset = expected.len() - 4; + expected[tail_offset..].copy_from_slice(b"tail"); + assert_eq!(std::fs::read(published_path).unwrap(), expected); + } + + #[test] + fn exact_memory_handle_survives_path_replacement_before_publish() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + let moved_path = dir.path().join("mapped-memory.bin"); + std::fs::write(&mem_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&mem_path) + .unwrap(); + std::fs::rename(&mem_path, &moved_path).unwrap(); + std::fs::write(&mem_path, vec![0xa5_u8; 1024]).unwrap(); + + write_snapshot_from_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) + .unwrap(); + + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + vec![0x5a_u8; 1024] + ); + } + + #[test] + fn write_snapshot_rejects_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + std::fs::create_dir(&snap_dir).unwrap(); + std::fs::write(snap_dir.join("sentinel"), b"keep").unwrap(); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("already exists")); + assert_eq!(std::fs::read(snap_dir.join("sentinel")).unwrap(), b"keep"); + } + + #[test] + fn publish_does_not_replace_destination_created_after_staging() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mut staging = StagingDirectory::create(dir.path(), &snap_dir).unwrap(); + std::fs::create_dir(&snap_dir).unwrap(); + std::fs::write(snap_dir.join("sentinel"), b"keep").unwrap(); + + assert!(staging.publish(&snap_dir).is_err()); + assert_eq!(std::fs::read(snap_dir.join("sentinel")).unwrap(), b"keep"); + } + + #[test] + fn write_snapshot_rejects_oversized_memory_without_publishing() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1025]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("doesn't match manifest")); + assert!(!snap_dir.exists()); + assert!(std::fs::read_dir(dir.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("staging") + })); + } + + #[test] + fn write_snapshot_rejects_oversized_manifest_without_publishing() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + let mut manifest = test_manifest(); + manifest.openvmm_version = "x".repeat(MAX_MANIFEST_SIZE_BYTES as usize); + + let err = write_snapshot(&snap_dir, &manifest, b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("manifest exceeds")); + assert!(!snap_dir.exists()); + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs new file mode 100644 index 00000000000..3aba7c70446 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -0,0 +1,974 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Restore-side snapshot access: opening one published generation through a +//! retained directory handle, structural validation against its manifest, +//! paired scratch verification, and single-use resume claims. + +use super::SnapshotManifest; +use super::format::MANIFEST_FILE_NAME; +use super::format::MAX_MANIFEST_SIZE_BYTES; +use super::format::MAX_SAVED_STATE_SIZE_BYTES; +use super::format::MEMORY_FILE_NAME; +use super::format::RESUME_CLAIM_FILE_NAME; +use super::format::SCRATCH_FILE_NAME; +use super::format::SNAPSHOT_RESTORE_POLICY_RESUME; +use super::format::STATE_FILE_NAME; +use super::format::validate_manifest_contents; +use super::format::validate_manifest_header; +use super::fs::OpenedFileGeneration; +use super::fs::OpenedSnapshotDirectory; +use super::fs::allocated_file_bytes; +use super::fs::copy_exact; +use super::fs::create_hard_link_from_handle; +use super::fs::open_file_with_length; +use super::fs::opened_file_generation; +use super::fs::read_bounded_open_file; +use super::fs::reflink_exact; +use super::fs::verify_file_digest; +use super::fs::verify_hard_link_identity; +use super::microvm; +use anyhow::Context; +use std::collections::HashSet; +use std::io::Write; +use std::path::Path; + +/// One structurally validated snapshot generation opened for restore. +/// +/// Artifact access is relative to the retained directory handle. The open +/// artifact handles keep pathname replacement from substituting another +/// generation after validation. +pub struct OpenedSnapshot { + directory: OpenedSnapshotDirectory, + manifest_file: std::fs::File, + state_file: std::fs::File, + memory_file: std::fs::File, + memory_generation: OpenedFileGeneration, + manifest: SnapshotManifest, + state_bytes: Vec, +} + +impl OpenedSnapshot { + /// Opens and structurally validates one exact snapshot generation. + pub fn open(dir: &Path) -> anyhow::Result { + let directory = OpenedSnapshotDirectory::open(dir)?; + let manifest_file = directory.open_regular_file(MANIFEST_FILE_NAME, "snapshot manifest")?; + let state_file = directory.open_regular_file(STATE_FILE_NAME, "saved state")?; + let memory_file = directory.open_regular_file(MEMORY_FILE_NAME, "snapshot memory")?; + let manifest = decode_snapshot_manifest(&manifest_file)?; + validate_snapshot_directory(&directory, &manifest)?; + anyhow::ensure!( + manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, + "state.bin length in the manifest exceeds the maximum size of \ + {MAX_SAVED_STATE_SIZE_BYTES} bytes", + ); + + let state_bytes = + read_bounded_open_file(&state_file, MAX_SAVED_STATE_SIZE_BYTES, "saved state")?; + anyhow::ensure!( + state_bytes.len() as u64 == manifest.state_size_bytes, + "state.bin size ({} bytes) doesn't match manifest ({} bytes)", + state_bytes.len(), + manifest.state_size_bytes, + ); + + let memory_generation = opened_file_generation(&memory_file, MEMORY_FILE_NAME)?; + anyhow::ensure!( + memory_generation.length() == manifest.memory_size_bytes, + "memory.bin size ({} bytes) doesn't match manifest ({} bytes)", + memory_generation.length(), + manifest.memory_size_bytes, + ); + + Ok(Self { + directory, + manifest_file, + state_file, + memory_file, + memory_generation, + manifest, + state_bytes, + }) + } + + /// Returns the authoritative manifest read from this opened generation. + pub fn manifest(&self) -> &SnapshotManifest { + &self.manifest + } + + /// Returns the saved-state bytes read from this opened generation. + pub fn state_bytes(&self) -> &[u8] { + &self.state_bytes + } + + /// Returns total logical and allocated bytes for the opened artifacts. + /// + /// This is intended for opt-in profiling. Restore validation does not + /// depend on allocation accounting being available. + pub fn artifact_size_counters(&self) -> anyhow::Result<(u64, u64)> { + [&self.manifest_file, &self.state_file, &self.memory_file] + .into_iter() + .try_fold((0_u64, 0_u64), |(logical, allocated), file| { + let length = file.metadata()?.len(); + Ok(( + logical + .checked_add(length) + .context("logical byte count overflow")?, + allocated + .checked_add(allocated_file_bytes(file, length)?) + .context("allocated byte count overflow")?, + )) + }) + } + + /// Opens the paired scratch artifact relative to this snapshot generation. + pub fn open_paired_scratch_file(&self) -> anyhow::Result> { + open_paired_scratch_file_in_directory(&self.directory, &self.manifest) + } + + /// Claims this exact opened generation for a single-use resume. + pub fn claim_for_restore(&self) -> anyhow::Result<()> { + claim_snapshot_for_restore_in_directory(&self.directory, &self.manifest) + } + + /// Duplicates the exact memory handle used to create a private mapping. + pub fn duplicate_memory_file_for_mapping( + &self, + expected_memory_size: u64, + ) -> anyhow::Result { + self.validate_memory_generation(expected_memory_size)?; + let duplicate = self + .memory_file + .try_clone() + .context("failed to duplicate snapshot memory handle")?; + anyhow::ensure!( + opened_file_generation(&duplicate, MEMORY_FILE_NAME)? == self.memory_generation, + "snapshot memory duplicate does not refer to the opened generation", + ); + Ok(duplicate) + } + + /// Verifies that the opened memory generation and EOF are unchanged. + pub fn validate_memory_generation(&self, expected_memory_size: u64) -> anyhow::Result<()> { + anyhow::ensure!( + self.manifest.memory_size_bytes == expected_memory_size, + "memory.bin size in the manifest ({} bytes) doesn't match expected ({expected_memory_size} bytes)", + self.manifest.memory_size_bytes, + ); + anyhow::ensure!( + opened_file_generation(&self.memory_file, MEMORY_FILE_NAME)? == self.memory_generation, + "snapshot memory generation changed after it was opened", + ); + Ok(()) + } + + /// Consumes this snapshot into decoded data and lifetime guard handles. + pub fn into_parts( + self, + ) -> ( + SnapshotManifest, + Vec, + openvmm_defs::worker::SnapshotRestoreGuards, + ) { + ( + self.manifest, + self.state_bytes, + openvmm_defs::worker::SnapshotRestoreGuards { + directory: self.directory.into_file(), + manifest: self.manifest_file, + state: self.state_file, + memory: self.memory_file, + }, + ) + } +} + +fn read_snapshot_manifest_from_directory( + directory: &OpenedSnapshotDirectory, +) -> anyhow::Result<(SnapshotManifest, std::fs::File)> { + let manifest_file = directory.open_regular_file(MANIFEST_FILE_NAME, "snapshot manifest")?; + let manifest = decode_snapshot_manifest(&manifest_file)?; + Ok((manifest, manifest_file)) +} + +fn decode_snapshot_manifest(manifest_file: &std::fs::File) -> anyhow::Result { + let manifest_bytes = + read_bounded_open_file(manifest_file, MAX_MANIFEST_SIZE_BYTES, "snapshot manifest")?; + let manifest: SnapshotManifest = + mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; + validate_manifest_header(&manifest)?; + validate_manifest_contents(&manifest)?; + if let Some(contract) = &manifest.machine_contract { + microvm::validate_machine_contract_shape( + contract, + manifest.memory_size_bytes, + manifest.vp_count, + )?; + } + Ok(manifest) +} + +/// Read a snapshot from the given directory. +/// +/// Returns the decoded manifest and raw saved-state bytes after structurally +/// validating all three artifacts. `expected_memory_size` bounds memory. +pub fn read_snapshot( + dir: &Path, + expected_memory_size: u64, +) -> anyhow::Result<(SnapshotManifest, Vec)> { + let snapshot = OpenedSnapshot::open(dir)?; + snapshot.validate_memory_generation(expected_memory_size)?; + let (manifest, state_bytes, _) = snapshot.into_parts(); + Ok((manifest, state_bytes)) +} + +/// Reads and structurally validates only `manifest.bin`. +/// +/// Production restore should use [`OpenedSnapshot`] so later artifact access +/// remains anchored to the same opened directory generation. +pub fn read_snapshot_manifest(dir: &Path) -> anyhow::Result { + let directory = OpenedSnapshotDirectory::open(dir)?; + let (manifest, _) = read_snapshot_manifest_from_directory(&directory)?; + validate_snapshot_directory(&directory, &manifest)?; + Ok(manifest) +} + +/// Read and structurally validate a snapshot, returning its exact memory handle. +/// +/// The returned file is positioned at offset zero and must be used directly +/// for restore so a path replacement cannot substitute different memory. +pub fn read_snapshot_with_memory( + dir: &Path, + expected_memory_size: u64, +) -> anyhow::Result<(SnapshotManifest, Vec, std::fs::File)> { + let snapshot = OpenedSnapshot::open(dir)?; + snapshot.validate_memory_generation(expected_memory_size)?; + let (manifest, state_bytes, guards) = snapshot.into_parts(); + let openvmm_defs::worker::SnapshotRestoreGuards { memory, .. } = guards; + Ok((manifest, state_bytes, memory)) +} + +/// Opens snapshot artifacts against an already validated manifest. +/// +/// This compatibility helper reopens the directory. Production restore should +/// keep an [`OpenedSnapshot`] alive through worker construction instead. +pub fn read_snapshot_artifacts_with_memory( + dir: &Path, + manifest: &SnapshotManifest, + expected_memory_size: u64, +) -> anyhow::Result<(Vec, std::fs::File)> { + let directory = OpenedSnapshotDirectory::open(dir)?; + validate_manifest_header(manifest)?; + validate_manifest_contents(manifest)?; + if let Some(contract) = &manifest.machine_contract { + microvm::validate_machine_contract_shape( + contract, + manifest.memory_size_bytes, + manifest.vp_count, + )?; + } + validate_snapshot_directory(&directory, manifest)?; + anyhow::ensure!( + manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, + "state.bin length in the manifest exceeds the maximum size of \ + {MAX_SAVED_STATE_SIZE_BYTES} bytes", + ); + + let state_file = directory.open_regular_file(STATE_FILE_NAME, "saved state")?; + let state_bytes = + read_bounded_open_file(&state_file, MAX_SAVED_STATE_SIZE_BYTES, "saved state")?; + anyhow::ensure!( + state_bytes.len() as u64 == manifest.state_size_bytes, + "state.bin size ({} bytes) doesn't match manifest ({} bytes)", + state_bytes.len(), + manifest.state_size_bytes, + ); + anyhow::ensure!( + manifest.memory_size_bytes == expected_memory_size, + "memory.bin size in the manifest ({} bytes) doesn't match expected ({expected_memory_size} bytes)", + manifest.memory_size_bytes, + ); + let memory_file = directory.open_file_with_length( + MEMORY_FILE_NAME, + expected_memory_size, + MEMORY_FILE_NAME, + )?; + + Ok((state_bytes, memory_file)) +} + +/// Opens and verifies the scratch image paired to a snapshot, if present. +pub fn open_paired_scratch_file( + dir: &Path, + manifest: &SnapshotManifest, +) -> anyhow::Result> { + let directory = OpenedSnapshotDirectory::open(dir)?; + open_paired_scratch_file_in_directory(&directory, manifest) +} + +fn open_paired_scratch_file_in_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result> { + let Some(scratch) = microvm::paired_scratch_block(manifest) else { + return Ok(None); + }; + let file = + directory.open_file_with_length(SCRATCH_FILE_NAME, scratch.length, SCRATCH_FILE_NAME)?; + verify_sandbox_block_identity(&file, scratch, SCRATCH_FILE_NAME)?; + Ok(Some(file)) +} + +pub(super) fn verify_sandbox_block_identity( + file: &std::fs::File, + block: µvm::SnapshotMicrovmSandboxBlock, + description: &str, +) -> anyhow::Result<()> { + match block.identity_kind.as_str() { + microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + verify_file_digest(file, block.length, &block.identity, description) + } + microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION => Ok(()), + other => anyhow::bail!("{description} has unsupported identity kind '{other}'"), + } +} + +/// Copies a verified immutable artifact to a new private path. +pub fn copy_verified_file( + source: &std::fs::File, + destination: &Path, + expected_length: u64, + expected_digest: &[u8], + description: &str, +) -> anyhow::Result<()> { + verify_file_digest(source, expected_length, expected_digest, description)?; + copy_exact( + source, + destination, + expected_length, + description, + "private scratch copy", + )?; + let copy = open_file_with_length(destination, expected_length, "private scratch copy")?; + verify_file_digest( + ©, + expected_length, + expected_digest, + "private scratch copy", + ) +} + +/// Materializes paired scratch as an independent private file. +pub fn copy_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + copy_exact( + source, + destination, + block.length, + SCRATCH_FILE_NAME, + "private scratch copy", + )?; + let copy = open_file_with_length(destination, block.length, "private scratch copy")?; + verify_sandbox_block_identity(©, block, "private scratch copy") +} + +/// Materializes paired scratch with a required filesystem copy-on-write clone. +pub fn clone_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + reflink_exact( + source, + destination, + block.length, + SCRATCH_FILE_NAME, + "copy-on-write scratch clone", + )?; + let clone = open_file_with_length(destination, block.length, "copy-on-write scratch clone")?; + verify_sandbox_block_identity(&clone, block, "copy-on-write scratch clone") +} + +/// Creates a private path to the exact claimed paired-scratch generation. +pub fn link_claimed_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + let parent = destination + .parent() + .context("claimed scratch destination has no parent")?; + let name = destination + .file_name() + .and_then(|name| name.to_str()) + .context("claimed scratch destination name is invalid")?; + let directory = OpenedSnapshotDirectory::open_for_publication(parent)?; + create_hard_link_from_handle(source, &directory.file, name) + .context("failed to link claimed scratch generation")?; + let linked = open_file_with_length(destination, block.length, "claimed scratch link")?; + verify_hard_link_identity(source, &linked, block.length)?; + verify_sandbox_block_identity(&linked, block, "claimed scratch link") +} + +/// Atomically consumes a single-use resume snapshot. +/// +/// Call this after artifact and configuration validation and before constructing +/// execution-owned workers. A later startup failure does not roll back the claim. +/// Clone snapshots are unchanged. +pub fn claim_snapshot_for_restore(dir: &Path, manifest: &SnapshotManifest) -> anyhow::Result<()> { + let directory = OpenedSnapshotDirectory::open(dir)?; + claim_snapshot_for_restore_in_directory(&directory, manifest) +} + +fn claim_snapshot_for_restore_in_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result<()> { + validate_manifest_header(manifest)?; + validate_manifest_contents(manifest)?; + if manifest.restore_policy != SNAPSHOT_RESTORE_POLICY_RESUME { + return Ok(()); + } + + let mut claim = match directory.create_new_file(RESUME_CLAIM_FILE_NAME) { + Ok(claim) => claim, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + anyhow::bail!("resume snapshot has already been claimed") + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to claim resume snapshot at {}", + directory.display_path(RESUME_CLAIM_FILE_NAME).display() + ) + }); + } + }; + claim + .write_all(b"OPENVMM_RESUME_CLAIM_V1\n") + .context("failed to write resume snapshot claim")?; + claim + .sync_all() + .context("failed to flush resume snapshot claim")?; + directory + .sync() + .context("failed to commit resume snapshot claim")?; + Ok(()) +} + +fn validate_snapshot_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result<()> { + let has_scratch = microvm::paired_scratch_block(manifest).is_some(); + let mut entries = HashSet::new(); + for name in directory.entry_names()? { + if name == RESUME_CLAIM_FILE_NAME + && manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_RESUME + { + anyhow::bail!("resume snapshot has already been claimed"); + } + anyhow::ensure!( + name == MANIFEST_FILE_NAME + || name == STATE_FILE_NAME + || name == MEMORY_FILE_NAME + || (has_scratch && name == SCRATCH_FILE_NAME), + "unexpected artifact in snapshot directory: {}", + directory.display_path(&name).display(), + ); + entries.insert(name); + } + anyhow::ensure!( + entries.len() == 3 + usize::from(has_scratch) + && entries.contains(std::ffi::OsStr::new(MANIFEST_FILE_NAME)) + && entries.contains(std::ffi::OsStr::new(STATE_FILE_NAME)) + && entries.contains(std::ffi::OsStr::new(MEMORY_FILE_NAME)) + && (!has_scratch || entries.contains(std::ffi::OsStr::new(SCRATCH_FILE_NAME))), + "snapshot directory is incomplete" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT; + use super::super::microvm::paired_scratch_manifest; + use super::super::publish::write_snapshot; + #[cfg(target_os = "linux")] + use super::super::publish::write_snapshot_from_memory_and_scratch_files; + use super::super::tests::test_manifest; + use super::*; + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + use std::io::Write; + + #[cfg(target_os = "linux")] + #[test] + fn copy_on_write_scratch_clone_is_independent() { + let scratch = vec![0x5a; 512]; + let manifest = paired_scratch_manifest(&scratch); + let block = microvm::paired_scratch_block(&manifest).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("source-scratch.img"); + let destination_path = dir.path().join("cloned-scratch.img"); + std::fs::write(&source_path, &scratch).unwrap(); + let source = std::fs::File::open(&source_path).unwrap(); + + if let Err(error) = clone_paired_scratch_file(&source, &destination_path, block) { + let error = format!("{error:#}"); + assert!( + error.contains("requires filesystem reflink support"), + "{error}" + ); + return; + } + + let mut clone = std::fs::OpenOptions::new() + .write(true) + .open(&destination_path) + .unwrap(); + clone.write_all(b"clone").unwrap(); + clone.sync_all().unwrap(); + + assert_eq!(&std::fs::read(&source_path).unwrap()[..5], b"ZZZZZ"); + assert_eq!(&std::fs::read(&destination_path).unwrap()[..5], b"clone"); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn direct_claimed_scratch_links_exact_claimed_generation() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + let block = microvm::paired_scratch_block(&manifest).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let snapshot_dir = dir.path().join("snapshot"); + let claimed_dir = dir.path().join("claimed"); + std::fs::create_dir(&snapshot_dir).unwrap(); + std::fs::create_dir(&claimed_dir).unwrap(); + let source_path = snapshot_dir.join(SCRATCH_FILE_NAME); + let destination_path = claimed_dir.join(SCRATCH_FILE_NAME); + std::fs::write(&source_path, &scratch).unwrap(); + let source = std::fs::File::open(&source_path).unwrap(); + + claim_snapshot_for_restore(&snapshot_dir, &manifest).unwrap(); + assert!( + claim_snapshot_for_restore(&snapshot_dir, &manifest) + .unwrap_err() + .to_string() + .contains("already been claimed") + ); + link_claimed_paired_scratch_file(&source, &destination_path, block).unwrap(); + + let mut claimed = std::fs::OpenOptions::new() + .write(true) + .open(&destination_path) + .unwrap(); + claimed.seek(SeekFrom::Start(0)).unwrap(); + claimed.write_all(b"claim").unwrap(); + claimed.sync_all().unwrap(); + + assert_eq!(&std::fs::read(&source_path).unwrap()[..5], b"claim"); + } + + #[test] + fn resume_snapshot_claim_is_single_use() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + let dir = tempfile::tempdir().unwrap(); + + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + let error = claim_snapshot_for_restore(dir.path(), &manifest).unwrap_err(); + assert!(error.to_string().contains("already been claimed")); + } + + #[test] + fn clone_snapshot_claim_is_a_noop() { + let scratch = vec![0x5a; 512]; + let manifest = paired_scratch_manifest(&scratch); + let dir = tempfile::tempdir().unwrap(); + + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + assert!(!dir.path().join(RESUME_CLAIM_FILE_NAME).exists()); + } + + #[test] + fn read_snapshot_accepts_same_length_state_change_without_checksums() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(STATE_FILE_NAME), b"other").unwrap(); + + let (_, state) = read_snapshot(&snap_dir, 1024).unwrap(); + assert_eq!(state, b"other"); + } + + #[test] + fn read_snapshot_accepts_same_length_memory_change_without_checksums() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1024]).unwrap(); + + let (_, _, mut memory) = read_snapshot_with_memory(&snap_dir, 1024).unwrap(); + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, vec![1_u8; 1024]); + } + + #[test] + fn supplied_manifest_remains_authoritative_during_artifact_open() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let manifest = read_snapshot_manifest(&snap_dir).unwrap(); + + std::fs::write(snap_dir.join(MANIFEST_FILE_NAME), b"replacement").unwrap(); + let (state, mut memory) = + read_snapshot_artifacts_with_memory(&snap_dir, &manifest, 1024).unwrap(); + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + + assert_eq!(state, b"state"); + assert_eq!(bytes, vec![0_u8; 1024]); + } + + #[test] + fn read_snapshot_rejects_an_earlier_manifest_version() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let mut manifest = read_snapshot_manifest(&snap_dir).unwrap(); + manifest.version = 5; + manifest.format_magic = b"OPENVMM_SNAPSHOT_V5\0".to_vec(); + std::fs::write( + snap_dir.join(MANIFEST_FILE_NAME), + mesh::payload::encode(manifest), + ) + .unwrap(); + + let error = format!("{:#}", read_snapshot(&snap_dir, 1024).err().unwrap()); + assert!(error.contains("[E_SNAPSHOT_VERSION]"), "{error}"); + assert!(error.contains("recapture the snapshot"), "{error}"); + } + + #[test] + fn read_snapshot_still_requires_exact_memory_length() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1023]).unwrap(); + + let error = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(error.to_string().contains("memory.bin size")); + } + + #[test] + fn read_snapshot_rejects_corrupt_manifest() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MANIFEST_FILE_NAME), b"not-a-manifest").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("decode snapshot manifest")); + } + + #[test] + fn read_snapshot_rejects_truncated_state() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(STATE_FILE_NAME), b"sta").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("state.bin size")); + } + + #[test] + fn read_snapshot_rejects_truncated_memory() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(MEMORY_FILE_NAME)) + .unwrap() + .set_len(512) + .unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("memory.bin size")); + } + + #[test] + fn read_snapshot_rejects_oversized_state() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(STATE_FILE_NAME)) + .unwrap() + .set_len(MAX_SAVED_STATE_SIZE_BYTES + 1) + .unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("saved state is")); + assert!(err.to_string().contains("exceeding the maximum")); + } + + #[test] + fn read_snapshot_rejects_unexpected_artifact() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join("extra.bin"), b"unexpected").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("unexpected artifact")); + } + + #[cfg(unix)] + #[test] + fn read_snapshot_rejects_symlinked_artifact() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let state_path = snap_dir.join(STATE_FILE_NAME); + let moved_state_path = snap_dir.join("state-target.bin"); + std::fs::rename(&state_path, &moved_state_path).unwrap(); + symlink(&moved_state_path, &state_path).unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!( + err.to_string().contains("unexpected artifact") + || err.to_string().contains("not a regular file") + || err.to_string().contains("failed to open saved state") + ); + } + + #[cfg(unix)] + #[test] + fn opened_memory_handle_survives_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0x5a_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + + let (_, _, mut opened_memory) = read_snapshot_with_memory(&snap_dir, 1024).unwrap(); + let original_path = snap_dir.join(MEMORY_FILE_NAME); + let moved_path = snap_dir.join("opened-memory.bin"); + std::fs::rename(&original_path, &moved_path).unwrap(); + std::fs::write(&original_path, vec![0xa5_u8; 1024]).unwrap(); + + let mut bytes = Vec::new(); + opened_memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, vec![0x5a_u8; 1024]); + } + + #[cfg(target_os = "linux")] + #[test] + fn opened_snapshot_survives_directory_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("opened-snapshot"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0x5a_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + std::fs::write(&memory_source, vec![0xa5_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"other", &memory_source).unwrap(); + + snapshot.validate_memory_generation(1024).unwrap(); + let (_, state, guards) = snapshot.into_parts(); + let mut memory = guards.memory; + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(state, b"state"); + assert_eq!(bytes, vec![0x5a_u8; 1024]); + } + + #[cfg(target_os = "linux")] + #[test] + fn resume_claim_targets_opened_directory_generation() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("opened-snapshot"); + let memory_source = dir.path().join("memory-source.bin"); + let scratch_source = dir.path().join("scratch-source.bin"); + let scratch = vec![0x5a_u8; 512]; + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_source, &scratch).unwrap(); + let memory_file = std::fs::File::open(&memory_source).unwrap(); + let scratch_file = std::fs::File::open(&scratch_source).unwrap(); + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + std::fs::create_dir(&snap_dir).unwrap(); + snapshot.claim_for_restore().unwrap(); + + assert!(moved_dir.join(RESUME_CLAIM_FILE_NAME).exists()); + assert!(!snap_dir.join(RESUME_CLAIM_FILE_NAME).exists()); + } + + #[cfg(target_os = "linux")] + #[test] + fn opened_snapshot_detects_same_length_memory_write_before_mapping() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + let mut writer = std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(MEMORY_FILE_NAME)) + .unwrap(); + writer.write_all(&[1]).unwrap(); + writer.sync_all().unwrap(); + + let error = snapshot + .duplicate_memory_file_for_mapping(1024) + .unwrap_err(); + assert!( + error + .to_string() + .contains("memory generation changed after it was opened") + ); + } + + #[cfg(windows)] + #[test] + fn opened_snapshot_denies_mutation_until_guards_drop() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("moved"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + let memory_path = snap_dir.join(MEMORY_FILE_NAME); + assert!( + std::fs::OpenOptions::new() + .write(true) + .open(&memory_path) + .is_err() + ); + assert!( + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&memory_path) + .is_err() + ); + assert!(std::fs::remove_file(&memory_path).is_err()); + assert!(std::fs::rename(&snap_dir, &moved_dir).is_err()); + + drop(snapshot); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + } + + #[cfg(windows)] + #[test] + fn opened_snapshot_rejects_reparse_artifact() { + use std::os::windows::fs::symlink_file; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_source = dir.path().join("memory-source.bin"); + let replacement = dir.path().join("replacement.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + std::fs::rename(snap_dir.join(MEMORY_FILE_NAME), &replacement).unwrap(); + match symlink_file(&replacement, snap_dir.join(MEMORY_FILE_NAME)) { + Ok(()) => {} + Err(error) + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) => + { + return; + } + Err(error) => panic!("failed to create test symlink: {error}"), + } + + let error = OpenedSnapshot::open(&snap_dir).err().unwrap(); + assert!( + error.to_string().contains("reparse point") + || error.to_string().contains("not a regular file") + ); + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs new file mode 100644 index 00000000000..53da8914521 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -0,0 +1,877 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI records of a snapshot manifest: the time contract and the +//! CPU profile record of a microVM machine contract. + +use super::SnapshotManifest; +use super::microvm::SnapshotMachineContract; +use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; +use openvmm_defs::time_abi::decode_effective_cpuid; +use virt::time_abi::CaptureTimeRecord; +use virt::time_abi::Downtime; +use virt::time_abi::HostClockKind; +use virt::time_abi::HostIdentity; +use virt::time_abi::HostTimeSample; +use virt::time_abi::TIME_ABI_VERSION; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiTestHooks; +use virt::time_abi::downtime::select_downtime; +use virt::time_abi::rate; + +/// The largest canonical CPU profile encoding a manifest may carry. +pub const MAX_CPU_PROFILE_BYTES: usize = 1024 * 1024; + +fn manifest_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::ManifestTime, message) +} + +fn identity(bytes: &[u8], description: &str) -> Result<[u8; 16], TimeAbiError> { + bytes + .try_into() + .map_err(|_| manifest_error(format!("{description} is {} bytes, not 16", bytes.len()))) +} + +/// Validates a time contract (restore step 2 of the specification). +pub fn validate_time_contract(contract: &SnapshotTimeContract) -> Result<(), TimeAbiError> { + let SnapshotTimeContract { + time_abi_version, + tsc_frequency_hz, + tsc_tolerance_ppm, + apic_frequency_hz, + capture_tsc: _, + capture_utc_ns: _, + capture_monotonic_ns: _, + host_clock, + host_id, + host_boot_id, + capture_generation, + } = contract; + if *time_abi_version != TIME_ABI_VERSION { + return Err(manifest_error(format!( + "time ABI version {time_abi_version} is not {TIME_ABI_VERSION}" + ))); + } + if *tsc_tolerance_ppm != rate::TSC_TOLERANCE_PPM { + return Err(manifest_error(format!( + "TSC tolerance {tsc_tolerance_ppm} ppm is not {}", + rate::TSC_TOLERANCE_PPM + ))); + } + rate::check_plausible_tsc_hz(*tsc_frequency_hz) + .map_err(|err| manifest_error(format!("snapshot {}", err.message)))?; + if ![rate::LAPIC_HZ_KVM, rate::LAPIC_HZ_HYPERV].contains(apic_frequency_hz) { + return Err(manifest_error(format!( + "LAPIC rate {apic_frequency_hz} Hz is not a backend constant" + ))); + } + if HostClockKind::from_manifest(host_clock).is_none() { + return Err(manifest_error(format!( + "host clock '{host_clock}' is unknown" + ))); + } + identity(host_id, "host identity")?; + identity(host_boot_id, "host boot identity")?; + if *capture_generation == u32::MAX { + return Err(TimeAbiError::new( + TimeAbiCode::GenerationExhausted, + "the snapshot's generation counter cannot be incremented", + )); + } + Ok(()) +} + +/// Validates a CPU profile record's shape: the ID, the 32-byte digest, the +/// document's size, and a well-formed effective CPUID record. It computes no +/// digest and decodes no document, because manifests are validated several +/// times per restore. The restore preflight compares the digest and the +/// document with the pinned profile's precomputed ones, and the worker +/// compares the effective CPUID record with its recomputation. +pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), TimeAbiError> { + let SnapshotCpuProfile { + id, + sha256, + profile, + effective_cpuid, + capture_cpu_signature: _, + } = record; + let id_valid = !id.is_empty() + && id.len() <= 64 + && id.split('.').all(|component| { + !component.is_empty() + && component + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }); + if !id_valid { + return Err(manifest_error(format!( + "CPU profile ID '{id}' is malformed" + ))); + } + if sha256.len() != 32 { + return Err(manifest_error(format!( + "CPU profile digest is {} bytes, not 32", + sha256.len() + ))); + } + if profile.is_empty() || profile.len() > MAX_CPU_PROFILE_BYTES { + return Err(manifest_error("CPU profile record size is invalid")); + } + decode_effective_cpuid(effective_cpuid) + .map_err(|err| manifest_error(format!("effective CPUID record: {err}")))?; + Ok(()) +} + +/// Returns the capture record of a validated time contract. +pub fn capture_record(contract: &SnapshotTimeContract) -> Result { + validate_time_contract(contract)?; + contract.capture_record() +} + +/// Validates the time ABI records of a microVM machine contract: both are +/// present and valid, and the command line sets no clock parameter. +pub fn validate_time_abi_contract(contract: &SnapshotMachineContract) -> Result<(), TimeAbiError> { + let time = contract + .time + .as_ref() + .ok_or_else(|| manifest_error("the time contract is missing"))?; + let cpu_profile = contract + .cpu_profile + .as_ref() + .ok_or_else(|| manifest_error("the CPU profile record is missing"))?; + validate_time_contract(time)?; + validate_cpu_profile_record(cpu_profile)?; + virt::time_abi::check_command_line_clock_tokens(&contract.effective_command_line) +} + +/// Returns the machine contract of a microVM snapshot's manifest, which +/// carries the time ABI records (`E_MANIFEST_TIME` when it is missing). +pub fn required_machine_contract( + manifest: &SnapshotManifest, +) -> Result<&SnapshotMachineContract, TimeAbiError> { + manifest.machine_contract.as_ref().ok_or_else(|| { + manifest_error("the microVM snapshot has no machine contract, so no time ABI records") + }) +} +/// The controller's view of a time ABI restore after restore steps 2 to 5 of +/// the specification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeAbiRestorePreflight { + /// The capture record. + pub capture: CaptureTimeRecord, + /// The preflight downtime. The worker measures `D` again at the restore + /// anchor. + pub downtime: Downtime, + /// The generation counter `g` of the restored process. + pub generation: u32, + /// The CPU profile the restored VM uses: the snapshot's. + pub cpu_profile: String, +} + +/// Runs restore steps 2 to 5 of the specification on a version 6 machine +/// contract: the time ABI records, the backend, the CPU profile and host CPU +/// generation, and the downtime preflight against the destination host's +/// identity and clocks. +/// +/// The snapshot's profile must be pinned in this OpenVMM with the same +/// digest, and the embedded document must be the pinned profile's canonical +/// encoding (`E_PROFILE_UNKNOWN`, `E_PROFILE_DIGEST`). Both are compared with +/// the build's precomputed record, without encoding or hashing. A host +/// profile (`--cpu-profile host`) has no pinned record, so its embedded +/// document is decoded and checked against the recorded digest instead +/// (`E_PROFILE_DIGEST`). `host` must be in the profile's generation. The +/// worker recomputes the effective CPUID and compares it with the record. +pub fn preflight_time_abi_restore( + contract: &SnapshotMachineContract, + hypervisor: &str, + requested_cpu_profile: &str, + host: Option, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, +) -> Result { + validate_time_abi_contract(contract)?; + let (Some(time), Some(record)) = (&contract.time, &contract.cpu_profile) else { + unreachable!("a validated time ABI contract carries both records"); + }; + if contract.source_hypervisor != hypervisor { + return Err(TimeAbiError::new( + TimeAbiCode::BackendMismatch, + format!( + "the snapshot was captured on the {} backend and cannot be restored on {hypervisor}", + contract.source_hypervisor + ), + )); + } + let host_profile = cpu_profile::is_host_profile_id(&record.id); + if requested_cpu_profile != cpu_profile::AUTO + && requested_cpu_profile != record.id + && !(host_profile && requested_cpu_profile == cpu_profile::HOST) + { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!( + "--cpu-profile {requested_cpu_profile} does not name the snapshot's profile '{}'", + record.id + ), + )); + } + let host = host.ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::CpuGeneration, + "the host CPU cannot be identified", + ) + })?; + if host_profile { + let profile = + cpu_profile::host_profile_for_restore(&record.id, &record.sha256, &record.profile)?; + cpu_profile::check_generation(&profile, &host)?; + } else { + let profile = cpu_profile::pinned_for_restore(&record.id, &record.sha256)?; + let pinned = cpu_profile::pinned_record(&record.id).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {} is not pinned", record.id), + ) + })?; + if record.profile != pinned.encoding { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileDigest, + format!( + "the snapshot's embedded CPU profile is not the canonical encoding of the pinned profile {}", + record.id + ), + )); + } + cpu_profile::check_generation(profile, &host)?; + } + let capture = capture_record(time)?; + let downtime = select_downtime(&capture, destination, now, hooks)?; + Ok(TimeAbiRestorePreflight { + capture, + downtime, + generation: time.capture_generation + 1, + cpu_profile: record.id.clone(), + }) +} + +/// Returns the vendor and signature of the host CPU, or `None` on a host that +/// is not x86-64. +pub fn host_cpu() -> Option { + // The host CPU is identified with the host's CPUID instruction. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Some(cpu_profile::HostCpuSignature::current()) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + None + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + pub(in crate::snapshot) fn test_time_contract() -> SnapshotTimeContract { + SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: rate::LAPIC_HZ_HYPERV, + capture_tsc: 12_345_678, + capture_utc_ns: 1_700_000_000_000_000_000, + capture_monotonic_ns: 123_000_000_000, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation: 3, + } + } + + /// The CPU profile of the test records. + pub(in crate::snapshot) const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + /// A host CPU signature in the test profile's generation. + const TEST_SIGNATURE: u32 = 0x0006_06a6; + + /// Returns the effective CPUID of a partition with `vp_count` VPs and + /// `profile`: the topology fields OpenVMM sets for one socket, of the + /// profile's vendor, an xAPIC, and no identity leaves. + fn test_effective_cpuid( + profile: &cpu_profile::CpuProfile, + vp_count: u32, + ) -> cpu_profile::EffectiveCpuid { + use cpu_profile::CpuVendor; + use cpu_profile::CpuidResult; + let mut vm = vec![ + CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), + cpu_profile::x2apic_cpuid(false), + ]; + match profile.cpu_vendor() { + CpuVendor::Intel => vm.push( + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + ), + CpuVendor::Amd => { + let apic_id_size = vp_count.next_power_of_two().trailing_zeros(); + vm.push( + CpuidResult::new(0x8000_0008, [0, 0, (vp_count - 1) | apic_id_size << 12, 0]) + .masked([0, 0, 0xf0ff, 0]), + ); + // The L3 cache is the socket's; the others are each VP's. + for subleaf in 0.. { + let eax = profile.lookup(0x8000_001d, subleaf)[0]; + if eax & 0x1f == 0 { + break; + } + let sharing = if (eax >> 5) & 7 == 3 { vp_count - 1 } else { 0 }; + vm.push( + CpuidResult::new(0x8000_001d, [sharing << 14, 0, 0, 0]) + .indexed(subleaf) + .masked([0x03ff_c000, 0, 0, 0]), + ); + } + vm.push(CpuidResult::new(0x8000_001e, [0; 4]).masked([!0, 0xffff, 0x7ff, 0])); + } + } + let max_basic = profile.lookup(0, 0)[0]; + for leaf in cpu_profile::VM_OWNED_LEAVES + .into_iter() + .filter(|&leaf| leaf <= max_basic) + { + vm.push(CpuidResult::new(leaf, [0, 1, 0x100, 0]).indexed(0)); + vm.push(CpuidResult::new(leaf, [0, vp_count, 0x201, 0]).indexed(1)); + } + profile.effective_cpuid(&vm, &[]).unwrap() + } + + /// Returns the snapshot record of `effective`. + fn effective_cpuid_record(effective: &cpu_profile::EffectiveCpuid) -> Vec { + openvmm_defs::time_abi::encode_effective_cpuid(effective.results().map(|result| { + openvmm_defs::time_abi::EffectiveCpuidEntry { + function: result.function, + index: result.index, + result: result.result, + mask: result.mask, + } + })) + } + + pub(in crate::snapshot) fn test_cpu_profile() -> SnapshotCpuProfile { + let pinned = cpu_profile::pinned(TEST_PROFILE).unwrap(); + let record = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + SnapshotCpuProfile { + id: TEST_PROFILE.to_owned(), + sha256: record.digest.to_vec(), + profile: record.encoding.to_vec(), + effective_cpuid: effective_cpuid_record(&test_effective_cpuid(pinned, 2)), + capture_cpu_signature: TEST_SIGNATURE, + } + } + + fn code(result: Result<(), TimeAbiError>) -> TimeAbiCode { + result.unwrap_err().code + } + + #[test] + fn time_contract_rules() { + validate_time_contract(&test_time_contract()).unwrap(); + let mutations: [(fn(&mut SnapshotTimeContract), TimeAbiCode); 8] = [ + (|c| c.time_abi_version = 2, TimeAbiCode::ManifestTime), + (|c| c.tsc_tolerance_ppm = 251, TimeAbiCode::ManifestTime), + (|c| c.tsc_frequency_hz = 100, TimeAbiCode::ManifestTime), + ( + |c| c.apic_frequency_hz = 100_000_000, + TimeAbiCode::ManifestTime, + ), + ( + |c| c.host_clock = "utc".to_owned(), + TimeAbiCode::ManifestTime, + ), + ( + |c| { + c.host_id.pop(); + }, + TimeAbiCode::ManifestTime, + ), + (|c| c.host_boot_id.push(0), TimeAbiCode::ManifestTime), + ( + |c| c.capture_generation = u32::MAX, + TimeAbiCode::GenerationExhausted, + ), + ]; + for (mutate, expected) in mutations { + let mut contract = test_time_contract(); + mutate(&mut contract); + assert_eq!( + code(validate_time_contract(&contract)), + expected, + "{contract:?}" + ); + } + } + + #[test] + fn cpu_profile_record_rules() { + validate_cpu_profile_record(&test_cpu_profile()).unwrap(); + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 11] = [ + (|r| r.id.clear(), TimeAbiCode::ManifestTime), + (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), + ( + |r| r.id = "intel..kvm.v1".to_owned(), + TimeAbiCode::ManifestTime, + ), + ( + |r| r.id = "intel.skylake-sp.kvm.v1.".to_owned(), + TimeAbiCode::ManifestTime, + ), + (|r| r.profile.clear(), TimeAbiCode::ManifestTime), + (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), + (|r| r.sha256.push(0), TimeAbiCode::ManifestTime), + (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), + ( + |r| { + r.effective_cpuid.pop(); + }, + TimeAbiCode::ManifestTime, + ), + // The subleaf flag of the first entry. + (|r| r.effective_cpuid[4] = 2, TimeAbiCode::ManifestTime), + ( + |r| r.profile = vec![0; MAX_CPU_PROFILE_BYTES + 1], + TimeAbiCode::ManifestTime, + ), + ]; + for (mutate, expected) in mutations { + let mut record = test_cpu_profile(); + mutate(&mut record); + assert_eq!( + code(validate_cpu_profile_record(&record)), + expected, + "{}", + record.id + ); + } + + // Validation checks only the shape. The restore preflight rejects a + // document or digest that is not the named pinned profile's, and the + // worker an effective CPUID record that is not this partition's. + let mut renamed = test_cpu_profile(); + renamed.id = "intel.skylake-sp.v1".to_owned(); + validate_cpu_profile_record(&renamed).unwrap(); + let mut other_document = test_cpu_profile(); + other_document.profile.push(b' '); + validate_cpu_profile_record(&other_document).unwrap(); + let mut other_cpuid = test_cpu_profile(); + other_cpuid.effective_cpuid[12] ^= 1; + validate_cpu_profile_record(&other_cpuid).unwrap(); + } + + #[test] + fn capture_record_from_contract() { + let record = capture_record(&test_time_contract()).unwrap(); + assert_eq!(record.tsc, 12_345_678); + assert_eq!(record.sample.utc_ns, 1_700_000_000_000_000_000); + assert_eq!(record.sample.monotonic_ns, 123_000_000_000); + assert_eq!(record.identity.host_id, [1; 16]); + assert_eq!(record.identity.boot_id, [2; 16]); + assert_eq!(record.identity.clock, HostClockKind::LinuxBoottime); + } + + fn version_6_manifest() -> SnapshotManifest { + let mut manifest = crate::snapshot::tests::test_manifest(); + manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); + manifest + } + + fn validate(manifest: &SnapshotManifest) -> anyhow::Result<()> { + crate::snapshot::format::validate_manifest_header(manifest)?; + crate::snapshot::format::validate_manifest_contents(manifest)?; + crate::snapshot::microvm::validate_microvm_machine_contract( + manifest, + &crate::snapshot::microvm::test_machine_contract(), + ) + } + + #[test] + fn version_6_manifest_is_accepted() { + let manifest = version_6_manifest(); + assert_eq!(manifest.version, 6); + assert_eq!(manifest.format_magic, b"OPENVMM_SNAPSHOT_V6\0"); + validate(&manifest).unwrap(); + } + + #[test] + fn version_6_manifest_requires_records() { + let mut manifest = version_6_manifest(); + manifest.machine_contract.as_mut().unwrap().time = None; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + + let mut manifest = version_6_manifest(); + manifest.machine_contract.as_mut().unwrap().cpu_profile = None; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + + // A manifest without a machine contract is a regular VM's; a microVM + // restore requires the contract. + let mut manifest = version_6_manifest(); + manifest.machine_contract = None; + crate::snapshot::format::validate_manifest_contents(&manifest).unwrap(); + let err = required_machine_contract(&manifest).unwrap_err(); + assert_eq!(err.code, TimeAbiCode::ManifestTime); + } + + #[test] + fn version_6_manifest_rejects_clock_tokens_and_invalid_records() { + for token in ["tsc_early_khz=2100000", "lapic_timer_hz=200000000"] { + let mut manifest = version_6_manifest(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line(format!("console=hvc0 {token}")); + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + } + + let mut manifest = version_6_manifest(); + manifest + .machine_contract + .as_mut() + .unwrap() + .time + .as_mut() + .unwrap() + .tsc_tolerance_ppm = 100; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + } + + /// A version 6 machine contract captured on WHP with the test profile, + /// and a destination sample 5 s after its capture anchor on the same host + /// boot. + fn test_restore() -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { + let capture = capture_record(&test_time_contract()).unwrap(); + let now = HostTimeSample { + utc_ns: capture.sample.utc_ns + 5_000_000_000, + monotonic_ns: capture.sample.monotonic_ns + 5_000_000_000, + }; + ( + crate::snapshot::microvm::test_machine_contract(), + capture.identity, + now, + ) + } + fn preflight( + contract: &SnapshotMachineContract, + hypervisor: &str, + requested: &str, + signature: Option, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, + ) -> Result { + preflight_time_abi_restore( + contract, + hypervisor, + requested, + signature + .map(|signature| cpu_profile::HostCpuSignature::new(*b"GenuineIntel", signature)), + destination, + now, + hooks, + ) + } + + #[test] + fn restore_preflight_selects_the_downtime_and_generation() { + let (contract, destination, now) = test_restore(); + let hooks = TimeAbiTestHooks::default(); + for requested in ["auto", TEST_PROFILE] { + let result = preflight( + &contract, + "whp", + requested, + Some(TEST_SIGNATURE), + &destination, + &now, + &hooks, + ) + .unwrap(); + assert_eq!(result.generation, 4); + assert_eq!(result.cpu_profile, TEST_PROFILE); + assert_eq!(result.downtime.nanos, 5_000_000_000); + assert_eq!( + result.downtime.source, + virt::time_abi::DowntimeSource::HostMonotonic + ); + } + + let hooks = TimeAbiTestHooks { + force_utc_downtime: true, + ..Default::default() + }; + let result = preflight( + &contract, + "whp", + "auto", + Some(TEST_SIGNATURE), + &destination, + &now, + &hooks, + ) + .unwrap(); + assert_eq!(result.downtime.source, virt::time_abi::DowntimeSource::Utc); + } + + #[test] + fn restore_preflight_failures_have_stable_codes() { + let (contract, destination, now) = test_restore(); + let hooks = TimeAbiTestHooks::default(); + let fail = |contract: &SnapshotMachineContract, + hypervisor: &str, + requested: &str, + signature: Option, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks| { + preflight( + contract, + hypervisor, + requested, + signature, + &destination, + now, + hooks, + ) + .unwrap_err() + .code + }; + let host = Some(TEST_SIGNATURE); + + assert_eq!( + fail(&contract, "kvm", "auto", host, &now, &hooks), + TimeAbiCode::BackendMismatch + ); + assert_eq!( + fail(&contract, "whp", "intel.skylake-sp.v1", host, &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + // A Skylake-SP host, and a host whose CPU cannot be identified. + assert_eq!( + fail(&contract, "whp", "auto", Some(0x0005_0654), &now, &hooks), + TimeAbiCode::CpuGeneration + ); + assert_eq!( + fail(&contract, "whp", "auto", None, &now, &hooks), + TimeAbiCode::CpuGeneration + ); + + // A snapshot that recorded an interim profile, before CPU profiles. + let mut interim = contract.clone(); + let record = interim.cpu_profile.as_mut().unwrap(); + record.id = "interim.host.whp.v1".to_owned(); + record.profile = Vec::new(); + record.sha256 = vec![0; 32]; + record.effective_cpuid = vec![7; 48]; + assert_eq!( + fail(&interim, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ManifestTime + ); + + // A profile that this OpenVMM does not pin. + let mut unknown = contract.clone(); + let record = unknown.cpu_profile.as_mut().unwrap(); + record.id = "intel.icelake-sp.v9".to_owned(); + record.profile = String::from_utf8(record.profile.clone()) + .unwrap() + .replace("\"intel.icelake-sp.v1\"", "\"intel.icelake-sp.v9\"") + .into_bytes(); + record.sha256 = vec![9; 32]; + assert_eq!( + fail(&unknown, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + + // The record's digest and document are another pinned profile's. + let mut swapped = contract.clone(); + let record = swapped.cpu_profile.as_mut().unwrap(); + let other = cpu_profile::pinned_record("intel.skylake-sp.v1").unwrap(); + record.profile = other.encoding.to_vec(); + record.sha256 = other.digest.to_vec(); + assert_eq!( + fail(&swapped, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileDigest + ); + + // The record names another pinned profile than its digest. + let mut renamed = contract.clone(); + renamed.cpu_profile.as_mut().unwrap().id = "intel.skylake-sp.v1".to_owned(); + assert_eq!( + fail(&renamed, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileDigest + ); + + // The digest is the pinned profile's, but the document is not its + // canonical encoding. + let mut altered = contract.clone(); + altered.cpu_profile.as_mut().unwrap().profile.push(b' '); + assert_eq!( + fail(&altered, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileDigest + ); + + let mut exhausted = contract.clone(); + exhausted.time.as_mut().unwrap().capture_generation = u32::MAX; + assert_eq!( + fail(&exhausted, "whp", "auto", host, &now, &hooks), + TimeAbiCode::GenerationExhausted + ); + + let rollback = HostTimeSample { + utc_ns: now.utc_ns, + monotonic_ns: now.monotonic_ns - 10_000_000_000, + }; + assert_eq!( + fail(&contract, "whp", "auto", host, &rollback, &hooks), + TimeAbiCode::DowntimeNegative + ); + let excessive = TimeAbiTestHooks { + downtime_add_s: 30 * 24 * 60 * 60, + ..Default::default() + }; + assert_eq!( + fail(&contract, "whp", "auto", host, &now, &excessive), + TimeAbiCode::DowntimeExcessive + ); + // `--cpu-profile host` names no pinned profile. + assert_eq!( + fail(&contract, "whp", cpu_profile::HOST, host, &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + } + + /// Returns the host profile that `--cpu-profile host` derives on a WHP + /// host of the test profile's generation whose surface is the test + /// profile's. + fn test_host_profile() -> cpu_profile::CpuProfile { + let pinned = cpu_profile::pinned(TEST_PROFILE).unwrap(); + let cpuid = pinned + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let host = cpu_profile::host::HostIdentity { + cpu: cpu_profile::host::HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: cpu_profile::Hex32(TEST_SIGNATURE), + family: 6, + model: 106, + stepping: 6, + brand: String::new(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: cpu_profile::host::HostOs { + kind: "windows".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + }; + let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + }, + host, + cpu_profile::fingerprint::BackendFingerprint::new("whp", "test", cpuid), + ); + cpu_profile::derive_host_profile(&fingerprint).unwrap() + } + + #[test] + fn restore_preflight_checks_a_recorded_host_profile() { + let (mut contract, destination, now) = test_restore(); + let hooks = TimeAbiTestHooks::default(); + let profile = test_host_profile(); + assert_eq!(profile.id(), "intel.host.v1"); + *contract.cpu_profile.as_mut().unwrap() = SnapshotCpuProfile { + id: profile.id().to_owned(), + sha256: profile.digest().to_vec(), + profile: profile.encode(), + effective_cpuid: effective_cpuid_record(&test_effective_cpuid(&profile, 2)), + capture_cpu_signature: TEST_SIGNATURE, + }; + let result = |contract: &SnapshotMachineContract, requested: &str, signature: u32| { + preflight( + contract, + "whp", + requested, + Some(signature), + &destination, + &now, + &hooks, + ) + }; + for requested in ["auto", cpu_profile::HOST, profile.id()] { + let restored = result(&contract, requested, TEST_SIGNATURE).unwrap(); + assert_eq!(restored.cpu_profile, profile.id()); + assert_eq!(restored.generation, 4); + } + let code = |result: Result| result.unwrap_err().code; + // A pinned profile is not the snapshot's. + assert_eq!( + code(result(&contract, TEST_PROFILE, TEST_SIGNATURE)), + TimeAbiCode::ProfileUnknown + ); + // A host profile serves only its own CPU model and stepping. + assert_eq!( + code(result(&contract, "auto", TEST_SIGNATURE + 1)), + TimeAbiCode::CpuGeneration + ); + // The digest must match the embedded document. + let mut corrupted = contract.clone(); + corrupted.cpu_profile.as_mut().unwrap().sha256[0] ^= 1; + assert_eq!( + code(result(&corrupted, "auto", TEST_SIGNATURE)), + TimeAbiCode::ProfileDigest + ); + // A pinned profile's document under the host profile's ID. + let mut swapped = contract.clone(); + let record = swapped.cpu_profile.as_mut().unwrap(); + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + record.profile = pinned.encoding.to_vec(); + record.sha256 = pinned.digest.to_vec(); + assert_eq!( + code(result(&swapped, "auto", TEST_SIGNATURE)), + TimeAbiCode::ProfileDigest + ); + } + + #[test] + fn effective_cpuid_records_fit_the_manifest() { + for profile in cpu_profile::pinned_profiles() { + let record = effective_cpuid_record(&test_effective_cpuid(profile, 8)); + let entries = record.len() / openvmm_defs::time_abi::EFFECTIVE_CPUID_ENTRY_BYTES; + assert!( + entries < openvmm_defs::time_abi::MAX_EFFECTIVE_CPUID_ENTRIES / 4, + "{}: {entries} entries", + profile.id() + ); + let pinned = cpu_profile::pinned_record(profile.id()).unwrap(); + assert!(pinned.encoding.len() < MAX_CPU_PROFILE_BYTES / 16); + } + } +} diff --git a/openvmm/openvmm_hypervisors/Cargo.toml b/openvmm/openvmm_hypervisors/Cargo.toml index 0dc63b0dbb2..687807d3b38 100644 --- a/openvmm/openvmm_hypervisors/Cargo.toml +++ b/openvmm/openvmm_hypervisors/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true virt_mshv = ["dep:virt_mshv"] # Enable building with KVM support. -virt_kvm = [] +virt_kvm = ["dep:virt_kvm"] # Enable building with WHP support. virt_whp = ["dep:virt_whp"] @@ -21,11 +21,13 @@ virt_hvf = [] [dependencies] anyhow.workspace = true +cpu_profile.workspace = true fs-err.workspace = true hypervisor_resources.workspace = true vm_resource.workspace = true [target.'cfg(target_os = "linux")'.dependencies] +virt_kvm = { workspace = true, optional = true } virt_mshv = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] diff --git a/openvmm/openvmm_hypervisors/src/kvm.rs b/openvmm/openvmm_hypervisors/src/kvm.rs index 2a1c65f8eda..829bb2061a8 100644 --- a/openvmm/openvmm_hypervisors/src/kvm.rs +++ b/openvmm/openvmm_hypervisors/src/kvm.rs @@ -35,6 +35,17 @@ impl hypervisor_resources::HypervisorProbe for KvmProbe { let kvm = open_kvm().context("KVM is not available")?; Ok(KvmHandle { kvm: kvm.into() }.into_resource()) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("kvm", params)?; + let kvm = open_kvm().context("KVM is not available")?; + let kvm = virt_kvm::Kvm::from_kvm(kvm.into())?; + Ok(kvm.cpu_fingerprint()?) + } } fn open_kvm() -> std::io::Result { diff --git a/openvmm/openvmm_hypervisors/src/lib.rs b/openvmm/openvmm_hypervisors/src/lib.rs index e724a9ef4d8..61819772b5c 100644 --- a/openvmm/openvmm_hypervisors/src/lib.rs +++ b/openvmm/openvmm_hypervisors/src/lib.rs @@ -40,3 +40,33 @@ pub(crate) fn parse_bool_param(key: &str, val: &str) -> anyhow::Result { _ => anyhow::bail!("invalid boolean value for {key}: {val}"), } } + +/// Rejects backend parameters for CPU fingerprinting, which none of them +/// affect. +#[expect(clippy::allow_attributes, reason = "lots of conditions")] +#[allow(dead_code)] +pub(crate) fn reject_fingerprint_params( + backend: &str, + params: &[(&str, &str)], +) -> anyhow::Result<()> { + if let Some(&(key, _)) = params.first() { + anyhow::bail!("{backend} parameter {key} does not apply to CPU fingerprinting"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::reject_fingerprint_params; + use test_with_tracing::test; + + #[test] + fn fingerprinting_rejects_backend_parameters() { + reject_fingerprint_params("whp", &[]).unwrap(); + let error = reject_fingerprint_params("whp", &[("user_mode_apic", "true")]).unwrap_err(); + assert_eq!( + error.to_string(), + "whp parameter user_mode_apic does not apply to CPU fingerprinting" + ); + } +} diff --git a/openvmm/openvmm_hypervisors/src/mshv.rs b/openvmm/openvmm_hypervisors/src/mshv.rs index 50608eaf53e..a7d356a8bdc 100644 --- a/openvmm/openvmm_hypervisors/src/mshv.rs +++ b/openvmm/openvmm_hypervisors/src/mshv.rs @@ -55,6 +55,31 @@ impl hypervisor_resources::HypervisorProbe for MshvProbe { snp_disable_cpuid_offload, })) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("mshv", params)?; + anyhow::ensure!(virt_mshv::is_available()?, "MSHV is not available"); + let mshv = + virt_mshv::LinuxMshv::from(std::fs::File::from(fs_err::File::open("/dev/mshv")?)); + Ok(mshv.cpu_fingerprint()?) + } + + #[cfg(guest_arch = "x86_64")] + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + crate::reject_fingerprint_params("mshv", params)?; + anyhow::ensure!(virt_mshv::is_available()?, "MSHV is not available"); + let mshv = + virt_mshv::LinuxMshv::from(std::fs::File::from(fs_err::File::open("/dev/mshv")?)); + Ok(Some(mshv.profile_unlisted_cpuid(profile)?)) + } } #[cfg(test)] diff --git a/openvmm/openvmm_hypervisors/src/whp.rs b/openvmm/openvmm_hypervisors/src/whp.rs index 8b9c1ef20b2..844406edce6 100644 --- a/openvmm/openvmm_hypervisors/src/whp.rs +++ b/openvmm/openvmm_hypervisors/src/whp.rs @@ -46,4 +46,25 @@ impl hypervisor_resources::HypervisorProbe for WhpProbe { anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); Ok(Resource::new(handle)) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("whp", params)?; + anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); + Ok(virt_whp::cpu_fingerprint()?) + } + + #[cfg(guest_arch = "x86_64")] + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + crate::reject_fingerprint_params("whp", params)?; + anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); + Ok(Some(virt_whp::profile_unlisted_cpuid(profile)?)) + } } diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index 91054d9fcf9..55eb1edc361 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -26,6 +26,12 @@ vm_resource::register_static_resolvers! { #[cfg(guest_arch = "x86_64")] chipset::pit::resolver::PitResolver, #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmPortbResolver, + #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmShutdownResolver, + #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmSnapshotRequestResolver, + #[cfg(guest_arch = "x86_64")] chipset::pic::resolver::PicResolver, #[cfg(guest_arch = "x86_64")] chipset::ioapic::resolver::GenericIoApicResolver, @@ -97,6 +103,7 @@ vm_resource::register_static_resolvers! { // Virtio devices virtio_blk::resolver::VirtioBlkResolver, + virtio_console::resolver::control::VirtioControlConsoleResolver, virtio_console::resolver::VirtioConsoleResolver, #[cfg(any(windows, target_os = "linux"))] virtiofs::resolver::VirtioFsResolver, diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 8842c942771..f693335fafb 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -209,6 +209,8 @@ message DevicesConfig { repeated WindowsPCIDevice windows_device = 4; repeated VirtioFs virtiofs_config = 5; VirtioConsoleConfig virtio_console = 6; + reserved 7; + reserved "virtio_blk"; } // An endpoint PCIe device function. Attached behind a port in the topology @@ -613,6 +615,14 @@ message PcieAttachment { } message VMConfig { + // Guest-visible machine contract. + enum MachineProfile { + STANDARD = 0; + // Deterministic SMP-capable microVM ABI. + MICROVM = 2; + reserved 1, 3, 4; + } + // Action for the VMM to take when the guest triggers a power event. enum GuestPowerAction { // Use the default for this event. @@ -644,6 +654,8 @@ message VMConfig { UEFI uefi = 6; IgvmBoot igvm = 18; } + reserved 14; + reserved "pvh_boot"; WindowsOptions windows_options = 7; // Field 8 was previously map extra_data. reserved 8; @@ -658,25 +670,29 @@ message VMConfig { // Actions to take on guest power events. Each unset action uses its // default. GuestPowerActions guest_power_actions = 12; - // SMBIOS (DMI) identity overrides. Applied to whichever boot type is // selected; note that PCAT boot honors only the system UUID and serial // number. IGVM boot does not support these overrides. SMBIOSConfig smbios_config = 13; - optional string crash_dump_path = 14; + // Tag 14 is retained for microVM PVH boot compatibility. + optional string crash_dump_path = 16; // Host iommufd contexts to create with the VM. Linux only; a non-empty // list on other hosts is an error. Contexts remain available until VM // teardown, including when unused or after their last device is removed, // so AddPcieDevice can reference them. No runtime context add/remove. repeated IommufdConfig iommufds = 15; // Run without VMBus. Incompatible with VMBus devices and HVSocket. - bool disable_vmbus = 16; + // Upstream uses field 16, which this fork assigns to crash_dump_path. + bool disable_vmbus = 20; // Do not expose Hyper-V (HV#1) enlightenments. Requires disable_vmbus. // Unsupported for x86_64 UEFI boot. - bool disable_hv = 17; + // Upstream uses field 17, which this fork assigns to machine_profile. + bool disable_hv = 21; // Confidential-computing isolation mode. IsolationConfig isolation_config = 19; + // Machine selection is independent from hypervisor selection. + MachineProfile machine_profile = 17; } // WindowsOptions contains virtual machine configurations that are only present on a Windows host. @@ -747,12 +763,50 @@ message SMBIOSConfig { System system = 2; } +// Snapshot options for the microVM machine profile. +message MicrovmSnapshotConfig { + // Publish a guest-requested snapshot to this previously nonexistent path. + string destination_path = 1; + // Restore authoritatively from this snapshot directory. + string restore_path = 2; + // Expose fresh host entropy through the private portb restore channel. + bool restore_entropy = 3; + // Maximum quiesce time for capture. Zero selects the five-second default. + uint64 quiesce_timeout_ms = 4; + // Unix socket (Linux) or named pipe (Windows) that receives exactly one + // OPENVMM_RESTORE_READY_V1 event before restored guest execution begins. + string restore_ready_path = 5; + // Bring this contiguous prefix of capacity VPs online before restore readiness. + // Zero preserves legacy restore behavior. + uint32 restore_processor_count = 6; + // Maximum time allowed for gated guest repair. Zero selects 60 seconds. + uint64 restore_gate_timeout_ms = 7; + // Immutable total RAM capacity reserved by a captured microVM snapshot. + // Zero disables restore-time memory expansion. + uint64 memory_capacity_bytes = 8; + // Per-launch total RAM target for restore. Zero selects the snapshot base. + uint64 restore_memory_bytes = 9; + // Declare the fixed four-slot microVM ABI 2 topology on a cold boot. + bool image_slots = 10; + // Cold-boot active image-slot count. Zero selects one when image_slots is + // true; a nonzero count requires image_slots. + uint32 image_slot_boot_count = 11; + // Restore active image-slot count. Zero preserves the captured boot count. + uint32 restore_image_slot_count = 12; + // Host-only control endpoint path. + string host_control_path = 13; + // Exact 32-byte host-control capability carried by the authenticated management RPC. + bytes host_control_capability = 14; +} + message CreateVMRequest { VMConfig config = 1; // Optional ID to be used by the VM service in log messages. It's up to the // server/virtstack to make use of this field. Useful for debugging to be able to // correlate events in the virtstack for a given vm that the client launched. string log_id = 2; + // Optional microVM snapshot capture or restore configuration. + MicrovmSnapshotConfig microvm_snapshot = 3; } message MemoryStats { @@ -905,7 +959,14 @@ message WindowsPCIDevice { message VirtioFs { string tag = 1; string root_path = 2; - bool read_only = 3; + // Absolute guest target for the microVM fixed HostFs device. + // Must be empty for a standard-machine virtio-fs device. + string guest_mount_target = 3; + // Enable the microVM common read-write contract. False selects read-only. + // Must be false for a standard-machine virtio-fs device. + bool read_write = 4; + // Standard-machine access flag. Tags 3 and 4 retain microVM wire compatibility. + bool read_only = 5; } message VirtioConsoleConfig { diff --git a/petri/burette/src/tests/network.rs b/petri/burette/src/tests/network.rs index 2d281ee0171..10e3168eeeb 100644 --- a/petri/burette/src/tests/network.rs +++ b/petri/burette/src/tests/network.rs @@ -510,6 +510,10 @@ mod tap { max_queues: None, mac_address: TAP_MAC_ADDRESS, endpoint, + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(), ) diff --git a/petri/burette/src/tests/virtio_fs.rs b/petri/burette/src/tests/virtio_fs.rs index 6cb66cfbacd..faba4e75440 100644 --- a/petri/burette/src/tests/virtio_fs.rs +++ b/petri/burette/src/tests/virtio_fs.rs @@ -183,6 +183,7 @@ impl crate::harness::WarmPerfTest for VirtioFsTest { root_path: vfs_root_path, mount_options: String::new(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(), ) diff --git a/petri/src/tracing.rs b/petri/src/tracing.rs index 474d87aeb04..800f377f276 100644 --- a/petri/src/tracing.rs +++ b/petri/src/tracing.rs @@ -373,8 +373,28 @@ pub async fn log_task( log_file: PetriLogFile, reader: impl AsyncRead + Unpin + Send + 'static, name: &str, +) -> anyhow::Result<()> { + log_task_inner(log_file, reader, name, None).await +} + +/// Logs lines from `reader` and forwards their raw bytes to `output`. +pub async fn log_task_with_output( + log_file: PetriLogFile, + reader: impl AsyncRead + Unpin + Send + 'static, + name: &str, + output: mesh::Sender>, +) -> anyhow::Result<()> { + log_task_inner(log_file, reader, name, Some(output)).await +} + +async fn log_task_inner( + log_file: PetriLogFile, + reader: impl AsyncRead + Unpin + Send + 'static, + name: &str, + output: Option>>, ) -> anyhow::Result<()> { const MAX_LINE_LENGTH: usize = 4096; + tracing::info!("connected to {name}"); let mut buf = Vec::with_capacity(MAX_LINE_LENGTH); let mut reader = BufReader::new(reader); @@ -396,6 +416,10 @@ pub async fn log_task( _ => {} } + if let Some(output) = &output { + output.send(buf.clone()); + } + let string_buf = String::from_utf8_lossy(&buf); let string_buf_trimmed = string_buf.trim_end(); diff --git a/petri/src/vm/hyperv/powershell.rs b/petri/src/vm/hyperv/powershell.rs index 418c1ee3c76..2c450cab8a0 100644 --- a/petri/src/vm/hyperv/powershell.rs +++ b/petri/src/vm/hyperv/powershell.rs @@ -492,6 +492,7 @@ impl HyperVNewCustomVMArgs { let PetriVmConfig { name, arch, + machine_profile, firmware, memory, proc_topology, @@ -501,6 +502,8 @@ impl HyperVNewCustomVMArgs { .. } = config; + crate::vm::microvm::ensure_hyperv_compatible(*machine_profile)?; + if firmware .openhcl_config() .is_some_and(|c| c.vtl2_base_address_type.is_some()) diff --git a/petri/src/vm/microvm.rs b/petri/src/vm/microvm.rs new file mode 100644 index 00000000000..143245025dd --- /dev/null +++ b/petri/src/vm/microvm.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Backend-independent microVM builder support. + +use super::ApicMode; +use super::BootDeviceType; +use super::PetriVmBuilder; +use super::PetriVmmBackend; +use openvmm_defs::microvm::MachineProfile; + +impl PetriVmBuilder { + /// Select the microVM machine profile with deterministic SMP topology. + pub fn with_microvm_machine(mut self, processor_count: u32) -> Self { + assert!( + openvmm_defs::microvm::microvm_processor_count_supported(processor_count), + "microVM supports only 1, 2, 4, or 8 vCPUs" + ); + self.config.machine_profile = MachineProfile::Microvm; + self.config.proc_topology.vp_count = processor_count; + self.config.proc_topology.vps_per_socket = Some(processor_count); + self.config.proc_topology.enable_smt = Some(false); + self.config.proc_topology.apic_mode = Some(ApicMode::Xapic); + self.minimal_mode = true; + self.enable_serial = true; + self.use_virtio_vsock = false; + self.no_vmbus = true; + self.no_hv = true; + self.config.vmbus_storage_controllers.clear(); + self.config.pcie_nvme_drives.clear(); + self.config.pcie_virtio_blk_drives.clear(); + self.config.physical_nvme_devices.clear(); + self.agent_image = None; + self.openhcl_agent_image = None; + self.boot_device_type = BootDeviceType::None; + self + } +} + +pub(super) fn uses_pipette_as_init(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Standard +} + +#[cfg(windows)] +pub(super) fn ensure_hyperv_compatible(machine_profile: MachineProfile) -> anyhow::Result<()> { + anyhow::ensure!( + machine_profile == MachineProfile::Standard, + "the microVM profile is only supported by OpenVMM" + ); + Ok(()) +} diff --git a/petri/src/vm/mod.rs b/petri/src/vm/mod.rs index e448b7cacf8..312e40350e3 100644 --- a/petri/src/vm/mod.rs +++ b/petri/src/vm/mod.rs @@ -4,6 +4,7 @@ /// Hyper-V VM management #[cfg(windows)] pub mod hyperv; +mod microvm; /// OpenVMM VM management pub mod openvmm; /// QEMU full machine emulation management @@ -28,6 +29,7 @@ use get_resources::ged::FirmwareEvent; use guid::Guid; use mesh::CancelContext; use openvmm_defs::config::Vtl2BaseAddressType; +use openvmm_defs::microvm::MachineProfile; use pal_async::DefaultDriver; use pal_async::task::Spawn; use pal_async::task::Task; @@ -237,6 +239,8 @@ pub struct PetriVmConfig { pub name: String, /// The architecture of the VM pub arch: MachineArch, + /// The guest-visible machine contract. + pub machine_profile: MachineProfile, /// Log levels for the host VMM process. pub host_log_levels: Option, /// Firmware and/or OS to load into the VM and associated settings @@ -605,6 +609,7 @@ impl PetriVmBuilder { config: PetriVmConfig { name: make_vm_safe_name(test_name), arch: artifacts.arch, + machine_profile: MachineProfile::Standard, host_log_levels: None, firmware: artifacts.firmware, hibernation_enabled: false, @@ -664,7 +669,7 @@ impl PetriVmBuilder { self.minimal_mode } - /// Supply a pre-built initrd with pipette already injected. + /// Supply a pre-built Linux-direct initrd. /// /// When set, the builder skips the runtime gzip decompress/inject/ /// recompress cycle, using this initrd directly. Use @@ -746,6 +751,35 @@ impl PetriVmBuilder { }) } + /// Build a copy of the Linux-direct initrd with one file injected as the + /// guest's `rdinit=` program. + pub fn prepare_initrd_with_file( + &self, + path: &str, + data: &[u8], + mode: u32, + ) -> anyhow::Result { + let initrd_path = self + .config + .firmware + .linux_direct_initrd() + .context("initrd injection requires Linux direct boot with initrd")?; + let initrd_gz = std::fs::read(initrd_path) + .with_context(|| format!("failed to read initrd at {}", initrd_path.display()))?; + let merged_gz = initrd_cpio::inject_into_initrd(&initrd_gz, path, data, mode) + .with_context(|| format!("failed to inject {path} into initrd"))?; + + let mut tmp = tempfile::NamedTempFile::new() + .context("failed to create temp file for modified initrd")?; + tmp.write_all(&merged_gz) + .context("failed to write modified initrd")?; + + Ok(PetriInitrd { + path: TempOrPersistentPath::Temp(Arc::new(tmp.into_temp_path())), + rdinit_param: format!("/{path}"), + }) + } + /// Enable serial port output even in minimal mode. /// /// Useful for diagnostics — the serial device overhead is negligible; @@ -994,11 +1028,11 @@ impl PetriVmBuilder { ); } - if self.boot_device_type.requires_vpci_boot() { + if self.boot_device_type.requires_uefi_vpci_boot() { self.config .firmware .uefi_config_mut() - .expect("vpci boot requires uefi") + .expect("UEFI vPCI boot support requires UEFI firmware") .enable_vpci_boot = true; } @@ -1154,6 +1188,16 @@ impl PetriVmBuilder { } } + /// Returns the kernel and initrd paths for a direct-boot Linux VM. + pub fn linux_direct_boot_files(&self) -> Option<(&Path, &Path)> { + match &self.config.firmware { + Firmware::LinuxDirect { kernel, initrd } => { + Some((kernel.get(), initrd.as_ref()?.get())) + } + _ => None, + } + } + /// Whether pipette will run as PID 1 init in the initrd. /// /// True for non-OpenHCL Linux direct boot when a pipette binary is @@ -1162,6 +1206,7 @@ impl PetriVmBuilder { fn uses_pipette_as_init(&self) -> bool { self.config.firmware.is_linux_direct() && !self.config.firmware.is_openhcl() + && microvm::uses_pipette_as_init(self.config.machine_profile) && self.pipette_binary.is_some() } @@ -2782,8 +2827,8 @@ pub enum Firmware { LinuxDirect { /// The kernel to boot. kernel: ResolvedArtifact, - /// The initrd to use. - initrd: ResolvedArtifact, + /// The optional initrd to use. + initrd: Option, }, /// Boot Linux directly, without any firmware, with OpenHCL in VTL2. OpenhclLinuxDirect { @@ -2894,6 +2939,13 @@ impl BootDeviceType { ) } + /// Whether UEFI must enable its vPCI boot support. + /// + /// The firmware also uses this flag to enable NVMe boot from emulated PCIe. + fn requires_uefi_vpci_boot(&self) -> bool { + self.requires_vpci_boot() || matches!(self, BootDeviceType::PcieNvme) + } + fn requires_vmbus(&self) -> bool { match self { BootDeviceType::None @@ -2919,11 +2971,11 @@ impl Firmware { match arch { MachineArch::X86_64 => Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_KERNEL_X64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase()), }, MachineArch::Aarch64 => Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_KERNEL_AARCH64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_AARCH64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_AARCH64).erase()), }, } } @@ -2936,7 +2988,7 @@ impl Firmware { use petri_artifacts_vmm_test::artifacts::loadable::*; Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_BZIMAGE_X64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase()), } } @@ -3049,7 +3101,7 @@ impl Firmware { /// Get the initrd path for Linux direct boot firmware. pub fn linux_direct_initrd(&self) -> Option<&Path> { match self { - Firmware::LinuxDirect { initrd, .. } => Some(initrd.get()), + Firmware::LinuxDirect { initrd, .. } => initrd.as_ref().map(|initrd| initrd.get()), _ => None, } } @@ -3824,6 +3876,7 @@ pub(crate) fn petri_disk_cache_dir() -> String { #[cfg(test)] mod tests { use super::*; + use crate::BootDeviceType; use crate::Drive; use crate::VmbusStorageController; use crate::VmbusStorageType; @@ -4074,6 +4127,13 @@ mod tests { assert_ne!(&result1[96..], &result2[96..]); } + #[test] + fn pcie_nvme_requires_uefi_vpci_boot_without_using_vpci() { + assert!(BootDeviceType::PcieNvme.requires_uefi_vpci_boot()); + assert!(!BootDeviceType::PcieNvme.requires_vpci_boot()); + assert!(!BootDeviceType::PcieVirtioBlk.requires_uefi_vpci_boot()); + } + #[test] fn test_vmbus_storage_controller() { let mut controller = VmbusStorageController::new(Vtl::Vtl0, VmbusStorageType::Scsi); diff --git a/petri/src/vm/openvmm/construct.rs b/petri/src/vm/openvmm/construct.rs index 1d9d231f4fc..07f0fb5f3ce 100644 --- a/petri/src/vm/openvmm/construct.rs +++ b/petri/src/vm/openvmm/construct.rs @@ -117,6 +117,7 @@ impl PetriVmConfigOpenVmm { let PetriVmConfig { name: _, arch, + machine_profile, host_log_levels, firmware, hibernation_enabled, @@ -131,6 +132,8 @@ impl PetriVmConfigOpenVmm { physical_nvme_devices, } = petri_vm_config; + super::microvm::validate_profile(machine_profile, arch, &firmware)?; + if !physical_nvme_devices.is_empty() { anyhow::bail!("Physical NVMe devices are only supported with the Hyper-V backend"); } @@ -165,10 +168,14 @@ impl PetriVmConfigOpenVmm { use_virtio_vsock: properties.use_virtio_vsock, no_vmbus: properties.no_vmbus, no_hv: properties.no_hv, + machine_profile, }; let mut chipset = VmManifestBuilder::new( match firmware { + _ if super::microvm::is_enabled(machine_profile) => { + vm_manifest_builder::BaseChipsetType::Microvm + } Firmware::LinuxDirect { .. } => { vm_manifest_builder::BaseChipsetType::HyperVGen2LinuxDirect } @@ -188,14 +195,9 @@ impl PetriVmConfigOpenVmm { let mut load_mode = setup.load_firmware()?; - // If using pipette-as-init, replace the initrd with the pre-built - // one that has pipette injected. run_core() guarantees that - // prebuilt_initrd is set when uses_pipette_as_init is true. - if properties.uses_pipette_as_init { + // Replace the initrd when the builder supplied a modified copy. + if let Some(prebuilt) = prebuilt_initrd.as_ref() { if let LoadMode::Linux { initrd, .. } = &mut load_mode { - let prebuilt = prebuilt_initrd - .as_ref() - .expect("uses_pipette_as_init requires prebuilt_initrd"); let file = std::fs::File::open(prebuilt.path.as_ref()).with_context(|| { format!( "failed to open prebuilt initrd at {}", @@ -206,6 +208,13 @@ impl PetriVmConfigOpenVmm { } } + super::microvm::configure_load_mode( + machine_profile, + &mut load_mode, + proc_topology.vp_count, + )?; + + let mut microvm_serial = None; let (emulated_serial_config, log_stream_tasks, linux_direct_serial_agent) = if !properties.enable_serial { // No emulated serial backends (OpenHCL VMBus serial stubs may still exist) @@ -215,7 +224,9 @@ impl PetriVmConfigOpenVmm { emulated_serial_config, serial_tasks, linux_direct_serial_agent, + microvm, } = setup.configure_serial(log_source)?; + microvm_serial = microvm; ( emulated_serial_config, serial_tasks, @@ -356,7 +367,7 @@ impl PetriVmConfigOpenVmm { // Configure the serial ports now that they have been updated by the // OpenHCL configuration. - if properties.enable_serial { + if properties.enable_serial && super::microvm::configures_standard_serial(machine_profile) { chipset = chipset.with_serial(emulated_serial_config); // Set so that we don't pull serial data until the guest is // ready. Otherwise, Linux will drop the input serial data @@ -593,7 +604,7 @@ impl PetriVmConfigOpenVmm { } }; - let vmgs = if firmware.is_openhcl() { + let vmgs = if firmware.is_openhcl() || !super::microvm::has_vmgs(machine_profile) { None } else { Some(memdiff_vmgs(&vmgs).await?) @@ -607,6 +618,12 @@ impl PetriVmConfigOpenVmm { capabilities, } = chipset; + let microvm = super::microvm::attach_chipset_devices( + machine_profile, + &mut chipset_devices, + microvm_serial, + ); + // Add the TPM if let Some(tpm) = setup.config_tpm().await? { chipset_devices.push(tpm); @@ -651,8 +668,13 @@ impl PetriVmConfigOpenVmm { let config = Config { // Firmware + machine_profile, + microvm: super::microvm::microvm_config(machine_profile), load_mode, - firmware_event_send: Some(firmware_event_send), + firmware_event_send: super::microvm::firmware_event_send( + machine_profile, + firmware_event_send, + ), // CPU and RAM numa, @@ -668,7 +690,7 @@ impl PetriVmConfigOpenVmm { // Basic virtualization device support hypervisor: HypervisorConfig { - with_hv: !properties.no_hv, + with_hv: super::microvm::hypervisor_enabled(machine_profile, !properties.no_hv), with_vtl2, with_isolation: match firmware.isolation() { Some(IsolationType::Vbs) => Some(openvmm_defs::config::IsolationType::Vbs), @@ -677,7 +699,7 @@ impl PetriVmConfigOpenVmm { }, nested_virt: false, }, - vmbus: if properties.no_vmbus { + vmbus: if properties.no_vmbus || super::microvm::vmbus_disabled(machine_profile) { None } else { Some(VmbusConfig { @@ -723,6 +745,8 @@ impl PetriVmConfigOpenVmm { rtc_delta_milliseconds: 0, }; + super::microvm::validate_config(&config)?; + // Make the pipette connection listener. let path = format!("{vsock_path_string}_{PIPETTE_PORT}"); let pipette_listener = PolledSocket::new( @@ -759,6 +783,7 @@ impl PetriVmConfigOpenVmm { pipette_listener, vtl2_pipette_listener, linux_direct_serial_agent, + microvm, tcp_pipette_port: None, driver: driver.clone(), output_dir: log_source.output_dir().to_owned(), @@ -779,6 +804,7 @@ impl PetriVmConfigOpenVmm { framebuffer_view, pending_iommu: Vec::new(), + pcie_ports_without_save_restore: Vec::new(), }) } } @@ -799,12 +825,14 @@ struct PetriVmConfigSetupCore<'a> { use_virtio_vsock: bool, no_vmbus: bool, no_hv: bool, + machine_profile: openvmm_defs::microvm::MachineProfile, } -struct SerialData { - emulated_serial_config: [Option>; 4], - serial_tasks: Vec>>, - linux_direct_serial_agent: Option, +pub(super) struct SerialData { + pub(super) emulated_serial_config: [Option>; 4], + pub(super) serial_tasks: Vec>>, + pub(super) linux_direct_serial_agent: Option, + pub(super) microvm: Option, } enum VideoDevice { @@ -825,6 +853,15 @@ impl PetriVmConfigSetupCore<'_> { let (serial0_host, serial0) = self .create_serial_stream() .context("failed to create serial0 stream")?; + if super::microvm::is_enabled(self.machine_profile) { + return super::microvm::configure_serial( + self.driver, + serial0_log_file, + serial0_host, + serial0, + ); + } + let (serial0_read, serial0_write) = serial0_host.split(); let serial0_task = self.driver.spawn( "serial0-console", @@ -857,12 +894,14 @@ impl PetriVmConfigSetupCore<'_> { emulated_serial_config: [serial0, serial1, serial2, None], serial_tasks, linux_direct_serial_agent: Some(linux_direct_serial_agent), + microvm: None, }) } else { Ok(SerialData { emulated_serial_config: [serial0, None, serial2, None], serial_tasks, linux_direct_serial_agent: None, + microvm: None, }) } } @@ -903,9 +942,14 @@ impl PetriVmConfigSetupCore<'_> { let kernel = File::open(kernel.clone()) .context("Failed to open kernel")? .into(); - let initrd = File::open(initrd.clone()) - .context("Failed to open initrd")? - .into(); + let initrd = initrd + .as_ref() + .map(|initrd| { + File::open(initrd.clone()) + .context("Failed to open initrd") + .map(Into::into) + }) + .transpose()?; let init = self .pipette_rdinit_param @@ -917,12 +961,17 @@ impl PetriVmConfigSetupCore<'_> { } else { String::new() }; - let cmdline = format!("{serial_args}panic=-1 rdinit={init} {vsock_blacklist}"); + let cmdline = super::microvm::linux_command_line( + self.machine_profile, + cmdline, + init, + vsock_blacklist, + ); LoadMode::Linux { kernel, - initrd: Some(initrd), + initrd, cmdline, enable_serial: self.enable_serial, isolation: openvmm_defs::config::LinuxIsolationConfig::None, diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs new file mode 100644 index 00000000000..0fd096cf81f --- /dev/null +++ b/petri/src/vm/openvmm/microvm.rs @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! OpenVMM-specific microVM construction and runtime support. + +use super::construct::SerialData; +use super::runtime::PetriVmInner; +use crate::Firmware; +use crate::PetriLogFile; +use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmPortbTimeAbi; +use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use chipset_resources::pit::PitDeviceHandle; +use futures::AsyncWriteExt; +use openvmm_defs::config::Config; +use openvmm_defs::config::LinuxDirectBootMode; +use openvmm_defs::config::LinuxIsolationConfig; +use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmConfig; +use openvmm_defs::time_abi::TimeAbiParameters; +use pal_async::DefaultDriver; +use pal_async::socket::PolledSocket; +use pal_async::socket::WriteHalf; +use pal_async::task::Spawn; +use petri_artifacts_common::tags::MachineArch; +use serial_core::resources::DisconnectedSerialBackendHandle; +use unix_socket::UnixStream; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vmotherboard::ChipsetDeviceHandle; + +pub(super) struct RuntimeResources { + input: WriteHalf, + output: mesh::Receiver>, +} + +pub(super) struct PortbSerial { + io: Resource, + runtime: RuntimeResources, +} + +pub(super) fn is_enabled(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Microvm +} + +/// Returns the microVM configuration of a VM: every microVM uses the NVX time +/// ABI, with the host's CPU profile. +pub(super) fn microvm_config(machine_profile: MachineProfile) -> MicrovmConfig { + MicrovmConfig { + time_abi: is_enabled(machine_profile).then(|| TimeAbiParameters { + cpu_profile: "auto".to_owned(), + generation: 0, + hooks: Default::default(), + }), + ..Default::default() + } +} + +pub(super) fn validate_profile( + machine_profile: MachineProfile, + arch: MachineArch, + firmware: &Firmware, +) -> anyhow::Result<()> { + if is_enabled(machine_profile) { + anyhow::ensure!( + matches!(arch, MachineArch::X86_64), + "microVM requires an x86-64 guest" + ); + anyhow::ensure!( + matches!(firmware, Firmware::LinuxDirect { .. }), + "microVM requires an uncompressed Linux direct artifact" + ); + } + Ok(()) +} + +pub(super) fn linux_command_line( + machine_profile: MachineProfile, + standard: String, + init: &str, + vsock_blacklist: &str, +) -> String { + if is_enabled(machine_profile) { + format!("rdinit={init} {vsock_blacklist}") + } else { + standard + } +} + +pub(super) fn configure_load_mode( + machine_profile: MachineProfile, + load_mode: &mut LoadMode, + processor_count: u32, +) -> anyhow::Result<()> { + if !is_enabled(machine_profile) { + return Ok(()); + } + + let LoadMode::Linux { + cmdline, + enable_serial, + isolation, + boot_mode, + smbios, + .. + } = load_mode + else { + unreachable!("microVM firmware was validated as LinuxDirect"); + }; + let mut configured = + openvmm_defs::microvm::build_microvm_command_line(&[std::mem::take(cmdline)], false)?; + openvmm_defs::microvm::append_microvm_processor_limit(&mut configured, processor_count)?; + *cmdline = configured; + *enable_serial = false; + *isolation = LinuxIsolationConfig::None; + *boot_mode = LinuxDirectBootMode::MpTable; + **smbios = Default::default(); + Ok(()) +} + +pub(super) fn configure_serial( + driver: &DefaultDriver, + log_file: PetriLogFile, + host: PolledSocket, + io: Option>, +) -> anyhow::Result { + let (read, input) = host.split(); + let (output_send, output) = mesh::channel(); + let task = driver.spawn( + "microvm-portb-console", + crate::log_task_with_output(log_file, read, "microvm-portb-console", output_send), + ); + Ok(SerialData { + emulated_serial_config: [None, None, None, None], + serial_tasks: vec![task], + linux_direct_serial_agent: None, + microvm: Some(PortbSerial { + io: io.unwrap_or_else(|| DisconnectedSerialBackendHandle.into_resource()), + runtime: RuntimeResources { input, output }, + }), + }) +} + +pub(super) fn attach_chipset_devices( + machine_profile: MachineProfile, + chipset_devices: &mut Vec, + serial: Option, +) -> Option { + if !is_enabled(machine_profile) { + assert!(serial.is_none()); + return None; + } + + let (io, runtime) = match serial { + Some(PortbSerial { io, runtime }) => (io, Some(runtime)), + None => (DisconnectedSerialBackendHandle.into_resource(), None), + }; + // The time ABI puts the PIT in strict mode. + for device in chipset_devices.iter_mut() { + if device.name == PitDeviceHandle::ID { + device.resource = PitDeviceHandle { time_abi: true }.into_resource(); + } + } + chipset_devices.extend([ + ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { + io, + generation_id: [0x5a; 16], + output_drain: None, + time_abi: MicrovmPortbTimeAbi { + generation: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: false, + restore: None, + }, + } + .into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: MicrovmSnapshotRequestHandle { + notify: None, + input_gate_timeout: std::time::Duration::from_secs(5), + } + .into_resource(), + }, + ]); + runtime +} + +pub(super) fn configures_standard_serial(machine_profile: MachineProfile) -> bool { + !is_enabled(machine_profile) +} + +pub(super) fn has_vmgs(machine_profile: MachineProfile) -> bool { + !is_enabled(machine_profile) +} + +pub(super) fn firmware_event_send(machine_profile: MachineProfile, sender: T) -> Option { + (!is_enabled(machine_profile)).then_some(sender) +} + +pub(super) fn hypervisor_enabled(machine_profile: MachineProfile, default: bool) -> bool { + default && !is_enabled(machine_profile) +} + +pub(super) fn vmbus_disabled(machine_profile: MachineProfile) -> bool { + is_enabled(machine_profile) +} + +pub(super) fn validate_config(config: &Config) -> anyhow::Result<()> { + openvmm_defs::microvm::validate_machine_config(config, None) +} + +pub(crate) fn choose_hypervisor( + machine_profile: MachineProfile, + standard: impl FnOnce() -> anyhow::Result, + microvm: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + if is_enabled(machine_profile) { + microvm() + } else { + standard() + } +} + +impl PetriVmInner { + pub(super) async fn wait_for_microvm_portb_output( + &mut self, + marker: &str, + ) -> anyhow::Result<()> { + self.wait_for_microvm_portb_bytes(marker.as_bytes()).await + } + + pub(super) async fn wait_for_microvm_portb_bytes( + &mut self, + marker: &[u8], + ) -> anyhow::Result<()> { + anyhow::ensure!(!marker.is_empty(), "microVM portb marker cannot be empty"); + let output = &mut self + .resources + .microvm + .as_mut() + .context("microVM portb output is not configured")? + .output; + let mut buffered = Vec::new(); + loop { + let chunk = output.recv().await.map_err(|error| { + anyhow::anyhow!( + "microVM portb output disconnected ({error}) after bytes {:?}", + String::from_utf8_lossy(&buffered[..buffered.len().min(256)]) + ) + })?; + buffered.extend_from_slice(&chunk); + if buffered + .windows(marker.len()) + .any(|window| window == marker) + { + return Ok(()); + } + } + } + + pub(super) async fn write_microvm_portb_input(&mut self, input: &[u8]) -> anyhow::Result<()> { + self.resources + .microvm + .as_mut() + .context("microVM portb input is not configured")? + .input + .write_all(input) + .await + .context("writing microVM portb input") + } + + pub(super) async fn pulse_save_restore(&self) -> anyhow::Result<()> { + self.worker + .pulse_save_restore() + .await + .map_err(anyhow::Error::from) + } +} diff --git a/petri/src/vm/openvmm/mod.rs b/petri/src/vm/openvmm/mod.rs index 06901959402..5efe4e4bf4c 100644 --- a/petri/src/vm/openvmm/mod.rs +++ b/petri/src/vm/openvmm/mod.rs @@ -11,6 +11,7 @@ mod construct; #[cfg(target_os = "linux")] mod hugetlb; +pub(crate) mod microvm; mod modify; mod runtime; mod start; @@ -190,6 +191,11 @@ pub struct PetriVmConfigOpenVmm { // Deferred IOMMU configuration: (rc_name, iommu_config) pairs resolved // against pcie_root_complexes at VM start time. pending_iommu: Vec<(String, openvmm_defs::config::PcieIommuConfig)>, + + // PCIe ports of devices that were configured without save/restore + // support. The startup save/restore test is skipped while any of these + // ports has a device. + pcie_ports_without_save_restore: Vec, } /// Various channels and resources used to interact with the VM while it is running. struct PetriVmResourcesOpenVmm { @@ -202,6 +208,7 @@ struct PetriVmResourcesOpenVmm { pipette_listener: PolledSocket, vtl2_pipette_listener: Option>, linux_direct_serial_agent: Option, + microvm: Option, /// When set, the host connects to pipette via TCP through consomme /// port forwarding instead of accepting on the Unix socket listener. diff --git a/petri/src/vm/openvmm/modify.rs b/petri/src/vm/openvmm/modify.rs index 32a4bc611c1..af2538ac138 100644 --- a/petri/src/vm/openvmm/modify.rs +++ b/petri/src/vm/openvmm/modify.rs @@ -36,6 +36,15 @@ use vm_resource::IntoResource; use vmotherboard::ChipsetDeviceHandle; impl PetriVmConfigOpenVmm { + /// Modify the effective Linux direct kernel command line. + pub fn with_linux_command_line(mut self, f: impl FnOnce(&mut String)) -> Self { + let LoadMode::Linux { cmdline, .. } = &mut self.config.load_mode else { + panic!("Linux command-line configuration requires Linux direct boot.") + }; + f(cmdline); + self + } + /// Enable the VTL0 alias map. // TODO: Remove once #912 is fixed. pub fn with_vtl0_alias_map(mut self) -> Self { @@ -116,8 +125,12 @@ impl PetriVmConfigOpenVmm { pub fn with_nic(mut self) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); if let Some(vtl2_settings) = self.runtime_config.vtl2_settings.as_mut() { @@ -162,8 +175,12 @@ impl PetriVmConfigOpenVmm { pub fn with_pcie_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); self.config.pcie_devices.push(PcieDeviceConfig { @@ -209,26 +226,33 @@ impl PetriVmConfigOpenVmm { /// /// This exposes a virtio-net device on a PCIe root port, suitable for /// guests running virtio drivers (e.g. Linux with UEFI boot). + /// + /// The NIC does not support save/restore, so the VM skips its startup + /// save/restore test. pub fn with_virtio_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); - self.config.pcie_devices.push(PcieDeviceConfig { - port_name: port_name.to_string(), - resource: virtio_resources::VirtioPciDeviceHandle( - virtio_resources::net::VirtioNetHandle { - max_queues: None, - mac_address, - endpoint, - } - .into_resource(), - ) - .into_resource(), - }); + self.push_virtio_nic( + port_name, + virtio_resources::net::VirtioNetHandle { + max_queues: None, + mac_address, + endpoint, + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, + }, + ); self } @@ -240,10 +264,14 @@ impl PetriVmConfigOpenVmm { /// This configures consomme to forward the pipette TCP port from the /// host into the guest, so the petri framework can connect to the /// pipette agent over TCP. + /// + /// The NIC does not support save/restore, so the VM skips its startup + /// save/restore test. pub fn with_tcp_pipette_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let (port_send, port_recv) = mesh::oneshot(); let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: vec![net_backend_resources::consomme::HostPortConfig { protocol: net_backend_resources::consomme::HostPortProtocol::Tcp, host_address: Some(net_backend_resources::consomme::HostIpAddress::Ipv4( @@ -253,22 +281,42 @@ impl PetriVmConfigOpenVmm { guest_port: pipette_client::PIPETTE_PORT as u16, }], recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); + self.push_virtio_nic( + port_name, + virtio_resources::net::VirtioNetHandle { + max_queues: None, + mac_address, + endpoint, + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, + }, + ); + self.resources.tcp_pipette_port = Some(port_recv); + self + } + + /// Add a virtio-net device on a PCIe root port. + /// + /// A virtio-net device supports save/restore only when its handle opts in + /// with a static IPv4 identity and a fixed feature contract. Saving a VM + /// with any other virtio-net device fails, so record the port to make the + /// VM skip its startup save/restore test while that device is configured. + fn push_virtio_nic(&mut self, port_name: &str, nic: virtio_resources::net::VirtioNetHandle) { + if !nic.save_restore { + self.pcie_ports_without_save_restore + .push(port_name.to_string()); + } self.config.pcie_devices.push(PcieDeviceConfig { port_name: port_name.to_string(), - resource: virtio_resources::VirtioPciDeviceHandle( - virtio_resources::net::VirtioNetHandle { - max_queues: None, - mac_address, - endpoint, - } - .into_resource(), - ) - .into_resource(), + resource: virtio_resources::VirtioPciDeviceHandle(nic.into_resource()).into_resource(), }); - self.resources.tcp_pipette_port = Some(port_recv); - self } /// Request nested virtualization support from the host hypervisor. diff --git a/petri/src/vm/openvmm/runtime.rs b/petri/src/vm/openvmm/runtime.rs index 082b75efd0c..29782c39ab0 100644 --- a/petri/src/vm/openvmm/runtime.rs +++ b/petri/src/vm/openvmm/runtime.rs @@ -90,7 +90,9 @@ impl PetriVmRuntime for PetriVmOpenVmm { tracing::info!(?halt_reason, "Got halt reason"); let reason = match halt_reason { - HaltReason::PowerOff => PetriHaltReason::PowerOff, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } => { + PetriHaltReason::PowerOff + } HaltReason::Reset => PetriHaltReason::Reset, HaltReason::Hibernate => PetriHaltReason::Hibernate, HaltReason::TripleFault { .. } => PetriHaltReason::TripleFault, @@ -368,6 +370,22 @@ impl PetriVmOpenVmm { pub async fn verify_save_restore(&mut self) -> anyhow::Result<()> ); petri_vm_fn!(pub(crate) async fn launch_linux_direct_pipette(&mut self) -> anyhow::Result<()>); + petri_vm_fn!( + /// Wait for a microVM portb console marker. + pub async fn wait_for_microvm_portb_output(&mut self, marker: &str) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Wait for an exact byte sequence from the microVM portb console. + pub async fn wait_for_microvm_portb_bytes(&mut self, marker: &[u8]) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Write raw bytes to the microVM portb console input stream. + pub async fn write_microvm_portb_input(&mut self, input: &[u8]) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Perform one pulse save/restore operation. + pub async fn pulse_save_restore(&mut self) -> anyhow::Result<()> + ); /// Wrap the provided future in a race with the worker process's halt /// notification channel. This is useful for preventing a future from @@ -811,6 +829,10 @@ impl PetriVmInner { tracing::warn!("Reset not supported, could not test save + restore."); break; } + Err(RpcError::Call(PulseSaveRestoreError::UnsupportedMachineProfile)) => { + tracing::warn!("Save + restore is unavailable for this machine profile."); + break; + } Err(RpcError::Call(PulseSaveRestoreError::Other(err))) => { return Err(anyhow::Error::from(err)) .context(format!("Save + restore {i} failed.")); diff --git a/petri/src/vm/openvmm/start.rs b/petri/src/vm/openvmm/start.rs index b6b4044e20a..4f02ed9629f 100644 --- a/petri/src/vm/openvmm/start.rs +++ b/petri/src/vm/openvmm/start.rs @@ -46,6 +46,7 @@ impl PetriVmConfigOpenVmm { framebuffer_view, pending_iommu, + pcie_ports_without_save_restore, } = self; // Resolve deferred IOMMU assignments. @@ -64,10 +65,13 @@ impl PetriVmConfigOpenVmm { // TODO: VPCI and some PCIe endpoints (NVMe/GDMA) don't support // TODO: virtio vsock doesn't support save/restore yet // save/restore yet. - let has_unsupported_pcie_save_restore_device = config - .pcie_devices - .iter() - .any(|device| matches!(device.resource.id(), "nvme" | "gdma")); + // Other PCIe devices that do not support save/restore, such as + // virtio-net NICs without a save/restore contract, are recorded by + // port when they are added. + let has_unsupported_pcie_save_restore_device = config.pcie_devices.iter().any(|device| { + matches!(device.resource.id(), "nvme" | "gdma") + || pcie_ports_without_save_restore.contains(&device.port_name) + }); let supports_save_restore = !resources.properties.is_openhcl && !resources.properties.is_pcat && !matches!(arch, MachineArch::Aarch64) diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 5071503305d..297722662a7 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -29,11 +29,24 @@ impl Worker { let (vm_rpc, rpc_recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); + let hypervisor = crate::openvmm::microvm::choose_hypervisor( + cfg.machine_profile, + openvmm_helpers::hypervisor::choose_hypervisor, + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor, + )?; let params = VmWorkerParameters { - hypervisor: openvmm_helpers::hypervisor::choose_hypervisor()?, + hypervisor, cfg, saved_state: None, shared_memory, + shared_memory_copy_on_write: false, + snapshot_restore_guards: None, + snapshot_boundary_requests: None, + snapshot_ready: None, + restore_time: None, + restore_ready_sink: None, + restore_gate_timeout: None, + restore_vp_count: None, rpc: rpc_recv, notify: notify_send, }; @@ -52,8 +65,8 @@ impl Worker { self.rpc.call(VmRpc::Pause, ()).await } - pub(crate) async fn resume(&self) -> Result { - self.rpc.call(VmRpc::Resume, ()).await + pub(crate) async fn resume(&self) -> anyhow::Result { + Ok(self.rpc.call_failable(VmRpc::Resume, ()).await?) } pub(crate) async fn save(&self) -> anyhow::Result { diff --git a/support/mesh/mesh_protobuf/src/message.rs b/support/mesh/mesh_protobuf/src/message.rs index 096513dd4f5..88ea275f24f 100644 --- a/support/mesh/mesh_protobuf/src/message.rs +++ b/support/mesh/mesh_protobuf/src/message.rs @@ -29,7 +29,7 @@ use thiserror::Error; // // TODO: delay encoding like in mesh::Message. This requires splitting some of // the encoding traits up to remove the resource type. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ProtobufMessage(Vec); impl ProtobufMessage { @@ -81,7 +81,7 @@ impl MessageDecode<'_, ProtobufMessage, R> for ProtobufMessageEncoding { /// A protobuf message and the associated protobuf type URL. /// /// This has the encoding of `google.protobuf.Any`. -#[derive(Protobuf)] +#[derive(Clone, Protobuf)] pub struct ProtobufAny { #[mesh(1)] type_url: String, // FUTURE: avoid allocation here @@ -143,6 +143,11 @@ impl ProtobufAny { self.value.parse() } + /// Returns the encoded message payload length without decoding it. + pub fn value_len(&self) -> usize { + self.value.0.len() + } + /// Returns `true` if this message is an encoding of `T`. pub fn is_message(&self) -> bool { &T::TYPE_URL == self.type_url.as_str() @@ -204,6 +209,7 @@ mod tests { assert!(any.is_message::()); assert!(!any.is_message::()); assert_eq!(any.parse::().unwrap(), msg); + assert_eq!(any.clone().parse::().unwrap(), msg); println!("{:?}", any.parse::().unwrap_err()); } } diff --git a/support/pal/pal_async/src/windows/pipe.rs b/support/pal/pal_async/src/windows/pipe.rs index ff4f2ee7234..8ba2b96763f 100644 --- a/support/pal/pal_async/src/windows/pipe.rs +++ b/support/pal/pal_async/src/windows/pipe.rs @@ -139,10 +139,10 @@ impl PolledPipe { /// Polls the pipe for entering the closing state, where the client has /// closed its handle. pub fn poll_closing(&mut self, cx: &mut Context<'_>) -> Poll> { - if self.events & FILE_PIPE_DISCONNECTED != 0 { - // Make sure the pipe is still disconnected. - self.refresh_events()?; + if self.file.is_pipe_peer_closed()? { + return Poll::Ready(Ok(())); } + self.refresh_events()?; while self.events & FILE_PIPE_DISCONNECTED == 0 { ready!( self.wakers @@ -410,6 +410,9 @@ impl Future for ListeningPipe { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); let inner = this.inner.as_mut().expect("polled after completion"); + if inner.sync_success { + return Poll::Ready(Ok(this.inner.take().unwrap().file)); + } ready!(inner.event.poll_wait(cx))?; let (status, _) = inner.overlapped.io_status().expect("io should be complete"); chk_status(status)?; @@ -419,6 +422,8 @@ impl Future for ListeningPipe { #[cfg(test)] mod tests { + mod reconnect; + use super::PolledPipe; use crate::DefaultDriver; use crate::sys::pipe::NamedPipeServer; diff --git a/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs b/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs new file mode 100644 index 00000000000..bac629e7876 --- /dev/null +++ b/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for reusing server pipes: detecting a closed client without a +//! disconnect event, and completing a listen for an already-connected +//! client. + +use crate::DefaultDriver; +use crate::interest::InterestSlot; +use crate::sys::pipe::ListeningPipe; +use crate::sys::pipe::PolledPipe; +use futures::FutureExt; +use futures::future::poll_fn; +use pal::windows::chk_status; +use pal::windows::pipe::Disposition; +use pal::windows::pipe::FILE_PIPE_DISCONNECTED; +use pal::windows::pipe::FILE_PIPE_READ_READY; +use pal::windows::pipe::FILE_PIPE_WRITE_READY; +use pal::windows::pipe::PipeExt; +use pal::windows::pipe::PipeMode; +use pal::windows::pipe::new_named_pipe; +use pal_async_test::async_test; +use std::fs::OpenOptions; +use std::io; +use std::task::Context; +use std::task::Poll; +use std::task::ready; +use std::time::Duration; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; + +impl PolledPipe { + fn test_set_cached_events(&mut self, events: u32) { + self.events = events; + } + + fn test_set_select_events(&self, event_types: u32) -> io::Result<()> { + self.file.set_pipe_select_event(&self._event, event_types) + } + + fn test_is_pipe_peer_closed(&self) -> io::Result { + self.file.is_pipe_peer_closed() + } + + fn test_poll_closing_event_only(&mut self, cx: &mut Context<'_>) -> Poll> { + self.refresh_events()?; + while self.events & FILE_PIPE_DISCONNECTED == 0 { + ready!( + self.wakers + .poll_wrapped(cx, InterestSlot::Read as usize, |cx| self + .wait + .poll_wait(cx)) + )?; + + self.refresh_events()?; + } + Poll::Ready(Ok(())) + } +} + +impl ListeningPipe { + fn test_poll_without_sync_fastpath(&mut self, cx: &mut Context<'_>) -> Poll> { + let inner = self.inner.as_mut().expect("polled after completion"); + ready!(inner.event.poll_wait(cx))?; + let (status, _) = inner.overlapped.io_status().expect("io should be complete"); + chk_status(status)?; + Poll::Ready(Ok(())) + } +} + +#[async_test] +async fn reuse_server_pipe_after_client_close(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let mut listener = ListeningPipe::new(&driver, server).unwrap(); + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut connected = PolledPipe::new(&driver, listener.await.unwrap()).unwrap(); + + drop(client); + poll_fn(|cx| connected.poll_closing(cx)).await.unwrap(); + let server = connected.into_inner(); + server.disconnect_pipe().unwrap(); + + listener = ListeningPipe::new(&driver, server).unwrap(); + let _client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + listener.await.unwrap(); +} + +#[async_test] +async fn listening_pipe_ready_when_client_already_connected(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let _client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + + let mut listener = ListeningPipe::new(&driver, server).unwrap(); + let old_poll = poll_fn(|cx| listener.test_poll_without_sync_fastpath(cx)); + assert!( + old_poll.now_or_never().is_none(), + "event-only listen polling should stay pending in this setup" + ); + let server = listener + .now_or_never() + .expect("listen future should complete immediately") + .unwrap(); + assert!(server.is_pipe_connected().unwrap()); +} + +#[async_test] +async fn poll_closing_checks_pipe_state_without_disconnect_event(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let listener = ListeningPipe::new(&driver, server).unwrap(); + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut connected = PolledPipe::new(&driver, listener.await.unwrap()).unwrap(); + + connected + .test_set_select_events(FILE_PIPE_READ_READY | FILE_PIPE_WRITE_READY) + .unwrap(); + drop(client); + connected.test_set_cached_events(FILE_PIPE_READ_READY | FILE_PIPE_WRITE_READY); + for _ in 0..100 { + if connected.test_is_pipe_peer_closed().unwrap() { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + assert!(connected.test_is_pipe_peer_closed().unwrap()); + let old_poll = poll_fn(|cx| connected.test_poll_closing_event_only(cx)); + assert!( + old_poll.now_or_never().is_none(), + "event-only closing polling should stay pending when disconnected events are not selected" + ); + + let poll = poll_fn(|cx| connected.poll_closing(cx)); + let result = poll.now_or_never(); + assert!( + matches!(result, Some(Ok(()))), + "expected poll_closing to finish immediately when peer is closed" + ); +} diff --git a/support/pal/src/unix.rs b/support/pal/src/unix.rs index 4de090fb2a5..0667308f600 100644 --- a/support/pal/src/unix.rs +++ b/support/pal/src/unix.rs @@ -6,10 +6,15 @@ #![expect(unsafe_code)] pub mod affinity; +pub mod credentials; +#[cfg(target_os = "linux")] +pub mod fd_table; +pub mod fs; pub mod pipe; pub mod process; pub mod pthread; +pub use credentials::effective_user_id; use std::fs::File; use std::io; use std::io::Error; diff --git a/support/pal/src/unix/credentials.rs b/support/pal/src/unix/credentials.rs new file mode 100644 index 00000000000..e88a8d77573 --- /dev/null +++ b/support/pal/src/unix/credentials.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Credentials of the current process and of Unix-socket peers. + +#[cfg(target_os = "linux")] +use std::io; +#[cfg(target_os = "linux")] +use std::io::Error; +#[cfg(target_os = "linux")] +use std::os::unix::prelude::*; + +/// Returns the effective user ID of the current process. +pub fn effective_user_id() -> u32 { + // SAFETY: geteuid has no preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(target_os = "linux")] +pub fn unix_socket_peer_user_id(socket: BorrowedFd<'_>) -> io::Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = size_of::() as libc::socklen_t; + // SAFETY: `socket` is valid for the call and the output pointers refer to + // writable objects with the supplied sizes. + let result = unsafe { + libc::getsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + std::ptr::from_mut(&mut credentials).cast(), + &mut length, + ) + }; + if result != 0 { + return Err(Error::last_os_error()); + } + if length as usize != size_of::() { + return Err(Error::new( + io::ErrorKind::InvalidData, + "SO_PEERCRED returned an invalid credential length", + )); + } + Ok(credentials.uid) +} diff --git a/support/pal/src/unix/fd_table.rs b/support/pal/src/unix/fd_table.rs new file mode 100644 index 00000000000..91600a47ad5 --- /dev/null +++ b/support/pal/src/unix/fd_table.rs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Early expansion of the Linux file descriptor table. + +use crate::unix::SyscallResult; +use std::fs::File; +use std::io; +use std::io::Error; +use std::os::unix::prelude::*; + +const FIRST_DYNAMIC_FD: RawFd = usize::BITS as RawFd; + +/// Expands the Linux file descriptor table before worker threads are started. +/// +/// Growing a full descriptor table after other threads exist may wait for an +/// RCU grace period. Touching a high descriptor while startup is still +/// single-threaded avoids that restore-time latency. +pub fn expand_fd_table() -> io::Result<()> { + let mut limits = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + + // SAFETY: `limits` is valid writable memory for `getrlimit`. + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) }.syscall_result()?; + + if limits.rlim_cur <= FIRST_DYNAMIC_FD as libc::rlim_t { + return Ok(()); + } + + // Linux keeps BITS_PER_LONG descriptors inline. Allocate only the first + // dynamic table: later growth caused the observed stall, while expanding + // all the way toward RLIMIT_NOFILE adds unnecessary process-exit work. + expand_fd_table_to(FIRST_DYNAMIC_FD) +} + +fn expand_fd_table_to(target_fd: RawFd) -> io::Result<()> { + // An existing descriptor proves that the table already reaches the target. + // SAFETY: `F_GETFD` only inspects the descriptor number. + if unsafe { libc::fcntl(target_fd, libc::F_GETFD) } >= 0 { + return Ok(()); + } + let error = Error::last_os_error(); + if error.raw_os_error() != Some(libc::EBADF) { + return Err(error); + } + + let source = File::open("/dev/null")?; + if source.as_raw_fd() >= target_fd { + return Ok(()); + } + + // Unlike dup2, F_DUPFD_CLOEXEC never replaces an inherited descriptor if + // the target becomes occupied. + // SAFETY: `source` is valid and `target_fd` is a nonnegative lower bound. + let duplicate = unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, target_fd) } + .syscall_result()?; + // SAFETY: `duplicate` was returned as a new owned descriptor above. + unsafe { libc::close(duplicate) }.syscall_result()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::FIRST_DYNAMIC_FD; + use super::SyscallResult; + use super::expand_fd_table_to; + use std::io::Read; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::unix::net::UnixStream; + + #[test] + fn expand_fd_table_preserves_occupied_target() { + let (source, mut peer) = UnixStream::pair().unwrap(); + // SAFETY: `source` is valid and the returned descriptor is checked. + let inherited_fd = + unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, FIRST_DYNAMIC_FD) } + .syscall_result() + .unwrap(); + // SAFETY: `inherited_fd` is a new owned descriptor. + let mut inherited = unsafe { UnixStream::from_raw_fd(inherited_fd) }; + + expand_fd_table_to(inherited.as_raw_fd()).unwrap(); + + peer.write_all(b"x").unwrap(); + let mut byte = [0]; + inherited.read_exact(&mut byte).unwrap(); + assert_eq!(byte, [b'x']); + } +} diff --git a/support/pal/src/unix/fs.rs b/support/pal/src/unix/fs.rs new file mode 100644 index 00000000000..ec25fbf48c4 --- /dev/null +++ b/support/pal/src/unix/fs.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs::File; +use std::io; + +/// Clones all file extents into an independently writable destination. +pub fn reflink(source: &File, destination: &File) -> io::Result<()> { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + + // SAFETY: Both arguments are live regular-file descriptors and FICLONE + // does not retain either descriptor after the call. + let result = + unsafe { libc::ioctl(destination.as_raw_fd(), libc::FICLONE, source.as_raw_fd()) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (source, destination); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file extent cloning is unsupported on this platform", + )) + } +} + +/// Finds the next data extent at or after `offset`. +pub fn seek_data(file: &File, offset: u64) -> io::Result> { + seek_sparse(file, offset, libc::SEEK_DATA) +} + +/// Finds the next hole at or after `offset`. +pub fn seek_hole(file: &File, offset: u64) -> io::Result> { + seek_sparse(file, offset, libc::SEEK_HOLE) +} + +fn seek_sparse(file: &File, offset: u64, whence: i32) -> io::Result> { + use std::os::fd::AsRawFd; + + let offset = i64::try_from(offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file offset exceeds i64"))?; + // SAFETY: The descriptor is live and `whence` is selected internally from + // the platform's SEEK_DATA and SEEK_HOLE constants. + let result = unsafe { libc::lseek(file.as_raw_fd(), offset, whence) }; + if result >= 0 { + Ok(Some(result as u64)) + } else { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENXIO) { + Ok(None) + } else { + Err(error) + } + } +} diff --git a/support/pal/src/windows/fs.rs b/support/pal/src/windows/fs.rs index dc5d937f326..6ed3d32cfc1 100644 --- a/support/pal/src/windows/fs.rs +++ b/support/pal/src/windows/fs.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod relative; +pub mod sparse; + use super::chk_status; use super::dos_to_nt_path; use std::ffi::c_void; diff --git a/support/pal/src/windows/fs/relative.rs b/support/pal/src/windows/fs/relative.rs new file mode 100644 index 00000000000..314f6a70cee --- /dev/null +++ b/support/pal/src/windows/fs/relative.rs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Directory-relative operations on opened handles: opening, creating, +//! hard-linking, and enumerating files relative to an opened directory, and +//! querying the identity of an opened file. + +use crate::windows::ObjectAttributes; +use crate::windows::UnicodeString; +use crate::windows::chk_status; +use std::fs; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::OsStringExt; +use std::os::windows::io::AsHandle; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::ptr; +use std::ptr::null_mut; +use windows_sys::Wdk::Storage::FileSystem as ntioapi; +use windows_sys::Win32::Foundation::OBJ_CASE_INSENSITIVE; +use windows_sys::Win32::Foundation::STATUS_NO_MORE_FILES; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_ID_INFO; +use windows_sys::Win32::Storage::FileSystem::FILE_READ_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_STANDARD_INFO; +use windows_sys::Win32::Storage::FileSystem::FileIdInfo; +use windows_sys::Win32::Storage::FileSystem::FileStandardInfo; +use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx; +use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE; +use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; + +/// Stable identity and EOF for one opened file generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FileIdentity { + pub volume_serial_number: u64, + pub file_id: [u8; 16], + pub end_of_file: u64, +} + +/// Opens an existing regular file relative to an opened directory. +/// +/// The resulting handle allows read sharing only, so writers and +/// delete/rename attempts are rejected while the handle remains open. +pub fn open_relative_read_only( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + open_relative_file( + directory, + name, + FILE_GENERIC_READ, + FILE_SHARE_READ, + ntioapi::FILE_OPEN, + "open", + ) +} + +/// Opens an existing file relative to a directory without denying access to +/// other handles. This is intended for identity checks during publication, +/// not for retaining an immutable restore generation. +pub fn open_relative_for_identity( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + open_relative_file( + directory, + name, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ntioapi::FILE_OPEN, + "open for identity check", + ) +} + +/// Creates a new file relative to an opened directory. +pub fn create_relative_new(directory: &fs::File, name: &std::ffi::OsStr) -> io::Result { + open_relative_file( + directory, + name, + FILE_GENERIC_WRITE, + FILE_SHARE_READ, + ntioapi::FILE_CREATE, + "create", + ) +} + +fn open_relative_file( + directory: &fs::File, + name: &std::ffi::OsStr, + desired_access: u32, + share_access: u32, + create_disposition: u32, + operation: &str, +) -> io::Result { + let name_string = name.to_string_lossy(); + let name = UnicodeString::try_from(name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?; + let mut attributes = ObjectAttributes::new(); + attributes + .name(&name) + .root(directory.as_handle()) + .attributes(OBJ_CASE_INSENSITIVE); + let mut handle = null_mut(); + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + ntioapi::NtCreateFile( + &mut handle, + desired_access | SYNCHRONIZE, + attributes.as_ptr(), + &mut io_status, + ptr::null(), + FILE_ATTRIBUTE_NORMAL, + share_access, + create_disposition, + ntioapi::FILE_NON_DIRECTORY_FILE + | ntioapi::FILE_OPEN_REPARSE_POINT + | ntioapi::FILE_SYNCHRONOUS_IO_NONALERT, + ptr::null(), + 0, + ) + }; + chk_status(status).map_err(|error| { + io::Error::new( + error.kind(), + format!("failed to {operation} {name_string}: {error}"), + ) + })?; + Ok(unsafe { fs::File::from_raw_handle(handle) }) +} + +/// Creates a hard link to `source` relative to an exact opened directory. +pub fn hard_link_relative( + source: &fs::File, + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result<()> { + let name: Vec = name.encode_wide().collect(); + if name.is_empty() + || name.contains(&0) + || name.as_slice() == ['.' as u16] + || name.as_slice() == ['.' as u16, '.' as u16] + || name + .iter() + .any(|character| *character == b'/' as u16 || *character == b'\\' as u16) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "hard-link name must be one non-special path component", + )); + } + + let name_bytes = name + .len() + .checked_mul(size_of::()) + .and_then(|length| u32::try_from(length).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))?; + let header_bytes = std::mem::offset_of!(ntioapi::FILE_LINK_INFORMATION, FileName); + let information_bytes = header_bytes + .checked_add(name_bytes as usize) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))? + .max(size_of::()); + let information_length = u32::try_from(information_bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))?; + let mut information = vec![0_u64; information_bytes.div_ceil(size_of::())]; + let information = information + .as_mut_ptr() + .cast::(); + let mut io_status = IO_STATUS_BLOCK::default(); + + // SAFETY: `information` is aligned and sized for FILE_LINK_INFORMATION + // followed by the exact UTF-16 name. Both handles remain live for the + // duration of the synchronous call, which does not retain the buffer. + unsafe { + (*information).Anonymous.ReplaceIfExists = false; + (*information).RootDirectory = directory.as_raw_handle(); + (*information).FileNameLength = name_bytes; + ptr::copy_nonoverlapping( + name.as_ptr(), + (&raw mut (*information).FileName).cast::(), + name.len(), + ); + chk_status(ntioapi::NtSetInformationFile( + source.as_raw_handle(), + &mut io_status, + information.cast(), + information_length, + ntioapi::FileLinkInformation, + ))?; + } + Ok(()) +} + +/// Enumerates names relative to an opened directory handle. +pub fn directory_entry_names(directory: &fs::File) -> io::Result> { + const BUFFER_SIZE: usize = 4096; + const BUFFER_WORDS: usize = BUFFER_SIZE / size_of::(); + + let mut names = Vec::new(); + let mut restart_scan = true; + loop { + let mut buffer = [0_u64; BUFFER_WORDS]; + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + ntioapi::NtQueryDirectoryFile( + directory.as_raw_handle(), + null_mut(), + None, + ptr::null(), + &mut io_status, + buffer.as_mut_ptr().cast(), + BUFFER_SIZE as u32, + ntioapi::FileNamesInformation, + true, + ptr::null(), + restart_scan, + ) + }; + restart_scan = false; + if status == STATUS_NO_MORE_FILES { + break; + } + chk_status(status)?; + + let returned = io_status.Information; + let header_size = std::mem::offset_of!(ntioapi::FILE_NAMES_INFORMATION, FileName); + if returned < header_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "directory query returned a truncated entry", + )); + } + let entry = unsafe { &*buffer.as_ptr().cast::() }; + let name_length = usize::try_from(entry.FileNameLength).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "directory entry name length exceeds usize", + ) + })?; + if !name_length.is_multiple_of(2) || header_size + name_length > returned { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "directory query returned an invalid entry name", + )); + } + let name = unsafe { + std::slice::from_raw_parts(entry.FileName.as_ptr(), name_length / size_of::()) + }; + let name = std::ffi::OsString::from_wide(name); + if name != "." && name != ".." { + names.push(name); + } + } + Ok(names) +} + +/// Queries `FILE_ID_INFO` and the current EOF for an opened file. +pub fn file_identity(file: &fs::File) -> io::Result { + let mut identity = FILE_ID_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut identity).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + let mut standard = FILE_STANDARD_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStandardInfo, + (&raw mut standard).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if standard.Directory || standard.DeletePending { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "handle is not an available regular file", + )); + } + let end_of_file = u64::try_from(standard.EndOfFile) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "file has a negative EOF"))?; + Ok(FileIdentity { + volume_serial_number: identity.VolumeSerialNumber, + file_id: identity.FileId.Identifier, + end_of_file, + }) +} diff --git a/support/pal/src/windows/fs/sparse.rs b/support/pal/src/windows/fs/sparse.rs new file mode 100644 index 00000000000..d9d76b4c04a --- /dev/null +++ b/support/pal/src/windows/fs/sparse.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Sparse-file helpers: marking a file sparse, querying its allocation, +//! block-cloning its extents, enumerating its allocated ranges, and +//! deallocating zeroed ranges. + +use std::fs; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::ptr; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_STANDARD_INFO; +use windows_sys::Win32::Storage::FileSystem::FileStandardInfo; +use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::DUPLICATE_EXTENTS_DATA; +use windows_sys::Win32::System::Ioctl::FILE_ALLOCATED_RANGE_BUFFER; +use windows_sys::Win32::System::Ioctl::FILE_ZERO_DATA_INFORMATION; +use windows_sys::Win32::System::Ioctl::FSCTL_DUPLICATE_EXTENTS_TO_FILE; +use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_ALLOCATED_RANGES; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_ZERO_DATA; + +/// Marks a file as sparse. +pub fn set_sparse(file: &fs::File) -> io::Result<()> { + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_SPARSE, + ptr::null(), + 0, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +/// Returns the physical allocation charged to a file. +pub fn allocation_size(file: &fs::File) -> io::Result { + let mut info = FILE_STANDARD_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStandardInfo, + (&raw mut info).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + u64::try_from(info.AllocationSize).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "negative file allocation size") + }) + } +} + +/// Block-clones all source extents into an independently writable destination. +pub fn duplicate_extents(source: &fs::File, destination: &fs::File, length: u64) -> io::Result<()> { + let byte_count = i64::try_from(length) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file is too large"))?; + let input = DUPLICATE_EXTENTS_DATA { + FileHandle: source.as_raw_handle(), + SourceFileOffset: 0, + TargetFileOffset: 0, + ByteCount: byte_count, + }; + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + destination.as_raw_handle(), + FSCTL_DUPLICATE_EXTENTS_TO_FILE, + (&raw const input).cast(), + size_of::() as u32, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +/// Returns allocated file ranges clipped to `[0, length)`. +pub fn allocated_ranges(file: &fs::File, length: u64) -> io::Result> { + const RANGE_CAPACITY: usize = 64; + + let mut ranges = Vec::new(); + let mut cursor = 0_u64; + while cursor < length { + let input = FILE_ALLOCATED_RANGE_BUFFER { + FileOffset: i64::try_from(cursor).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "file offset exceeds i64") + })?, + Length: i64::try_from(length - cursor).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "file length exceeds i64") + })?, + }; + let mut output = [FILE_ALLOCATED_RANGE_BUFFER::default(); RANGE_CAPACITY]; + let mut returned = 0_u32; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_QUERY_ALLOCATED_RANGES, + (&raw const input).cast(), + size_of::() as u32, + output.as_mut_ptr().cast(), + size_of_val(&output) as u32, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_MORE_DATA as i32) { + return Err(error); + } + } + if !(returned as usize).is_multiple_of(size_of::()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query returned a partial record", + )); + } + let count = returned as usize / size_of::(); + if count > output.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query overflowed its buffer", + )); + } + for range in &output[..count] { + let offset = u64::try_from(range.FileOffset).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "negative allocated range offset", + ) + })?; + let range_length = u64::try_from(range.Length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "negative allocated range length", + ) + })?; + let end = offset.checked_add(range_length).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "allocated range overflowed u64") + })?; + if offset < cursor || range_length == 0 || end > length { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query returned an invalid range", + )); + } + ranges.push((offset, range_length)); + cursor = end; + } + if result != 0 { + break; + } + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query made no progress", + )); + } + } + Ok(ranges) +} + +/// Deallocates a range in a sparse file and makes reads return zeroes. +pub fn zero_range(file: &fs::File, start: u64, end: u64) -> io::Result<()> { + if start >= end { + return Ok(()); + } + let input = FILE_ZERO_DATA_INFORMATION { + FileOffset: i64::try_from(start).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "zero range offset exceeds i64") + })?, + BeyondFinalZero: i64::try_from(end).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "zero range end exceeds i64") + })?, + }; + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_ZERO_DATA, + (&raw const input).cast(), + size_of::() as u32, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} diff --git a/support/pal/src/windows/pipe.rs b/support/pal/src/windows/pipe.rs index 0296e66f2d9..0e5de5a7cba 100644 --- a/support/pal/src/windows/pipe.rs +++ b/support/pal/src/windows/pipe.rs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod peer; + use super::UnicodeString; use super::chk_status; use super::dos_to_nt_path; +use super::security::SecurityDescriptor; use super::status_to_error; // TODO: Revert this ntapi fallback once windows/windows-sys expose // NtCreateNamedPipeFile directly. @@ -151,6 +154,7 @@ pub fn new_named_pipe( }, true, mode == PipeMode::Message, + None, ) } @@ -161,6 +165,7 @@ fn create_named_pipe( disposition: u32, overlapped: bool, message_mode: bool, + security_descriptor: Option<&SecurityDescriptor>, ) -> Result { unsafe { let mut pathu = if root.is_null() { @@ -174,7 +179,7 @@ fn create_named_pipe( RootDirectory: root.cast::(), ObjectName: pathu.as_mut_ptr(), Attributes: OBJ_CASE_INSENSITIVE, - SecurityDescriptor: null_mut(), + SecurityDescriptor: peer::security_descriptor_ptr(security_descriptor), SecurityQualityOfService: null_mut(), }; @@ -222,6 +227,7 @@ pub fn bidirectional_pair(message_mode: bool) -> io::Result<(File, File)> { FILE_CREATE, false, message_mode, + None, )?; let mut empty_name = zeroed(); @@ -255,6 +261,7 @@ pub trait PipeExt { fn set_pipe_select_event(&self, event: &Event, event_types: u32) -> io::Result<()>; fn get_pipe_select_events(&self) -> io::Result; fn is_pipe_connected(&self) -> io::Result; + fn is_pipe_peer_closed(&self) -> io::Result; fn disconnect_pipe(&self) -> io::Result<()>; } @@ -389,6 +396,10 @@ impl PipeExt for File { Ok(connected) } + fn is_pipe_peer_closed(&self) -> io::Result { + peer::is_peer_closed(self) + } + fn disconnect_pipe(&self) -> io::Result<()> { // SAFETY: calling on a known valid handle. if unsafe { DisconnectNamedPipe(self.as_raw_handle()) } == 0 { diff --git a/support/pal/src/windows/pipe/peer.rs b/support/pal/src/windows/pipe/peer.rs new file mode 100644 index 00000000000..2f6e95334db --- /dev/null +++ b/support/pal/src/windows/pipe/peer.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Named-pipe helpers for local peer endpoints: creating a pipe with an +//! explicit security descriptor, identifying the client process, checking +//! whether a file is a pipe, and detecting a closed peer. + +use super::Disposition; +use super::PipeMode; +use super::create_named_pipe; +use crate::windows::chk_status; +use crate::windows::security::SecurityDescriptor; +use std::ffi::c_void; +use std::fs::File; +use std::io; +use std::mem::zeroed; +use std::os::windows::prelude::*; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Wdk::Storage::FileSystem::FILE_CREATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_OPEN; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_CLOSING_STATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_DISCONNECTED_STATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_LOCAL_INFORMATION; +use windows_sys::Wdk::Storage::FileSystem::FilePipeLocalInformation; +use windows_sys::Wdk::Storage::FileSystem::NtQueryInformationFile; +use windows_sys::Win32::Storage::FileSystem::FILE_TYPE_PIPE; +use windows_sys::Win32::Storage::FileSystem::GetFileType; +use windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId; + +pub fn new_named_pipe_with_security( + path: impl AsRef, + access: u32, + disposition: Disposition, + mode: PipeMode, + security_descriptor: &SecurityDescriptor, +) -> io::Result { + create_named_pipe( + null_mut(), + path.as_ref(), + access, + match disposition { + Disposition::Create => FILE_CREATE, + Disposition::Open => FILE_OPEN, + }, + true, + mode == PipeMode::Message, + Some(security_descriptor), + ) +} + +/// Returns the `OBJECT_ATTRIBUTES` security descriptor pointer that +/// `create_named_pipe` uses for an optional security descriptor. +pub(super) fn security_descriptor_ptr( + security_descriptor: Option<&SecurityDescriptor>, +) -> *const windows_sys::Win32::Security::SECURITY_DESCRIPTOR { + security_descriptor.map_or(null_mut(), |descriptor| { + descriptor + .as_ptr() + .cast::() + .cast_const() + }) +} + +pub fn client_process_id(pipe: &File) -> io::Result { + let mut process_id = 0; + // SAFETY: `pipe` is a live named-pipe handle and the output pointer is + // valid for the documented call. + if unsafe { GetNamedPipeClientProcessId(pipe.as_raw_handle(), &mut process_id) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(process_id) +} + +pub fn is_pipe(file: &File) -> bool { + // SAFETY: `file` owns a valid Windows handle. + unsafe { GetFileType(file.as_raw_handle()) == FILE_TYPE_PIPE } +} + +/// Returns whether the peer of a named pipe is gone, that is, whether the pipe +/// is in the disconnected or closing state. +pub(super) fn is_peer_closed(pipe: &File) -> io::Result { + // SAFETY: calling with an appropriately sized output buffer. + let state = unsafe { + let mut iosb = zeroed(); + let mut info: FILE_PIPE_LOCAL_INFORMATION = zeroed(); + chk_status(NtQueryInformationFile( + pipe.as_raw_handle().cast::(), + &mut iosb, + std::ptr::from_mut(&mut info).cast(), + size_of_val(&info) as u32, + FilePipeLocalInformation, + ))?; + info.NamedPipeState + }; + Ok(matches!( + state, + FILE_PIPE_DISCONNECTED_STATE | FILE_PIPE_CLOSING_STATE + )) +} diff --git a/support/pal/src/windows/security.rs b/support/pal/src/windows/security.rs index 7eb48d41381..8ca106909f7 100644 --- a/support/pal/src/windows/security.rs +++ b/support/pal/src/windows/security.rs @@ -3,6 +3,9 @@ //! Windows security API wrappers. +pub mod create; +pub mod user_sid; + use std::ffi::c_void; use std::fmt::Debug; use std::io::ErrorKind; diff --git a/support/pal/src/windows/security/create.rs b/support/pal/src/windows/security/create.rs new file mode 100644 index 00000000000..934d3435779 --- /dev/null +++ b/support/pal/src/windows/security/create.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Atomic creation of files and directories with an explicit security +//! descriptor. + +use super::SecurityDescriptor; +use std::io::ErrorKind; +use std::os::windows::prelude::*; +use std::path::Path; +use std::ptr::null_mut; +use widestring::U16CString; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; +use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; + +/// Creates a directory with `security_descriptor` applied atomically. +pub fn create_directory_with_security( + path: &Path, + security_descriptor: &SecurityDescriptor, +) -> std::io::Result<()> { + let path = U16CString::from_os_str(path.as_os_str()) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error))?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: security_descriptor.as_ptr(), + bInheritHandle: 0, + }; + // SAFETY: the NUL-terminated path and security descriptor remain valid for + // the duration of the documented Win32 call. + if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// Exclusively creates a regular file with `security_descriptor` applied atomically. +pub fn create_file_with_security( + path: &Path, + security_descriptor: &SecurityDescriptor, +) -> std::io::Result { + let path = U16CString::from_os_str(path.as_os_str()) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error))?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: security_descriptor.as_ptr(), + bInheritHandle: 0, + }; + // SAFETY: arguments satisfy CreateFileW's contracts. Ownership of a valid + // returned handle is transferred exactly once into `File`. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: `handle` is valid and uniquely owned after successful CreateFileW. + Ok(unsafe { std::fs::File::from_raw_handle(handle) }) +} diff --git a/support/pal/src/windows/security/user_sid.rs b/support/pal/src/windows/security/user_sid.rs new file mode 100644 index 00000000000..b21b682c6e4 --- /dev/null +++ b/support/pal/src/windows/security/user_sid.rs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! User SIDs of local processes, and a fixed-size binary encoding of SIDs. + +use super::MAX_SUBAUTHORITY_COUNT; +use super::MaximumSid; +use std::io::ErrorKind; +use std::os::windows::prelude::*; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Security::GetLengthSid; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::Security::TOKEN_USER; +use windows_sys::Win32::Security::TokenUser; +use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; +use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; + +impl MaximumSid { + /// Returns the binary SID length encoded by this value. + pub fn byte_len(&self) -> usize { + 8 + usize::from(self.sub_authority_count) * size_of::() + } + + /// Returns a zero-padded fixed representation and its meaningful length. + pub fn to_fixed_bytes(self) -> ([u8; 68], u8) { + let length = self.byte_len(); + assert!(length <= 68); + let mut bytes = [0; 68]; + // SAFETY: `MaximumSid` is a 68-byte repr(C) value and `length` was + // derived from its validated subauthority count. + unsafe { + bytes[..length].copy_from_slice(std::slice::from_raw_parts( + std::ptr::from_ref(&self).cast::(), + length, + )); + } + (bytes, length as u8) + } +} + +fn process_user_sid_from_handle(process: HANDLE) -> std::io::Result { + // SAFETY: the process handle is valid for TOKEN_QUERY and all output + // buffers remain live for the documented calls. + unsafe { + let mut token = null_mut(); + if OpenProcessToken(process, TOKEN_QUERY, &mut token) == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = OwnedHandle::from_raw_handle(token); + + let mut required = 0; + let _ = GetTokenInformation( + token.as_raw_handle(), + TokenUser, + null_mut(), + 0, + &mut required, + ); + if required < size_of::() as u32 { + return Err(std::io::Error::last_os_error()); + } + let mut buffer = vec![0u8; required as usize]; + if GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) == 0 + { + return Err(std::io::Error::last_os_error()); + } + let token_user = &*buffer.as_ptr().cast::(); + let length = GetLengthSid(token_user.User.Sid) as usize; + if !(8..=size_of::()).contains(&length) { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "process token contains an invalid user SID", + )); + } + let mut sid = MaximumSid::new([0; 6], [0; MAX_SUBAUTHORITY_COUNT]); + std::ptr::copy_nonoverlapping( + token_user.User.Sid.cast::(), + std::ptr::from_mut(&mut sid).cast::(), + length, + ); + Ok(sid) + } +} + +/// Returns the current process user SID. +pub fn current_process_user_sid() -> std::io::Result { + process_user_sid_from_handle(unsafe { GetCurrentProcess() }) +} + +/// Returns the user SID for a local process. +pub fn process_user_sid(process_id: u32) -> std::io::Result { + // SAFETY: OpenProcess is called with a query-only access mask and a + // concrete process identifier supplied by the named-pipe subsystem. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the successful OpenProcess result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + process_user_sid_from_handle(process.as_raw_handle()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_process_sid_has_a_bounded_binary_identity() { + let sid = current_process_user_sid().unwrap(); + let (bytes, length) = sid.to_fixed_bytes(); + assert!((8..=68).contains(&length)); + assert_eq!(bytes[0], 1); + assert!(sid.to_string_sid().starts_with("S-1-")); + } +} diff --git a/support/sparse_mmap/Cargo.toml b/support/sparse_mmap/Cargo.toml index de64db84664..e9d4bba2ead 100644 --- a/support/sparse_mmap/Cargo.toml +++ b/support/sparse_mmap/Cargo.toml @@ -32,5 +32,9 @@ windows-sys = { workspace = true, features = [ "Win32_System_Threading", ]} +[dev-dependencies] +tempfile.workspace = true +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/support/sparse_mmap/src/lib.rs b/support/sparse_mmap/src/lib.rs index d77ebbba135..3071624f547 100644 --- a/support/sparse_mmap/src/lib.rs +++ b/support/sparse_mmap/src/lib.rs @@ -18,6 +18,7 @@ pub use sys::MappableRef; pub use sys::SparseMapping; pub use sys::alloc_shared_memory; pub use sys::alloc_shared_memory_hugetlb; +pub use sys::copy_on_write::new_mappable_from_file_copy_on_write; pub use sys::new_mappable_from_file; use std::mem::MaybeUninit; @@ -188,6 +189,9 @@ impl SparseMapping { #[cfg(test)] mod tests { + mod copy_on_write; + mod flush; + use super::*; static BUF: [u8; 65536] = [0xcc; 65536]; diff --git a/support/sparse_mmap/src/tests/copy_on_write.rs b/support/sparse_mmap/src/tests/copy_on_write.rs new file mode 100644 index 00000000000..058ecfff2d7 --- /dev/null +++ b/support/sparse_mmap/src/tests/copy_on_write.rs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for copy-on-write file mappings. + +use crate::SparseMapping; +use crate::new_mappable_from_file_copy_on_write; +use std::io::Read; +use std::io::Seek; +use std::io::Write; + +#[test] +fn copy_on_write_file_mapping_does_not_modify_file() { + let page_size = SparseMapping::page_size(); + let mapping_size = page_size * 16; + let original = (0..mapping_size) + .map(|offset| (offset / page_size) as u8) + .collect::>(); + let mut artifact = tempfile::NamedTempFile::new().unwrap(); + artifact.write_all(&original).unwrap(); + artifact.as_file().sync_all().unwrap(); + let mut file = std::fs::File::open(artifact.path()).unwrap(); + + let mappable = new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let mapping = SparseMapping::new(mapping_size).unwrap(); + mapping + .map_file_copy_on_write(0, mapping_size, &mappable, 0, true) + .unwrap(); + mapping.fill_at(0, 0xa5, mapping_size).unwrap(); + + let mut mapped_bytes = vec![0; mapping_size]; + mapping.read_at(0, &mut mapped_bytes).unwrap(); + assert_eq!(mapped_bytes, vec![0xa5; mapping_size]); + drop(mapping); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + + let mappable = new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let mapping = SparseMapping::new(mapping_size).unwrap(); + mapping + .map_file_copy_on_write(0, mapping_size, &mappable, 0, true) + .unwrap(); + let mut remapped_bytes = vec![0; mapping_size]; + mapping.read_at(0, &mut remapped_bytes).unwrap(); + assert_eq!(remapped_bytes, original); +} diff --git a/support/sparse_mmap/src/tests/flush.rs b/support/sparse_mmap/src/tests/flush.rs new file mode 100644 index 00000000000..8b230e9b0b2 --- /dev/null +++ b/support/sparse_mmap/src/tests/flush.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for flushing shared file mappings. + +use crate::SparseMapping; +use crate::new_mappable_from_file; +use std::io::Read; +use std::io::Seek; + +#[test] +fn test_flush_shared_file_mapping() { + let page_size = SparseMapping::page_size(); + let mut file = tempfile::tempfile().unwrap(); + file.set_len(page_size as u64).unwrap(); + let mappable = new_mappable_from_file(&file, true, false).unwrap(); + let mapping = SparseMapping::new(page_size).unwrap(); + mapping.map_file(0, page_size, &mappable, 0, true).unwrap(); + + mapping.write_at(0, b"flushed").unwrap(); + mapping.flush(0, page_size).unwrap(); + + let mut bytes = [0_u8; 7]; + file.seek(std::io::SeekFrom::Start(0)).unwrap(); + file.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"flushed"); +} diff --git a/support/sparse_mmap/src/unix.rs b/support/sparse_mmap/src/unix.rs index 67480de3fbc..feb05fb810b 100644 --- a/support/sparse_mmap/src/unix.rs +++ b/support/sparse_mmap/src/unix.rs @@ -5,6 +5,9 @@ #![cfg(unix)] +pub(crate) mod copy_on_write; +mod flush; + use pal::unix::SyscallResult; use std::ffi::c_void; use std::fs::File; diff --git a/support/sparse_mmap/src/unix/copy_on_write.rs b/support/sparse_mmap/src/unix/copy_on_write.rs new file mode 100644 index 00000000000..998e2264c13 --- /dev/null +++ b/support/sparse_mmap/src/unix/copy_on_write.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Copy-on-write file mappings: private mappings whose writes are not +//! carried through to the file. + +use super::Mappable; +use super::SparseMapping; +use std::fs::File; +use std::io; +use std::io::Error; +use std::os::unix::prelude::*; + +/// Creates a mappable whose writable views are private to the mapping. +pub fn new_mappable_from_file_copy_on_write( + file: &File, + _executable: bool, +) -> io::Result { + file.as_fd().try_clone_to_owned() +} + +impl SparseMapping { + /// Maps a portion of a file privately at `offset`. + pub fn map_file_copy_on_write( + &self, + offset: usize, + len: usize, + file_mapping: impl AsFd, + file_offset: u64, + writable: bool, + ) -> Result<(), Error> { + let prot = if writable { + libc::PROT_READ | libc::PROT_WRITE + } else { + libc::PROT_READ + }; + + // SAFETY: The flags passed in are guaranteed to be valid. + unsafe { + self.mmap( + offset, + len, + prot, + libc::MAP_PRIVATE, + file_mapping.as_fd(), + file_offset as i64, + ) + } + } +} diff --git a/support/sparse_mmap/src/unix/flush.rs b/support/sparse_mmap/src/unix/flush.rs new file mode 100644 index 00000000000..8f0bfc547f8 --- /dev/null +++ b/support/sparse_mmap/src/unix/flush.rs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Flushing modified pages of shared file mappings to their files. + +use super::SparseMapping; +use std::io::Error; + +impl SparseMapping { + /// Flushes modified shared file pages in a populated range. + pub fn flush(&self, offset: usize, len: usize) -> Result<(), Error> { + let _ = self.validate_offset_len(offset, len)?; + // SAFETY: `validate_offset_len` proves the range is within this + // reservation. Callers use this only for populated shared mappings. + if unsafe { libc::msync(self.address.add(offset), len, libc::MS_SYNC) } < 0 { + return Err(Error::last_os_error()); + } + Ok(()) + } +} diff --git a/support/sparse_mmap/src/windows.rs b/support/sparse_mmap/src/windows.rs index 74ed2237aa7..e9b5a1b1f36 100644 --- a/support/sparse_mmap/src/windows.rs +++ b/support/sparse_mmap/src/windows.rs @@ -5,6 +5,9 @@ #![cfg(windows)] +pub(crate) mod copy_on_write; +mod flush; + use Memory::CreateFileMappingNumaW; use Memory::CreateFileMappingW; use Memory::GetLargePageMinimum; diff --git a/support/sparse_mmap/src/windows/copy_on_write.rs b/support/sparse_mmap/src/windows/copy_on_write.rs new file mode 100644 index 00000000000..3eb6e9c3bdb --- /dev/null +++ b/support/sparse_mmap/src/windows/copy_on_write.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Copy-on-write file mappings: sections whose writable views are private +//! copies of the file pages. + +use super::Mappable; +use super::SparseMapping; +use std::io; +use std::io::Error; +use std::os::windows::prelude::*; +use std::ptr::null; +use std::ptr::null_mut; +use windows_sys::Win32::System::Memory::CreateFileMappingW; +use windows_sys::Win32::System::Memory::PAGE_EXECUTE_WRITECOPY; +use windows_sys::Win32::System::Memory::PAGE_READONLY; +use windows_sys::Win32::System::Memory::PAGE_WRITECOPY; + +/// Creates a section whose writable views are private copies of file pages. +pub fn new_mappable_from_file_copy_on_write( + file: &std::fs::File, + executable: bool, +) -> io::Result { + let protection = if executable { + PAGE_EXECUTE_WRITECOPY + } else { + PAGE_WRITECOPY + }; + + unsafe { + let section = CreateFileMappingW(file.as_raw_handle(), null_mut(), protection, 0, 0, null()) + as RawHandle; + if section.is_null() { + return Err(Error::last_os_error()); + } + Ok(OwnedHandle::from_raw_handle(section)) + } +} + +impl SparseMapping { + /// Maps a portion of a file as a private copy-on-write view. + pub fn map_file_copy_on_write( + &self, + offset: usize, + len: usize, + file_mapping: impl AsHandle, + file_offset: u64, + writable: bool, + ) -> Result<(), Error> { + let protect = if writable { + PAGE_WRITECOPY + } else { + PAGE_READONLY + }; + self.map_view_of_file( + offset, + len, + file_mapping.as_handle(), + file_offset, + protect, + None, + ) + } +} diff --git a/support/sparse_mmap/src/windows/flush.rs b/support/sparse_mmap/src/windows/flush.rs new file mode 100644 index 00000000000..d0688ec2f34 --- /dev/null +++ b/support/sparse_mmap/src/windows/flush.rs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Flushing modified pages of shared file views to their files. + +use super::PAGE_SIZE; +use super::SparseMapping; +use std::io; +use std::io::Error; +use windows_sys::Win32::System::Memory::FlushViewOfFile; + +const MIN_FLUSH_BYTES_PER_WORKER: usize = 16 * 1024 * 1024; +const MAX_FLUSH_WORKERS: usize = 8; + +fn flush_file_range( + offset: usize, + len: usize, + flush: impl Fn(usize, usize) -> io::Result<()> + Sync, +) -> io::Result<()> { + if len == 0 { + return Ok(()); + } + let workers = (len / MIN_FLUSH_BYTES_PER_WORKER).clamp(1, MAX_FLUSH_WORKERS); + if workers == 1 { + return flush(offset, len); + } + + // Fragmented dirty pages make one synchronous flush I/O-latency bound. + // Use bounded, page-aligned chunks and finish all of them before returning. + let chunk_size = (len / PAGE_SIZE).div_ceil(workers) * PAGE_SIZE; + std::thread::scope(|scope| { + let flush = &flush; + let mut threads = Vec::with_capacity(workers - 1); + for start in (chunk_size..len).step_by(chunk_size) { + let chunk_len = (len - start).min(chunk_size); + threads.push( + std::thread::Builder::new() + .name("mapped-flush".to_owned()) + .spawn_scoped(scope, move || flush(offset + start, chunk_len))?, + ); + } + let mut result = flush(offset, chunk_size); + for thread in threads { + let worker_result = thread.join().expect("mapped-memory flush worker panicked"); + result = result.and(worker_result); + } + result + }) +} + +impl SparseMapping { + /// Flushes modified shared file pages in a populated local view. + pub fn flush(&self, offset: usize, len: usize) -> Result<(), Error> { + let _ = self.validate_offset_len(offset, len)?; + if self.process.is_some() { + return Err(Error::new( + io::ErrorKind::Unsupported, + "flushing a remote mapped view is unsupported", + )); + } + flush_file_range(offset, len, |offset, len| { + // SAFETY: `validate_offset_len` proves the range is within this + // reservation. Callers use this only for populated shared views. + if unsafe { FlushViewOfFile(self.as_ptr().add(offset), len) } == 0 { + return Err(Error::last_os_error()); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::MAX_FLUSH_WORKERS; + use super::MIN_FLUSH_BYTES_PER_WORKER; + use super::PAGE_SIZE; + use super::SparseMapping; + use super::flush_file_range; + use parking_lot::Condvar; + use parking_lot::Mutex; + use std::io; + use std::io::Read; + use std::io::Seek; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::sync::mpsc; + use std::time::Duration; + use std::time::Instant; + use test_with_tracing::test; + + const SNAPSHOT_RAM_BYTES: usize = 128 * 1024 * 1024; + + #[test] + fn flush_file_range_covers_each_page_once() { + for len in [ + 0, + PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER - PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER, + MIN_FLUSH_BYTES_PER_WORKER + PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER * 2 + PAGE_SIZE, + SNAPSHOT_RAM_BYTES, + MIN_FLUSH_BYTES_PER_WORKER * 20 + PAGE_SIZE * 3, + ] { + let ranges = Mutex::new(Vec::new()); + flush_file_range(PAGE_SIZE, len, |offset, len| { + assert!(offset.is_multiple_of(PAGE_SIZE)); + assert!(len.is_multiple_of(PAGE_SIZE)); + assert_ne!(len, 0); + ranges.lock().push(offset..offset + len); + Ok(()) + }) + .unwrap(); + let mut ranges = ranges.into_inner(); + ranges.sort_by_key(|range| range.start); + let expected_workers = if len == 0 { + 0 + } else { + (len / MIN_FLUSH_BYTES_PER_WORKER).clamp(1, MAX_FLUSH_WORKERS) + }; + assert_eq!(ranges.len(), expected_workers, "length {len}"); + let mut end = PAGE_SIZE; + for range in ranges { + assert_eq!(range.start, end); + end = range.end; + } + assert_eq!(end, PAGE_SIZE + len); + } + } + + #[test] + fn fragmented_flush_is_bounded_parallel_and_joins_on_error() { + let chunk_len = SNAPSHOT_RAM_BYTES / MAX_FLUSH_WORKERS; + for failing_chunk in [None, Some(0), Some(1)] { + let (started, receiver) = mpsc::channel(); + let released = Mutex::new(false); + let release = Condvar::new(); + let completed = AtomicUsize::new(0); + std::thread::scope(|scope| { + let released = &released; + let release = &release; + let coordinator = scope.spawn(move || { + let all_started = (0..MAX_FLUSH_WORKERS) + .all(|_| receiver.recv_timeout(Duration::from_secs(10)).is_ok()); + *released.lock() = true; + release.notify_all(); + all_started + }); + let result = flush_file_range(0, SNAPSHOT_RAM_BYTES, |offset, len| { + if len != chunk_len { + return Err(io::Error::other( + "fragmented writeback was submitted as one serial flush", + )); + } + started.send(()).unwrap(); + let mut released = released.lock(); + while !*released { + release.wait(&mut released); + } + completed.fetch_add(1, Ordering::SeqCst); + if failing_chunk == Some(offset / chunk_len) { + Err(io::Error::from_raw_os_error(5)) + } else { + Ok(()) + } + }); + drop(started); + let all_started = coordinator.join().unwrap(); + if failing_chunk.is_some() { + assert_eq!(result.unwrap_err().raw_os_error(), Some(5)); + } else { + result.unwrap(); + } + assert!(all_started, "flush workers did not overlap"); + let completed = completed.load(Ordering::SeqCst); + assert!(completed > 1, "large flushes must overlap"); + assert_eq!(completed, MAX_FLUSH_WORKERS); + }); + } + } + + fn flush_fragmented_file( + len: usize, + flush: impl FnOnce(&SparseMapping) -> io::Result<()>, + ) -> (Duration, Duration) { + let mut file = tempfile::tempfile().unwrap(); + file.set_len(len as u64).unwrap(); + file.sync_all().unwrap(); + let mappable = crate::windows::new_mappable_from_file(&file, true, false).unwrap(); + let mapping = SparseMapping::new(len).unwrap(); + mapping.map_file(0, len, &mappable, 0, true).unwrap(); + for offset in (0..len).step_by(PAGE_SIZE * 2) { + mapping.fill_at(offset, 0x5a, PAGE_SIZE).unwrap(); + } + + let start = Instant::now(); + flush(&mapping).unwrap(); + let mapped_flush = start.elapsed(); + let start = Instant::now(); + file.sync_all().unwrap(); + let file_flush = start.elapsed(); + drop(mapping); + drop(mappable); + + file.rewind().unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes.len(), len); + for (index, page) in bytes.as_chunks::().0.iter().enumerate() { + let expected = if index.is_multiple_of(2) { 0x5a } else { 0 }; + assert!(page.iter().all(|&byte| byte == expected), "page {index}"); + } + (mapped_flush, file_flush) + } + + #[test] + fn flush_fragmented_file_preserves_bytes() { + flush_fragmented_file(MIN_FLUSH_BYTES_PER_WORKER * 2 + PAGE_SIZE * 3, |mapping| { + mapping.flush(0, mapping.len()) + }); + } + + #[test] + #[ignore = "manual storage timing comparison; run without concurrent workloads"] + fn profile_fragmented_file_flush() { + let mut serial = Vec::new(); + let mut parallel = Vec::new(); + for iteration in 0_usize..5 { + let order = if iteration.is_multiple_of(2) { + [false, true] + } else { + [true, false] + }; + for parallel_flush in order { + let (mapped, file) = flush_fragmented_file(SNAPSHOT_RAM_BYTES, |mapping| { + if parallel_flush { + mapping.flush(0, mapping.len()) + } else { + // SAFETY: the complete local shared view is populated + // and remains mapped until the synchronous call returns. + if unsafe { super::FlushViewOfFile(mapping.as_ptr(), mapping.len()) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + }); + eprintln!( + "parallel={parallel_flush} mapped_flush_ms={:.3} file_sync_ms={:.3}", + mapped.as_secs_f64() * 1000.0, + file.as_secs_f64() * 1000.0, + ); + if parallel_flush { + parallel.push(mapped + file); + } else { + serial.push(mapped + file); + } + } + } + serial.sort_unstable(); + parallel.sort_unstable(); + let serial = serial[serial.len() / 2].as_secs_f64(); + let parallel = parallel[parallel.len() / 2].as_secs_f64(); + eprintln!( + "fragmented_flush serial_p50_ms={:.3} parallel_p50_ms={:.3} speedup={:.2}", + serial * 1000.0, + parallel * 1000.0, + serial / parallel, + ); + } +} diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index dec2de4bb41..b3d67dca5d0 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -47,6 +47,10 @@ impl RunContext<'_> { nested_virt: false, #[cfg(guest_arch = "aarch64")] device_assignment_msi_iova_range: None, + user_mode_memory_faults: true, + lazy_memory_registration: false, + #[cfg(guest_arch = "x86_64")] + time_abi: None, }) .context("failed to create proto partition")?; @@ -87,6 +91,10 @@ impl RunContext<'_> { }?; } + partition + .finalize_memory() + .context("failed to finalize partition memory")?; + let mut threads = Vec::new(); let r = self .run( diff --git a/vm/chipset_device_resources/Cargo.toml b/vm/chipset_device_resources/Cargo.toml index 4421fb5cc72..f8b19413036 100644 --- a/vm/chipset_device_resources/Cargo.toml +++ b/vm/chipset_device_resources/Cargo.toml @@ -16,6 +16,7 @@ vm_resource.workspace = true inspect.workspace = true async-trait.workspace = true +anyhow.workspace = true [lints] workspace = true diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index f8ba3fa07a0..6401ab1abf6 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -92,8 +92,13 @@ pub trait ConfigureChipsetDevice: Send { #[async_trait] trait DynChipsetDevice: ChipsetDevice + ProtobufSaveRestore + InspectMut { fn start(&mut self); + async fn start_fallible(&mut self) -> anyhow::Result<()>; + async fn quiesce_input(&mut self) -> anyhow::Result<()>; + async fn resume_input(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); async fn reset(&mut self); + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()>; + fn advances_time(&self) -> bool; } #[async_trait] @@ -101,7 +106,16 @@ impl Dy for T { fn start(&mut self) { - self.start() + ChangeDeviceState::start(self) + } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::start_fallible(self).await + } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::quiesce_input(self).await + } + async fn resume_input(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::resume_input(self).await } async fn stop(&mut self) { self.stop().await @@ -109,6 +123,12 @@ impl Dy async fn reset(&mut self) { self.reset().await } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.advance_time(duration).await + } + fn advances_time(&self) -> bool { + ChangeDeviceState::advances_time(self) + } } /// A resolved chipset device resource. @@ -132,6 +152,18 @@ impl ChangeDeviceState for ErasedChipsetDevice { self.0.start() } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.0.start_fallible().await + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.0.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.0.resume_input().await + } + async fn stop(&mut self) { self.0.stop().await } @@ -139,6 +171,14 @@ impl ChangeDeviceState for ErasedChipsetDevice { async fn reset(&mut self) { self.0.reset().await } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.0.advance_time(duration).await + } + + fn advances_time(&self) -> bool { + self.0.advances_time() + } } impl ChipsetDevice for ErasedChipsetDevice { diff --git a/vm/devices/chipset/Cargo.toml b/vm/devices/chipset/Cargo.toml index e49939a4c8a..a21c2369e39 100644 --- a/vm/devices/chipset/Cargo.toml +++ b/vm/devices/chipset/Cargo.toml @@ -11,6 +11,7 @@ chipset_device.workspace = true chipset_device_resources.workspace = true chipset_resources.workspace = true power_resources.workspace = true +serial_core.workspace = true vm_resource.workspace = true input_core.workspace = true @@ -18,6 +19,7 @@ vmcore.workspace = true x86defs.workspace = true async-trait.workspace = true +anyhow.workspace = true bitfield-struct.workspace = true futures.workspace = true inspect_counters.workspace = true @@ -32,6 +34,7 @@ tracing.workspace = true [dev-dependencies] pal_async.workspace = true +parking_lot.workspace = true test_with_tracing.workspace = true [lints] diff --git a/vm/devices/chipset/src/cmos_rtc.rs b/vm/devices/chipset/src/cmos_rtc.rs index d0dc8c1a016..ae091aec84b 100644 --- a/vm/devices/chipset/src/cmos_rtc.rs +++ b/vm/devices/chipset/src/cmos_rtc.rs @@ -6,6 +6,10 @@ #![warn(missing_docs)] +pub mod clock; +mod mode; + +pub use self::mode::RtcMode; use self::spec::CmosReg; use self::spec::ENABLE_OSCILLATOR_CONTROL; use self::spec::StatusRegA; @@ -174,9 +178,11 @@ pub struct Rtc { century_reg: CmosReg, initial_cmos: Option<[u8; 256]>, enlightened_interrupts: bool, + #[inspect(skip)] + mode: RtcMode, // Runtime deps - real_time_source: Box, + real_time_source: Box, interrupt: LineInterrupt, vmtime_alarm: VmTimeAccess, vmtimer_periodic: VmTimerPeriodic, @@ -194,6 +200,8 @@ pub struct Rtc { struct RtcState { addr: u8, cmos: CmosData, + time_valid: bool, + transaction_read_mask: Option, } impl RtcState { @@ -220,6 +228,8 @@ impl RtcState { // default Hyper-V used, so we'll stick with it addr: 0x80, cmos, + time_valid: true, + transaction_read_mask: None, } } } @@ -230,11 +240,19 @@ impl ChangeDeviceState for Rtc { async fn stop(&mut self) {} async fn reset(&mut self) { - self.state = RtcState::new(self.initial_cmos); + self.state = RtcState::with_mode(self.initial_cmos, self.mode); self.update_timers(); self.update_interrupt_line_level(); } + + async fn advance_time(&mut self, duration: Duration) -> anyhow::Result<()> { + self.advance_clock(duration) + } + + fn advances_time(&self) -> bool { + true + } } impl ChipsetDevice for Rtc { @@ -311,8 +329,9 @@ impl Rtc { century_reg: CmosReg(century_reg_idx), initial_cmos, enlightened_interrupts, + mode: RtcMode::Standard, - real_time_source, + real_time_source: Box::new(clock::LocalClockUtcSource(real_time_source)), interrupt, vmtime_alarm: vmtime_source.access("rtc-alarm"), vmtimer_periodic: VmTimerPeriodic::new(vmtime_source.access("rtc-periodic")), @@ -674,7 +693,9 @@ impl Rtc { // wait for a rising or falling transition of the bit (typically rising edge), // and then the guest OS will wait for another of the same transition. if !StatusRegB::from(self.state.cmos[CmosReg::STATUS_B]).set() { - let now = self.real_time_source.get_time(); + let Ok(now) = self.real_time_source.get_time() else { + return self.status_a_without_clock(data); + }; let elapsed = now - self.last_update_bit_blip; // check if the programmed time jumped backwards @@ -718,6 +739,7 @@ impl Rtc { data.into() } + CmosReg::STATUS_D if !self.state.time_valid => StatusRegD::new().with_vrt(false).into(), CmosReg::STATUS_D => { // always report valid ram time StatusRegD::new().with_vrt(true).into() @@ -774,16 +796,22 @@ impl Rtc { return; } - let real_time = self.real_time_source.get_time(); + let Some(real_time) = self.sample_utc_clock() else { + return; + }; let Ok(clock_time): Result = real_time.try_into() else { tracelimit::warn_ratelimited!( ?real_time, "invalid date/time in real_time_source, skipping sync" ); + self.invalidate_calendar(); return; }; let clock_time = clock_time.to_zoned(jiff::tz::TimeZone::UTC).datetime(); + if !self.validate_calendar_year(&clock_time) { + return; + } let status_b = StatusRegB::from(self.state.cmos[CmosReg::STATUS_B]); @@ -830,6 +858,7 @@ impl Rtc { self.state.cmos[CmosReg::HOUR] |= 0x80; } } + self.state.time_valid = true; tracing::trace!( cmos_reg_status_b = self.state.cmos[CmosReg::STATUS_B], @@ -918,6 +947,8 @@ impl PollDevice for Rtc { } mod save_restore { + mod snapshot; + use super::*; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; @@ -935,6 +966,15 @@ mod save_restore { pub addr: u8, #[mesh(2)] pub cmos: [u8; 256], + /// Guest-visible RTC time at the stopped snapshot boundary. + #[mesh(3)] + pub clock_time_millis: i64, + /// Calendar registers already read from an active coherent sample. + #[mesh(4)] + pub transaction_read_mask: Option, + /// Whether the latched calendar sample is valid. + #[mesh(5)] + pub time_valid: Option, } } @@ -942,20 +982,30 @@ mod save_restore { type SavedState = state::SavedState; fn save(&mut self) -> Result { - let RtcState { addr, ref cmos } = self.state; + let RtcState { addr, ref cmos, .. } = self.state; - let saved_state = state::SavedState { addr, cmos: cmos.0 }; + let saved_state = state::SavedState { + addr, + cmos: cmos.0, + clock_time_millis: self.saved_clock_time()?, + transaction_read_mask: self.state.transaction_read_mask, + time_valid: Some(self.state.time_valid), + }; Ok(saved_state) } fn restore(&mut self, state: Self::SavedState) -> Result<(), RestoreError> { - let state::SavedState { addr, cmos } = state; + let fields = snapshot::Fields::validate(&state)?; + let state::SavedState { addr, cmos, .. } = state; self.state = RtcState { addr, cmos: CmosData(cmos), + time_valid: fields.time_valid, + transaction_read_mask: fields.transaction_read_mask, }; + self.restore_clock(fields.clock_time_millis); self.update_timers(); self.update_interrupt_line_level(); @@ -967,6 +1017,8 @@ mod save_restore { #[cfg(test)] mod tests { + mod snapshot; + use super::*; use local_clock::MockLocalClock; use local_clock::MockLocalClockAccessor; diff --git a/vm/devices/chipset/src/cmos_rtc/clock.rs b/vm/devices/chipset/src/cmos_rtc/clock.rs new file mode 100644 index 00000000000..95a2fdd94ae --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/clock.rs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The UTC clock source of the RTC: a fallible clock abstraction, calendar +//! invalidation when no representable time is available, and advancing the +//! guest-visible time. + +use super::Rtc; +use super::RtcMode; +use super::RtcState; +use super::spec::CmosReg; +use super::spec::StatusRegA; +use anyhow::Context; +use inspect::Inspect; +use local_clock::InspectableLocalClock; +use local_clock::LocalClockTime; +use std::time::Duration; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::vmtime::VmTimeSource; +use vmcore::vmtime::VmTimerPeriodic; + +/// A fallible UTC clock source for the RTC. +pub trait UtcClockSource: Inspect + Send { + /// Samples UTC as milliseconds from the Unix epoch. + fn get_time(&mut self) -> Result; + + /// Updates the guest-visible UTC epoch. + fn set_time(&mut self, new_time: LocalClockTime); +} + +/// Indicates that an RTC UTC clock source could not provide a sample. +#[derive(Debug, thiserror::Error)] +#[error("UTC clock sample is unavailable")] +pub struct UtcClockSourceError; + +#[derive(Inspect)] +#[inspect(transparent)] +pub(super) struct LocalClockUtcSource(pub(super) Box); + +impl UtcClockSource for LocalClockUtcSource { + fn get_time(&mut self) -> Result { + Ok(self.0.get_time()) + } + + fn set_time(&mut self, new_time: LocalClockTime) { + self.0.set_time(new_time) + } +} + +impl Rtc { + /// Creates a CMOS RTC with an injectable, fallible UTC clock source. + pub fn new_with_mode_and_utc_clock( + real_time_source: Box, + interrupt: LineInterrupt, + vmtime_source: &VmTimeSource, + century_reg_idx: u8, + initial_cmos: Option<[u8; 256]>, + enlightened_interrupts: bool, + mode: RtcMode, + ) -> Self { + Rtc { + century_reg: CmosReg(century_reg_idx), + initial_cmos, + enlightened_interrupts, + mode, + + real_time_source, + interrupt, + vmtime_alarm: vmtime_source.access("rtc-alarm"), + vmtimer_periodic: VmTimerPeriodic::new(vmtime_source.access("rtc-periodic")), + vmtimer_update: VmTimerPeriodic::new(vmtime_source.access("rtc-update")), + + last_update_bit_blip: LocalClockTime::from_millis_since_unix_epoch(0), + + state: RtcState::with_mode(initial_cmos, mode), + } + } + + /// Advances the guest-visible UTC time by `duration`. + pub(super) fn advance_clock(&mut self, duration: Duration) -> anyhow::Result<()> { + let delta = i64::try_from(duration.as_millis()) + .context("RTC downtime does not fit in milliseconds")?; + let current = self + .real_time_source + .get_time() + .context("failed to sample RTC UTC clock")?; + let advanced = current + .as_millis_since_unix_epoch() + .checked_add(delta) + .context("RTC time overflow while applying snapshot downtime")?; + self.real_time_source + .set_time(LocalClockTime::from_millis_since_unix_epoch(advanced)); + self.update_timers(); + self.update_interrupt_line_level(); + Ok(()) + } + + /// Samples the UTC clock, invalidating the calendar if no sample is + /// available. + pub(super) fn sample_utc_clock(&mut self) -> Option { + match self.real_time_source.get_time() { + Ok(real_time) => Some(real_time), + Err(error) => { + tracelimit::warn_ratelimited!(?error, "failed to sample RTC UTC clock"); + self.invalidate_calendar(); + None + } + } + } + + /// Returns whether the calendar registers can represent the year of + /// `clock_time`, invalidating the calendar if they cannot. + pub(super) fn validate_calendar_year(&mut self, clock_time: &jiff::civil::DateTime) -> bool { + if !(0..=9999).contains(&clock_time.year()) { + tracelimit::warn_ratelimited!( + year = clock_time.year(), + "RTC UTC year is not representable" + ); + self.invalidate_calendar(); + return false; + } + true + } + + /// Returns status register A when the UTC clock cannot be sampled: an + /// update is reported in progress and the calendar is invalidated. + pub(super) fn status_a_without_clock(&mut self, mut data: StatusRegA) -> u8 { + data.set_update(true); + self.invalidate_calendar(); + data.into() + } + + pub(super) fn invalidate_calendar(&mut self) { + for reg in [ + CmosReg::SECOND, + CmosReg::MINUTE, + CmosReg::HOUR, + CmosReg::DAY_OF_WEEK, + CmosReg::DAY_OF_MONTH, + CmosReg::MONTH, + CmosReg::YEAR, + self.century_reg, + ] { + self.state.cmos[reg] = 0; + } + self.state.time_valid = false; + } +} diff --git a/vm/devices/chipset/src/cmos_rtc/mode.rs b/vm/devices/chipset/src/cmos_rtc/mode.rs new file mode 100644 index 00000000000..b07e3a029d2 --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/mode.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Guest-visible RTC behavior modes. + +use super::Rtc; +use super::RtcState; +use local_clock::InspectableLocalClock; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::vmtime::VmTimeSource; + +/// Guest-visible RTC behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RtcMode { + /// Standard programmable MC146818-compatible behavior. + Standard, +} + +impl RtcState { + /// Returns the initial register state for `mode`. All modes currently + /// share the standard initial state. + pub(super) fn with_mode(initial_cmos: Option<[u8; 256]>, _mode: RtcMode) -> Self { + Self::new(initial_cmos) + } +} + +impl Rtc { + /// Creates a CMOS RTC with an explicit guest-visible behavior mode. + pub fn new_with_mode( + real_time_source: Box, + interrupt: LineInterrupt, + vmtime_source: &VmTimeSource, + century_reg_idx: u8, + initial_cmos: Option<[u8; 256]>, + enlightened_interrupts: bool, + mode: RtcMode, + ) -> Self { + Rtc { + mode, + state: RtcState::with_mode(initial_cmos, mode), + ..Self::new( + real_time_source, + interrupt, + vmtime_source, + century_reg_idx, + initial_cmos, + enlightened_interrupts, + ) + } + } +} diff --git a/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs new file mode 100644 index 00000000000..f53f5d2ce03 --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The snapshot fields of the RTC saved state: the guest-visible UTC time, +//! the calendar validity, and the retired coherent-transaction state. + +use super::state::SavedState; +use crate::cmos_rtc::Rtc; +use crate::cmos_rtc::spec::CmosReg; +use crate::cmos_rtc::spec::StatusRegC; +use local_clock::LocalClockTime; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; + +/// The validated snapshot fields of a saved state. +pub(super) struct Fields { + pub(super) time_valid: bool, + pub(super) transaction_read_mask: Option, + pub(super) clock_time_millis: i64, +} + +impl Fields { + /// Validates the snapshot fields of `state` before any RTC state changes. + pub(super) fn validate(state: &SavedState) -> Result { + let time_valid = state.time_valid.unwrap_or(true); + + if state.transaction_read_mask.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "invalid RTC coherent transaction state" + ))); + } + + let status_c = StatusRegC::from(state.cmos[CmosReg::STATUS_C.0 as usize]); + if status_c.irq_combined() + != (status_c.irq_update() || status_c.irq_periodic() || status_c.irq_alarm()) + { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "inconsistent RTC status C interrupt flags" + ))); + } + + Ok(Self { + time_valid, + transaction_read_mask: state.transaction_read_mask, + clock_time_millis: state.clock_time_millis, + }) + } +} + +impl Rtc { + /// Samples the guest-visible UTC time for the saved state. + pub(super) fn saved_clock_time(&mut self) -> Result { + Ok(self + .real_time_source + .get_time() + .map_err(|error| SaveError::Other(error.into()))? + .as_millis_since_unix_epoch()) + } + + /// Restores the guest-visible UTC time of the saved state. + pub(super) fn restore_clock(&mut self, clock_time_millis: i64) { + self.real_time_source + .set_time(LocalClockTime::from_millis_since_unix_epoch( + clock_time_millis, + )); + } +} diff --git a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs new file mode 100644 index 00000000000..8b9e104a5a6 --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the snapshot fields of the RTC saved state and for advancing +//! the guest-visible time. + +use super::get_cmos_data; +use super::new_test_rtc; +use crate::cmos_rtc::spec::CmosReg; +use std::time::Duration; +use test_with_tracing::test; +use vmcore::device_state::ChangeDeviceState; +use vmcore::save_restore::SaveRestore; + +#[test] +fn restore_rejects_inconsistent_status_c_before_mutating_state() { + use vmcore::save_restore::{ProtobufSaveRestore, SavedStateBlob}; + + for status_c in [0x10, 0x80] { + let (_pool, _keeper, _, mut rtc) = new_test_rtc(); + let before = SaveRestore::save(&mut rtc).unwrap(); + let mut invalid = SaveRestore::save(&mut rtc).unwrap(); + invalid.cmos[CmosReg::STATUS_C.0 as usize] = status_c; + + let error = + ProtobufSaveRestore::restore(&mut rtc, SavedStateBlob::new(invalid)).unwrap_err(); + match error { + vmcore::save_restore::RestoreError::InvalidSavedState(error) => { + assert_eq!( + error.to_string(), + "inconsistent RTC status C interrupt flags" + ) + } + error => panic!("unexpected restore error: {error}"), + } + + let after = SaveRestore::save(&mut rtc).unwrap(); + assert_eq!(after.addr, before.addr); + assert_eq!(after.cmos, before.cmos); + assert_eq!(after.clock_time_millis, before.clock_time_millis); + assert_eq!(after.transaction_read_mask, before.transaction_read_mask); + assert_eq!(after.time_valid, before.time_valid); + } +} + +#[test] +fn restore_preserves_guest_epoch_and_advances_downtime() { + let (mut pool, _vm_time_keeper, clock, mut rtc) = new_test_rtc(); + clock.tick(Duration::from_secs(10)); + let captured_time = clock.get_time(); + let saved = rtc.save().unwrap(); + + clock.tick(Duration::from_secs(60)); + rtc.restore(saved).unwrap(); + assert_eq!(clock.get_time(), captured_time); + + pool.run_until(rtc.advance_time(Duration::from_secs(3))) + .unwrap(); + assert_eq!( + clock.get_time() - captured_time, + Duration::from_secs(3).into() + ); + // The RTC keeps its own guest-visible time, so the state unit that wraps + // it must receive the advance. + assert!(rtc.advances_time()); +} + +#[test] +fn rtc_rejects_retired_coherent_transaction_state() { + let (_, _, _, mut rtc) = new_test_rtc(); + let mut saved = rtc.save().unwrap(); + saved.transaction_read_mask = Some(1 << 15); + + assert!(matches!( + rtc.restore(saved), + Err(vmcore::save_restore::RestoreError::InvalidSavedState(_)) + )); +} + +#[test] +fn rtc_restores_legacy_valid_time_semantics() { + let (_, _, _, mut rtc) = new_test_rtc(); + let mut saved = rtc.save().unwrap(); + saved.time_valid = None; + saved.transaction_read_mask = None; + + rtc.restore(saved).unwrap(); + assert_eq!(get_cmos_data(&mut rtc, CmosReg::STATUS_D), 0x80); +} diff --git a/vm/devices/chipset/src/ioapic/mod.rs b/vm/devices/chipset/src/ioapic/mod.rs index b0ed8007ed6..6c6adb6da4e 100644 --- a/vm/devices/chipset/src/ioapic/mod.rs +++ b/vm/devices/chipset/src/ioapic/mod.rs @@ -424,6 +424,8 @@ mod save_restore { pub(super) index: u8, #[mesh(3)] pub(super) redirection_entries: Vec, + #[mesh(4)] + pub(super) line_levels: Vec, } } @@ -446,6 +448,7 @@ mod save_restore { Ok(state::SavedState { redirection_entries: irqs.iter().map(|irq| irq.redirection.into()).collect(), + line_levels: irqs.iter().map(|irq| irq.line_level).collect(), index: index.0, id: *id, }) @@ -454,21 +457,32 @@ mod save_restore { fn restore(&mut self, state: state::SavedState) -> Result<(), RestoreError> { let state::SavedState { redirection_entries, + line_levels, id, index, } = state; if redirection_entries.len() != self.irqs.len() { return Err(RestoreError::Other(WrongNumberOfRedirectionEntries.into())); } - for (n, (state, irq)) in redirection_entries + let line_levels = if line_levels.is_empty() { + vec![false; self.irqs.len()] + } else if line_levels.len() == self.irqs.len() { + line_levels + } else { + return Err(RestoreError::Other(WrongNumberOfRedirectionEntries.into())); + }; + for (n, ((state, line_level), irq)) in redirection_entries .into_iter() + .zip(line_levels) .zip(self.irqs.iter_mut()) .enumerate() { irq.redirection = RedirectionEntry::from(state); + irq.line_level = line_level; let request = irq.redirection.as_msi(); self.routing.set_route(n as u8, request); irq.registered_request = request; + irq.evaluate(self.routing.as_ref(), &mut self.stats, n as u8, false); } self.id = id; self.index = IndexRegister(index); diff --git a/vm/devices/chipset/src/lib.rs b/vm/devices/chipset/src/lib.rs index 488fbb4f43e..a0a7fd6ee32 100644 --- a/vm/devices/chipset/src/lib.rs +++ b/vm/devices/chipset/src/lib.rs @@ -21,6 +21,7 @@ pub mod cmos_rtc; pub mod dma; pub mod i8042; pub mod ioapic; +pub mod microvm; pub mod pic; pub mod pit; pub mod pm; diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs new file mode 100644 index 00000000000..516537d3868 --- /dev/null +++ b/vm/devices/chipset/src/microvm.rs @@ -0,0 +1,1977 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM chipset devices. + +pub mod resolver; + +use chipset_device::ChipsetDevice; +use chipset_device::io::IoError; +use chipset_device::io::IoResult; +use chipset_device::io::deferred::DeferredWrite; +use chipset_device::io::deferred::defer_write; +use chipset_device::pio::PortIoIntercept; +use chipset_device::poll_device::PollDevice; +use chipset_resources::microvm::MicrovmPortbDrain; +use chipset_resources::microvm::MicrovmPortbTimeAbi; +use chipset_resources::microvm_time::RestorePacketBase; +use chipset_resources::microvm_time::RestorePacketV4; +use chipset_resources::microvm_time::RestoreTimeRecord; +use chipset_resources::microvm_time::TimeSample; +use futures::AsyncRead; +use futures::AsyncWrite; +use inspect::InspectMut; +use power_resources::PowerRequest; +use power_resources::PowerRequestClient; +use serial_core::SerialIo; +use serial_core::disconnected::Disconnected; +use std::collections::VecDeque; +use std::future::Future; +use std::io; +use std::io::ErrorKind; +use std::ops::RangeInclusive; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use std::time::SystemTime; +use vmcore::device_state::ChangeDeviceState; +use vmcore::save_restore::NoSavedState; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SaveRestore; +use vmcore::save_restore::SavedStateRoot; + +const DATA_PORT: u16 = 0xe9; +const STATUS_PORT: u16 = 0xea; +const TIME_WINDOW_PORT: u16 = chipset_resources::microvm_time::TIME_WINDOW_PORT; +const SHUTDOWN_PORT: u16 = 0x604; +const SNAPSHOT_PORT: u16 = 0x605; +const RESTORE_PACKET_SELECT: u8 = 0xa5; +const GENERATION_ID_SELECT: u8 = 0xa6; +const TIME_SAMPLE_SELECT: u8 = chipset_resources::microvm_time::TIME_SAMPLE_SELECT; +const GENERATION_ID_SIZE: usize = 16; +const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; +const STATUS_RESTORE_PACKET_AVAILABLE: u8 = 1 << 1; +const STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE: u8 = 1 << 2; +const STATUS_RESTORE_MEMORY_TARGET_AVAILABLE: u8 = 1 << 3; +const STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE: u8 = 1 << 4; +const STATUS_GENERATION_ID_AVAILABLE: u8 = 1 << 5; +const STATUS_TIME_SAMPLE_AVAILABLE: u8 = + chipset_resources::microvm_time::STATUS_TIME_SAMPLE_AVAILABLE; +const BUFFER_MAX: usize = 1024 * 1024; + +/// The restore packet version 4 of a restored VM process. +enum RestorePacketState { + /// No packet, or the packet was consumed. + None, + /// Waiting for the worker to seal the time fields. + Pending(RestorePacketBase, mesh::OneshotReceiver), + /// Sealed and not yet selected; host UTC is latched at the first + /// selection. + Sealed(RestorePacketBase, RestoreTimeRecord), + /// Encoded into the restore-packet queue. + Encoded, +} + +/// NVX time ABI v1 state of the portb device. +struct PortbTimeAbi { + generation: u32, + utc_offset_ns: i128, + sample_delay: std::time::Duration, + test_hooks: bool, + restore: RestorePacketState, + /// Notified at the guest's first selection of the restore packet. + packet_selected: Option>, +} + +impl PortbTimeAbi { + /// Returns host UTC in nanoseconds, after the test hooks' delay and + /// offset. + fn latch_utc_ns(&self) -> u64 { + if !self.sample_delay.is_zero() { + std::thread::sleep(self.sample_delay); + } + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |since_epoch| since_epoch.as_nanos() as i128); + (now + self.utc_offset_ns).clamp(0, u64::MAX.into()) as u64 + } + + /// Returns whether a sealed packet waits for its first selection, + /// receiving the sealed time fields if they arrived. + fn poll_sealed(&mut self) -> bool { + self.restore = match std::mem::replace(&mut self.restore, RestorePacketState::None) { + RestorePacketState::Pending(base, mut time) => { + match Pin::new(&mut time).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(record)) => RestorePacketState::Sealed(base, record), + Poll::Ready(Err(_)) => { + tracelimit::error_ratelimited!( + "microVM restore packet time fields were never sealed" + ); + RestorePacketState::None + } + Poll::Pending => RestorePacketState::Pending(base, time), + } + } + state => state, + }; + matches!(self.restore, RestorePacketState::Sealed(..)) + } +} + +/// Raw bidirectional microVM portb console. +#[derive(InspectMut)] +pub struct MicrovmPortb { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(mut)] + io: Box, + #[inspect(with = "VecDeque::len")] + rx_buffer: VecDeque, + #[inspect(with = "VecDeque::len")] + tx_buffer: VecDeque, + generation_id: [u8; GENERATION_ID_SIZE], + generation_id_read_index: Option, + /// The encoded restore packet, from its first selection until it is read. + #[inspect(with = "VecDeque::len")] + restore_packet: VecDeque, + restore_packet_selected: bool, + restore_processor_target_available: bool, + restore_memory_target_available: bool, + restore_memory_expansion_available: bool, + input_gated: bool, + #[inspect(skip)] + time_abi: PortbTimeAbi, + #[inspect(with = "VecDeque::len")] + time_window: VecDeque, + #[inspect(skip)] + rx_waker: Option, + #[inspect(skip)] + tx_waker: Option, + #[inspect(skip)] + output_drain_requests: Option>>, + #[inspect(skip)] + output_drain: Option<(MicrovmPortbDrain, mesh::rpc::FailableRpc<(), ()>)>, +} + +impl MicrovmPortb { + /// Creates a portb console using `io` as its host endpoint, with the NVX + /// time ABI v1 configuration `time_abi`: restore packet version 4 and the + /// time-sample window at port `0xeb`. + pub fn new( + io: Box, + generation_id: [u8; GENERATION_ID_SIZE], + time_abi: MicrovmPortbTimeAbi, + ) -> Self { + let MicrovmPortbTimeAbi { + generation, + utc_offset_ms, + sample_delay_us, + test_hooks, + restore, + } = time_abi; + let (processor_target, memory_target, memory_expansion) = + restore.as_ref().map_or((false, false, false), |source| { + ( + source.base.online_vp_count != 0, + source.base.memory_target, + !source.base.ranges.is_empty(), + ) + }); + let (restore, packet_selected) = match restore { + Some(source) => ( + RestorePacketState::Pending(source.base, source.time), + source.selected, + ), + None => (RestorePacketState::None, None), + }; + Self { + io_region: ("microvm-portb", DATA_PORT..=TIME_WINDOW_PORT), + io, + rx_buffer: VecDeque::new(), + tx_buffer: VecDeque::new(), + generation_id, + generation_id_read_index: None, + restore_packet: VecDeque::new(), + restore_packet_selected: false, + restore_processor_target_available: processor_target, + restore_memory_target_available: memory_target, + restore_memory_expansion_available: memory_expansion, + input_gated: false, + time_abi: PortbTimeAbi { + generation, + utc_offset_ns: i128::from(utc_offset_ms) * 1_000_000, + sample_delay: std::time::Duration::from_micros(sample_delay_us.into()), + test_hooks, + restore, + packet_selected, + }, + time_window: VecDeque::new(), + rx_waker: None, + tx_waker: None, + output_drain_requests: None, + output_drain: None, + } + } + + /// Handles selector `0xa5`: encodes the sealed packet with host UTC at + /// its first selection, then selects it. + fn select_restore_packet(&mut self) { + let time_abi = &mut self.time_abi; + if time_abi.poll_sealed() { + let RestorePacketState::Sealed(base, time) = + std::mem::replace(&mut time_abi.restore, RestorePacketState::Encoded) + else { + return; + }; + if let Some(selected) = time_abi.packet_selected.take() { + selected.send(()); + } + let packet = RestorePacketV4 { + base, + time, + utc_ns: time_abi.latch_utc_ns(), + }; + match packet.encode() { + Ok(bytes) => self.restore_packet = bytes.into(), + Err(error) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM restore packet is invalid" + ); + self.clear_restore_packet(); + return; + } + } + } + if !self.restore_packet.is_empty() { + self.generation_id_read_index = None; + self.restore_packet_selected = true; + } + } + + /// Handles selector `0xa7`: latches a fresh time sample into the window. + fn latch_time_sample(&mut self) { + let sample = TimeSample { + test_hooks: self.time_abi.test_hooks, + generation: self.time_abi.generation, + utc_ns: self.time_abi.latch_utc_ns(), + }; + self.time_window = sample.encode().into(); + } + + fn clear_restore_packet(&mut self) { + self.restore_packet.clear(); + self.restore_packet_selected = false; + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + self.time_abi.restore = RestorePacketState::None; + } + + /// Installs the output drain request channel. + /// + /// A drain request writes and flushes every byte the device has accepted. + /// [`MicrovmPortbDrain::Close`] then closes the endpoint before process + /// exit, while [`MicrovmPortbDrain::Flush`] keeps it open because a + /// snapshot capture may still roll back and resume the guest. + pub fn with_output_drain( + mut self, + requests: Option>>, + ) -> Self { + self.output_drain_requests = requests; + self + } + + /// Completes an active flush that has no connected endpoint to write to. + /// + /// The accepted bytes stay buffered, so a snapshot saves them for the + /// restored VM, as it does for output accepted while no peer is connected. + fn complete_flush_without_endpoint(&mut self, cx: &mut Context<'_>) { + if matches!(self.output_drain, Some((MicrovmPortbDrain::Flush, _))) + && let Some((_, request)) = self.output_drain.take() + { + tracing::debug!( + buffered_bytes = self.tx_buffer.len(), + "microVM portb endpoint is not connected; keeping buffered output" + ); + request.complete(Ok(())); + // Poll again to accept the next drain request. + cx.waker().wake_by_ref(); + } + } + + fn poll_rx(&mut self, cx: &mut Context<'_>) { + let mut buffer = [0; 256]; + loop { + if self.rx_buffer.len() == BUFFER_MAX { + self.rx_waker = Some(cx.waker().clone()); + return; + } + + let available = BUFFER_MAX - self.rx_buffer.len(); + let read_len = available.min(buffer.len()); + match Pin::new(&mut self.io).poll_read(cx, &mut buffer[..read_len]) { + Poll::Ready(Ok(0)) | Poll::Pending => return, + Poll::Ready(Ok(count)) => self.rx_buffer.extend(&buffer[..count]), + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM portb input failed" + ); + return; + } + } + } + } + + fn poll_tx(&mut self, cx: &mut Context<'_>) -> Poll> { + while !self.tx_buffer.is_empty() { + let (buffer, _) = self.tx_buffer.as_slices(); + match Pin::new(&mut self.io).poll_write(cx, buffer) { + Poll::Ready(Ok(0)) => return Poll::Ready(Err(ErrorKind::WriteZero.into())), + Poll::Ready(Ok(count)) => { + self.tx_buffer.drain(..count); + } + Poll::Ready(Err(error)) if error.kind() == ErrorKind::BrokenPipe => { + return Poll::Ready(Err(error)); + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + len = buffer.len(), + error = &error as &dyn std::error::Error, + "microVM portb output failed; dropping buffered bytes" + ); + self.tx_buffer.clear(); + self.tx_waker = Some(cx.waker().clone()); + return Poll::Ready(Err(error)); + } + Poll::Pending => return Poll::Pending, + } + } + self.tx_waker = Some(cx.waker().clone()); + Poll::Ready(Ok(())) + } + + fn wake_tx(&mut self) { + if let Some(waker) = self.tx_waker.take() { + waker.wake(); + } + } + + fn wake_rx(&mut self) { + if let Some(waker) = self.rx_waker.take() { + waker.wake(); + } + } +} + +impl ChangeDeviceState for MicrovmPortb { + fn start(&mut self) {} + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.input_gated = true; + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.input_gated = false; + self.wake_rx(); + Ok(()) + } + + async fn stop(&mut self) { + // Drain every byte the endpoint accepts immediately. A guest-requested + // snapshot flushes the output before stopping the device. Any remaining + // VMM-owned bytes are serialized and retried against the reconstructed + // endpoint after restore. + let _ = self.poll_tx(&mut Context::from_waker(Waker::noop())); + } + + async fn reset(&mut self) { + self.rx_buffer.clear(); + self.tx_buffer.clear(); + self.generation_id_read_index = None; + self.input_gated = false; + self.time_window.clear(); + self.clear_restore_packet(); + } +} + +impl ChipsetDevice for MicrovmPortb { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } + + fn supports_poll_device(&mut self) -> Option<&mut dyn PollDevice> { + Some(self) + } +} + +impl PollDevice for MicrovmPortb { + fn poll_device(&mut self, cx: &mut Context<'_>) { + if self.output_drain.is_none() + && let Some(requests) = &mut self.output_drain_requests + { + match requests.poll_recv(cx) { + Poll::Ready(Ok(request)) => { + let (drain, request) = request.split(); + tracing::debug!( + buffered_bytes = self.tx_buffer.len(), + ?drain, + "draining microVM portb output" + ); + self.output_drain = Some((drain, request)); + } + Poll::Ready(Err(_)) => self.output_drain_requests = None, + Poll::Pending => {} + } + } + if !self.io.is_connected() { + match self.io.poll_connect(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM portb backend connection failed" + ); + self.complete_flush_without_endpoint(cx); + return; + } + Poll::Pending => { + self.complete_flush_without_endpoint(cx); + return; + } + } + } + if !self.input_gated { + self.poll_rx(cx); + } + let output = self.poll_tx(cx); + if self.output_drain.is_some() { + let output = match output { + Poll::Ready(Ok(())) => Pin::new(&mut self.io).poll_flush(cx), + output => output, + }; + if let Poll::Ready(result) = output + && let Some((drain, request)) = self.output_drain.take() + { + match drain { + MicrovmPortbDrain::Flush => { + // Poll again to accept the next drain request. + cx.waker().wake_by_ref(); + } + MicrovmPortbDrain::Close => { + // Closing the endpoint publishes EOF only after every + // accepted byte has reached it. The controller also + // waits for the relay. + self.io = Box::new(Disconnected); + self.output_drain_requests = None; + } + } + request.handle_failable_sync(|()| result); + } + } + } +} + +impl PortIoIntercept for MicrovmPortb { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if data.is_empty() { + return IoResult::Err(IoError::InvalidAccessSize); + } + data.fill(0); + match io_port { + // Selected records are read 1, 2, or 4 bytes at a time; console + // input stays one byte per read. + DATA_PORT => { + if self.generation_id_read_index.is_some() { + for byte in data.iter_mut() { + let Some(index) = self.generation_id_read_index else { + break; + }; + *byte = self.generation_id[index]; + self.generation_id_read_index = + (index + 1 < self.generation_id.len()).then_some(index + 1); + } + } else if self.restore_packet_selected { + for byte in data.iter_mut() { + let Some(value) = self.restore_packet.pop_front() else { + break; + }; + *byte = value; + } + if self.restore_packet.is_empty() { + self.restore_packet_selected = false; + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + } + } else if !self.input_gated { + data[0] = self.rx_buffer.pop_front().unwrap_or(0); + self.wake_rx(); + } + } + STATUS_PORT => { + data[0] = if !self.input_gated + && self.generation_id_read_index.is_none() + && !self.restore_packet_selected + && !self.rx_buffer.is_empty() + { + STATUS_INPUT_AVAILABLE + } else { + 0 + }; + let sealed_packet = self.time_abi.poll_sealed(); + if matches!(self.time_abi.restore, RestorePacketState::None) + && self.restore_packet.is_empty() + { + // An unsealed packet was dropped: no targets. + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + } + if !self.restore_packet.is_empty() || sealed_packet { + data[0] |= STATUS_RESTORE_PACKET_AVAILABLE; + } + if self.restore_processor_target_available { + data[0] |= STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE; + } + if self.restore_memory_target_available { + data[0] |= STATUS_RESTORE_MEMORY_TARGET_AVAILABLE; + } + if self.restore_memory_expansion_available { + data[0] |= STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE; + } + data[0] |= STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE; + } + TIME_WINDOW_PORT => { + for byte in data.iter_mut() { + let Some(value) = self.time_window.pop_front() else { + break; + }; + *byte = value; + } + } + _ => return IoResult::Err(IoError::InvalidRegister), + } + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + match io_port { + DATA_PORT => { + let available = BUFFER_MAX - self.tx_buffer.len(); + self.tx_buffer.extend(data.iter().copied().take(available)); + if data.len() > available { + tracelimit::warn_ratelimited!( + dropped = data.len() - available, + "microVM portb output buffer full; dropping newest bytes" + ); + } + self.wake_tx(); + } + STATUS_PORT => match data.first() { + Some(&RESTORE_PACKET_SELECT) => self.select_restore_packet(), + Some(&GENERATION_ID_SELECT) => { + self.restore_packet_selected = false; + self.generation_id_read_index = Some(0); + } + Some(&TIME_SAMPLE_SELECT) => self.latch_time_sample(), + _ => {} + }, + TIME_WINDOW_PORT => {} + _ => return IoResult::Err(IoError::InvalidRegister), + } + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmPortb { + type SavedState = MicrovmPortbSavedState; + + fn save(&mut self) -> Result { + Ok(MicrovmPortbSavedState { + rx_buffer: self.rx_buffer.iter().copied().collect(), + tx_buffer: self.tx_buffer.iter().copied().collect(), + }) + } + + fn restore(&mut self, state: Self::SavedState) -> Result<(), RestoreError> { + if state.rx_buffer.len() > BUFFER_MAX || state.tx_buffer.len() > BUFFER_MAX { + return Err(RestoreError::InvalidSavedState( + io::Error::new( + ErrorKind::InvalidData, + format!("microVM portb buffer exceeds {BUFFER_MAX} bytes"), + ) + .into(), + )); + } + self.rx_buffer = state.rx_buffer.into(); + self.tx_buffer = state.tx_buffer.into(); + self.generation_id_read_index = None; + self.restore_packet_selected = false; + self.time_window.clear(); + Ok(()) + } +} + +/// Saved guest-visible bytes owned by the microVM portb device. +#[derive(mesh::payload::Protobuf, SavedStateRoot)] +#[mesh(package = "chipset.microvm_portb")] +pub struct MicrovmPortbSavedState { + /// Host bytes accepted but not consumed by the guest. + #[mesh(1)] + pub rx_buffer: Vec, + /// Guest bytes accepted but not acknowledged by the host endpoint. + #[mesh(2)] + pub tx_buffer: Vec, +} + +/// microVM process-status shutdown port. +#[derive(InspectMut)] +pub struct MicrovmShutdown { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(skip)] + power_request: PowerRequestClient, +} + +impl MicrovmShutdown { + /// Creates the shutdown device. + pub fn new(power_request: PowerRequestClient) -> Self { + Self { + io_region: ("microvm-shutdown", SHUTDOWN_PORT..=SHUTDOWN_PORT), + power_request, + } + } +} + +impl ChangeDeviceState for MicrovmShutdown { + fn start(&mut self) {} + async fn stop(&mut self) {} + async fn reset(&mut self) {} +} + +impl ChipsetDevice for MicrovmShutdown { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } +} + +impl PortIoIntercept for MicrovmShutdown { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if io_port != SHUTDOWN_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + data.fill(0xff); + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + if io_port != SHUTDOWN_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + self.power_request + .power_request(PowerRequest::PowerOffWithStatus { + code: data.first().copied().unwrap_or(0), + }); + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmShutdown { + type SavedState = NoSavedState; + + fn save(&mut self) -> Result { + Ok(NoSavedState) + } + + fn restore(&mut self, NoSavedState: Self::SavedState) -> Result<(), RestoreError> { + Ok(()) + } +} + +/// Snapshot-request port with at most one unacknowledged notification. +#[derive(InspectMut)] +pub struct MicrovmSnapshotRequest { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(skip)] + notify: Option>, + input_gate_timeout: std::time::Duration, + #[inspect(skip)] + pending: Option, + #[inspect(skip)] + poll_waker: Option, +} + +struct PendingSnapshotWrite { + release_write: mesh::OneshotReceiver<()>, + deferred_write: Option, + write_completed: Option>, + transaction_complete: mesh::rpc::PendingRpc<()>, + write_released: bool, +} + +impl MicrovmSnapshotRequest { + /// Creates a snapshot-request device with an optional asynchronous notification target. + pub fn new( + notify: Option>, + input_gate_timeout: std::time::Duration, + ) -> Self { + Self { + io_region: ("microvm-snapshot-request", SNAPSHOT_PORT..=SNAPSHOT_PORT), + notify, + input_gate_timeout, + pending: None, + poll_waker: None, + } + } + + fn poll_pending(&mut self) { + use std::future::Future; + + let mut cx = Context::from_waker(self.poll_waker.as_ref().unwrap_or(Waker::noop())); + let Some(pending) = &mut self.pending else { + return; + }; + if !pending.write_released + && Pin::new(&mut pending.release_write) + .poll(&mut cx) + .is_ready() + { + pending.write_released = true; + if let Some(deferred_write) = pending.deferred_write.take() { + deferred_write.complete(); + } + if let Some(write_completed) = pending.write_completed.take() { + write_completed.send(()); + } + } + if Pin::new(&mut pending.transaction_complete) + .poll(&mut cx) + .is_ready() + { + self.pending = None; + } + } +} + +impl ChangeDeviceState for MicrovmSnapshotRequest { + fn start(&mut self) {} + async fn stop(&mut self) { + if self + .pending + .as_ref() + .is_some_and(|pending| pending.write_released) + { + return; + } + if let Some(mut pending) = self.pending.take() { + if let Some(deferred_write) = pending.deferred_write.take() { + deferred_write.complete_error(IoError::InvalidRegister); + } + if let Some(write_completed) = pending.write_completed.take() { + write_completed.send(()); + } + } + } + async fn reset(&mut self) { + self.stop().await; + } +} + +impl ChipsetDevice for MicrovmSnapshotRequest { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } + + fn supports_poll_device(&mut self) -> Option<&mut dyn PollDevice> { + Some(self) + } +} + +impl PollDevice for MicrovmSnapshotRequest { + fn poll_device(&mut self, cx: &mut Context<'_>) { + self.poll_waker = Some(cx.waker().clone()); + self.poll_pending(); + } +} + +impl PortIoIntercept for MicrovmSnapshotRequest { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if io_port != SNAPSHOT_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + data.fill(0xff); + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + use mesh::rpc::RpcSend; + + if io_port != SNAPSHOT_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + // A completed transaction can resume the vCPUs before the device's + // poll task runs. Reap it before deciding this write is a duplicate. + self.poll_pending(); + if self.pending.is_some() { + tracelimit::warn_ratelimited!("coalescing duplicate microVM snapshot request"); + return IoResult::Ok; + } + if let Some(notify) = &self.notify { + let scratch_policy = if data.first().copied().unwrap_or(0) == 0 { + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + } else { + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + }; + let (deferred_write, token) = defer_write(); + let (release_write, release_recv) = mesh::oneshot(); + let (write_completed, write_completed_recv) = mesh::oneshot(); + let transaction_complete = notify.call( + |transaction_complete| chipset_resources::microvm::MicrovmSnapshotBoundaryRequest { + scratch_policy, + release_write, + write_completed: write_completed_recv, + input_gate_timeout: self.input_gate_timeout, + transaction_complete, + }, + (), + ); + self.pending = Some(PendingSnapshotWrite { + release_write: release_recv, + deferred_write: Some(deferred_write), + write_completed: Some(write_completed), + transaction_complete, + write_released: false, + }); + if let Some(waker) = &self.poll_waker { + waker.wake_by_ref(); + } + return IoResult::Defer(token); + } + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmSnapshotRequest { + type SavedState = NoSavedState; + + fn save(&mut self) -> Result { + Ok(NoSavedState) + } + + fn restore(&mut self, NoSavedState: Self::SavedState) -> Result<(), RestoreError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::AsyncRead; + use futures::AsyncWrite; + use futures::FutureExt; + use mesh::rpc::RpcSend; + use parking_lot::Mutex; + use serial_core::disconnected::Disconnected; + use serial_core::serial_io::Connected; + use std::io; + use std::pin::Pin; + use std::sync::Arc; + use std::task::Context; + use std::task::Poll; + use test_with_tracing::test; + + const TEST_GENERATION_ID: [u8; GENERATION_ID_SIZE] = [0x3c; GENERATION_ID_SIZE]; + + fn test_time_abi( + restore: Option, + ) -> MicrovmPortbTimeAbi { + MicrovmPortbTimeAbi { + generation: 7, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: true, + restore, + } + } + + #[derive(Default)] + struct OutputState { + bytes: Vec, + writable: bool, + flushed: bool, + closed: bool, + error: Option, + } + + struct BufferedOutput(Arc>); + + impl Drop for BufferedOutput { + fn drop(&mut self) { + self.0.lock().closed = true; + } + } + + impl AsyncRead for BufferedOutput { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buffer: &mut [u8], + ) -> Poll> { + Poll::Pending + } + } + + impl AsyncWrite for BufferedOutput { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + let mut state = self.0.lock(); + if let Some(error) = state.error { + return Poll::Ready(Err(error.into())); + } + if !state.writable { + return Poll::Pending; + } + let count = buffer.len().min(3); + state.bytes.extend_from_slice(&buffer[..count]); + Poll::Ready(Ok(count)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if self.0.lock().flushed { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.poll_flush(cx) + } + } + + #[test] + fn portb_exit_drain_waits_for_pending_writes_and_flush_before_closing() { + let state = Arc::new(Mutex::new(OutputState::default())); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); + let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n\0\xff"; + assert!(matches!(portb.io_write(DATA_PORT, payload), IoResult::Ok)); + let mut result = + Box::pin(requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close)); + let mut cx = Context::from_waker(Waker::noop()); + portb.poll_device(&mut cx); + assert!(result.as_mut().now_or_never().is_none()); + assert!(!state.lock().closed); + assert_eq!(portb.save().unwrap().tx_buffer, payload); + + state.lock().writable = true; + portb.poll_device(&mut cx); + assert!(result.as_mut().now_or_never().is_none()); + assert_eq!(state.lock().bytes, payload); + assert!(!state.lock().closed); + + state.lock().flushed = true; + portb.poll_device(&mut cx); + result.now_or_never().unwrap().unwrap(); + assert!(state.lock().closed); + assert!(portb.tx_buffer.is_empty()); + } + + #[test] + fn portb_exit_drain_reports_output_failure() { + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::BrokenPipe), + ..Default::default() + })); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let result = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert!(result.now_or_never().unwrap().is_err()); + assert!(state.lock().closed); + } + + #[test] + fn portb_snapshot_flush_delivers_output_and_keeps_endpoint_open() { + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::task::Wake; + + struct FlushWake(AtomicBool); + impl Wake for FlushWake { + fn wake(self: Arc) { + self.0.store(true, Ordering::Relaxed); + } + } + + let wake = Arc::new(FlushWake(AtomicBool::new(false))); + let waker = Waker::from(wake.clone()); + let mut cx = Context::from_waker(&waker); + let state = Arc::new(Mutex::new(OutputState::default())); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); + // The guest writes a marker just before its snapshot request, while + // the endpoint does not yet accept writes. + let marker = b"OPENVMM-LINUX-MPTABLE-SNAPSHOT-READY"; + assert!(matches!(portb.io_write(DATA_PORT, marker), IoResult::Ok)); + let mut flush = + Box::pin(requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush)); + portb.poll_device(&mut cx); + assert!(flush.as_mut().now_or_never().is_none()); + + state.lock().writable = true; + portb.poll_device(&mut cx); + assert!(flush.as_mut().now_or_never().is_none()); + assert_eq!(state.lock().bytes, marker); + + state.lock().flushed = true; + wake.0.store(false, Ordering::Relaxed); + portb.poll_device(&mut cx); + flush.now_or_never().unwrap().unwrap(); + assert!(wake.0.load(Ordering::Relaxed)); + assert!(!state.lock().closed); + + // A capture that rolls back keeps delivering guest output. + assert!(matches!(portb.io_write(DATA_PORT, b"+"), IoResult::Ok)); + portb.poll_device(&mut cx); + assert_eq!( + state.lock().bytes, + [marker.as_slice(), b"+".as_slice()].concat() + ); + + // A committed capture saves no stale source output, and the source + // endpoint holds the marker when the terminating process closes it. + futures::executor::block_on(portb.stop()); + assert!(portb.save().unwrap().tx_buffer.is_empty()); + drop(portb); + assert!(state.lock().closed); + } + + #[test] + fn portb_snapshot_flush_without_endpoint_keeps_output_for_restore() { + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let flush = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + flush.now_or_never().unwrap().unwrap(); + assert_eq!(portb.save().unwrap().tx_buffer, b"marker"); + } + + #[test] + fn portb_snapshot_flush_failure_keeps_endpoint_for_exit_drain() { + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::BrokenPipe), + ..Default::default() + })); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let flush = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush); + portb.poll_device(&mut cx); + assert!(flush.now_or_never().unwrap().is_err()); + assert!(!state.lock().closed); + assert_eq!(portb.save().unwrap().tx_buffer, b"marker"); + + *state.lock() = OutputState { + writable: true, + flushed: true, + ..Default::default() + }; + let close = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close); + portb.poll_device(&mut cx); + close.now_or_never().unwrap().unwrap(); + assert_eq!(state.lock().bytes, b"marker"); + assert!(state.lock().closed); + } + + #[test] + fn portb_output_error_preserves_the_next_write_wakeup() { + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::task::Wake; + + struct OutputWake(AtomicBool); + impl Wake for OutputWake { + fn wake(self: Arc) { + self.0.store(true, Ordering::Relaxed); + } + } + + let wake = Arc::new(OutputWake(AtomicBool::new(false))); + let waker = Waker::from(wake.clone()); + let mut cx = Context::from_waker(&waker); + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::Other), + ..Default::default() + })); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + test_time_abi(None), + ); + assert!(matches!(portb.io_write(DATA_PORT, b"lost"), IoResult::Ok)); + portb.poll_device(&mut cx); + state.lock().error = None; + state.lock().writable = true; + assert!(matches!( + portb.io_write(DATA_PORT, b"next write"), + IoResult::Ok + )); + assert!(wake.0.load(Ordering::Relaxed)); + portb.poll_device(&mut cx); + assert_eq!(state.lock().bytes, b"next write"); + } + + struct ConnectWithByte { + connected: bool, + byte: Option, + } + + impl InspectMut for ConnectWithByte { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } + } + + impl SerialIo for ConnectWithByte { + fn is_connected(&self) -> bool { + self.connected + } + + fn poll_connect(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.connected = true; + Poll::Ready(Ok(())) + } + + fn poll_disconnect(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + } + + impl AsyncRead for ConnectWithByte { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + data: &mut [u8], + ) -> Poll> { + let Some(byte) = self.byte.take() else { + return Poll::Pending; + }; + data[0] = byte; + Poll::Ready(Ok(1)) + } + } + + impl AsyncWrite for ConnectWithByte { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + Poll::Ready(Ok(data.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[test] + fn portb_preserves_wide_binary_output_and_zero_fills_reads() { + let mut portb = time_abi_portb(None); + assert!(matches!( + portb.io_write(DATA_PORT, b"\0\xffA\x80"), + IoResult::Ok + )); + assert_eq!(portb.tx_buffer, b"\0\xffA\x80"); + + portb.rx_buffer.extend([0x5a, 0x6b]); + let mut status = [0xff; 4]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut status), + IoResult::Ok + )); + assert_eq!( + status, + [ + STATUS_INPUT_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE, + 0, + 0, + 0 + ] + ); + + let mut data = [0xff; 4]; + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0x5a, 0, 0, 0]); + } + + #[test] + fn pending_portb_bytes_survive_restore_and_the_restore_packet_is_private() { + let mut portb = time_abi_portb(None); + portb.rx_buffer.extend([1, 2, 3]); + portb.tx_buffer.extend([4, 5, 6]); + let state = portb.save().unwrap(); + + let base = RestorePacketBase { + online_vp_count: 0, + image_slot_target: 0, + memory_target: false, + ack_required: false, + generation: 8, + ranges: Vec::new(), + entropy: [0x7e; 64], + }; + let (send, recv) = mesh::oneshot(); + let mut restored = MicrovmPortb::new( + Box::new(Disconnected), + [2; 16], + test_time_abi(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + selected: None, + }, + )), + ); + restored.restore(state).unwrap(); + assert_eq!(restored.rx_buffer, [1, 2, 3]); + assert_eq!(restored.tx_buffer, [4, 5, 6]); + assert_eq!(restored.generation_id, [2; 16]); + + let mut data = [0]; + for expected in [1, 2, 3] { + assert!(matches!( + restored.io_read(DATA_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [expected]); + } + assert!(matches!( + restored.io_read(DATA_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [0]); + + // The restored process serves its own packet, never saved state. + send.send(RestoreTimeRecord { + downtime_ns: 1_000, + downtime_utc: false, + rate_deviation: 0, + test_hooks: true, + }); + assert!(matches!( + restored.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + let bytes = read_record(&mut restored, DATA_PORT, base.encoded_len()); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap().base, base); + } + + #[test] + fn generation_id_is_repeatable_and_not_consumed() { + let mut portb = time_abi_portb(None); + let mut data = [0]; + + for _ in 0..2 { + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE] + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), + IoResult::Ok + )); + for expected in TEST_GENERATION_ID { + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [expected]); + } + } + } + + #[test] + fn restore_packet_v4_advertises_its_targets() { + let range = chipset_resources::microvm_time::RestoreMemoryRange { + gpa_start: 0x2000_0000, + length: 0x2000_0000, + }; + for (online_vp_count, memory_target, ranges, targets) in [ + ( + 2, + false, + Vec::new(), + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE, + ), + (0, true, Vec::new(), STATUS_RESTORE_MEMORY_TARGET_AVAILABLE), + ( + 0, + true, + vec![range], + STATUS_RESTORE_MEMORY_TARGET_AVAILABLE | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE, + ), + ( + 2, + true, + vec![range], + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE, + ), + ] { + let base = RestorePacketBase { + online_vp_count, + image_slot_target: 0, + memory_target, + ack_required: true, + generation: 7, + ranges, + entropy: [0x5a; 64], + }; + let (send, recv) = mesh::oneshot(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + selected: None, + }, + )); + send.send(RestoreTimeRecord { + downtime_ns: 0, + downtime_utc: false, + rate_deviation: 0, + test_hooks: true, + }); + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PACKET_AVAILABLE + | targets + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + let bytes = read_record(&mut portb, DATA_PORT, base.encoded_len()); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap().base, base); + // Reading the whole packet clears every target. + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + } + } + + #[test] + fn accepted_connection_is_polled_immediately() { + let mut portb = MicrovmPortb::new( + Box::new(ConnectWithByte { + connected: false, + byte: Some(0x5a), + }), + TEST_GENERATION_ID, + test_time_abi(None), + ); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert_eq!(portb.rx_buffer, [0x5a]); + } + + fn time_abi_portb( + restore: Option, + ) -> MicrovmPortb { + MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + test_time_abi(restore), + ) + } + + fn read_status(portb: &mut MicrovmPortb) -> u8 { + let mut data = [0]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + data[0] + } + + fn read_record(portb: &mut MicrovmPortb, port: u16, len: usize) -> Vec { + let mut bytes = Vec::new(); + while bytes.len() < len { + let mut data = [0; 4]; + assert!(matches!(portb.io_read(port, &mut data), IoResult::Ok)); + bytes.extend(data); + } + bytes.truncate(len); + bytes + } + + fn now_ns() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + #[test] + fn time_abi_portb_cold_boot_offers_time_samples() { + let mut portb = time_abi_portb(None); + assert_eq!(portb.get_static_regions()[0].1, 0xe9..=0xeb); + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + // No packet on a cold boot: selecting it reads console input. + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + assert!(!portb.restore_packet_selected); + + // The window is empty until a sample is latched. + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + let before = now_ns(); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + let after = now_ns(); + let sample = TimeSample::decode(&read_record(&mut portb, TIME_WINDOW_PORT, 16)).unwrap(); + assert_eq!((sample.generation, sample.test_hooks), (7, true)); + assert!((before..=after).contains(&sample.utc_ns)); + // Reads past the end are zero, and selecting a sample keeps the + // generation-ID selection. + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + assert!(matches!( + portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), + IoResult::Ok + )); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + assert_eq!(read_record(&mut portb, DATA_PORT, 16), TEST_GENERATION_ID); + } + + #[test] + fn time_abi_portb_serves_restore_packet_v4() { + let base = RestorePacketBase { + online_vp_count: 2, + image_slot_target: 0, + memory_target: false, + ack_required: true, + generation: 7, + ranges: Vec::new(), + entropy: [0x3c; 64], + }; + let time = RestoreTimeRecord { + downtime_ns: 1_000_000, + downtime_utc: true, + rate_deviation: -5, + test_hooks: true, + }; + let (send, recv) = mesh::oneshot(); + let (selected_send, mut selected) = mesh::oneshot(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + selected: Some(selected_send), + }, + )); + // Unsealed: only the targets are advertised. + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + send.send(time); + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + assert!( + (&mut selected).now_or_never().is_none(), + "selection is notified only at the first selection" + ); + + let before = now_ns(); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + let after = now_ns(); + assert!(matches!(selected.now_or_never(), Some(Ok(())))); + let bytes = read_record(&mut portb, DATA_PORT, base.encoded_len()); + let packet = RestorePacketV4::decode(&bytes).unwrap(); + assert_eq!((packet.base, packet.time), (base, time)); + assert!((before..=after).contains(&packet.utc_ns)); + + // The packet is consumed once read. + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + assert!(!portb.restore_packet_selected); + } + + #[test] + fn time_abi_portb_drops_an_unsealed_packet() { + let (send, recv) = mesh::oneshot::(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: RestorePacketBase { + online_vp_count: 2, + image_slot_target: 0, + memory_target: false, + ack_required: false, + generation: 1, + ranges: Vec::new(), + entropy: [0; 64], + }, + time: recv, + selected: None, + }, + )); + drop(send); + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + } + + #[test] + fn time_abi_portb_window_is_not_saved() { + let mut portb = time_abi_portb(None); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + let state = portb.save().unwrap(); + portb.restore(state).unwrap(); + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + futures::executor::block_on(portb.reset()); + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + } + + #[test] + fn portb_input_gate_blocks_rx_but_not_tx() { + let mut portb = MicrovmPortb::new( + Box::new(ConnectWithByte { + connected: true, + byte: Some(0x5a), + }), + TEST_GENERATION_ID, + test_time_abi(None), + ); + portb.rx_buffer.push_back(0x44); + futures::executor::block_on(portb.quiesce_input()).unwrap(); + assert!(matches!(portb.io_write(DATA_PORT, b"output"), IoResult::Ok)); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + let mut data = [0xff]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE] + ); + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0]); + assert_eq!(portb.rx_buffer, [0x44]); + assert!(portb.tx_buffer.is_empty()); + + futures::executor::block_on(portb.resume_input()).unwrap(); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert_eq!(portb.rx_buffer, [0x44, 0x5a]); + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0x44]); + } + + #[test] + fn lifecycle_ports_preserve_status_and_remain_nonblocking() { + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let mut shutdown = + MicrovmShutdown::new((move |request| captured.lock().push(request)).into()); + assert!(matches!( + shutdown.io_write(SHUTDOWN_PORT, &[37, 99]), + IoResult::Ok + )); + assert_eq!( + *requests.lock(), + [PowerRequest::PowerOffWithStatus { code: 37 }] + ); + requests.lock().clear(); + assert!(matches!( + shutdown.io_write(SHUTDOWN_PORT, &[0x25]), + IoResult::Ok + )); + assert_eq!( + *requests.lock(), + [PowerRequest::PowerOffWithStatus { code: 0x25 }] + ); + + let mut snapshot = MicrovmSnapshotRequest::new(None, std::time::Duration::from_secs(1)); + let mut data = [0; 4]; + assert!(matches!( + snapshot.io_read(SNAPSHOT_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [0xff; 4]); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + } + + #[test] + fn snapshot_requests_are_coalesced_until_acknowledged() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[2, 3, 4, 5]), + IoResult::Ok + )); + let mut first = recv.try_recv().unwrap(); + assert_eq!( + first.scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + ); + assert!(recv.try_recv().is_err()); + assert!( + deferred_write + .poll_write(&mut Context::from_waker(Waker::noop())) + .is_pending() + ); + + first.release_write.send(()); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + assert!( + deferred_write + .poll_write(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); + assert!( + Pin::new(&mut first.write_completed) + .poll(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Ok + )); + assert!(recv.try_recv().is_err()); + + first.transaction_complete.complete(()); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[]), + IoResult::Defer(_) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); + } + + #[test] + fn snapshot_request_completed_transaction_is_reusable_without_poll() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + first.release_write.send(()); + snapshot.poll_device(&mut cx); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + + first.transaction_complete.complete(()); + // The next write can acquire the device before its poll task runs. + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); + } + + #[test] + fn snapshot_request_early_completion_releases_previous_write() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + first.release_write.send(()); + first.transaction_complete.complete(()); + + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Defer(_) + )); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + ); + } + + #[test] + fn snapshot_request_closed_transaction_is_reusable() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + drop(first.transaction_complete); + + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::NoResponse)) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Err(_)) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); + } + + #[test] + fn snapshot_request_duplicate_preserves_poll_waker() { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct WakeCount(AtomicUsize); + + impl futures::task::ArcWake for WakeCount { + fn wake_by_ref(arc_self: &Arc) { + arc_self.0.fetch_add(1, Ordering::Relaxed); + } + } + + let wakes = Arc::new(WakeCount::default()); + let waker = futures::task::waker(Arc::clone(&wakes)); + let mut cx = Context::from_waker(&waker); + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + let first = recv.try_recv().unwrap(); + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + wakes.0.store(0, Ordering::Relaxed); + first.release_write.send(()); + assert_ne!(wakes.0.load(Ordering::Relaxed), 0); + + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + wakes.0.store(0, Ordering::Relaxed); + first.transaction_complete.complete(()); + assert_ne!(wakes.0.load(Ordering::Relaxed), 0); + snapshot.poll_device(&mut cx); + assert!(snapshot.pending.is_none()); + } + + #[test] + fn snapshot_request_stop_and_reset_cancel_unreleased_write() { + for reset in [false, true] { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + if reset { + futures::executor::block_on(snapshot.reset()); + } else { + futures::executor::block_on(snapshot.stop()); + } + assert!(snapshot.pending.is_none()); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::InvalidRegister)) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); + } + } + + #[test] + fn snapshot_request_stop_and_reset_preserve_released_transaction() { + for reset in [false, true] { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let first = recv.try_recv().unwrap(); + first.release_write.send(()); + snapshot.poll_device(&mut cx); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + if reset { + futures::executor::block_on(snapshot.reset()); + } else { + futures::executor::block_on(snapshot.stop()); + } + assert!(snapshot.pending.is_some()); + snapshot.start(); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Ok + )); + assert!(recv.try_recv().is_err()); + first.transaction_complete.complete(()); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); + } + } +} diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs new file mode 100644 index 00000000000..8384912f93d --- /dev/null +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource resolver for microVM chipset devices. + +use super::MicrovmPortb; +use super::MicrovmShutdown; +use super::MicrovmSnapshotRequest; +use async_trait::async_trait; +use chipset_device_resources::ResolveChipsetDeviceHandleParams; +use chipset_device_resources::ResolvedChipsetDevice; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use power_resources::PowerRequestHandleKind; +use serial_core::resources::ResolveSerialBackendParams; +use thiserror::Error; +use vm_resource::AsyncResolveResource; +use vm_resource::IntoResource; +use vm_resource::PlatformResource; +use vm_resource::ResolveError; +use vm_resource::ResolveResource; +use vm_resource::ResourceResolver; +use vm_resource::declare_static_async_resolver; +use vm_resource::declare_static_resolver; +use vm_resource::kind::ChipsetDeviceHandleKind; + +/// Resolver for the microVM portb console. +pub struct MicrovmPortbResolver; + +declare_static_async_resolver! { + MicrovmPortbResolver, + (ChipsetDeviceHandleKind, MicrovmPortbHandle), +} + +#[derive(Debug, Error)] +pub enum ResolveMicrovmPortbError { + #[error("failed to resolve microVM portb backend")] + ResolveBackend(#[source] ResolveError), +} + +#[async_trait] +impl AsyncResolveResource for MicrovmPortbResolver { + type Output = ResolvedChipsetDevice; + type Error = ResolveMicrovmPortbError; + + async fn resolve( + &self, + resolver: &ResourceResolver, + resource: MicrovmPortbHandle, + input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + let io = resolver + .resolve( + resource.io, + ResolveSerialBackendParams { + driver: Box::new(input.task_driver_source.simple()), + _async_trait_workaround: &(), + }, + ) + .await + .map_err(ResolveMicrovmPortbError::ResolveBackend)?; + let portb = MicrovmPortb::new(io.0.into_io(), resource.generation_id, resource.time_abi) + .with_output_drain(resource.output_drain); + Ok(portb.into()) + } +} + +/// Resolver for the microVM shutdown port. +pub struct MicrovmShutdownResolver; + +declare_static_async_resolver! { + MicrovmShutdownResolver, + (ChipsetDeviceHandleKind, MicrovmShutdownHandle), +} + +/// Resolver for the microVM snapshot-request port. +pub struct MicrovmSnapshotRequestResolver; + +declare_static_resolver! { + MicrovmSnapshotRequestResolver, + (ChipsetDeviceHandleKind, MicrovmSnapshotRequestHandle), +} + +#[async_trait] +impl AsyncResolveResource + for MicrovmShutdownResolver +{ + type Output = ResolvedChipsetDevice; + type Error = ResolveMicrovmPortbError; + + async fn resolve( + &self, + resolver: &ResourceResolver, + _resource: MicrovmShutdownHandle, + _input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + let power_request = resolver + .resolve::(PlatformResource.into_resource(), ()) + .await + .map_err(ResolveMicrovmPortbError::ResolveBackend)?; + Ok(MicrovmShutdown::new(power_request).into()) + } +} + +impl ResolveResource + for MicrovmSnapshotRequestResolver +{ + type Output = ResolvedChipsetDevice; + type Error = std::convert::Infallible; + + fn resolve( + &self, + resource: MicrovmSnapshotRequestHandle, + _input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + Ok(MicrovmSnapshotRequest::new(resource.notify, resource.input_gate_timeout).into()) + } +} diff --git a/vm/devices/chipset/src/pit/mod.rs b/vm/devices/chipset/src/pit/mod.rs index 7df675e9810..6ee008aa123 100644 --- a/vm/devices/chipset/src/pit/mod.rs +++ b/vm/devices/chipset/src/pit/mod.rs @@ -35,6 +35,14 @@ struct ControlWord { #[bits(2)] select: u8, } +impl PartialEq for ControlWord { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for ControlWord {} + #[rustfmt::skip] #[bitfield(u8)] struct StatusWord { @@ -75,7 +83,7 @@ struct Timer { state: TimerState, } -#[derive(Copy, Clone, Debug, Inspect)] +#[derive(Copy, Clone, Debug, Inspect, PartialEq, Eq)] struct TimerState { ce: u16, // "counting element", i.e. the counter cr: u16, // count register, the new value @@ -216,6 +224,30 @@ impl Timer { let mode = self.state.op_mode(); let bcd = self.state.control.bcd(); while ticks > 0 { + if self.state.state == CountState::Active + && self.state.ce == self.state.cr + && matches!(mode, Mode::RateGenerator | Mode::SquareWave) + { + let reload = if bcd { + u64::from(from_bcd(self.state.cr)) + } else { + u64::from(self.state.cr) + }; + let period = if reload == 0 { + if bcd { 10_000 } else { 0x1_0000 } + } else { + reload + }; + if ticks >= period { + let out = self.state.out; + self.set_out(!out); + self.set_out(out); + ticks %= period; + if ticks == 0 { + break; + } + } + } match self.state.state { CountState::Inactive | CountState::WaitingForGate => break, CountState::Reloading => { @@ -513,10 +545,21 @@ pub struct PitDevice { // Runtime book-keeping dram_refresh: bool, // just jitters back and forth + // Static configuration + time_abi: bool, + // Volatile state last: VmTime, } +/// Channel 0 counts in a periodic mode, which the NVX time ABI forbids at +/// snapshot capture and restore. +#[derive(Debug, Error)] +#[error("[E_PIT_ACTIVE] PIT channel 0 is counting in periodic mode {mode:?}")] +struct PitActiveError { + mode: Mode, +} + impl PitDevice { pub fn new(interrupt: LineInterrupt, vmtime: VmTimeAccess) -> Self { PitDevice { @@ -530,7 +573,28 @@ impl PitDevice { last: vmtime.now(), vmtime, dram_refresh: false, + time_abi: false, + } + } + + /// Applies the NVX time ABI: saving or restoring the device while + /// channel 0 counts in a periodic mode fails with `E_PIT_ACTIVE`. + pub fn with_time_abi(mut self) -> Self { + self.time_abi = true; + self + } + + /// Fails if the time ABI applies and channel 0 counts in a periodic mode. + fn check_time_abi(&self) -> Result<(), PitActiveError> { + let state = &self.timers[0].state; + let mode = state.op_mode(); + if self.time_abi + && matches!(mode, Mode::RateGenerator | Mode::SquareWave) + && state.state != CountState::Inactive + { + return Err(PitActiveError { mode }); } + Ok(()) } fn evaluate(&mut self, now: VmTime) { @@ -563,7 +627,10 @@ impl PitDevice { } impl ChangeDeviceState for PitDevice { - fn start(&mut self) {} + fn start(&mut self) { + self.evaluate(self.vmtime.now()); + self.arm_wakeup(); + } async fn stop(&mut self) {} @@ -761,10 +828,13 @@ mod save_restore { type SavedState = state::SavedState; fn save(&mut self) -> Result { + self.check_time_abi() + .map_err(|err| SaveError::Other(err.into()))?; let Self { vmtime: _, timers, dram_refresh: _, + time_abi: _, last, } = self; @@ -856,6 +926,8 @@ mod save_restore { PitDeviceRestoreError::InvalidLastTick.into(), )); } + self.check_time_abi() + .map_err(|err| RestoreError::InvalidSavedState(err.into()))?; Ok(()) } @@ -870,6 +942,20 @@ mod tests { use super::Timer; use super::to_bcd; use crate::pit::from_bcd; + use parking_lot::Mutex; + use std::sync::Arc; + use test_with_tracing::test; + use vmcore::line_interrupt::LineInterrupt; + use vmcore::line_interrupt::LineSetTarget; + + #[derive(Default)] + struct RecordingTarget(Mutex>); + + impl LineSetTarget for RecordingTarget { + fn set_irq(&self, _vector: u32, high: bool) { + self.0.lock().push(high); + } + } #[test] fn test_bcd_comp() { @@ -972,4 +1058,123 @@ mod tests { fn test_bcd() { test_output(true); } + + #[test] + fn elapsed_downtime_matches_incremental_pit_evaluation() { + for mode in [ + Mode::TerminalCount, + Mode::RateGenerator, + Mode::SquareWave, + Mode::SoftwareStrobe, + ] { + let mut elapsed = Timer::new(true, None); + let mut incremental = Timer::new(true, None); + set_timer(&mut elapsed, mode, 100, false); + set_timer(&mut incremental, mode, 100, false); + + elapsed.evaluate(350); + for _ in 0..350 { + incremental.evaluate(1); + } + + assert_eq!(elapsed.state, incremental.state, "{mode:?}"); + } + } + + #[test] + fn elapsed_downtime_preserves_periodic_phase() { + let mut timer = Timer::new(true, None); + set_timer(&mut timer, Mode::RateGenerator, 100, false); + + timer.evaluate(350); + + assert_eq!(timer.state.ce, 51); + assert!(timer.state.out); + assert_eq!(timer.state.next_wakeup(), Some(50)); + } + + #[test] + fn long_elapsed_downtime_fast_forwards_periodic_pit() { + for mode in [Mode::RateGenerator, Mode::SquareWave] { + let mut elapsed = Timer::new(true, None); + let mut remainder = Timer::new(true, None); + set_timer(&mut elapsed, mode, 100, false); + set_timer(&mut remainder, mode, 100, false); + elapsed.evaluate(1); + remainder.evaluate(1); + + elapsed.evaluate(u64::MAX); + remainder.evaluate(u64::MAX % 100); + + assert_eq!(elapsed.state, remainder.state, "{mode:?}"); + } + } + + #[test] + fn long_elapsed_downtime_coalesces_periodic_irq0_edges() { + let target = Arc::new(RecordingTarget::default()); + let interrupt = LineInterrupt::new_with_target("pit", target.clone(), 0); + let mut timer = Timer::new(true, Some(interrupt)); + set_timer(&mut timer, Mode::RateGenerator, 2, false); + timer.evaluate(1); + target.0.lock().clear(); + + timer.evaluate(u64::MAX - 1); + + assert_eq!(*target.0.lock(), [false, true]); + assert!(timer.state.out); + } + + #[test] + fn time_abi_rejects_periodic_channel0_at_save_and_restore() { + use super::PitDevice; + use vmcore::save_restore::SaveRestore; + use vmcore::vmtime::VmTime; + + let mut pool = pal_async::DefaultPool::new(); + let driver = pool.driver(); + let keeper = vmcore::vmtime::VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let source = pool.run_until(keeper.builder().build(&driver)).unwrap(); + let device = |time_abi| { + let device = PitDevice::new(LineInterrupt::detached(), source.access("pit")); + if time_abi { + device.with_time_abi() + } else { + device + } + }; + + // Linux stops channel 0 by programming mode 0, which is accepted. + let mut stopped = device(true); + set_timer(&mut stopped.timers[0], Mode::TerminalCount, 0, false); + let saved = stopped.save().unwrap(); + + for mode in [Mode::RateGenerator, Mode::SquareWave] { + let mut periodic = device(true); + set_timer(&mut periodic.timers[0], mode, 100, false); + periodic.timers[0].evaluate(1); + let Err(err) = periodic.save() else { + panic!("a periodic channel 0 saved under the time ABI"); + }; + assert!(format!("{err:#}").contains("[E_PIT_ACTIVE]"), "{err:#}"); + + // Without the time ABI the same state saves, and restoring it + // into a time ABI device fails. + let mut legacy = device(false); + set_timer(&mut legacy.timers[0], mode, 100, false); + legacy.timers[0].evaluate(1); + let saved_periodic = legacy.save().unwrap(); + let err = device(true).restore(saved_periodic).unwrap_err(); + let mut chain = err.to_string(); + let mut source = std::error::Error::source(&err); + while let Some(error) = source { + chain = format!("{chain}: {error}"); + source = error.source(); + } + assert!(chain.contains("[E_PIT_ACTIVE]"), "{chain}"); + } + + device(true).restore(saved).unwrap(); + drop(keeper); + } } diff --git a/vm/devices/chipset/src/pit/resolver.rs b/vm/devices/chipset/src/pit/resolver.rs index f43ee07c6c3..061771c0528 100644 --- a/vm/devices/chipset/src/pit/resolver.rs +++ b/vm/devices/chipset/src/pit/resolver.rs @@ -26,11 +26,17 @@ impl ResolveResource for PitResolver { fn resolve( &self, - _resource: PitDeviceHandle, + resource: PitDeviceHandle, input: ResolveChipsetDeviceHandleParams<'_>, ) -> Result { let interrupt = input.configure.new_line(IRQ_LINE_SET, "timer0", 2); // hard-coded IRQ lines, as per x86 spec let vmtime = input.vmtime.access("pit"); - Ok(PitDevice::new(interrupt, vmtime).into()) + let device = PitDevice::new(interrupt, vmtime); + Ok(if resource.time_abi { + device.with_time_abi() + } else { + device + } + .into()) } } diff --git a/vm/devices/chipset_legacy/Cargo.toml b/vm/devices/chipset_legacy/Cargo.toml index 8999be9ec5a..cb37972717b 100644 --- a/vm/devices/chipset_legacy/Cargo.toml +++ b/vm/devices/chipset_legacy/Cargo.toml @@ -22,6 +22,7 @@ power_resources.workspace = true vmcore.workspace = true async-trait.workspace = true +anyhow.workspace = true inspect.workspace = true mesh.workspace = true open_enum.workspace = true diff --git a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs index d32668c78ce..e667766223f 100644 --- a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs +++ b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs @@ -76,6 +76,14 @@ impl ChangeDeviceState for Piix4CmosRtc { async fn reset(&mut self) { self.inner.reset().await; } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.inner.advance_time(duration).await + } + + fn advances_time(&self) -> bool { + self.inner.advances_time() + } } impl ChipsetDevice for Piix4CmosRtc { diff --git a/vm/devices/chipset_resources/Cargo.toml b/vm/devices/chipset_resources/Cargo.toml index 7659952adb0..f7c69627fba 100644 --- a/vm/devices/chipset_resources/Cargo.toml +++ b/vm/devices/chipset_resources/Cargo.toml @@ -14,6 +14,7 @@ vm_resource.workspace = true inspect.workspace = true memory_range.workspace = true mesh.workspace = true +thiserror.workspace = true arbitrary = { workspace = true, optional = true, features = ["derive"] } diff --git a/vm/devices/chipset_resources/src/lib.rs b/vm/devices/chipset_resources/src/lib.rs index cefbe8558f9..9b836062a71 100644 --- a/vm/devices/chipset_resources/src/lib.rs +++ b/vm/devices/chipset_resources/src/lib.rs @@ -218,14 +218,21 @@ pub mod pit { use vm_resource::kind::ChipsetDeviceHandleKind; /// A handle to a PIT (Intel 8253/8254 Programmable Interval Timer) device. - #[derive(MeshPayload)] - pub struct PitDeviceHandle; + #[derive(MeshPayload, Default)] + pub struct PitDeviceHandle { + /// Applies the NVX time ABI: saving or restoring the PIT while + /// channel 0 counts in a periodic mode fails with `E_PIT_ACTIVE`. + pub time_abi: bool, + } impl ResourceId for PitDeviceHandle { const ID: &'static str = "pit"; } } +pub mod microvm; +pub mod microvm_time; + pub mod battery { //! Resource definitions for the battery device diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs new file mode 100644 index 00000000000..cc913c41015 --- /dev/null +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource definitions for microVM chipset devices. + +use mesh::MeshPayload; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::ChipsetDeviceHandleKind; +use vm_resource::kind::SerialBackendHandle; + +/// Scratch handling requested at a microVM snapshot boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum MicrovmSnapshotScratchPolicy { + /// Scratch is unmounted and restore must attach a fresh image. + Fresh, + /// Scratch is mounted and must be paired with VM state. + Paired, +} + +/// Worker-local request used to establish the exact post-OUT snapshot boundary. +#[derive(MeshPayload)] +pub struct MicrovmSnapshotBoundaryRequest { + /// Whether capture must pair the current scratch image. + pub scratch_policy: MicrovmSnapshotScratchPolicy, + /// Signals the device after all vCPUs have stopped. + pub release_write: mesh::OneshotSender<()>, + /// Completes after the deferred PMIO write has completed. + pub write_completed: mesh::OneshotReceiver<()>, + /// Maximum time allowed to gate host input before stopping vCPUs. + pub input_gate_timeout: std::time::Duration, + /// Completed only after the final transaction outcome is known. + pub transaction_complete: mesh::rpc::Rpc<(), ()>, +} + +/// How the microVM portb device completes an output drain request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum MicrovmPortbDrain { + /// Write and flush the accepted output, keeping the endpoint open, before + /// a snapshot capture that may still roll back. + Flush, + /// Write and flush the accepted output, then close the endpoint so a host + /// relay sees EOF, before the VM process exits. + Close, +} + +/// The microVM bidirectional portb console at ports `0xe9` and `0xea`, with +/// the NVX time ABI time-sample window at `0xeb`. +#[derive(MeshPayload)] +pub struct MicrovmPortbHandle { + /// Host serial endpoint used for raw input and output. + pub io: Resource, + /// Fresh generation ID for this microVM instance. + pub generation_id: [u8; 16], + /// Requests to deliver accepted output before a snapshot capture or before + /// terminating the VM process. + pub output_drain: Option>>, + /// NVX time ABI v1 configuration: restore packet version 4 and the time + /// samples at port `0xeb`. + pub time_abi: MicrovmPortbTimeAbi, +} + +/// NVX time ABI v1 configuration of the portb device. +#[derive(MeshPayload)] +pub struct MicrovmPortbTimeAbi { + /// The generation counter of this VM process. + pub generation: u32, + /// Test hook: milliseconds added to every host UTC reading. + pub utc_offset_ms: i64, + /// Test hook: microseconds of delay before latching a UTC reading. + pub sample_delay_us: u32, + /// Whether a test hook is active. + pub test_hooks: bool, + /// The restore packet of a restored VM process. + pub restore: Option, +} + +/// The parts of a restore packet version 4. +#[derive(MeshPayload)] +pub struct MicrovmRestorePacketSource { + /// The fields the controller knows before the worker starts. + pub base: crate::microvm_time::RestorePacketBase, + /// The time fields the worker seals before the first restored VP runs. + pub time: mesh::OneshotReceiver, + /// Notified when the guest first selects the packet, which ends the + /// guest-resume phase of the restore profile. + pub selected: Option>, +} + +impl ResourceId for MicrovmPortbHandle { + const ID: &'static str = "microvm-portb"; +} + +/// microVM shutdown control port at `0x604`. +#[derive(MeshPayload)] +pub struct MicrovmShutdownHandle; + +impl ResourceId for MicrovmShutdownHandle { + const ID: &'static str = "microvm-shutdown"; +} + +/// microVM snapshot-request port at `0x605`. +#[derive(MeshPayload)] +pub struct MicrovmSnapshotRequestHandle { + /// Optional worker-local boundary coordination target. + pub notify: Option>, + /// Maximum time allowed to gate host input before stopping vCPUs. + pub input_gate_timeout: std::time::Duration, +} + +impl ResourceId for MicrovmSnapshotRequestHandle { + const ID: &'static str = "microvm-snapshot-request"; +} diff --git a/vm/devices/chipset_resources/src/microvm_time.rs b/vm/devices/chipset_resources/src/microvm_time.rs new file mode 100644 index 00000000000..23a9d3d7926 --- /dev/null +++ b/vm/devices/chipset_resources/src/microvm_time.rs @@ -0,0 +1,588 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The microVM restore packet version 4 and the time sample of the NVX time +//! ABI v1, as the guest reads them through the portb device. +//! +//! Every byte the guest reads is a port exit, so the records are compact and +//! readable four bytes at a time. + +use mesh::MeshPayload; +use thiserror::Error; + +/// The restore packet magic. +pub const RESTORE_PACKET_MAGIC: [u8; 3] = *b"OVR"; +/// The restore packet version. +pub const RESTORE_PACKET_VERSION: u8 = 4; +/// The length of the restore packet header. +pub const RESTORE_PACKET_HEADER_LEN: usize = 32; +/// The length of one memory range record. +pub const RESTORE_RANGE_LEN: usize = 16; +/// The length of the restore entropy. +pub const RESTORE_ENTROPY_LEN: usize = 64; +/// The most memory ranges a packet can carry. +pub const MAX_RESTORE_RANGES: usize = u8::MAX as usize; +/// The online VP targets a packet can carry; 0 means no processor target. +pub const VALID_ONLINE_VP_COUNTS: [u8; 5] = [0, 1, 2, 4, 8]; +/// The largest rate deviation magnitude: 250 ppm scaled by 2^16. +pub const MAX_RATE_DEVIATION: i32 = 250 * 65_536; + +/// `D` came from the UTC delta. +pub const FLAG_DOWNTIME_UTC: u8 = 1 << 0; +/// An explicit RAM target was requested. +pub const FLAG_MEMORY_TARGET: u8 = 1 << 1; +/// Host input is gated until the guest acknowledges through port `0x605`. +pub const FLAG_ACK_REQUIRED: u8 = 1 << 2; +/// A test hook altered time ABI behavior. +pub const FLAG_TEST_HOOKS: u8 = 1 << 3; +/// An image-slot target occupies header byte 7. +pub const FLAG_IMAGE_SLOT_TARGET: u8 = 1 << 4; +const RESTORE_PACKET_FLAGS: u8 = FLAG_DOWNTIME_UTC + | FLAG_MEMORY_TARGET + | FLAG_ACK_REQUIRED + | FLAG_TEST_HOOKS + | FLAG_IMAGE_SLOT_TARGET; +/// The largest image-slot target. +pub const MAX_IMAGE_SLOT_TARGET: u8 = 4; + +/// The portb selector of the restore packet. +pub const RESTORE_PACKET_SELECT: u8 = 0xa5; +/// The portb selector of the generation ID. +pub const GENERATION_ID_SELECT: u8 = 0xa6; +/// The portb selector that latches a time sample. +pub const TIME_SAMPLE_SELECT: u8 = 0xa7; +/// The port of the time-sample window. +pub const TIME_WINDOW_PORT: u16 = 0xeb; +/// The portb status bit advertising the time-sample window. +pub const STATUS_TIME_SAMPLE_AVAILABLE: u8 = 1 << 6; +/// The time sample version. +pub const TIME_SAMPLE_VERSION: u8 = 1; +/// The length of a time sample. +pub const TIME_SAMPLE_LEN: usize = 16; + +/// A restore packet or time sample is malformed. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RestorePacketError { + /// The record is shorter than its header. + #[error("record is {0} bytes, shorter than its header")] + Truncated(usize), + /// The magic is wrong. + #[error("bad restore packet magic")] + Magic, + /// The version is not supported. + #[error("unsupported version {0}")] + Version(u8), + /// Reserved flag bits or bytes are set. + #[error("reserved bits are set")] + Reserved, + /// The online VP target is not 0, 1, 2, 4, or 8. + #[error("invalid online VP target {0}")] + OnlineVpCount(u8), + /// The image-slot target is above [`MAX_IMAGE_SLOT_TARGET`]. + #[error("invalid image-slot target {0}")] + ImageSlotTarget(u8), + /// An image-slot target without [`FLAG_ACK_REQUIRED`]. + #[error("image-slot target {0} requires an acknowledgement")] + ImageSlotTargetUnacknowledged(u8), + /// Memory ranges without a memory target, or too many ranges. + #[error("invalid memory range count {0}")] + MemoryRanges(usize), + /// The generation counter of a restore is zero. + #[error("restore generation is zero")] + Generation, + /// The rate deviation is beyond the tolerance. + #[error("rate deviation {0} is beyond the tolerance")] + RateDeviation(i32), + /// The record length does not match its counts. + #[error("record is {actual} bytes, expected {expected}")] + Length { + /// The expected length. + expected: usize, + /// The actual length. + actual: usize, + }, +} + +/// A guest RAM range in the restore packet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, MeshPayload)] +pub struct RestoreMemoryRange { + /// The first guest physical address. + pub gpa_start: u64, + /// The length in bytes. + pub length: u64, +} + +/// The restore packet fields the controller knows before the worker starts. +#[derive(Debug, Clone, PartialEq, Eq, MeshPayload)] +pub struct RestorePacketBase { + /// The online VP target: 0 for none, else 1, 2, 4, or 8. + pub online_vp_count: u8, + /// The active image-slot target: 0 for none, else 1 to + /// [`MAX_IMAGE_SLOT_TARGET`]. + pub image_slot_target: u8, + /// Whether an explicit RAM target was requested. + pub memory_target: bool, + /// Whether the guest must acknowledge through port `0x605`. + pub ack_required: bool, + /// The generation counter `g` of the restored process, at least 1. + pub generation: u32, + /// The RAM ranges to online, only with a memory target. + pub ranges: Vec, + /// The restore entropy; its first 16 bytes are the generation ID. + pub entropy: [u8; RESTORE_ENTROPY_LEN], +} + +impl RestorePacketBase { + /// Checks the fields the packet encoding constrains. + pub fn validate(&self) -> Result<(), RestorePacketError> { + if !VALID_ONLINE_VP_COUNTS.contains(&self.online_vp_count) { + return Err(RestorePacketError::OnlineVpCount(self.online_vp_count)); + } + if self.image_slot_target > MAX_IMAGE_SLOT_TARGET { + return Err(RestorePacketError::ImageSlotTarget(self.image_slot_target)); + } + // The guest verifies the active prefix before it acknowledges. + if self.image_slot_target != 0 && !self.ack_required { + return Err(RestorePacketError::ImageSlotTargetUnacknowledged( + self.image_slot_target, + )); + } + if self.ranges.len() > MAX_RESTORE_RANGES + || (!self.memory_target && !self.ranges.is_empty()) + { + return Err(RestorePacketError::MemoryRanges(self.ranges.len())); + } + if self.generation == 0 { + return Err(RestorePacketError::Generation); + } + Ok(()) + } + + /// Returns the encoded length of the packet. + pub fn encoded_len(&self) -> usize { + RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * self.ranges.len() + RESTORE_ENTROPY_LEN + } +} + +/// The time fields of the restore packet, sealed by the worker before the +/// first restored VP runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, MeshPayload)] +pub struct RestoreTimeRecord { + /// The downtime `D`, in nanoseconds. + pub downtime_ns: u64, + /// Whether `D` came from the UTC delta. + pub downtime_utc: bool, + /// `(F_d - F_s) / F_s` in ppm scaled by 2^16. + pub rate_deviation: i32, + /// Whether a test hook is active. + pub test_hooks: bool, +} + +/// A complete restore packet version 4. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestorePacketV4 { + /// The controller's fields. + pub base: RestorePacketBase, + /// The worker's sealed time fields. + pub time: RestoreTimeRecord, + /// Host UTC when the guest first selected the packet, in nanoseconds + /// since the Unix epoch. + pub utc_ns: u64, +} + +impl RestorePacketV4 { + /// Encodes the packet. + pub fn encode(&self) -> Result, RestorePacketError> { + self.base.validate()?; + if self.time.rate_deviation.unsigned_abs() > MAX_RATE_DEVIATION.unsigned_abs() { + return Err(RestorePacketError::RateDeviation(self.time.rate_deviation)); + } + let mut flags = 0; + for (set, flag) in [ + (self.time.downtime_utc, FLAG_DOWNTIME_UTC), + (self.base.memory_target, FLAG_MEMORY_TARGET), + (self.base.ack_required, FLAG_ACK_REQUIRED), + (self.time.test_hooks, FLAG_TEST_HOOKS), + (self.base.image_slot_target != 0, FLAG_IMAGE_SLOT_TARGET), + ] { + if set { + flags |= flag; + } + } + let mut bytes = Vec::with_capacity(self.base.encoded_len()); + bytes.extend_from_slice(&RESTORE_PACKET_MAGIC); + bytes.extend_from_slice(&[ + RESTORE_PACKET_VERSION, + flags, + self.base.online_vp_count, + self.base.ranges.len() as u8, + self.base.image_slot_target, + ]); + bytes.extend_from_slice(&self.base.generation.to_le_bytes()); + bytes.extend_from_slice(&self.time.rate_deviation.to_le_bytes()); + bytes.extend_from_slice(&self.time.downtime_ns.to_le_bytes()); + bytes.extend_from_slice(&self.utc_ns.to_le_bytes()); + for range in &self.base.ranges { + bytes.extend_from_slice(&range.gpa_start.to_le_bytes()); + bytes.extend_from_slice(&range.length.to_le_bytes()); + } + bytes.extend_from_slice(&self.base.entropy); + debug_assert_eq!(bytes.len(), self.base.encoded_len()); + Ok(bytes) + } + + /// Decodes and validates a packet. + pub fn decode(bytes: &[u8]) -> Result { + let header: &[u8; RESTORE_PACKET_HEADER_LEN] = bytes + .get(..RESTORE_PACKET_HEADER_LEN) + .and_then(|header| header.try_into().ok()) + .ok_or(RestorePacketError::Truncated(bytes.len()))?; + if header[..3] != RESTORE_PACKET_MAGIC { + return Err(RestorePacketError::Magic); + } + if header[3] != RESTORE_PACKET_VERSION { + return Err(RestorePacketError::Version(header[3])); + } + let flags = header[4]; + // The image-slot flag and header byte 7 must agree. + if flags & !RESTORE_PACKET_FLAGS != 0 + || (flags & FLAG_IMAGE_SLOT_TARGET != 0) != (header[7] != 0) + { + return Err(RestorePacketError::Reserved); + } + let range_count = usize::from(header[6]); + let expected = + RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * range_count + RESTORE_ENTROPY_LEN; + if bytes.len() != expected { + return Err(RestorePacketError::Length { + expected, + actual: bytes.len(), + }); + } + let u32_at = + |offset: usize| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let u64_at = + |offset: usize| u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + let ranges = (0..range_count) + .map(|i| { + let offset = RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * i; + RestoreMemoryRange { + gpa_start: u64_at(offset), + length: u64_at(offset + 8), + } + }) + .collect(); + let entropy_offset = expected - RESTORE_ENTROPY_LEN; + let packet = Self { + base: RestorePacketBase { + online_vp_count: header[5], + image_slot_target: header[7], + memory_target: flags & FLAG_MEMORY_TARGET != 0, + ack_required: flags & FLAG_ACK_REQUIRED != 0, + generation: u32_at(8), + ranges, + entropy: bytes[entropy_offset..].try_into().unwrap(), + }, + time: RestoreTimeRecord { + downtime_ns: u64_at(16), + downtime_utc: flags & FLAG_DOWNTIME_UTC != 0, + rate_deviation: u32_at(12) as i32, + test_hooks: flags & FLAG_TEST_HOOKS != 0, + }, + utc_ns: u64_at(24), + }; + packet.base.validate()?; + if packet.time.rate_deviation.unsigned_abs() > MAX_RATE_DEVIATION.unsigned_abs() { + return Err(RestorePacketError::RateDeviation( + packet.time.rate_deviation, + )); + } + Ok(packet) + } +} + +/// A time sample, latched by selector [`TIME_SAMPLE_SELECT`] and read from +/// port [`TIME_WINDOW_PORT`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeSample { + /// Whether a test hook is active. + pub test_hooks: bool, + /// The generation counter `g` of this VM process. + pub generation: u32, + /// Host UTC at the latch, in nanoseconds since the Unix epoch. + pub utc_ns: u64, +} + +impl TimeSample { + /// Encodes the sample. + pub fn encode(&self) -> [u8; TIME_SAMPLE_LEN] { + let mut bytes = [0; TIME_SAMPLE_LEN]; + bytes[0] = TIME_SAMPLE_VERSION; + bytes[1] = if self.test_hooks { FLAG_TEST_HOOKS } else { 0 }; + bytes[4..8].copy_from_slice(&self.generation.to_le_bytes()); + bytes[8..16].copy_from_slice(&self.utc_ns.to_le_bytes()); + bytes + } + + /// Decodes and validates a sample. + pub fn decode(bytes: &[u8]) -> Result { + let bytes: &[u8; TIME_SAMPLE_LEN] = + bytes.try_into().map_err(|_| RestorePacketError::Length { + expected: TIME_SAMPLE_LEN, + actual: bytes.len(), + })?; + if bytes[0] != TIME_SAMPLE_VERSION { + return Err(RestorePacketError::Version(bytes[0])); + } + if bytes[1] & !FLAG_TEST_HOOKS != 0 || bytes[2..4] != [0, 0] { + return Err(RestorePacketError::Reserved); + } + Ok(Self { + test_hooks: bytes[1] & FLAG_TEST_HOOKS != 0, + generation: u32::from_le_bytes(bytes[4..8].try_into().unwrap()), + utc_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entropy() -> [u8; RESTORE_ENTROPY_LEN] { + std::array::from_fn(|i| i as u8) + } + + fn packet_without_ranges() -> RestorePacketV4 { + RestorePacketV4 { + base: RestorePacketBase { + online_vp_count: 2, + image_slot_target: 0, + memory_target: false, + ack_required: true, + generation: 7, + ranges: Vec::new(), + entropy: entropy(), + }, + time: RestoreTimeRecord { + downtime_ns: 0x0102_0304_0506_0708, + downtime_utc: true, + rate_deviation: -2, + test_hooks: false, + }, + utc_ns: 0x1122_3344_5566_7788, + } + } + + fn packet_with_ranges() -> RestorePacketV4 { + RestorePacketV4 { + base: RestorePacketBase { + online_vp_count: 0, + image_slot_target: 0, + memory_target: true, + ack_required: false, + generation: 1, + ranges: vec![ + RestoreMemoryRange { + gpa_start: 0x1_0000_0000, + length: 0x800_0000, + }, + RestoreMemoryRange { + gpa_start: 0x2_0000_0000, + length: 0x1000_0000, + }, + ], + entropy: entropy(), + }, + time: RestoreTimeRecord { + downtime_ns: 30_000_000_000, + downtime_utc: false, + rate_deviation: MAX_RATE_DEVIATION, + test_hooks: true, + }, + utc_ns: 1_700_000_000_000_000_000, + } + } + + #[test] + fn golden_packet_without_ranges() { + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x05, 2, 0, 0, // header + 7, 0, 0, 0, // generation + 0xfe, 0xff, 0xff, 0xff, // rate deviation + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // downtime + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // UTC + ]; + expected.extend(entropy()); + let packet = packet_without_ranges(); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes.len(), 96); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + } + + #[test] + fn golden_packet_with_ranges() { + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x0a, 0, 2, 0, // header + 1, 0, 0, 0, // generation + 0x00, 0x00, 0xfa, 0x00, // rate deviation 250 * 2^16 + 0x00, 0xac, 0x23, 0xfc, 0x06, 0x00, 0x00, 0x00, // downtime 30 s + 0x00, 0x00, 0x2a, 0x36, 0xfe, 0x9c, 0x97, 0x17, // UTC + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // range 0 start + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, // range 0 length + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, // range 1 start + 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, // range 1 length + ]; + expected.extend(entropy()); + let packet = packet_with_ranges(); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes.len(), 128); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + } + + #[test] + fn golden_packet_with_image_slot_target() { + let mut packet = packet_without_ranges(); + packet.base.image_slot_target = 3; + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x15, 2, 0, 3, // header + 7, 0, 0, 0, // generation + 0xfe, 0xff, 0xff, 0xff, // rate deviation + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // downtime + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // UTC + ]; + expected.extend(entropy()); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + + let mut bytes = bytes; + bytes[7] = MAX_IMAGE_SLOT_TARGET + 1; + assert_eq!( + RestorePacketV4::decode(&bytes).unwrap_err(), + RestorePacketError::ImageSlotTarget(MAX_IMAGE_SLOT_TARGET + 1) + ); + bytes[4] &= !FLAG_ACK_REQUIRED; + bytes[7] = 3; + assert_eq!( + RestorePacketV4::decode(&bytes).unwrap_err(), + RestorePacketError::ImageSlotTargetUnacknowledged(3) + ); + } + + #[test] + fn decode_rejects_malformed_packets() { + let good = packet_with_ranges().encode().unwrap(); + let mutate = |offset: usize, value: u8| { + let mut bytes = good.clone(); + bytes[offset] = value; + RestorePacketV4::decode(&bytes).unwrap_err() + }; + assert_eq!(mutate(0, b'X'), RestorePacketError::Magic); + assert_eq!(mutate(3, 3), RestorePacketError::Version(3)); + assert_eq!(mutate(4, 0x2a), RestorePacketError::Reserved); + // An image-slot flag without a target, and a target without the flag. + assert_eq!(mutate(4, 0x1a), RestorePacketError::Reserved); + assert_eq!(mutate(7, 1), RestorePacketError::Reserved); + assert_eq!(mutate(5, 3), RestorePacketError::OnlineVpCount(3)); + assert_eq!(mutate(4, 0x08), RestorePacketError::MemoryRanges(2)); + assert!(matches!(mutate(6, 3), RestorePacketError::Length { .. })); + assert_eq!(mutate(8, 0), RestorePacketError::Generation); + assert!(matches!( + mutate(14, 0xfb), + RestorePacketError::RateDeviation(_) + )); + assert_eq!( + RestorePacketV4::decode(&good[..31]).unwrap_err(), + RestorePacketError::Truncated(31) + ); + assert!(matches!( + RestorePacketV4::decode(&good[..good.len() - 1]).unwrap_err(), + RestorePacketError::Length { .. } + )); + } + + #[test] + fn encode_rejects_invalid_fields() { + let mut packet = packet_without_ranges(); + packet.base.online_vp_count = 3; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::OnlineVpCount(3) + ); + + let mut packet = packet_with_ranges(); + packet.base.memory_target = false; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::MemoryRanges(2) + ); + + let mut packet = packet_without_ranges(); + packet.base.image_slot_target = MAX_IMAGE_SLOT_TARGET + 1; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::ImageSlotTarget(MAX_IMAGE_SLOT_TARGET + 1) + ); + + let mut packet = packet_with_ranges(); + packet.base.image_slot_target = 1; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::ImageSlotTargetUnacknowledged(1) + ); + + let mut packet = packet_without_ranges(); + packet.base.generation = 0; + assert_eq!(packet.encode().unwrap_err(), RestorePacketError::Generation); + + let mut packet = packet_without_ranges(); + packet.time.rate_deviation = -MAX_RATE_DEVIATION - 1; + assert!(matches!( + packet.encode().unwrap_err(), + RestorePacketError::RateDeviation(_) + )); + } + + #[test] + fn golden_time_sample() { + let sample = TimeSample { + test_hooks: true, + generation: 3, + utc_ns: 0x0102_0304_0506_0708, + }; + let bytes = sample.encode(); + assert_eq!( + bytes, + [ + 1, 0x08, 0, 0, 3, 0, 0, 0, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 + ] + ); + assert_eq!(TimeSample::decode(&bytes).unwrap(), sample); + + let mut bad = bytes; + bad[0] = 2; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Version(2) + ); + let mut bad = bytes; + bad[1] = 0x01; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Reserved + ); + let mut bad = bytes; + bad[3] = 1; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Reserved + ); + assert!(matches!( + TimeSample::decode(&bytes[..15]).unwrap_err(), + RestorePacketError::Length { .. } + )); + } +} diff --git a/vm/devices/net/linux_net_bindings/src/lib.rs b/vm/devices/net/linux_net_bindings/src/lib.rs index c402dbb9f8b..5f957b60b11 100644 --- a/vm/devices/net/linux_net_bindings/src/lib.rs +++ b/vm/devices/net/linux_net_bindings/src/lib.rs @@ -44,6 +44,12 @@ ioctl_write_ptr_bad!( gen_if::ifreq ); +// #define TUNSETPERSIST _IOW('T', 203, int) +ioctl_write_int_bad!( + tun_set_persist, + request_code_write!(b'T', 203, size_of::()) +); + // #define TUNSETOFFLOAD _IOW('T', 208, unsigned int) // Note: the kernel reads the offload flags directly from the ioctl arg, // not via copy_from_user, so we must pass the value, not a pointer. diff --git a/vm/devices/net/net_backend/src/lib.rs b/vm/devices/net/net_backend/src/lib.rs index 16d4b67cd17..3a975172855 100644 --- a/vm/devices/net/net_backend/src/lib.rs +++ b/vm/devices/net/net_backend/src/lib.rs @@ -37,6 +37,7 @@ pub mod loopback; pub mod null; +pub mod quiesce; pub mod resolve; pub mod tests; @@ -52,6 +53,7 @@ use inspect::InspectMut; use inspect_counters::Counter; use mesh::rpc::Rpc; use mesh::rpc::RpcSend; +use net_backend_resources::egress::EgressPolicy; use null::NullEndpoint; use pal_async::driver::Driver; use std::future::pending; @@ -93,6 +95,19 @@ pub trait Endpoint: Send + Sync + InspectMut { queues: &mut Vec>, ) -> anyhow::Result<()>; + /// Configures a run-scoped egress policy before queues are created. + /// + /// Backends that accept an active policy must apply it to the immutable + /// packet bytes immediately before externally visible transmission. + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + anyhow::ensure!( + !policy.is_active(), + "network backend '{}' does not support active egress policy", + self.endpoint_type() + ); + Ok(()) + } + /// Stops the endpoint. /// /// All queues returned via `get_queues` must have been dropped. @@ -215,6 +230,23 @@ pub trait Queue: Send + InspectMut { fn tx_poll(&mut self, pool: &mut dyn BufferAccess, done: &mut [TxId]) -> Result; + /// Finishes work already accepted by this queue without accepting new work. + /// + /// On success, all resulting RX and TX completions must be available through + /// [`Queue::rx_poll`] and [`Queue::tx_poll`]. Implementations must not retain + /// any buffer IDs or guest-memory segments after returning. + async fn quiesce( + &mut self, + _pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + anyhow::bail!("network queue does not support quiesce") + } + + /// Resumes host RX admission after a rolled-back quiesce transaction. + fn resume(&mut self) -> anyhow::Result<()> { + Ok(()) + } + /// Get queue statistics fn queue_stats(&self) -> Option<&dyn BackendQueueStats> { None // Default implementation - not all queues implement stats @@ -231,7 +263,7 @@ pub trait Queue: Send + InspectMut { /// to [`Queue`] methods. This means no `Arc`/`Mutex` is needed /// between the frontend and backend for buffer access—the borrow /// checker enforces exclusive access statically. -pub trait BufferAccess { +pub trait BufferAccess: Send { /// The associated guest memory accessor. fn guest_memory(&self) -> &GuestMemory; diff --git a/vm/devices/net/net_backend/src/quiesce.rs b/vm/devices/net/net_backend/src/quiesce.rs new file mode 100644 index 00000000000..130c2f7db11 --- /dev/null +++ b/vm/devices/net/net_backend/src/quiesce.rs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Queue quiesce. +//! +//! [`Queue::quiesce`](crate::Queue::quiesce) finishes the work that a queue +//! already accepted without accepting new work, and reports the completions +//! that the endpoint still holds in a [`QueueQuiesceResult`]. +//! [`Queue::resume`](crate::Queue::resume) resumes host RX admission after a +//! rolled-back quiesce. + +/// Completion ownership retained by an endpoint after quiesce. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct QueueQuiesceResult { + /// RX completions available through [`Queue::rx_poll`](crate::Queue::rx_poll). + pub rx_ready: usize, + /// TX completions available through [`Queue::tx_poll`](crate::Queue::tx_poll). + pub tx_ready: usize, +} diff --git a/vm/devices/net/net_backend_resources/Cargo.toml b/vm/devices/net/net_backend_resources/Cargo.toml index 57cb80d0d35..5b7777f1a0d 100644 --- a/vm/devices/net/net_backend_resources/Cargo.toml +++ b/vm/devices/net/net_backend_resources/Cargo.toml @@ -15,5 +15,8 @@ mesh.workspace = true thiserror.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs b/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs new file mode 100644 index 00000000000..86ebe311695 --- /dev/null +++ b/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact static IPv4 identity of a Consomme endpoint. +//! +//! A [`StaticIpv4Config`] in a [`ConsommeHandle`](super::ConsommeHandle) +//! replaces the CIDR-derived guest and gateway addresses with an exact +//! identity. + +use crate::mac_address::MacAddress; +use mesh::MeshPayload; + +/// Exact static IPv4 identity used by the microVM network profile. +#[derive(Clone, Debug, MeshPayload)] +pub struct StaticIpv4Config { + /// Guest IPv4 address. + pub guest_ipv4: std::net::Ipv4Addr, + /// IPv4 subnet prefix length. + pub prefix_length: u8, + /// Host gateway IPv4 address. + pub gateway_ipv4: std::net::Ipv4Addr, + /// Host gateway Ethernet address. + pub gateway_mac: MacAddress, +} diff --git a/vm/devices/net/net_backend_resources/src/egress.rs b/vm/devices/net/net_backend_resources/src/egress.rs new file mode 100644 index 00000000000..2c483836a43 --- /dev/null +++ b/vm/devices/net/net_backend_resources/src/egress.rs @@ -0,0 +1,2411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical microVM IPv4 egress policy and packet validation. + +use crate::mac_address::MacAddress; +use mesh::MeshPayload; +use std::net::Ipv4Addr; +use std::str::FromStr; +use thiserror::Error; + +/// Current encoding version used for snapshot egress-policy digests. +pub const EGRESS_POLICY_ENCODING_VERSION: u32 = 3; +const PREVIOUS_EGRESS_POLICY_ENCODING_VERSION: u32 = 2; +const LEGACY_EGRESS_POLICY_ENCODING_VERSION: u32 = 1; +const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6]; +const ETHERNET_UNSPECIFIED: [u8; 6] = [0; 6]; + +/// A canonical IPv4 network prefix. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct Ipv4Cidr { + network: Ipv4Addr, + prefix_length: u8, +} + +impl Ipv4Cidr { + /// Returns whether `address` belongs to this prefix. + pub fn contains(self, address: Ipv4Addr) -> bool { + let mask = prefix_mask(self.prefix_length); + u32::from(address) & mask == u32::from(self.network) + } + + /// Returns the canonical network address. + pub fn network(self) -> Ipv4Addr { + self.network + } + + /// Returns the prefix length. + pub fn prefix_length(self) -> u8 { + self.prefix_length + } +} + +/// Error returned when parsing an IPv4 address or CIDR policy rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseIpv4CidrError { + /// The address portion is not IPv4. + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + /// The prefix portion is not an integer. + #[error("invalid IPv4 prefix '{0}'")] + InvalidPrefix(String), + /// The prefix is greater than 32. + #[error("IPv4 prefix /{0} is outside the supported range /0 through /32")] + PrefixOutOfRange(u8), + /// The rule has more than one prefix separator. + #[error("invalid IPv4 CIDR '{0}'")] + InvalidFormat(String), +} + +impl FromStr for Ipv4Cidr { + type Err = ParseIpv4CidrError; + + fn from_str(value: &str) -> Result { + let (address, prefix) = match value.split_once('/') { + Some((address, prefix)) if !prefix.contains('/') => (address, Some(prefix)), + Some(_) => return Err(ParseIpv4CidrError::InvalidFormat(value.to_owned())), + None => (value, None), + }; + let address = address + .parse::() + .map_err(|_| ParseIpv4CidrError::InvalidAddress(address.to_owned()))?; + let prefix_length = prefix + .map(|prefix| { + prefix + .parse::() + .map_err(|_| ParseIpv4CidrError::InvalidPrefix(prefix.to_owned())) + }) + .transpose()? + .unwrap_or(32); + if prefix_length > 32 { + return Err(ParseIpv4CidrError::PrefixOutOfRange(prefix_length)); + } + let network = Ipv4Addr::from(u32::from(address) & prefix_mask(prefix_length)); + Ok(Self { + network, + prefix_length, + }) + } +} + +/// An exact IPv4 TCP destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct TcpEndpoint { + address: Ipv4Addr, + port: u16, +} + +impl TcpEndpoint { + /// Returns the destination address. + pub fn address(self) -> Ipv4Addr { + self.address + } + + /// Returns the destination TCP port. + pub fn port(self) -> u16 { + self.port + } +} + +/// Error returned when parsing an exact TCP endpoint policy rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseTcpEndpointError { + /// The endpoint is missing its port separator. + #[error("expected :")] + InvalidFormat, + /// The address portion is not IPv4. + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + /// The port portion is not a `u16`. + #[error("invalid TCP port '{0}'")] + InvalidPort(String), + /// Port zero is not a connectable endpoint. + #[error("TCP port must be nonzero")] + ZeroPort, +} + +impl FromStr for TcpEndpoint { + type Err = ParseTcpEndpointError; + + fn from_str(value: &str) -> Result { + let (address, port) = value + .rsplit_once(':') + .ok_or(ParseTcpEndpointError::InvalidFormat)?; + let address = address + .parse::() + .map_err(|_| ParseTcpEndpointError::InvalidAddress(address.to_owned()))?; + let port = port + .parse::() + .map_err(|_| ParseTcpEndpointError::InvalidPort(port.to_owned()))?; + if port == 0 { + return Err(ParseTcpEndpointError::ZeroPort); + } + Ok(Self { address, port }) + } +} + +/// Default action for a rule-based egress policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum EgressAction { + /// Permit traffic that matches no rule. + Allow, + /// Deny traffic that matches no rule. + Deny, +} + +/// Optional transport restriction for an egress rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub enum EgressTransport { + /// Match every IPv4 protocol and port. + Any, + /// Match TCP on one destination port, on an inclusive range of them, or + /// on every port. + Tcp, + /// Match UDP on one destination port, on an inclusive range of them, or + /// on every port. + Udp, + /// Match every ICMP message. + Icmp, +} + +/// A canonical IPv4 destination rule with an optional protocol and TCP or UDP +/// destination port or inclusive range of destination ports. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct EgressRule { + destination: Ipv4Cidr, + transport: EgressTransport, + port: u16, + end_port: u16, +} + +impl EgressRule { + /// Returns the destination prefix. + pub fn destination(self) -> Ipv4Cidr { + self.destination + } + + /// Returns the optional transport restriction. + pub fn transport(self) -> EgressTransport { + self.transport + } + + /// Returns the restricted TCP or UDP destination port, which is the first + /// port of a range, or zero for a rule without a port: an address-only + /// rule, a TCP or UDP rule for every port, or an ICMP rule. + pub fn port(self) -> u16 { + self.port + } + + /// Returns the last restricted TCP or UDP destination port of the + /// inclusive range that starts at [`Self::port`], which equals + /// [`Self::port`] for a single port, or zero for a rule without a port. + pub fn end_port(self) -> u16 { + self.end_port + } + + /// Returns whether the rule has a form that parsing produces: only a TCP + /// or UDP rule restricts ports, and its range is nonzero and not reversed. + fn has_canonical_ports(self) -> bool { + match self.transport { + EgressTransport::Tcp | EgressTransport::Udp => { + (self.port, self.end_port) == (0, 0) + || (self.port != 0 && self.port <= self.end_port) + } + EgressTransport::Any | EgressTransport::Icmp => (self.port, self.end_port) == (0, 0), + } + } + + fn matches(self, destination: Ipv4Addr, packet: Option) -> bool { + if !self.destination.contains(destination) { + return false; + } + match self.transport { + EgressTransport::Any => true, + expected => packet.is_some_and(|packet| { + packet.transport == expected + && (self.port == 0 + || packet + .port + .is_some_and(|port| (self.port..=self.end_port).contains(&port))) + }), + } + } +} + +/// The transport of one IPv4 packet, as far as rule evaluation can classify it. +#[derive(Clone, Copy)] +struct PacketTransport { + /// TCP, UDP, or ICMP, and never `Any`, which only rules use. + transport: EgressTransport, + /// Destination port of an unfragmented TCP or UDP packet. + port: Option, +} + +/// Error returned when parsing an L3/L4 egress rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseEgressRuleError { + /// The rule did not use a supported address, protocol, or port form. + #[error( + "expected , :, or ::" + )] + InvalidFormat, + /// The destination prefix is invalid. + #[error(transparent)] + InvalidDestination(#[from] ParseIpv4CidrError), + /// The transport is not TCP, UDP, or ICMP. + #[error("invalid egress transport '{0}'; expected tcp, udp, or icmp")] + InvalidTransport(String), + /// ICMP has no destination ports to select. + #[error("ICMP egress rules do not take a port")] + IcmpPort, + /// The port, or a port of a range, is not a `u16`. + #[error("invalid egress port '{0}'")] + InvalidPort(String), + /// Port zero is not a connectable destination. + #[error("egress rule port must be nonzero")] + ZeroPort, + /// The port range lacks its first or last port, or has more than two. + #[error("invalid egress port range '{0}'; expected -")] + InvalidPortRange(String), + /// The port range ends below its first port. + #[error("egress port range {first}-{last} ends below its first port")] + ReversedPortRange { + /// First port of the range. + first: u16, + /// Last port of the range. + last: u16, + }, +} + +impl FromStr for EgressRule { + type Err = ParseEgressRuleError; + + fn from_str(value: &str) -> Result { + let fields = value.split(':').collect::>(); + let (destination, protocol, ports) = match fields.as_slice() { + [destination] => (*destination, None, None), + [destination, protocol] => (*destination, Some(*protocol), None), + [destination, protocol, ports] => (*destination, Some(*protocol), Some(*ports)), + _ => return Err(ParseEgressRuleError::InvalidFormat), + }; + let transport = match protocol { + None => EgressTransport::Any, + Some("tcp") => EgressTransport::Tcp, + Some("udp") => EgressTransport::Udp, + Some("icmp") => EgressTransport::Icmp, + Some(other) => { + return Err(ParseEgressRuleError::InvalidTransport(other.to_owned())); + } + }; + let (port, end_port) = match ports { + None => (0, 0), + Some(_) if transport == EgressTransport::Icmp => { + return Err(ParseEgressRuleError::IcmpPort); + } + Some(ports) => parse_port_range(ports)?, + }; + Ok(Self { + destination: destination.parse()?, + transport, + port, + end_port, + }) + } +} + +/// Parses `PORT`, or the inclusive range `FIRST-LAST`, into its first and last +/// ports. A range of one port is that port. +fn parse_port_range(value: &str) -> Result<(u16, u16), ParseEgressRuleError> { + let parse = |text: &str| { + let port = text + .parse::() + .map_err(|_| ParseEgressRuleError::InvalidPort(text.to_owned()))?; + if port == 0 { + return Err(ParseEgressRuleError::ZeroPort); + } + Ok(port) + }; + let Some((first, last)) = value.split_once('-') else { + let port = parse(value)?; + return Ok((port, port)); + }; + if first.is_empty() || last.is_empty() || last.contains('-') { + return Err(ParseEgressRuleError::InvalidPortRange(value.to_owned())); + } + let (first, last) = (parse(first)?, parse(last)?); + if last < first { + return Err(ParseEgressRuleError::ReversedPortRange { first, last }); + } + Ok((first, last)) +} + +/// Run-scoped egress policy mode. +#[derive(Clone, Debug, PartialEq, Eq, MeshPayload)] +pub enum EgressPolicyMode { + /// No packet filtering. + AllowAll, + /// Permit only destinations in these IPv4 prefixes. + AllowList(Vec), + /// Permit IPv4 destinations except those in these prefixes. + BlockList(Vec), + /// Permit only exact IPv4 TCP destinations. + TcpEndpoints(Vec), + /// Deny every guest-originated frame. + DenyAll, + /// Apply canonical allow and deny rules with deny precedence. + Rules { + /// Action for traffic that matches no rule. + default_action: EgressAction, + /// Rules that permit matching traffic. + allow: Vec, + /// Rules that reject matching traffic before allow evaluation. + deny: Vec, + }, +} + +/// Reason an endpoint address cannot be used by exact endpoint policy. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum InvalidEndpointAddress { + /// The address is `0.0.0.0`. + #[error("it is unspecified")] + Unspecified, + /// The address belongs to the reserved current-network block. + #[error("it belongs to the reserved 0.0.0.0/8 current-network block")] + CurrentNetwork, + /// The address belongs to IPv4 loopback. + #[error("it is loopback")] + Loopback, + /// The address is IPv4 link-local. + #[error("it is link-local")] + LinkLocal, + /// The address is multicast. + #[error("it is multicast")] + Multicast, + /// The address is the limited broadcast address. + #[error("it is the limited broadcast address")] + LimitedBroadcast, + /// The address belongs to the reserved high-address block. + #[error("it belongs to the reserved 240.0.0.0/4 block")] + Reserved, + /// The address is the guest itself. + #[error("it matches the guest IPv4 address")] + Guest, + /// The address is the guest subnet's network address. + #[error("it is the guest subnet network address")] + SubnetNetwork, + /// The address is the guest subnet's broadcast address. + #[error("it is the guest subnet broadcast address")] + SubnetBroadcast, +} + +/// Error returned when binding an egress policy to a static network identity. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum InvalidEgressPolicy { + /// The prefix cannot describe a microVM subnet. + #[error("IPv4 prefix /{0} is outside the supported range /1 through /30")] + PrefixOutOfRange(u8), + /// The guest address is the subnet network address. + #[error("guest IPv4 address {0} is the subnet network address")] + GuestNetworkAddress(Ipv4Addr), + /// The guest address is the subnet broadcast address. + #[error("guest IPv4 address {0} is the subnet broadcast address")] + GuestBroadcastAddress(Ipv4Addr), + /// The guest address collides with the canonical gateway. + #[error("guest IPv4 address {0} collides with the canonical gateway")] + GuestGatewayCollision(Ipv4Addr), + /// The gateway is not the first usable address in the guest subnet. + #[error("gateway IPv4 address {actual} does not match canonical gateway {expected}")] + GatewayMismatch { + /// Supplied gateway address. + actual: Ipv4Addr, + /// Canonical first usable subnet address. + expected: Ipv4Addr, + }, + /// The guest MAC cannot identify a transmitting endpoint. + #[error("guest MAC address {0} is not a nonzero unicast address")] + InvalidGuestMac(MacAddress), + /// The gateway cannot serve as an endpoint next hop. + #[error("gateway IPv4 address {address} is not a valid endpoint next hop: {reason}")] + InvalidGateway { + /// Invalid gateway address. + address: Ipv4Addr, + /// Reason the address is invalid. + reason: InvalidEndpointAddress, + }, + /// An exact endpoint address is invalid. + #[error("endpoint IPv4 address {address} is invalid: {reason}")] + InvalidEndpoint { + /// Invalid endpoint address. + address: Ipv4Addr, + /// Reason the address is invalid. + reason: InvalidEndpointAddress, + }, + /// A decoded policy did not preserve canonical rule forms, order, and + /// uniqueness. + #[error("egress policy rules are not canonical")] + NonCanonicalRules, + /// A decoded policy carried next hops that do not match its bound rules. + #[error("egress policy next hops do not match the bound endpoint rules")] + InvalidNextHops, + /// A proxy exception did not use the guest-visible gateway address. + #[error("network proxy address {actual} must match the guest gateway {expected}")] + InvalidProxyAddress { + /// Supplied proxy address. + actual: Ipv4Addr, + /// Required guest-visible address. + expected: Ipv4Addr, + }, +} + +/// Egress policy bound to one static microVM link. +#[derive(Clone, Debug, PartialEq, Eq, MeshPayload)] +pub struct EgressPolicy { + guest_ipv4: Ipv4Addr, + prefix_length: u8, + guest_mac: MacAddress, + gateway_ipv4: Ipv4Addr, + next_hops: Vec, + mode: EgressPolicyMode, + host_loopback: EgressAction, + proxy_endpoint: Option, +} + +impl EgressPolicy { + /// Binds a policy to a validated static identity and canonicalizes its rules. + pub fn bind( + guest_ipv4: Ipv4Addr, + prefix_length: u8, + guest_mac: MacAddress, + gateway_ipv4: Ipv4Addr, + mut mode: EgressPolicyMode, + ) -> Result { + if !(1..=30).contains(&prefix_length) { + return Err(InvalidEgressPolicy::PrefixOutOfRange(prefix_length)); + } + let mask = prefix_mask(prefix_length); + let network = Ipv4Addr::from(u32::from(guest_ipv4) & mask); + let broadcast = Ipv4Addr::from(u32::from(network) | !mask); + if guest_ipv4 == network { + return Err(InvalidEgressPolicy::GuestNetworkAddress(guest_ipv4)); + } + if guest_ipv4 == broadcast { + return Err(InvalidEgressPolicy::GuestBroadcastAddress(guest_ipv4)); + } + let expected_gateway = Ipv4Addr::from(u32::from(network) + 1); + if guest_ipv4 == expected_gateway { + return Err(InvalidEgressPolicy::GuestGatewayCollision(guest_ipv4)); + } + if gateway_ipv4 != expected_gateway { + return Err(InvalidEgressPolicy::GatewayMismatch { + actual: gateway_ipv4, + expected: expected_gateway, + }); + } + let guest_mac_bytes = guest_mac.to_bytes(); + if guest_mac_bytes == ETHERNET_UNSPECIFIED || guest_mac_bytes[0] & 1 != 0 { + return Err(InvalidEgressPolicy::InvalidGuestMac(guest_mac)); + } + + match &mut mode { + EgressPolicyMode::AllowList(rules) | EgressPolicyMode::BlockList(rules) => { + rules.sort_unstable(); + rules.dedup(); + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + endpoints.sort_unstable(); + endpoints.dedup(); + } + EgressPolicyMode::Rules { allow, deny, .. } => { + if !allow + .iter() + .chain(deny.iter()) + .all(|rule| rule.has_canonical_ports()) + { + return Err(InvalidEgressPolicy::NonCanonicalRules); + } + allow.sort_unstable(); + allow.dedup(); + deny.sort_unstable(); + deny.dedup(); + } + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => {} + } + + let next_hops = match &mode { + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => Vec::new(), + EgressPolicyMode::AllowList(_) | EgressPolicyMode::BlockList(_) => { + vec![gateway_ipv4] + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + if let Some(reason) = + invalid_special_endpoint_address(gateway_ipv4, guest_ipv4, network, broadcast) + { + return Err(InvalidEgressPolicy::InvalidGateway { + address: gateway_ipv4, + reason, + }); + } + let mut next_hops = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + if let Some(reason) = invalid_special_endpoint_address( + endpoint.address, + guest_ipv4, + network, + broadcast, + ) { + return Err(InvalidEgressPolicy::InvalidEndpoint { + address: endpoint.address, + reason, + }); + } + next_hops.push(if contains_address(network, mask, endpoint.address) { + endpoint.address + } else { + gateway_ipv4 + }); + } + next_hops.sort_unstable(); + next_hops.dedup(); + next_hops + } + EgressPolicyMode::Rules { + default_action, + allow, + .. + } => { + if *default_action == EgressAction::Deny && allow.is_empty() { + Vec::new() + } else { + vec![gateway_ipv4] + } + } + }; + + Ok(Self { + guest_ipv4, + prefix_length, + guest_mac, + gateway_ipv4, + next_hops, + mode, + host_loopback: EgressAction::Allow, + proxy_endpoint: None, + }) + } + + /// Applies the host-loopback posture and optional exact proxy exception. + pub fn with_host_loopback( + mut self, + host_loopback: EgressAction, + proxy_endpoint: Option, + ) -> Result { + if let Some(proxy) = proxy_endpoint + && proxy.address != self.gateway_ipv4 + { + return Err(InvalidEgressPolicy::InvalidProxyAddress { + actual: proxy.address, + expected: self.gateway_ipv4, + }); + } + if proxy_endpoint.is_some() && !self.next_hops.contains(&self.gateway_ipv4) { + self.next_hops.push(self.gateway_ipv4); + self.next_hops.sort_unstable(); + self.next_hops.dedup(); + } + self.host_loopback = host_loopback; + self.proxy_endpoint = proxy_endpoint; + Ok(self) + } + + /// Revalidates canonical fields after a serialized policy crosses a boundary. + pub fn validate(&self) -> Result<(), InvalidEgressPolicy> { + let rebound = Self::bind( + self.guest_ipv4, + self.prefix_length, + self.guest_mac, + self.gateway_ipv4, + self.mode.clone(), + )? + .with_host_loopback(self.host_loopback, self.proxy_endpoint)?; + if rebound.mode != self.mode { + return Err(InvalidEgressPolicy::NonCanonicalRules); + } + if rebound.next_hops != self.next_hops { + return Err(InvalidEgressPolicy::InvalidNextHops); + } + Ok(()) + } + + /// Returns the canonical policy mode. + pub fn mode(&self) -> &EgressPolicyMode { + &self.mode + } + + /// Returns the stable manifest name of this policy mode. + pub fn mode_name(&self) -> &'static str { + match self.mode { + EgressPolicyMode::AllowAll => "allow-all", + EgressPolicyMode::DenyAll => "deny-all", + EgressPolicyMode::AllowList(_) => "allow-list", + EgressPolicyMode::BlockList(_) => "block-list", + EgressPolicyMode::TcpEndpoints(_) => "endpoint", + EgressPolicyMode::Rules { .. } => "rules", + } + } + + /// Returns the canonical next hops that ARP may resolve. + pub fn next_hops(&self) -> &[Ipv4Addr] { + &self.next_hops + } + + /// Returns whether general host-loopback connectivity is permitted. + pub fn host_loopback_action(&self) -> EgressAction { + self.host_loopback + } + + /// Returns the exact proxy endpoint exempted from host-loopback denial. + pub fn proxy_endpoint(&self) -> Option { + self.proxy_endpoint + } + + /// Returns whether packet parsing and filtering are active. + pub fn is_active(&self) -> bool { + match &self.mode { + EgressPolicyMode::AllowAll => false, + EgressPolicyMode::Rules { + default_action: EgressAction::Allow, + deny, + .. + } if deny.is_empty() => false, + _ => true, + } + } + + /// Returns whether the gateway DNS proxy is reachable under this policy. + pub fn allows_gateway_dns(&self) -> bool { + match &self.mode { + EgressPolicyMode::AllowAll => true, + EgressPolicyMode::DenyAll => false, + EgressPolicyMode::AllowList(rules) => { + rules.iter().any(|rule| rule.contains(self.gateway_ipv4)) + } + EgressPolicyMode::BlockList(rules) => { + !rules.iter().any(|rule| rule.contains(self.gateway_ipv4)) + } + EgressPolicyMode::TcpEndpoints(_) => false, + EgressPolicyMode::Rules { .. } => [EgressTransport::Tcp, EgressTransport::Udp] + .into_iter() + .any(|transport| { + self.rules_allow_destination( + self.gateway_ipv4, + Some(PacketTransport { + transport, + port: Some(53), + }), + ) + }), + } + } + + /// Returns canonical rule counts without exposing destinations. + pub fn rule_counts(&self) -> (usize, usize) { + match &self.mode { + EgressPolicyMode::Rules { allow, deny, .. } => (allow.len(), deny.len()), + EgressPolicyMode::AllowList(rules) => (rules.len(), 0), + EgressPolicyMode::BlockList(rules) => (0, rules.len()), + EgressPolicyMode::TcpEndpoints(rules) => (rules.len(), 0), + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => (0, 0), + } + } + + fn rules_allow_destination( + &self, + destination: Ipv4Addr, + packet: Option, + ) -> bool { + let EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = &self.mode + else { + return false; + }; + if deny.iter().any(|rule| rule.matches(destination, packet)) { + return false; + } + if allow.iter().any(|rule| rule.matches(destination, packet)) { + return true; + } + *default_action == EgressAction::Allow + } + + fn rules_allow_arp(&self, target: Ipv4Addr) -> bool { + let EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = &self.mode + else { + return false; + }; + if target == self.gateway_ipv4 { + return *default_action == EgressAction::Allow || !allow.is_empty(); + } + if deny + .iter() + .any(|rule| rule.transport == EgressTransport::Any && rule.destination.contains(target)) + { + return false; + } + if allow.iter().any(|rule| rule.destination.contains(target)) { + return true; + } + *default_action == EgressAction::Allow + } + + fn rules_have_port_restrictions(&self) -> bool { + matches!( + &self.mode, + EgressPolicyMode::Rules { allow, deny, .. } + if allow.iter().chain(deny).any(|rule| rule.port != 0) + ) + } + + /// Returns stable bytes suitable for a policy digest. + pub fn canonical_bytes(&self) -> Vec { + self.canonical_bytes_v3() + } + + /// Returns canonical bytes for a supported snapshot encoding version. + pub fn canonical_bytes_for_version(&self, version: u32) -> Option> { + match version { + LEGACY_EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v1()), + PREVIOUS_EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v2()), + EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v3()), + _ => None, + } + } + + fn canonical_bytes_v1(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.push(LEGACY_EGRESS_POLICY_ENCODING_VERSION as u8); + bytes.extend_from_slice(&self.guest_ipv4.octets()); + bytes.extend_from_slice(&self.gateway_ipv4.octets()); + append_policy_mode(&mut bytes, &self.mode); + bytes + } + + fn canonical_bytes_v2(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.push(EGRESS_POLICY_ENCODING_VERSION as u8); + bytes.extend_from_slice(&self.guest_ipv4.octets()); + bytes.push(self.prefix_length); + bytes.extend_from_slice(&self.guest_mac.to_bytes()); + bytes.extend_from_slice(&self.gateway_ipv4.octets()); + append_policy_mode(&mut bytes, &self.mode); + bytes.push(0xff); + bytes.extend_from_slice(&(self.next_hops.len() as u64).to_be_bytes()); + for next_hop in &self.next_hops { + bytes.extend_from_slice(&next_hop.octets()); + } + bytes + } + + fn canonical_bytes_v3(&self) -> Vec { + let mut bytes = self.canonical_bytes_v2(); + bytes[0] = EGRESS_POLICY_ENCODING_VERSION as u8; + bytes.push(match self.host_loopback { + EgressAction::Allow => 0, + EgressAction::Deny => 1, + }); + if let Some(proxy) = self.proxy_endpoint { + bytes.push(1); + bytes.extend_from_slice(&proxy.address.octets()); + bytes.extend_from_slice(&proxy.port.to_be_bytes()); + } else { + bytes.push(0); + } + bytes + } + + /// Validates and authorizes an Ethernet frame before backend submission. + pub fn authorize_frame( + &self, + frame_prefix: &[u8], + frame_length: usize, + ) -> Result<(), EgressDenied> { + if !self.is_active() { + return Ok(()); + } + if matches!(self.mode, EgressPolicyMode::DenyAll) && self.proxy_endpoint.is_none() { + return Err(EgressDenied::AllTrafficDenied); + } + ensure_available(frame_prefix, frame_length, 14, "Ethernet header")?; + if frame_prefix[6..12] != self.guest_mac.to_bytes() { + return Err(EgressDenied::SourceMacDenied); + } + let mut ether_type = read_u16(frame_prefix, 12); + let mut l3_offset = 14; + let vlan = matches!(ether_type, 0x8100 | 0x88a8); + if vlan { + if matches!(self.mode, EgressPolicyMode::TcpEndpoints(_)) { + return Err(EgressDenied::VlanDenied); + } + ensure_available(frame_prefix, frame_length, 18, "VLAN header")?; + ether_type = read_u16(frame_prefix, 16); + l3_offset = 18; + if matches!(ether_type, 0x8100 | 0x88a8) { + return Err(EgressDenied::Malformed("stacked VLAN header")); + } + } + + match ether_type { + 0x0806 => self.authorize_arp(frame_prefix, frame_length, l3_offset), + 0x0800 => self.authorize_ipv4(frame_prefix, frame_length, l3_offset), + other => Err(EgressDenied::UnsupportedEtherType(other)), + } + } + + fn authorize_arp( + &self, + frame: &[u8], + frame_length: usize, + offset: usize, + ) -> Result<(), EgressDenied> { + ensure_available(frame, frame_length, offset + 28, "ARP packet")?; + if read_u16(frame, offset) != 1 + || read_u16(frame, offset + 2) != 0x0800 + || frame[offset + 4] != 6 + || frame[offset + 5] != 4 + || read_u16(frame, offset + 6) != 1 + { + return Err(EgressDenied::Malformed("ARP request")); + } + let ethernet_destination = &frame[..6]; + let sender_hardware = &frame[offset + 8..offset + 14]; + let target_hardware = &frame[offset + 18..offset + 24]; + let broadcast_request = + ethernet_destination == ETHERNET_BROADCAST && target_hardware == ETHERNET_UNSPECIFIED; + let unicast_refresh = ethernet_destination[0] & 1 == 0 + && ethernet_destination != ETHERNET_UNSPECIFIED + && target_hardware == ethernet_destination; + let sender = read_ipv4(frame, offset + 14); + let target = read_ipv4(frame, offset + 24); + if sender_hardware != self.guest_mac.to_bytes() + || sender != self.guest_ipv4 + || !(match &self.mode { + EgressPolicyMode::Rules { .. } => self.rules_allow_arp(target), + _ => self.next_hops.contains(&target), + }) + || (!broadcast_request && !unicast_refresh) + { + return Err(EgressDenied::ArpDenied); + } + Ok(()) + } + + fn authorize_ipv4( + &self, + frame: &[u8], + frame_length: usize, + offset: usize, + ) -> Result<(), EgressDenied> { + ensure_available(frame, frame_length, offset + 20, "IPv4 header")?; + let version_ihl = frame[offset]; + let header_length = usize::from(version_ihl & 0x0f) * 4; + if version_ihl >> 4 != 4 || header_length < 20 { + return Err(EgressDenied::Malformed("IPv4 header")); + } + if header_length != 20 { + return Err(EgressDenied::Ipv4OptionsDenied); + } + ensure_available(frame, frame_length, offset + header_length, "IPv4 options")?; + let total_length = usize::from(read_u16(frame, offset + 2)); + if total_length < header_length + || offset + .checked_add(total_length) + .is_none_or(|end| end > frame_length) + { + return Err(EgressDenied::Malformed("IPv4 total length")); + } + if !ipv4_checksum_is_valid(&frame[offset..offset + header_length]) { + return Err(EgressDenied::Malformed("IPv4 checksum")); + } + if read_ipv4(frame, offset + 12) != self.guest_ipv4 { + return Err(EgressDenied::SourceAddressDenied); + } + + let destination = read_ipv4(frame, offset + 16); + let fragments = read_u16(frame, offset + 6) & 0x3fff; + if let Some(proxy) = self.proxy_endpoint + && destination == proxy.address + && fragments == 0 + && frame[offset + 9] == 6 + { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if read_u16(frame, offset + header_length + 2) == proxy.port { + return Ok(()); + } + } + match &self.mode { + EgressPolicyMode::AllowAll => Ok(()), + EgressPolicyMode::DenyAll => Err(EgressDenied::AllTrafficDenied), + EgressPolicyMode::AllowList(rules) => { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if rules.iter().any(|rule| rule.contains(destination)) { + Ok(()) + } else { + Err(EgressDenied::DestinationDenied) + } + } + EgressPolicyMode::BlockList(rules) => { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if rules.iter().any(|rule| rule.contains(destination)) { + Err(EgressDenied::DestinationDenied) + } else { + Ok(()) + } + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + if fragments != 0 || frame[offset + 9] != 6 { + return Err(EgressDenied::EndpointDenied); + } + let tcp_offset = offset + header_length; + ensure_available(frame, frame_length, tcp_offset + 20, "TCP header")?; + if total_length < header_length + 20 { + return Err(EgressDenied::Malformed("TCP length")); + } + let tcp_header_length = usize::from(frame[tcp_offset + 12] >> 4) * 4; + if tcp_header_length < 20 || total_length < header_length + tcp_header_length { + return Err(EgressDenied::Malformed("TCP header")); + } + let destination_port = read_u16(frame, tcp_offset + 2); + if endpoints.iter().any(|endpoint| { + endpoint.address == destination && endpoint.port == destination_port + }) { + Ok(()) + } else { + Err(EgressDenied::EndpointDenied) + } + } + EgressPolicyMode::Rules { .. } => { + let packet = rule_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + self.rules_have_port_restrictions(), + )?; + if self.rules_allow_destination(destination, packet) { + Ok(()) + } else { + Err(EgressDenied::DestinationDenied) + } + } + } + } +} + +/// Reason an active egress policy rejected a frame. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum EgressDenied { + /// The configured posture denies every guest-originated frame. + #[error("all egress traffic is denied")] + AllTrafficDenied, + /// A required packet structure was truncated or internally inconsistent. + #[error("malformed {0}")] + Malformed(&'static str), + /// The policy does not permit this layer-2 protocol. + #[error("unsupported Ethernet type {0:#06x}")] + UnsupportedEtherType(u16), + /// Exact endpoint mode does not permit VLAN encapsulation. + #[error("VLAN traffic is denied by exact endpoint policy")] + VlanDenied, + /// The Ethernet source does not match the bound guest identity. + #[error("Ethernet source does not match the configured guest")] + SourceMacDenied, + /// ARP was not a canonical request for an authorized next hop. + #[error("ARP request does not resolve an authorized next hop")] + ArpDenied, + /// The packet tried to spoof another source IPv4 address. + #[error("IPv4 source does not match the configured guest")] + SourceAddressDenied, + /// IPv4 options are not supported because source routing can change the + /// effective destination after policy evaluation. + #[error("IPv4 options are denied by policy")] + Ipv4OptionsDenied, + /// An allow-list or block-list denied the destination. + #[error("IPv4 destination is denied")] + DestinationDenied, + /// Exact endpoint mode denied the protocol, address, or TCP port. + #[error("traffic does not match an allowed TCP endpoint")] + EndpointDenied, + /// Port-specific rules cannot safely authorize fragmented transport headers. + #[error("IPv4 fragments are denied by port-specific egress policy")] + FragmentDenied, +} + +fn prefix_mask(prefix_length: u8) -> u32 { + if prefix_length == 0 { + 0 + } else { + u32::MAX << (32 - prefix_length) + } +} + +fn contains_address(network: Ipv4Addr, mask: u32, address: Ipv4Addr) -> bool { + u32::from(address) & mask == u32::from(network) +} + +fn invalid_special_endpoint_address( + address: Ipv4Addr, + guest: Ipv4Addr, + network: Ipv4Addr, + broadcast: Ipv4Addr, +) -> Option { + let first = address.octets()[0]; + if address.is_unspecified() { + Some(InvalidEndpointAddress::Unspecified) + } else if first == 0 { + Some(InvalidEndpointAddress::CurrentNetwork) + } else if address.is_loopback() { + Some(InvalidEndpointAddress::Loopback) + } else if address.is_link_local() { + Some(InvalidEndpointAddress::LinkLocal) + } else if address.is_multicast() { + Some(InvalidEndpointAddress::Multicast) + } else if address.is_broadcast() { + Some(InvalidEndpointAddress::LimitedBroadcast) + } else if first >= 240 { + Some(InvalidEndpointAddress::Reserved) + } else if address == guest { + Some(InvalidEndpointAddress::Guest) + } else if address == network { + Some(InvalidEndpointAddress::SubnetNetwork) + } else if address == broadcast { + Some(InvalidEndpointAddress::SubnetBroadcast) + } else { + None + } +} + +fn append_policy_mode(bytes: &mut Vec, mode: &EgressPolicyMode) { + match mode { + EgressPolicyMode::AllowAll => bytes.push(0), + EgressPolicyMode::DenyAll => bytes.push(4), + EgressPolicyMode::AllowList(rules) => { + bytes.push(1); + append_cidrs(bytes, rules); + } + EgressPolicyMode::BlockList(rules) => { + bytes.push(2); + append_cidrs(bytes, rules); + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + bytes.push(3); + let mut endpoints = endpoints.clone(); + endpoints.sort_unstable(); + endpoints.dedup(); + for endpoint in endpoints { + bytes.extend_from_slice(&endpoint.address.octets()); + bytes.extend_from_slice(&endpoint.port.to_be_bytes()); + } + } + EgressPolicyMode::Rules { + default_action, + allow, + deny, + } => { + bytes.push(5); + bytes.push(match default_action { + EgressAction::Allow => 0, + EgressAction::Deny => 1, + }); + append_rules(bytes, allow); + append_rules(bytes, deny); + } + } +} + +fn append_cidrs(bytes: &mut Vec, rules: &[Ipv4Cidr]) { + let mut rules = rules.to_vec(); + rules.sort_unstable(); + rules.dedup(); + for rule in rules { + bytes.extend_from_slice(&rule.network.octets()); + bytes.push(rule.prefix_length); + } +} + +fn append_rules(bytes: &mut Vec, rules: &[EgressRule]) { + let mut rules = rules.to_vec(); + rules.sort_unstable(); + rules.dedup(); + bytes.extend_from_slice(&(rules.len() as u64).to_be_bytes()); + for rule in rules { + bytes.extend_from_slice(&rule.destination.network.octets()); + bytes.push(rule.destination.prefix_length); + // A range of TCP or UDP ports encodes as transport 4 or 5 and appends + // its last port, so it differs from every other rule while their + // encodings stay unchanged. + let (transport, end_port) = match rule.transport { + EgressTransport::Any => (0, None), + EgressTransport::Tcp if rule.end_port == rule.port => (1, None), + EgressTransport::Udp if rule.end_port == rule.port => (2, None), + EgressTransport::Icmp => (3, None), + EgressTransport::Tcp => (4, Some(rule.end_port)), + EgressTransport::Udp => (5, Some(rule.end_port)), + }; + bytes.push(transport); + bytes.extend_from_slice(&rule.port.to_be_bytes()); + if let Some(end_port) = end_port { + bytes.extend_from_slice(&end_port.to_be_bytes()); + } + } +} + +fn ensure_available( + prefix: &[u8], + frame_length: usize, + needed: usize, + description: &'static str, +) -> Result<(), EgressDenied> { + if needed > frame_length || needed > prefix.len() { + Err(EgressDenied::Malformed(description)) + } else { + Ok(()) + } +} + +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn read_ipv4(bytes: &[u8], offset: usize) -> Ipv4Addr { + Ipv4Addr::new( + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ) +} + +fn ipv4_checksum_is_valid(header: &[u8]) -> bool { + let mut sum = header + .chunks_exact(2) + .fold(0u32, |sum, word| sum + u32::from(read_u16(word, 0))); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + sum == u32::from(u16::MAX) +} + +fn validate_transport( + frame: &[u8], + frame_length: usize, + ip_offset: usize, + ip_header_length: usize, + ip_total_length: usize, + fragments: u16, +) -> Result<(), EgressDenied> { + if fragments != 0 { + return Ok(()); + } + let transport_offset = ip_offset + ip_header_length; + let payload_length = ip_total_length - ip_header_length; + match frame[ip_offset + 9] { + 6 => { + ensure_available(frame, frame_length, transport_offset + 20, "TCP header")?; + if payload_length < 20 { + return Err(EgressDenied::Malformed("TCP length")); + } + let header_length = usize::from(frame[transport_offset + 12] >> 4) * 4; + if header_length < 20 || header_length > payload_length { + return Err(EgressDenied::Malformed("TCP header")); + } + } + 17 => { + ensure_available(frame, frame_length, transport_offset + 8, "UDP header")?; + let length = usize::from(read_u16(frame, transport_offset + 4)); + if length < 8 || length > payload_length { + return Err(EgressDenied::Malformed("UDP length")); + } + } + 1 => { + ensure_available(frame, frame_length, transport_offset + 8, "ICMP header")?; + if payload_length < 8 { + return Err(EgressDenied::Malformed("ICMP length")); + } + } + _ => {} + } + Ok(()) +} + +fn rule_transport( + frame: &[u8], + frame_length: usize, + ip_offset: usize, + ip_header_length: usize, + ip_total_length: usize, + fragments: u16, + has_port_restrictions: bool, +) -> Result, EgressDenied> { + validate_transport( + frame, + frame_length, + ip_offset, + ip_header_length, + ip_total_length, + fragments, + )?; + // Later fragments carry no transport header, so only port-free rules can + // classify a fragment, by its IPv4 protocol. + if fragments != 0 && has_port_restrictions { + return Err(EgressDenied::FragmentDenied); + } + let transport = match frame[ip_offset + 9] { + 1 => EgressTransport::Icmp, + 6 => EgressTransport::Tcp, + 17 => EgressTransport::Udp, + _ => return Ok(None), + }; + let port = (fragments == 0 && transport != EgressTransport::Icmp) + .then(|| read_u16(frame, ip_offset + ip_header_length + 2)); + Ok(Some(PacketTransport { transport, port })) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + const GUEST_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); + const PREFIX_LENGTH: u8 = 24; + const GATEWAY_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const GUEST_MAC_BYTES: [u8; 6] = [0x52, 0x54, 0, 0, 0, 2]; + + fn bind(mode: EgressPolicyMode) -> EgressPolicy { + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + mode, + ) + .unwrap() + } + + fn set_ipv4_checksum(frame: &mut [u8], ip_offset: usize) { + frame[ip_offset + 10..ip_offset + 12].fill(0); + let header_length = usize::from(frame[ip_offset] & 0x0f) * 4; + let header = &frame[ip_offset..ip_offset + header_length]; + let mut sum = header + .chunks_exact(2) + .fold(0u32, |sum, word| sum + u32::from(read_u16(word, 0))); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + frame[ip_offset + 10..ip_offset + 12].copy_from_slice(&(!(sum as u16)).to_be_bytes()); + } + + fn tcp_frame(destination: Ipv4Addr, destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 20]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&40u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 6; + ip[12..16].copy_from_slice(&Ipv4Addr::new(10, 0, 0, 2).octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[32] = 5 << 4; + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn udp_frame(destination: Ipv4Addr, destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 8]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&28u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 17; + ip[12..16].copy_from_slice(&Ipv4Addr::new(10, 0, 0, 2).octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[24..26].copy_from_slice(&8u16.to_be_bytes()); + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn icmp_echo_frame(destination: Ipv4Addr) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 8]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&28u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 1; + ip[12..16].copy_from_slice(&GUEST_IPV4.octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20] = 8; + set_ipv4_checksum(&mut frame, 14); + frame + } + + /// Rewrites the IPv4 flags and fragment offset of `frame`. + fn fragmented(mut frame: Vec, flags_and_offset: u16) -> Vec { + frame[20..22].copy_from_slice(&flags_and_offset.to_be_bytes()); + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn rule_policy(default_action: EgressAction, allow: &[&str], deny: &[&str]) -> EgressPolicy { + let parse = |rules: &[&str]| { + rules + .iter() + .map(|rule| rule.parse().unwrap()) + .collect::>() + }; + bind(EgressPolicyMode::Rules { + default_action, + allow: parse(allow), + deny: parse(deny), + }) + } + + fn arp_request( + target: Ipv4Addr, + ethernet_destination: [u8; 6], + target_hardware: [u8; 6], + ) -> Vec { + let mut frame = vec![0u8; 42]; + frame[..6].copy_from_slice(ðernet_destination); + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&GUEST_MAC_BYTES); + frame[28..32].copy_from_slice(&GUEST_IPV4.octets()); + frame[32..38].copy_from_slice(&target_hardware); + frame[38..42].copy_from_slice(&target.octets()); + frame + } + + #[test] + fn policy_rules_are_parsed_and_canonicalized() { + let policy = bind(EgressPolicyMode::AllowList(vec![ + "192.168.1.9/24".parse().unwrap(), + "10.0.0.1".parse().unwrap(), + "192.168.1.0/24".parse().unwrap(), + ])); + let EgressPolicyMode::AllowList(rules) = policy.mode() else { + panic!("unexpected policy mode") + }; + assert_eq!(rules.len(), 2); + assert_eq!(rules[1].network(), Ipv4Addr::new(192, 168, 1, 0)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + assert!(policy.allows_gateway_dns()); + assert!(policy.validate().is_ok()); + assert_ne!( + policy + .canonical_bytes_for_version(LEGACY_EGRESS_POLICY_ENCODING_VERSION) + .unwrap(), + policy.canonical_bytes() + ); + } + + #[test] + fn exact_endpoint_policy_filters_before_transmission() { + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "192.0.2.7:443".parse().unwrap(), + ])); + let allowed = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + + let denied = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 80); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::EndpointDenied) + ); + let mut malformed = allowed.clone(); + malformed[24] ^= 1; + assert_eq!( + policy.authorize_frame(&malformed, malformed.len()), + Err(EgressDenied::Malformed("IPv4 checksum")) + ); + let mut spoofed_mac = allowed.clone(); + spoofed_mac[11] ^= 1; + assert_eq!( + policy.authorize_frame(&spoofed_mac, spoofed_mac.len()), + Err(EgressDenied::SourceMacDenied) + ); + + let mut with_options = allowed.clone(); + with_options.splice(34..34, [1, 1, 1, 0]); + with_options[14] = 0x46; + with_options[16..18].copy_from_slice(&44u16.to_be_bytes()); + set_ipv4_checksum(&mut with_options, 14); + assert_eq!( + policy.authorize_frame(&with_options, with_options.len()), + Err(EgressDenied::Ipv4OptionsDenied) + ); + + let udp = udp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + assert_eq!( + policy.authorize_frame(&udp, udp.len()), + Err(EgressDenied::EndpointDenied) + ); + let mut ipv6 = vec![0u8; 14 + 40]; + ipv6[6..12].copy_from_slice(&GUEST_MAC_BYTES); + ipv6[12..14].copy_from_slice(&0x86ddu16.to_be_bytes()); + assert_eq!( + policy.authorize_frame(&ipv6, ipv6.len()), + Err(EgressDenied::UnsupportedEtherType(0x86dd)) + ); + let mut vlan = vec![0u8; allowed.len() + 4]; + vlan[..12].copy_from_slice(&allowed[..12]); + vlan[12..14].copy_from_slice(&0x8100u16.to_be_bytes()); + vlan[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + vlan[18..].copy_from_slice(&allowed[14..]); + assert_eq!( + policy.authorize_frame(&vlan, vlan.len()), + Err(EgressDenied::VlanDenied) + ); + } + + #[test] + fn endpoint_policy_resolves_only_required_next_hops_without_widening_ipv4() { + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let off_subnet = Ipv4Addr::new(192, 0, 2, 7); + let unrelated = Ipv4Addr::new(10, 0, 0, 10); + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:8443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + ])); + + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4, on_link]); + for target in [GATEWAY_IPV4, on_link] { + let request = arp_request(target, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy + .authorize_frame(&request, request.len()) + .expect("required next hop must be resolvable"); + } + let unrelated_request = arp_request(unrelated, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + assert_eq!( + policy.authorize_frame(&unrelated_request, unrelated_request.len()), + Err(EgressDenied::ArpDenied) + ); + + policy + .authorize_frame(&tcp_frame(on_link, 443), 54) + .unwrap(); + policy + .authorize_frame(&tcp_frame(off_subnet, 443), 54) + .unwrap(); + let wrong_port = tcp_frame(on_link, 80); + assert_eq!( + policy.authorize_frame(&wrong_port, wrong_port.len()), + Err(EgressDenied::EndpointDenied) + ); + let unrelated_ipv4 = tcp_frame(unrelated, 443); + assert_eq!( + policy.authorize_frame(&unrelated_ipv4, unrelated_ipv4.len()), + Err(EgressDenied::EndpointDenied) + ); + } + + #[test] + fn endpoint_policy_rejects_invalid_destination_identities() { + let invalid = [ + (Ipv4Addr::UNSPECIFIED, InvalidEndpointAddress::Unspecified), + ( + Ipv4Addr::new(0, 1, 2, 3), + InvalidEndpointAddress::CurrentNetwork, + ), + ( + Ipv4Addr::new(127, 0, 0, 1), + InvalidEndpointAddress::Loopback, + ), + ( + Ipv4Addr::new(169, 254, 1, 1), + InvalidEndpointAddress::LinkLocal, + ), + ( + Ipv4Addr::new(224, 0, 0, 1), + InvalidEndpointAddress::Multicast, + ), + ( + Ipv4Addr::BROADCAST, + InvalidEndpointAddress::LimitedBroadcast, + ), + ( + Ipv4Addr::new(240, 0, 0, 1), + InvalidEndpointAddress::Reserved, + ), + (GUEST_IPV4, InvalidEndpointAddress::Guest), + ( + Ipv4Addr::new(10, 0, 0, 0), + InvalidEndpointAddress::SubnetNetwork, + ), + ( + Ipv4Addr::new(10, 0, 0, 255), + InvalidEndpointAddress::SubnetBroadcast, + ), + ]; + + for (address, reason) in invalid { + let endpoint = format!("{address}:443").parse().unwrap(); + assert_eq!( + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + EgressPolicyMode::TcpEndpoints(vec![endpoint]), + ), + Err(InvalidEgressPolicy::InvalidEndpoint { address, reason }) + ); + } + } + + #[test] + fn decoded_policy_must_preserve_canonical_rules_and_next_hops() { + let mut noncanonical_rules = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + let EgressPolicyMode::TcpEndpoints(endpoints) = &mut noncanonical_rules.mode else { + panic!("expected endpoint policy") + }; + endpoints.push("10.0.0.9:443".parse().unwrap()); + assert_eq!( + noncanonical_rules.validate(), + Err(InvalidEgressPolicy::NonCanonicalRules) + ); + + let mut invalid_next_hops = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + invalid_next_hops + .next_hops + .push(Ipv4Addr::new(10, 0, 0, 10)); + assert_eq!( + invalid_next_hops.validate(), + Err(InvalidEgressPolicy::InvalidNextHops) + ); + } + + #[test] + fn arp_requires_canonical_guest_requests() { + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let peer_mac = [0x52, 0x54, 0, 0, 0, 9]; + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + + let broadcast = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&broadcast, broadcast.len()).unwrap(); + let unicast_refresh = arp_request(on_link, peer_mac, peer_mac); + policy + .authorize_frame(&unicast_refresh, unicast_refresh.len()) + .unwrap(); + + let mut ethernet_spoof = broadcast.clone(); + ethernet_spoof[11] ^= 1; + assert_eq!( + policy.authorize_frame(ðernet_spoof, ethernet_spoof.len()), + Err(EgressDenied::SourceMacDenied) + ); + let mut sender_hardware_spoof = broadcast.clone(); + sender_hardware_spoof[27] ^= 1; + assert_eq!( + policy.authorize_frame(&sender_hardware_spoof, sender_hardware_spoof.len()), + Err(EgressDenied::ArpDenied) + ); + let mut sender_ip_spoof = broadcast.clone(); + sender_ip_spoof[31] ^= 1; + assert_eq!( + policy.authorize_frame(&sender_ip_spoof, sender_ip_spoof.len()), + Err(EgressDenied::ArpDenied) + ); + let mut noncanonical_target_hardware = broadcast.clone(); + noncanonical_target_hardware[37] = 1; + assert_eq!( + policy.authorize_frame( + &noncanonical_target_hardware, + noncanonical_target_hardware.len() + ), + Err(EgressDenied::ArpDenied) + ); + let mut reply = broadcast.clone(); + reply[20..22].copy_from_slice(&2u16.to_be_bytes()); + assert_eq!( + policy.authorize_frame(&reply, reply.len()), + Err(EgressDenied::Malformed("ARP request")) + ); + assert_eq!( + policy.authorize_frame(&broadcast[..40], broadcast.len()), + Err(EgressDenied::Malformed("ARP packet")) + ); + } + + #[test] + fn list_policies_filter_destinations_and_allow_only_gateway_arp() { + let allowed_destination = Ipv4Addr::new(192, 0, 2, 7); + let blocked_destination = Ipv4Addr::new(198, 51, 100, 9); + let allow = bind(EgressPolicyMode::AllowList(vec![ + "192.0.2.0/24".parse().unwrap(), + ])); + let allowed = tcp_frame(allowed_destination, 80); + allow.authorize_frame(&allowed, allowed.len()).unwrap(); + let denied = tcp_frame(blocked_destination, 80); + assert_eq!( + allow.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + + let block = bind(EgressPolicyMode::BlockList(vec![ + "198.51.100.0/24".parse().unwrap(), + ])); + block.authorize_frame(&allowed, allowed.len()).unwrap(); + assert_eq!( + block.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + + let gateway_request = arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + allow + .authorize_frame(&gateway_request, gateway_request.len()) + .unwrap(); + let other_arp = arp_request( + Ipv4Addr::new(10, 0, 0, 9), + ETHERNET_BROADCAST, + ETHERNET_UNSPECIFIED, + ); + assert_eq!( + allow.authorize_frame(&other_arp, other_arp.len()), + Err(EgressDenied::ArpDenied) + ); + } + + #[test] + fn l3_l4_rules_parse_and_apply_deny_precedence() { + assert_eq!( + "192.0.2.9/24:tcp:443".parse::().unwrap(), + EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport: EgressTransport::Tcp, + port: 443, + end_port: 443, + } + ); + assert_eq!( + "198.51.100.0/24".parse::().unwrap().transport(), + EgressTransport::Any + ); + for invalid in [ + "192.0.2.0/24:icmp:8", + "192.0.2.0/24:tcp:0", + "192.0.2.0/24:any", + "192.0.2.0/24:tcp:443:extra", + ] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + + let policy = bind(EgressPolicyMode::Rules { + default_action: EgressAction::Deny, + allow: vec![ + "192.0.2.0/24:tcp:443".parse().unwrap(), + "192.0.2.0/24:udp:53".parse().unwrap(), + "10.0.0.9".parse().unwrap(), + "192.0.2.0/24:tcp:443".parse().unwrap(), + ], + deny: vec!["192.0.2.7:tcp:443".parse().unwrap()], + }); + assert_eq!(policy.mode_name(), "rules"); + assert_eq!(policy.rule_counts(), (3, 1)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + + let allowed_tcp = tcp_frame(Ipv4Addr::new(192, 0, 2, 8), 443); + policy + .authorize_frame(&allowed_tcp, allowed_tcp.len()) + .unwrap(); + let denied_by_precedence = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + assert_eq!( + policy.authorize_frame(&denied_by_precedence, denied_by_precedence.len()), + Err(EgressDenied::DestinationDenied) + ); + let wrong_port = tcp_frame(Ipv4Addr::new(192, 0, 2, 8), 80); + assert_eq!( + policy.authorize_frame(&wrong_port, wrong_port.len()), + Err(EgressDenied::DestinationDenied) + ); + let allowed_udp = udp_frame(Ipv4Addr::new(192, 0, 2, 7), 53); + policy + .authorize_frame(&allowed_udp, allowed_udp.len()) + .unwrap(); + + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let on_link_arp = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy + .authorize_frame(&on_link_arp, on_link_arp.len()) + .unwrap(); + + let mut fragment = allowed_tcp; + fragment[20..22].copy_from_slice(&0x2000u16.to_be_bytes()); + set_ipv4_checksum(&mut fragment, 14); + assert_eq!( + policy.authorize_frame(&fragment, fragment.len()), + Err(EgressDenied::FragmentDenied) + ); + assert!(policy.validate().is_ok()); + } + + #[test] + fn protocol_wide_rules_parse_canonically() { + for (rule, transport) in [ + ("192.0.2.9/24:tcp", EgressTransport::Tcp), + ("192.0.2.9/24:udp", EgressTransport::Udp), + ("192.0.2.9/24:icmp", EgressTransport::Icmp), + ] { + assert_eq!( + rule.parse::(), + Ok(EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport, + port: 0, + end_port: 0, + }), + "{rule}" + ); + } + for (rule, error) in [ + ("192.0.2.0/24:icmp:8", ParseEgressRuleError::IcmpPort), + ("192.0.2.0/24:icmp:", ParseEgressRuleError::IcmpPort), + ( + "192.0.2.0/24:any", + ParseEgressRuleError::InvalidTransport("any".to_owned()), + ), + ( + "192.0.2.0/24:any:443", + ParseEgressRuleError::InvalidTransport("any".to_owned()), + ), + ( + "192.0.2.0/24:", + ParseEgressRuleError::InvalidTransport(String::new()), + ), + ( + "192.0.2.0/24:tcp:", + ParseEgressRuleError::InvalidPort(String::new()), + ), + ("192.0.2.0/24:udp:0", ParseEgressRuleError::ZeroPort), + ( + "192.0.2.0/24:udp:65536", + ParseEgressRuleError::InvalidPort("65536".to_owned()), + ), + ( + "192.0.2.0/24:tcp:443:extra", + ParseEgressRuleError::InvalidFormat, + ), + ] { + assert_eq!(rule.parse::(), Err(error), "{rule}"); + } + } + + #[test] + fn protocol_selectors_match_only_their_traffic_in_allow_and_deny_rules() { + let target = Ipv4Addr::new(192, 0, 2, 7); + let neighbor = Ipv4Addr::new(192, 0, 2, 8); + let probes = [ + ("tcp:443", tcp_frame(target, 443)), + ("tcp:8443", tcp_frame(target, 8443)), + ("udp:443", udp_frame(target, 443)), + ("udp:8443", udp_frame(target, 8443)), + ("icmp", icmp_echo_frame(target)), + ]; + let neighbors = [ + tcp_frame(neighbor, 443), + udp_frame(neighbor, 443), + icmp_echo_frame(neighbor), + ]; + // Each selector lists the probes it matches. NVX lowers protocol `any` + // with port 443 to the TCP and UDP rules of the last selector. + let selectors: [(&[&str], &[&str]); 5] = [ + (&["192.0.2.7:tcp"], &["tcp:443", "tcp:8443"]), + (&["192.0.2.7:udp"], &["udp:443", "udp:8443"]), + (&["192.0.2.7:icmp"], &["icmp"]), + ( + &["192.0.2.7"], + &["tcp:443", "tcp:8443", "udp:443", "udp:8443", "icmp"], + ), + ( + &["192.0.2.7:tcp:443", "192.0.2.7:udp:443"], + &["tcp:443", "udp:443"], + ), + ]; + for (selector, matched) in selectors { + let allow = rule_policy(EgressAction::Deny, selector, &[]); + // The deny rules must win over an allow rule that covers every probe. + let deny = rule_policy(EgressAction::Deny, &["192.0.2.0/24"], selector); + assert!(allow.validate().is_ok()); + assert!(deny.validate().is_ok()); + for (name, frame) in &probes { + let (allowed, denied) = if matched.contains(name) { + (Ok(()), Err(EgressDenied::DestinationDenied)) + } else { + (Err(EgressDenied::DestinationDenied), Ok(())) + }; + assert_eq!( + allow.authorize_frame(frame, frame.len()), + allowed, + "allow {selector:?}: {name}" + ); + assert_eq!( + deny.authorize_frame(frame, frame.len()), + denied, + "deny {selector:?}: {name}" + ); + } + for frame in &neighbors { + assert_eq!( + allow.authorize_frame(frame, frame.len()), + Err(EgressDenied::DestinationDenied), + "allow {selector:?}: neighbor" + ); + deny.authorize_frame(frame, frame.len()).unwrap(); + } + } + } + + #[test] + fn protocol_wide_and_port_rules_compose_with_deny_precedence() { + let target = Ipv4Addr::new(192, 0, 2, 7); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.0/24:tcp"], + &["192.0.2.7:tcp:443"], + ); + let denied = tcp_frame(target, 443); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + let allowed = tcp_frame(target, 8443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + let udp = udp_frame(target, 8443); + assert_eq!( + policy.authorize_frame(&udp, udp.len()), + Err(EgressDenied::DestinationDenied) + ); + + let policy = rule_policy( + EgressAction::Allow, + &["192.0.2.7:tcp:443"], + &["192.0.2.0/24:tcp"], + ); + for port in [443, 8443] { + let tcp = tcp_frame(target, port); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied), + "{port}" + ); + } + let udp = udp_frame(target, 443); + policy.authorize_frame(&udp, udp.len()).unwrap(); + let icmp = icmp_echo_frame(target); + policy.authorize_frame(&icmp, icmp.len()).unwrap(); + } + + #[test] + fn port_free_rules_classify_fragments_by_ipv4_protocol() { + const FIRST_FRAGMENT: u16 = 0x2000; + const LATER_FRAGMENT: u16 = 0x00b9; + let target = Ipv4Addr::new(192, 0, 2, 7); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:icmp"], + &["192.0.2.7:tcp"], + ); + for flags in [FIRST_FRAGMENT, LATER_FRAGMENT] { + for frame in [ + fragmented(udp_frame(target, 53), flags), + fragmented(icmp_echo_frame(target), flags), + ] { + policy.authorize_frame(&frame, frame.len()).unwrap(); + } + let tcp = fragmented(tcp_frame(target, 443), flags); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied) + ); + } + + // A port-specific rule cannot classify a fragment of any protocol. + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:icmp", "192.0.2.7:tcp:443"], + &[], + ); + for frame in [ + fragmented(udp_frame(target, 53), FIRST_FRAGMENT), + fragmented(icmp_echo_frame(target), LATER_FRAGMENT), + ] { + assert_eq!( + policy.authorize_frame(&frame, frame.len()), + Err(EgressDenied::FragmentDenied) + ); + } + let unfragmented = udp_frame(target, 53); + policy + .authorize_frame(&unfragmented, unfragmented.len()) + .unwrap(); + } + + #[test] + fn protocol_wide_rules_have_distinct_canonical_encodings() { + // Each rule is encoded as its network, prefix length, transport, and port. + let encodings = [ + ("192.0.2.0/24", [192, 0, 2, 0, 24, 0, 0, 0]), + ("192.0.2.0/24:tcp", [192, 0, 2, 0, 24, 1, 0, 0]), + ("192.0.2.0/24:udp", [192, 0, 2, 0, 24, 2, 0, 0]), + ("192.0.2.0/24:icmp", [192, 0, 2, 0, 24, 3, 0, 0]), + ("192.0.2.0/24:tcp:443", [192, 0, 2, 0, 24, 1, 1, 187]), + ] + .map(|(rule, record)| { + let policy = rule_policy(EgressAction::Deny, &[rule], &[]); + assert!(policy.validate().is_ok(), "{rule}"); + let bytes = policy.canonical_bytes(); + assert!( + bytes.windows(record.len()).any(|window| window == record), + "{rule}" + ); + bytes + }); + for (index, encoding) in encodings.iter().enumerate() { + assert!(!encodings[index + 1..].contains(encoding)); + } + } + + #[test] + fn protocol_wide_rules_preserve_gateway_dns_and_arp_semantics() { + assert!(rule_policy(EgressAction::Deny, &["10.0.0.1:udp"], &[]).allows_gateway_dns()); + assert!(rule_policy(EgressAction::Deny, &["10.0.0.1:tcp"], &[]).allows_gateway_dns()); + assert!(!rule_policy(EgressAction::Deny, &["10.0.0.1:icmp"], &[]).allows_gateway_dns()); + assert!( + !rule_policy(EgressAction::Allow, &[], &["10.0.0.1:tcp", "10.0.0.1:udp"]) + .allows_gateway_dns() + ); + + // A protocol-wide deny leaves the destination's other protocols + // reachable, so the guest may still resolve it. + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let policy = rule_policy(EgressAction::Deny, &["10.0.0.9"], &["10.0.0.9:tcp"]); + let arp = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&arp, arp.len()).unwrap(); + let tcp = tcp_frame(on_link, 443); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied) + ); + let icmp = icmp_echo_frame(on_link); + policy.authorize_frame(&icmp, icmp.len()).unwrap(); + } + + #[test] + fn port_ranges_parse_canonically() { + for (rule, transport, port, end_port) in [ + ( + "192.0.2.9/24:tcp:8000-8010", + EgressTransport::Tcp, + 8000, + 8010, + ), + ( + "192.0.2.9/24:udp:5000-5010", + EgressTransport::Udp, + 5000, + 5010, + ), + ("192.0.2.9/24:tcp:1-65535", EgressTransport::Tcp, 1, 65535), + ( + "192.0.2.9/24:udp:65534-65535", + EgressTransport::Udp, + 65534, + 65535, + ), + // A range of one port is that port's rule. + ( + "192.0.2.9/24:tcp:8000-8000", + EgressTransport::Tcp, + 8000, + 8000, + ), + ("192.0.2.9/24:tcp:8000", EgressTransport::Tcp, 8000, 8000), + ] { + assert_eq!( + rule.parse::(), + Ok(EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport, + port, + end_port, + }), + "{rule}" + ); + } + for (rule, error) in [ + ( + "192.0.2.0/24:tcp:8010-8000", + ParseEgressRuleError::ReversedPortRange { + first: 8010, + last: 8000, + }, + ), + ( + "192.0.2.0/24:tcp:-8010", + ParseEgressRuleError::InvalidPortRange("-8010".to_owned()), + ), + ( + "192.0.2.0/24:udp:8000-", + ParseEgressRuleError::InvalidPortRange("8000-".to_owned()), + ), + ( + "192.0.2.0/24:tcp:-", + ParseEgressRuleError::InvalidPortRange("-".to_owned()), + ), + ( + "192.0.2.0/24:tcp:8000-8005-8010", + ParseEgressRuleError::InvalidPortRange("8000-8005-8010".to_owned()), + ), + ("192.0.2.0/24:tcp:0-8010", ParseEgressRuleError::ZeroPort), + ("192.0.2.0/24:udp:1-0", ParseEgressRuleError::ZeroPort), + ( + "192.0.2.0/24:udp:8000-65536", + ParseEgressRuleError::InvalidPort("65536".to_owned()), + ), + ( + "192.0.2.0/24:tcp:http-8010", + ParseEgressRuleError::InvalidPort("http".to_owned()), + ), + ("192.0.2.0/24:icmp:1-2", ParseEgressRuleError::IcmpPort), + ( + "192.0.2.0/24:tcp:8000-8010:extra", + ParseEgressRuleError::InvalidFormat, + ), + ] { + assert_eq!(rule.parse::(), Err(error), "{rule}"); + } + } + + #[test] + fn port_ranges_match_their_boundaries_in_allow_and_deny_rules() { + let target = Ipv4Addr::new(192, 0, 2, 7); + // The range's first and last ports, a port inside it, and the ports + // next to it, for TCP and UDP. + let probes = [7999, 8000, 8005, 8010, 8011] + .into_iter() + .flat_map(|port| { + [ + (("tcp", port), tcp_frame(target, port)), + (("udp", port), udp_frame(target, port)), + ] + }) + .collect::>(); + let inside = |transport| [(transport, 8000), (transport, 8005), (transport, 8010)]; + // Each selector lists the probes it matches. NVX lowers protocol `any` + // with a port range to the TCP and UDP ranges of the last selector. + let selectors: [(&[&str], Vec<(&str, u16)>); 3] = [ + (&["192.0.2.7:tcp:8000-8010"], inside("tcp").to_vec()), + (&["192.0.2.7:udp:8000-8010"], inside("udp").to_vec()), + ( + &["192.0.2.7:tcp:8000-8010", "192.0.2.7:udp:8000-8010"], + [inside("tcp"), inside("udp")].concat(), + ), + ]; + for (selector, matched) in selectors { + let allow = rule_policy(EgressAction::Deny, selector, &[]); + // The deny rules must win over an allow rule that covers every probe. + let deny = rule_policy(EgressAction::Deny, &["192.0.2.0/24"], selector); + assert!(allow.validate().is_ok()); + assert!(deny.validate().is_ok()); + for (probe, frame) in &probes { + let (allowed, denied) = if matched.contains(probe) { + (Ok(()), Err(EgressDenied::DestinationDenied)) + } else { + (Err(EgressDenied::DestinationDenied), Ok(())) + }; + assert_eq!( + allow.authorize_frame(frame, frame.len()), + allowed, + "allow {selector:?}: {probe:?}" + ); + assert_eq!( + deny.authorize_frame(frame, frame.len()), + denied, + "deny {selector:?}: {probe:?}" + ); + } + } + + // A deny rule for one port inside an allowed range blocks that port alone. + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:tcp:8000-8010"], + &["192.0.2.7:tcp:8005"], + ); + for (port, allowed) in [ + (7999, false), + (8000, true), + (8004, true), + (8005, false), + (8006, true), + (8010, true), + (8011, false), + ] { + let frame = tcp_frame(target, port); + assert_eq!( + policy.authorize_frame(&frame, frame.len()).is_ok(), + allowed, + "{port}" + ); + } + } + + #[test] + fn port_ranges_preserve_fragment_and_gateway_dns_semantics() { + // A range restricts ports, so it cannot classify a fragment of any + // protocol. + let fragment = fragmented(udp_frame(Ipv4Addr::new(192, 0, 2, 7), 53), 0x2000); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:tcp:8000-8010"], + &[], + ); + assert_eq!( + policy.authorize_frame(&fragment, fragment.len()), + Err(EgressDenied::FragmentDenied) + ); + + let cases: [(&[&str], &[&str], bool); 5] = [ + (&["10.0.0.1:udp:1-1024"], &[], true), + (&["10.0.0.1:tcp:53-80"], &[], true), + (&["10.0.0.1:tcp:1-52"], &[], false), + (&["10.0.0.1:udp:54-1024"], &[], false), + (&["10.0.0.1:udp:1-1024"], &["10.0.0.1:udp:53"], false), + ]; + for (allow, deny, allows_dns) in cases { + assert_eq!( + rule_policy(EgressAction::Deny, allow, deny).allows_gateway_dns(), + allows_dns, + "allow {allow:?}, deny {deny:?}" + ); + } + } + + #[test] + fn port_ranges_have_distinct_canonical_encodings() { + // A range encodes as its network, prefix length, transport 4 for TCP + // or 5 for UDP, and its first and last ports. + let encodings = [ + ( + "192.0.2.0/24:tcp:8000-8010", + vec![192, 0, 2, 0, 24, 4, 0x1f, 0x40, 0x1f, 0x4a], + ), + ( + "192.0.2.0/24:udp:8000-8010", + vec![192, 0, 2, 0, 24, 5, 0x1f, 0x40, 0x1f, 0x4a], + ), + ( + "192.0.2.0/24:tcp:8000-8001", + vec![192, 0, 2, 0, 24, 4, 0x1f, 0x40, 0x1f, 0x41], + ), + ( + "192.0.2.0/24:tcp:8000", + vec![192, 0, 2, 0, 24, 1, 0x1f, 0x40], + ), + ( + "192.0.2.0/24:udp:8000", + vec![192, 0, 2, 0, 24, 2, 0x1f, 0x40], + ), + ("192.0.2.0/24:tcp", vec![192, 0, 2, 0, 24, 1, 0, 0]), + ] + .map(|(rule, record)| { + let policy = rule_policy(EgressAction::Deny, &[rule], &[]); + assert!(policy.validate().is_ok(), "{rule}"); + let bytes = policy.canonical_bytes(); + assert!( + bytes.windows(record.len()).any(|window| window == record), + "{rule}" + ); + bytes + }); + for (index, encoding) in encodings.iter().enumerate() { + assert!(!encodings[index + 1..].contains(encoding)); + } + + // A range of one port encodes as that port, and a snapshot of either + // encoding version cannot be restored with a range in place of its + // first port. + let canonical = |rule: &str| rule_policy(EgressAction::Deny, &[rule], &[]); + assert_eq!( + canonical("192.0.2.0/24:tcp:8000-8000").canonical_bytes(), + canonical("192.0.2.0/24:tcp:8000").canonical_bytes() + ); + for version in [ + LEGACY_EGRESS_POLICY_ENCODING_VERSION, + PREVIOUS_EGRESS_POLICY_ENCODING_VERSION, + EGRESS_POLICY_ENCODING_VERSION, + ] { + assert_ne!( + canonical("192.0.2.0/24:tcp:8000").canonical_bytes_for_version(version), + canonical("192.0.2.0/24:tcp:8000-8010").canonical_bytes_for_version(version), + "{version}" + ); + } + } + + #[test] + fn decoded_policy_must_preserve_canonical_rule_forms() { + let destination = "192.0.2.7".parse().unwrap(); + for (transport, port, end_port) in [ + (EgressTransport::Tcp, 8010, 8000), + (EgressTransport::Udp, 0, 8000), + (EgressTransport::Tcp, 8000, 0), + (EgressTransport::Icmp, 8, 8), + (EgressTransport::Any, 443, 443), + ] { + let rule = EgressRule { + destination, + transport, + port, + end_port, + }; + let mut policy = rule_policy(EgressAction::Deny, &["192.0.2.7:tcp:8000-8010"], &[]); + let EgressPolicyMode::Rules { deny, .. } = &mut policy.mode else { + panic!("expected rule-based egress policy") + }; + deny.push(rule); + assert_eq!( + policy.validate(), + Err(InvalidEgressPolicy::NonCanonicalRules), + "{rule:?}" + ); + assert_eq!( + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + policy.mode.clone(), + ), + Err(InvalidEgressPolicy::NonCanonicalRules), + "{rule:?}" + ); + } + } + + #[test] + fn host_loopback_denial_preserves_only_the_exact_proxy() { + let proxy: TcpEndpoint = "10.0.0.1:8443".parse().unwrap(); + let policy = bind(EgressPolicyMode::DenyAll) + .with_host_loopback(EgressAction::Deny, Some(proxy)) + .unwrap(); + assert_eq!(policy.host_loopback_action(), EgressAction::Deny); + assert_eq!(policy.proxy_endpoint(), Some(proxy)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + + let arp = arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&arp, arp.len()).unwrap(); + let allowed = tcp_frame(GATEWAY_IPV4, 8443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + let denied = tcp_frame(GATEWAY_IPV4, 8080); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::AllTrafficDenied) + ); + assert_ne!( + policy + .canonical_bytes_for_version(PREVIOUS_EGRESS_POLICY_ENCODING_VERSION) + .unwrap(), + policy.canonical_bytes() + ); + assert!(policy.validate().is_ok()); + + let invalid_proxy: TcpEndpoint = "192.0.2.1:8443".parse().unwrap(); + assert_eq!( + bind(EgressPolicyMode::AllowAll) + .with_host_loopback(EgressAction::Deny, Some(invalid_proxy)), + Err(InvalidEgressPolicy::InvalidProxyAddress { + actual: Ipv4Addr::new(192, 0, 2, 1), + expected: GATEWAY_IPV4, + }) + ); + } + + #[test] + fn allow_all_does_not_parse_guest_frames() { + let policy = bind(EgressPolicyMode::AllowAll); + policy.authorize_frame(&[], 0).unwrap(); + } + + #[test] + fn deny_all_rejects_every_guest_frame_before_parsing() { + let policy = bind(EgressPolicyMode::DenyAll); + assert!(policy.is_active()); + assert!(!policy.allows_gateway_dns()); + assert!(policy.next_hops().is_empty()); + + for frame in [ + tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443), + arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED), + Vec::new(), + ] { + assert_eq!( + policy.authorize_frame(&frame, frame.len()), + Err(EgressDenied::AllTrafficDenied) + ); + } + } +} diff --git a/vm/devices/net/net_backend_resources/src/lib.rs b/vm/devices/net/net_backend_resources/src/lib.rs index e86d4b0c90c..fdb650b0c8d 100644 --- a/vm/devices/net/net_backend_resources/src/lib.rs +++ b/vm/devices/net/net_backend_resources/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] +pub mod egress; pub mod mac_address; /// Null backend. @@ -27,6 +28,8 @@ pub mod null { /// Consomme backend. pub mod consomme { + pub mod static_ipv4; + use mesh::MeshPayload; use vm_resource::ResourceId; use vm_resource::kind::NetEndpointHandleKind; @@ -108,6 +111,15 @@ pub mod consomme { pub ports: Vec, /// Optional channel for runtime port bind/unbind after the endpoint starts. pub recv: Option>, + /// Optional exact static identity, mutually exclusive with `cidr`. + pub static_ipv4: Option, + /// Override guest access to host-local destination addresses. + pub allow_host_local_access: Option, + /// Override translation of the guest gateway onto host loopback. + pub map_gateway_to_host_loopback: Option, + /// Exact gateway TCP port translated to host loopback even when the + /// general gateway mapping is disabled. + pub gateway_loopback_proxy_port: Option, } impl ResourceId for ConsommeHandle { diff --git a/vm/devices/net/net_consomme/Cargo.toml b/vm/devices/net/net_consomme/Cargo.toml index 3ef73901ab1..f1a50f2cfcd 100644 --- a/vm/devices/net/net_consomme/Cargo.toml +++ b/vm/devices/net/net_consomme/Cargo.toml @@ -25,5 +25,8 @@ async-trait.workspace = true parking_lot.workspace = true tracing.workspace = true +[dev-dependencies] +guestmem.workspace = true + [lints] workspace = true diff --git a/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs b/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs index 4ea63108fe3..283779f915b 100644 --- a/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs +++ b/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs @@ -14,6 +14,8 @@ use crate::DropReason; pub mod dns_tcp; mod static_records; +#[cfg(test)] +mod tests; pub(crate) use static_records::MAX_DNS_UDP_RESPONSE_LEN; pub use static_records::StaticDnsRecord; diff --git a/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs b/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs new file mode 100644 index 00000000000..6cc0885cfc3 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the DNS resolver's pending-request limit. + +use super::*; + +struct HoldingBackend; + +impl DnsBackend for HoldingBackend { + fn query( + &self, + _request: &DnsRequest<'_>, + _response_sender: Sender, + _query_id: u64, + ) { + } +} + +fn request() -> DnsRequest<'static> { + static QUERY: [u8; DNS_HEADER_SIZE + 1] = [0; DNS_HEADER_SIZE + 1]; + DnsRequest { + flow: DnsFlow { + src: "10.0.0.2:10000".parse().unwrap(), + dst: "10.0.0.1:53".parse().unwrap(), + gateway_mac: EthernetAddress([0x52, 0x54, 0, 0, 0, 1]), + client_mac: EthernetAddress([0x52, 0x54, 0, 0, 0, 2]), + transport: DnsTransport::Udp, + }, + dns_query: &QUERY, + } +} + +#[test] +fn default_pending_request_limit_returns_servfail() { + let mut resolver = DnsResolver::new_for_test(Arc::new(HoldingBackend)); + let request = request(); + for _ in 0..DEFAULT_MAX_PENDING_DNS_REQUESTS { + assert!(resolver.submit_udp_query(&request).unwrap().is_none()); + } + + let response = resolver + .submit_udp_query(&request) + .unwrap() + .expect("the pending-request limit must return SERVFAIL"); + assert_eq!(resolver.pending_requests, DEFAULT_MAX_PENDING_DNS_REQUESTS); + assert_eq!(response.response_data[3] & 0x0f, 2); +} diff --git a/vm/devices/net/net_consomme/consomme/src/icmp.rs b/vm/devices/net/net_consomme/consomme/src/icmp.rs index 24dbedb38a0..abc85b89b1a 100644 --- a/vm/devices/net/net_consomme/consomme/src/icmp.rs +++ b/vm/devices/net/net_consomme/consomme/src/icmp.rs @@ -4,6 +4,8 @@ // UNSAFETY: needed to cast the socket buffer to `MaybeUninit`. #![expect(unsafe_code)] +mod limits; + use super::Access; use super::Client; use super::ConsommeState; @@ -49,12 +51,14 @@ const ICMPV4_HEADER_LEN: usize = 8; pub(crate) struct Icmp { connections: HashMap, + max_connections: usize, } impl Icmp { pub fn new() -> Self { Self { connections: HashMap::new(), + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_ICMP_FLOWS, } } } @@ -62,6 +66,8 @@ impl Icmp { impl Inspect for Icmp { fn inspect(&self, req: inspect::Request<'_>) { let mut resp = req.respond(); + resp.field("max_connections", self.max_connections) + .field("active_connections", self.connections.len()); for (addr, conn) in &self.connections { resp.field(&format!("{}:{}", addr.ip(), addr.port()), conn); } @@ -242,6 +248,8 @@ impl Access<'_, T> { let icmp_packet = Icmpv4Packet::new_unchecked(payload); let guest_addr = SocketAddrV4::new(addresses.src_addr, 0); + self.inner.icmp.check_flow_limit(&guest_addr)?; + let entry = self.inner.icmp.connections.entry(guest_addr); let conn = match entry { hash_map::Entry::Occupied(conn) => conn.into_mut(), diff --git a/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs b/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs new file mode 100644 index 00000000000..70dd54091e1 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of ICMP source flows. + +use super::Icmp; +use crate::DropReason; +use std::net::SocketAddrV4; + +impl Icmp { + /// Rejects a new guest source flow when the active-flow limit is reached, + /// before a host socket is opened for it. + pub(super) fn check_flow_limit(&self, guest_addr: &SocketAddrV4) -> Result<(), DropReason> { + if !self.connections.contains_key(guest_addr) + && self.connections.len() >= self.max_connections + { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + guest = %guest_addr, + "rejecting ICMP flow before host socket creation because the active-flow limit was reached" + ); + return Err(DropReason::IcmpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/lib.rs b/vm/devices/net/net_consomme/consomme/src/lib.rs index 76b5b50dff5..11e12d4d22d 100644 --- a/vm/devices/net/net_consomme/consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/consomme/src/lib.rs @@ -22,8 +22,10 @@ mod dhcpv6; mod dns; mod dns_resolver; mod icmp; +pub mod limits; mod local_addr_map; mod ndp; +pub mod static_ipv4; mod tcp; mod udp; @@ -190,6 +192,11 @@ pub struct ConsommeParams { /// If true, allow guest traffic destined for host-local addresses /// (loopback, unspecified, link-local). pub allow_host_local_access: bool, + /// If true, translate guest traffic to the IPv4 gateway onto host loopback. + pub map_gateway_to_host_loopback: bool, + /// Exact gateway TCP port translated to host loopback independently of the + /// general gateway mapping. + pub gateway_loopback_proxy_port: Option, /// Per-connection TCP receive ring buffer bounds (guest-to-host). pub tcp_rx_buffer: TcpBufferBounds, /// Per-connection TCP transmit ring buffer bounds (host-to-guest). @@ -257,6 +264,8 @@ impl ConsommeParams { tcp_close_timeout: Duration::from_secs(60), skip_ipv6_checks: false, allow_host_local_access: false, + map_gateway_to_host_loopback: false, + gateway_loopback_proxy_port: None, tcp_rx_buffer: DEFAULT_TCP_BUFFER_BOUNDS, tcp_tx_buffer: DEFAULT_TCP_BUFFER_BOUNDS, }) @@ -669,6 +678,15 @@ pub enum DropReason { /// The send buffer is invalid. #[error("send buffer full")] SendBufferFull, + /// The active TCP flow limit was reached before a host socket was created. + #[error("active TCP flow limit reached")] + TcpConnectionLimit, + /// The active UDP flow limit was reached before a host socket was bound. + #[error("active UDP flow limit reached")] + UdpConnectionLimit, + /// The active ICMP flow limit was reached before a host socket was opened. + #[error("active ICMP flow limit reached")] + IcmpConnectionLimit, /// There was an IO error. #[error("io error")] Io(#[source] std::io::Error), diff --git a/vm/devices/net/net_consomme/consomme/src/limits.rs b/vm/devices/net/net_consomme/consomme/src/limits.rs new file mode 100644 index 00000000000..aadf1d19d9a --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/limits.rs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Limits on the number of simultaneously active guest flows. +//! +//! Each protocol rejects a new guest flow once it has this many active flows, +//! before creating any host socket for it. + +/// Default maximum number of simultaneously active guest TCP flows. +/// +/// The limit is checked before creating a host TCP socket or allocating +/// per-flow TCP buffers. +pub const DEFAULT_MAX_ACTIVE_TCP_FLOWS: usize = 128; +/// Default maximum number of simultaneously active guest UDP flows. +/// +/// The limit is checked before binding a host UDP socket. +pub const DEFAULT_MAX_ACTIVE_UDP_FLOWS: usize = 256; +/// Default maximum number of simultaneously active guest ICMP source flows. +/// +/// The limit is checked before opening a host ICMP socket. +pub const DEFAULT_MAX_ACTIVE_ICMP_FLOWS: usize = 16; diff --git a/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs b/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs new file mode 100644 index 00000000000..52669b58db7 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact static IPv4 identity and gateway loopback translation. +//! +//! [`ConsommeParams::set_static_ipv4`] replaces the default or CIDR-derived +//! addresses with an exact guest and gateway identity and enables the +//! translation of guest traffic to the gateway onto host loopback, which +//! [`ConsommeParams::map_gateway_to_host_loopback`] and +//! [`ConsommeParams::gateway_loopback_proxy_port`] control. + +use crate::ConsommeParams; +use crate::ConsommeState; +use smoltcp::wire::EthernetAddress; +use smoltcp::wire::IpProtocol; +use smoltcp::wire::Ipv4Address; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::SocketAddrV4; +use thiserror::Error; + +/// An error indicating that a static IPv4 identity is internally inconsistent. +#[derive(Debug, Error)] +#[error("invalid static IPv4 identity")] +pub struct InvalidStaticIpv4; + +impl ConsommeParams { + /// Sets an exact static IPv4 client and gateway identity. + pub fn set_static_ipv4( + &mut self, + guest_ipv4: Ipv4Addr, + prefix_length: u8, + gateway_ipv4: Ipv4Addr, + gateway_mac: [u8; 6], + ) -> Result<(), InvalidStaticIpv4> { + if !(1..=30).contains(&prefix_length) { + return Err(InvalidStaticIpv4); + } + let mask = u32::MAX << (32 - prefix_length); + let guest = u32::from(guest_ipv4); + let gateway = u32::from(gateway_ipv4); + let network = guest & mask; + let broadcast = network | !mask; + if guest == network || guest == broadcast || gateway != network + 1 || guest == gateway { + return Err(InvalidStaticIpv4); + } + + self.client_ip = Ipv4Address::from(guest_ipv4.octets()); + self.gateway_ip = Ipv4Address::from(gateway_ipv4.octets()); + self.net_mask = Ipv4Address::from(Ipv4Addr::from(mask).octets()); + self.gateway_mac = EthernetAddress(gateway_mac); + self.advertise_routable_ipv6 = false; + self.map_gateway_to_host_loopback = true; + Ok(()) + } +} + +impl ConsommeState { + /// Resolves the host destination of a guest flow of `protocol`. + /// + /// Guest traffic to the IPv4 gateway is translated onto host loopback when + /// the gateway mapping or the gateway proxy port allows it and is rejected + /// (`None`) otherwise. Other destinations are resolved as virtual mapped + /// addresses. + pub(crate) fn resolve_flow_destination( + &self, + addr: &SocketAddr, + protocol: IpProtocol, + ) -> Option { + if let SocketAddr::V4(address) = addr + && *address.ip() == self.params.gateway_ip + { + return (self.params.map_gateway_to_host_loopback + || (protocol == IpProtocol::Tcp + && self.params.gateway_loopback_proxy_port == Some(address.port()))) + .then(|| SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, address.port()))); + } + Some(self.resolve_destination(addr)) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/tcp.rs b/vm/devices/net/net_consomme/consomme/src/tcp.rs index 2d02c107d69..5a5df21d642 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. mod assembler; +mod limits; mod ring; use super::Access; @@ -56,7 +57,6 @@ use std::io::ErrorKind; use std::io::IoSlice; use std::io::IoSliceMut; use std::net::IpAddr; -use std::net::Shutdown; use std::net::SocketAddr; use std::net::SocketAddrV4; use std::net::SocketAddrV6; @@ -74,6 +74,7 @@ pub(crate) struct Tcp { listeners: HashMap, #[inspect(skip)] timer: Option, + max_connections: usize, connection_params: ConnectionParams, aggregate_stats: TcpAggregateStats, } @@ -164,6 +165,7 @@ impl Tcp { connections: HashMap::new(), listeners: HashMap::new(), timer: None, + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_TCP_FLOWS, connection_params: ConnectionParams { rx_buffer: NormalizedBufferBounds::from_bounds(rx_buffer), tx_buffer: NormalizedBufferBounds::from_bounds(tx_buffer), @@ -814,8 +816,17 @@ impl Access<'_, T> { dst: ft.src, }; + let at_capacity = self.inner.tcp.connections.len() + >= self.inner.tcp.max_connections; match self.inner.tcp.connections.entry(ft) { hash_map::Entry::Vacant(e) => { + if at_capacity { + tracelimit::warn_ratelimited!( + max_connections = self.inner.tcp.max_connections, + "dropping inbound TCP flow because the active-flow limit was reached" + ); + return true; + } let mut sender = Sender { ft: &ft, client: self.client, @@ -1050,6 +1061,8 @@ impl Access<'_, T> { state: &mut self.inner.state, }; + self.inner.tcp.check_flow_limit(&ft)?; + match self.inner.tcp.connections.entry(ft) { hash_map::Entry::Occupied(mut e) => { let keep = e.get_mut().inner.handle_packet(&mut sender, &tcp)?; @@ -1097,7 +1110,12 @@ impl Access<'_, T> { } else { // Resolve virtual mapped addresses back to real host // addresses before establishing the connection. - let resolved_dst = sender.state.resolve_destination(&sender.ft.dst); + let Some(resolved_dst) = sender + .state + .resolve_flow_destination(&sender.ft.dst, IpProtocol::Tcp) + else { + return Err(DropReason::DestinationNotAllowed); + }; // If this is directed to a local port owned by the guest, use the // appropriate host port substitution. let is_local_address = sender.state.params.is_local_address(&resolved_dst); @@ -1770,7 +1788,7 @@ impl TcpConnectionInner { &mut self, cx: &mut Context<'_>, sender: &mut Sender<'_, impl Client>, - socket: &mut PolledSocket, + socket: &mut (impl AsyncWrite + Unpin), static_dns: &mut Option, dns: &DnsResolver, ) -> bool { @@ -1866,19 +1884,22 @@ impl TcpConnectionInner { .is_none_or(StaticDnsTcpInspection::is_empty); if self.rx_buffer.is_empty() && static_dns_empty && self.state.rx_fin() && !self.is_shutdown { - if let Err(err) = socket.get().shutdown(Shutdown::Write) { - tracelimit::warn_ratelimited!( - error = &err as &dyn std::error::Error, - src = %sender.ft.src, - dst = %sender.ft.dst, - "shutdown error" - ); - if sender.try_rst(self.tx_send, Some(self.rx_seq)) { - self.stats.rsts_tx.increment(); + match Pin::new(socket).poll_close(cx) { + Poll::Ready(Ok(())) => self.is_shutdown = true, + Poll::Pending => {} + Poll::Ready(Err(err)) => { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + src = %sender.ft.src, + dst = %sender.ft.dst, + "shutdown error" + ); + if sender.try_rst(self.tx_send, Some(self.rx_seq)) { + self.stats.rsts_tx.increment(); + } + return false; } - return false; } - self.is_shutdown = true; } true diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs b/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs new file mode 100644 index 00000000000..68a0f6b0f0f --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of TCP connections. + +use super::Tcp; +use crate::DropReason; +use crate::FourTuple; + +impl Tcp { + /// Rejects a new guest flow when the active-flow limit is reached, before + /// a host socket is created for it. + pub(super) fn check_flow_limit(&self, ft: &FourTuple) -> Result<(), DropReason> { + if !self.connections.contains_key(ft) && self.connections.len() >= self.max_connections { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + src = %ft.src, + dst = %ft.dst, + "rejecting TCP flow before host socket creation because the active-flow limit was reached" + ); + return Err(DropReason::TcpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs index 0e93827f4d8..be4cc84310f 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(unix)] +mod limits; + use super::*; use crate::BindError; use crate::ChecksumState; @@ -21,10 +24,10 @@ use smoltcp::wire::DnsQueryType; use smoltcp::wire::EthernetAddress; use smoltcp::wire::Ipv4Address; use smoltcp::wire::Ipv4Repr; -use socket2::SockRef; use std::io::ErrorKind; use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use std::net::Shutdown; use std::net::SocketAddrV4; use std::net::SocketAddrV6; use std::sync::Arc; @@ -791,115 +794,6 @@ impl TcpTestHarness { }) .await; } - - /// Flood guest→host data (without reading the host socket) until the - /// available receive buffer (`rx_window_avail`) reaches zero, which drives - /// the advertised window to zero. Only sends segments that fit the current - /// window, so nothing is dropped. Returns bytes accepted. - async fn flood_guest_until_window_closed(&mut self) -> usize { - let guest_mac = self.guest_mac; - let gateway_mac = self.gateway_mac; - let guest_ip = self.guest_ip; - let dst_ip = self.dst_ip; - let guest_port = self.guest_port; - let dst_port = self.dst_port; - let server_ack = self.server_ack; - let ft = self.four_tuple(); - - let consomme = &mut self.consomme; - let client = &mut self.client; - // Deliberately do NOT read `host_stream`: the path backs up so the - // receive window fills. - let guest_seq = &mut self.guest_seq; - let mut buf = vec![0u8; 1514]; - let payload = [0x5Au8; 1400]; - let mut total = 0usize; - // Safety cap so a regression can't loop forever. - let mut budget = 64 << 20; - std::future::poll_fn(move |cx| { - consomme.access(client).poll(cx); - let avail = consomme - .tcp - .connections - .get(&ft) - .expect("connection should exist") - .inner - .rx_window_avail(); - if avail == 0 || budget == 0 { - return Poll::Ready(total); - } - let n = avail.min(payload.len()).min(budget); - let tcp = TcpRepr { - src_port: guest_port, - dst_port, - control: TcpControl::None, - seq_number: *guest_seq, - ack_number: Some(server_ack), - window_len: 64240, - window_scale: None, - max_seg_size: None, - sack_permitted: false, - sack_ranges: [None, None, None], - timestamp: None, - payload: &payload[..n], - }; - let len = build_tcp_packet(&mut buf, guest_mac, gateway_mac, guest_ip, dst_ip, &tcp); - consomme - .access(client) - .send(&buf[..len], &ChecksumState::NONE) - .unwrap(); - *guest_seq += n; - total += n; - budget -= n; - // Re-poll promptly to keep pushing; once the host socket buffers - // fill, consomme stops draining and `avail` reaches zero. - cx.waker().wake_by_ref(); - Poll::Pending - }) - .await - } - - /// Drain the host socket while polling consomme, waiting for a guest-bound - /// ACK that advertises a non-zero receive window. Returns the advertised - /// window length, or `None` if no such packet appeared within a few - /// seconds. - async fn drain_host_until_window_update(&mut self) -> Option { - let mut timer = pal_async::timer::PolledTimer::new(self.client.driver()); - let received = self.client.received_packets.clone(); - let consomme = &mut self.consomme; - let client = &mut self.client; - let host_stream = &mut self.host_stream; - let poll = std::future::poll_fn(move |cx| { - consomme.access(client).poll(cx); - // Drain the host socket so the receive window reopens. Registers a - // read-readiness waker on Pending so we are re-polled as more data - // arrives. - let mut rb = [0u8; 4096]; - loop { - match Pin::new(&mut *host_stream).poll_read(cx, &mut rb) { - Poll::Ready(Ok(0)) => break, - Poll::Ready(Ok(_)) => {} - Poll::Ready(Err(e)) => panic!("host read error: {e}"), - Poll::Pending => break, - } - } - let found = received.lock().iter().rev().find_map(|p| { - let t = TcpTestHarness::is_tcp_packet(p)?; - (t.ack_number.is_some() && t.window_len > 0).then_some(t.window_len) - }); - match found { - Some(w) => Poll::Ready(w), - None => Poll::Pending, - } - }); - let timeout = timer.sleep(std::time::Duration::from_secs(5)); - let poll = std::pin::pin!(poll); - let timeout = std::pin::pin!(timeout); - match futures::future::select(poll, timeout).await { - futures::future::Either::Left((w, _)) => Some(w), - futures::future::Either::Right(_) => None, - } - } } // ── Tests ────────────────────────────────────────────────────────── @@ -2503,42 +2397,105 @@ async fn test_tcp_tx_buffer_autotune_caps_at_max(driver: DefaultDriver) { /// ACK rather than waiting for the guest's zero-window probe. #[pal_async::async_test] async fn test_tcp_zero_window_reopen_sends_update(driver: DefaultDriver) { - let mut params = ConsommeParams::new().unwrap(); - // Small, fixed receive window so it closes quickly and autotune can't grow - // it (which would mask the reopen path via its own `needs_ack`). - params.tcp_rx_buffer = crate::TcpBufferBounds { - initial: 16 << 10, - max: 16 << 10, - }; - let mut h = TcpTestHarness::connect_with_params(driver, params).await; + struct HostWriter { + allowance: usize, + received: Vec, + } + + impl AsyncWrite for HostWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.allowance == 0 { + return Poll::Pending; + } + let n = self.allowance.min(buf.len()); + self.received.extend_from_slice(&buf[..n]); + self.allowance -= n; + Poll::Ready(Ok(n)) + } - // Shrink the host receive buffer so the egress path backs up after only a - // modest amount of data, keeping the flood bounded. - SockRef::from(h.host_stream.get()) - .set_recv_buffer_size(8 << 10) - .unwrap(); + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } - // Flood until consomme's advertised receive window closes to zero. - let sent = h.flood_guest_until_window_closed().await; - assert_eq!( - h.connection_inner().rx_window_avail(), - 0, - "receive window should have closed after flooding {sent} bytes" - ); - assert!( - h.connection_inner().rx_window_last_adv < h.connection_inner().tx_mss, - "consomme should have advertised a (near) zero window to the guest" - ); + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + panic!("established connection must not shut down"); + } + } - // Discard the zero-window ACKs, then drain the host socket. Consomme must - // emit a window-update ACK on its own, without the guest probing. - h.clear_guest_packets(); - let window_update = h.drain_host_until_window_update().await; - assert!( - window_update.is_some_and(|w| w > 0), - "consomme must proactively re-advertise a non-zero window after the \ - host drains the backlog; got {window_update:?}" - ); + // Control host backpressure directly: real TCP receive/send buffers and + // their persist timers make a bounded flood/drain test depend on OS timing. + for window_scale in [0, 2, 7] { + let params = ConnectionParams { + rx_buffer: NormalizedBufferBounds { + initial: 16 << 10, + max: 16 << 10, + }, + tx_buffer: NormalizedBufferBounds { + initial: 16 << 10, + max: 16 << 10, + }, + }; + let mut connection = TcpConnection::new_base(¶ms); + connection.state = TcpState::Established; + connection.rx_window_scale = window_scale; + connection.rx_buffer = ring::Ring::new(params.rx_buffer.initial); + let data = vec![0x5a; params.rx_buffer.initial]; + connection.rx_buffer.write_at(0, &data); + connection.rx_buffer.extend_by(data.len()); + connection.needs_ack = true; + + let mut consomme = Consomme::new(ConsommeParams::new().unwrap()); + let mut client = TestClient::new(driver.clone()); + let packets = client.received_packets.clone(); + let ft = FourTuple { + src: SocketAddr::from(([10, 0, 0, 2], 44444)), + dst: SocketAddr::from(([10, 0, 0, 1], 80)), + }; + let mut sender = Sender { + ft: &ft, + client: &mut client, + state: &mut consomme.state, + }; + let dns = DnsResolver::without_backend(dns_resolver::DEFAULT_MAX_PENDING_DNS_REQUESTS); + let mut host = HostWriter { + allowance: 0, + received: Vec::new(), + }; + let mut cx = Context::from_waker(std::task::Waker::noop()); + + assert!(connection.poll_socket_write(&mut cx, &mut sender, &mut host, &mut None, &dns)); + connection.send_next(&mut sender, AckPolicy::Flush); + let closed = packets.lock().pop().expect("zero-window ACK"); + assert_eq!(parse_tcp_packet(&closed).2.window_len, 0); + assert_eq!(connection.rx_window_last_adv, 0); + assert!(!connection.needs_ack); + + let quantum = 1 << window_scale; + let reopen = connection.tx_mss.div_ceil(quantum) * quantum; + for (drain, expect_update) in [(0, false), (reopen - 1, false), (1, true), (1, false)] { + packets.lock().clear(); + host.allowance = drain; + assert!(connection.poll_socket_write(&mut cx, &mut sender, &mut host, &mut None, &dns)); + assert_eq!(connection.needs_ack, expect_update); + connection.send_next(&mut sender, AckPolicy::Flush); + let packets = packets.lock(); + assert_eq!(packets.len(), usize::from(expect_update)); + if expect_update { + let (_, _, update) = parse_tcp_packet(&packets[0]); + assert_eq!(update.control, TcpControl::None); + assert_eq!(update.ack_number, Some(connection.rx_seq)); + assert_eq!(usize::from(update.window_len) << window_scale, reopen); + assert!(update.payload.is_empty()); + } + assert!(!connection.needs_ack); + } + assert_eq!(host.received, data[..reopen + 1]); + assert_eq!(connection.rx_window_cap, params.rx_buffer.initial); + } } /// Verifies that the zero-copy TCP checksum computed over the header and the diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs new file mode 100644 index 00000000000..4f63635982f --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the TCP active-flow limit. + +use super::*; + +#[cfg(unix)] +#[pal_async::async_test] +async fn test_tcp_connection_limit_rejects_and_recovers(driver: DefaultDriver) { + let mut harness = TcpTestHarness::connect(driver).await; + harness.consomme.tcp.max_connections = 1; + assert_eq!(harness.consomme.tcp.connections.len(), 1); + + let syn = TcpRepr { + src_port: harness.guest_port + 1, + dst_port: harness.dst_port, + control: TcpControl::Syn, + seq_number: TcpSeqNumber(2000), + ack_number: None, + window_len: 64240, + window_scale: Some(7), + max_seg_size: Some(1460), + sack_permitted: false, + sack_ranges: [None, None, None], + timestamp: None, + payload: &[], + }; + let len = build_tcp_packet( + &mut harness.buf, + harness.guest_mac, + harness.gateway_mac, + harness.guest_ip, + harness.dst_ip, + &syn, + ); + let rejected = harness + .consomme + .access(&mut harness.client) + .send(&harness.buf[..len], &ChecksumState::NONE); + assert!(matches!(rejected, Err(DropReason::TcpConnectionLimit))); + assert_eq!( + harness.consomme.tcp.connections.len(), + 1, + "a rejected flow must not create a host TCP socket or buffer" + ); + + // Simulate the normal poll-time removal after a connection closes. + harness.consomme.tcp.connections.clear(); + harness + .consomme + .access(&mut harness.client) + .send(&harness.buf[..len], &ChecksumState::NONE) + .unwrap(); + assert_eq!(harness.consomme.tcp.connections.len(), 1); +} diff --git a/vm/devices/net/net_consomme/consomme/src/tests.rs b/vm/devices/net/net_consomme/consomme/src/tests.rs index 2d6cc0ee5be..579e20e9654 100644 --- a/vm/devices/net/net_consomme/consomme/src/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tests.rs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod fragments; +mod limits; +mod static_ipv4; + use super::*; use pal_async::DefaultDriver; use smoltcp::phy::ChecksumCapabilities; diff --git a/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs b/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs new file mode 100644 index 00000000000..a26fbc6adba --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of IPv4 fragment rejection. + +use super::*; + +/// IPv4 fragments are dropped before checksum validation or host socket work. +#[pal_async::async_test] +async fn ipv4_fragments_are_rejected(driver: DefaultDriver) { + let mut consomme = Consomme::new(ConsommeParams::new().unwrap()); + let mut client = TestClient::new(driver); + let mut buf = vec![0u8; 1514]; + + let guest_mac = consomme.params_mut().client_mac; + let gateway_mac = consomme.params_mut().gateway_mac; + let guest_ip = consomme.params_mut().client_ip; + let len = build_ipv4_syn( + &mut buf, + guest_mac, + gateway_mac, + guest_ip, + Ipv4Address::new(192, 0, 2, 1), + ); + Ipv4Packet::new_unchecked(&mut buf[ETHERNET_HEADER_LEN..len]).set_more_frags(true); + + let result = consomme + .access(&mut client) + .send(&buf[..len], &ChecksumState::NONE); + assert!( + matches!(result, Err(DropReason::FragmentedPacket)), + "fragmented IPv4 traffic should be rejected, got {result:?}" + ); +} diff --git a/vm/devices/net/net_consomme/consomme/src/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/tests/limits.rs new file mode 100644 index 00000000000..c0c653d49c1 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/limits.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the default resource limits. + +use super::*; +use crate::limits::DEFAULT_MAX_ACTIVE_ICMP_FLOWS; +use crate::limits::DEFAULT_MAX_ACTIVE_TCP_FLOWS; +use crate::limits::DEFAULT_MAX_ACTIVE_UDP_FLOWS; + +#[test] +fn default_resource_limits_are_pinned() { + let params = ConsommeParams::new().unwrap(); + assert_eq!(params.udp_timeout, Duration::from_secs(300)); + assert_eq!( + params.tcp_rx_buffer, + TcpBufferBounds { + initial: 16 << 10, + max: 4 << 20, + } + ); + assert_eq!( + params.tcp_tx_buffer, + TcpBufferBounds { + initial: 16 << 10, + max: 4 << 20, + } + ); + assert_eq!(DEFAULT_MAX_ACTIVE_TCP_FLOWS, 128); + assert_eq!(DEFAULT_MAX_ACTIVE_UDP_FLOWS, 256); + assert_eq!(dns_resolver::DEFAULT_MAX_PENDING_DNS_REQUESTS, 256); + assert_eq!(DEFAULT_MAX_ACTIVE_ICMP_FLOWS, 16); +} diff --git a/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs b/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs new file mode 100644 index 00000000000..7f8ac0f27ba --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the exact static IPv4 identity and the gateway loopback +//! translation. + +use super::*; + +#[test] +fn static_ipv4_identity_is_exact_and_disables_ipv6_advertisement() { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4( + Ipv4Addr::new(192, 168, 5, 37), + 28, + Ipv4Addr::new(192, 168, 5, 33), + [0x52, 0x54, 0, 168, 5, 33], + ) + .unwrap(); + + assert_eq!(params.client_ip, Ipv4Address::new(192, 168, 5, 37)); + assert_eq!(params.gateway_ip, Ipv4Address::new(192, 168, 5, 33)); + assert_eq!(params.net_mask, Ipv4Address::new(255, 255, 255, 240)); + assert_eq!( + params.gateway_mac, + EthernetAddress([0x52, 0x54, 0, 168, 5, 33]) + ); + assert!(!params.advertise_routable_ipv6); + assert!(params.map_gateway_to_host_loopback); + + let consomme = Consomme::new(params); + for protocol in [IpProtocol::Tcp, IpProtocol::Udp] { + assert_eq!( + consomme + .state + .resolve_flow_destination(&"192.168.5.33:8080".parse().unwrap(), protocol), + Some("127.0.0.1:8080".parse().unwrap()) + ); + } +} + +#[test] +fn gateway_loopback_can_be_denied_with_one_proxy_exception() { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4( + Ipv4Addr::new(192, 168, 5, 37), + 28, + Ipv4Addr::new(192, 168, 5, 33), + [0x52, 0x54, 0, 168, 5, 33], + ) + .unwrap(); + params.map_gateway_to_host_loopback = false; + params.gateway_loopback_proxy_port = Some(8443); + let consomme = Consomme::new(params); + + for protocol in [IpProtocol::Tcp, IpProtocol::Udp] { + for port in [8442, 8443, 8444] { + let address = SocketAddr::from((Ipv4Addr::new(192, 168, 5, 33), port)); + let expected = (protocol == IpProtocol::Tcp && port == 8443) + .then_some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8443))); + assert_eq!( + consomme.state.resolve_flow_destination(&address, protocol), + expected, + "{protocol:?} port {port}" + ); + } + } + assert_eq!( + consomme + .state + .resolve_flow_destination(&"192.0.2.1:8443".parse().unwrap(), IpProtocol::Udp), + Some("192.0.2.1:8443".parse().unwrap()) + ); +} + +#[pal_async::async_test] +async fn gateway_proxy_exception_rejects_udp(driver: DefaultDriver) { + let listener = std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let proxy_port = listener.local_addr().unwrap().port(); + let mut params = ConsommeParams::new().unwrap(); + params.map_gateway_to_host_loopback = false; + params.gateway_loopback_proxy_port = Some(proxy_port); + let guest_mac = params.client_mac; + let gateway_mac = params.gateway_mac; + let guest_ip = params.client_ip; + let gateway_ip = params.gateway_ip; + let mut consomme = Consomme::new(params); + let mut client = TestClient::new(driver); + + for destination in [gateway_ip, Ipv4Addr::LOCALHOST] { + let mut buf = [0u8; 1514]; + let len = build_ipv4_dns_query( + &mut buf, + guest_mac, + gateway_mac, + guest_ip, + destination, + 44444, + b"proxy-exception-must-be-tcp", + ); + let mut ipv4 = Ipv4Packet::new_unchecked(&mut buf[ETHERNET_HEADER_LEN..len]); + let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut()); + udp.set_dst_port(proxy_port); + udp.fill_checksum(&guest_ip.into(), &destination.into()); + let result = consomme + .access(&mut client) + .send(&buf[..len], &ChecksumState::NONE); + assert!( + matches!(result, Err(DropReason::DestinationNotAllowed)), + "UDP proxy exception must be rejected, got {result:?}" + ); + } + assert_eq!( + listener.recv(&mut [0; 64]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); +} + +#[test] +fn static_ipv4_identity_rejects_inconsistent_values() { + let mut params = ConsommeParams::new().unwrap(); + assert!( + params + .set_static_ipv4( + Ipv4Addr::new(10, 0, 0, 2), + 31, + Ipv4Addr::new(10, 0, 0, 1), + [0x52, 0x54, 0, 0, 0, 1], + ) + .is_err() + ); + assert!( + params + .set_static_ipv4( + Ipv4Addr::new(10, 0, 0, 2), + 24, + Ipv4Addr::new(10, 0, 0, 9), + [0x52, 0x54, 0, 0, 0, 9], + ) + .is_err() + ); +} diff --git a/vm/devices/net/net_consomme/consomme/src/udp.rs b/vm/devices/net/net_consomme/consomme/src/udp.rs index 1a416025cce..7ad183ceed5 100644 --- a/vm/devices/net/net_consomme/consomme/src/udp.rs +++ b/vm/devices/net/net_consomme/consomme/src/udp.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod limits; + use super::Access; use super::BindError; use super::Client; @@ -69,6 +71,7 @@ pub(crate) struct Udp { connections: HashMap, listeners: HashMap, timeout: Duration, + max_connections: usize, } impl Udp { @@ -77,6 +80,7 @@ impl Udp { connections: HashMap::new(), listeners: HashMap::new(), timeout, + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_UDP_FLOWS, } } } @@ -84,6 +88,8 @@ impl Udp { impl InspectMut for Udp { fn inspect_mut(&mut self, req: inspect::Request<'_>) { let mut resp = req.respond(); + resp.field("max_connections", self.max_connections) + .field("active_connections", self.connections.len()); for (addr, conn) in &mut self.connections { let key = addr.to_string(); resp.field_mut(&key, conn); @@ -454,7 +460,11 @@ impl Access<'_, T> { }; // Resolve virtual mapped addresses back to the real host address. - let mut dst_sock_addr = self.inner.state.resolve_destination(&dst_sock_addr); + let mut dst_sock_addr = self + .inner + .state + .resolve_flow_destination(&dst_sock_addr, IpProtocol::Udp) + .ok_or(DropReason::DestinationNotAllowed)?; if self.inner.state.params.is_local_address(&dst_sock_addr) { // This packet is destined for a local address. If the port matches a listener, // translate it so that the connection loops back to the expected destination. @@ -494,6 +504,7 @@ impl Access<'_, T> { guest_addr: SocketAddr, guest_mac: Option, ) -> Result<&mut UdpConnection, DropReason> { + self.inner.udp.check_flow_limit(&guest_addr)?; let entry = self.inner.udp.connections.entry(guest_addr); match entry { hash_map::Entry::Occupied(conn) => Ok(conn.into_mut()), @@ -803,6 +814,8 @@ fn build_udp_packet + AsMut<[u8]> + ?Sized>( #[cfg(all(unix, test))] mod tests { + mod limits; + use super::*; use crate::Consomme; use crate::ConsommeParams; diff --git a/vm/devices/net/net_consomme/consomme/src/udp/limits.rs b/vm/devices/net/net_consomme/consomme/src/udp/limits.rs new file mode 100644 index 00000000000..7763b6a81fc --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/udp/limits.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of UDP flows. + +use super::Udp; +use crate::DropReason; +use std::net::SocketAddr; + +impl Udp { + /// Rejects a new guest flow when the active-flow limit is reached, before + /// a host socket is bound for it. + pub(super) fn check_flow_limit(&self, guest_addr: &SocketAddr) -> Result<(), DropReason> { + if !self.connections.contains_key(guest_addr) + && self.connections.len() >= self.max_connections + { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + guest = %guest_addr, + "rejecting UDP flow before host socket binding because the active-flow limit was reached" + ); + return Err(DropReason::UdpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs new file mode 100644 index 00000000000..4dd63587f36 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the UDP active-flow limit. + +use super::*; + +#[pal_async::async_test] +async fn test_udp_connection_limit_rejects_and_recovers(driver: DefaultDriver) { + let driver = Arc::new(driver); + let mut consomme = create_consomme_with_timeout(Duration::from_millis(1)); + consomme.udp.max_connections = 2; + let mut client = TestClient::new(driver); + let mut access = consomme.access(&mut client); + + for port in [10001, 10002] { + access + .get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), port)), + None, + ) + .unwrap(); + } + assert_eq!(access.udp_connection_count(), 2); + + let rejected = access.get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 10003)), + None, + ); + assert!(matches!(rejected, Err(DropReason::UdpConnectionLimit))); + assert_eq!( + access.udp_connection_count(), + 2, + "a rejected flow must not create a host UDP socket" + ); + + for connection in access.inner.udp.connections.values_mut() { + connection.last_activity = Instant::now() - Duration::from_millis(2); + } + let mut cx = Context::from_waker(std::task::Waker::noop()); + access.poll_udp(&mut cx); + assert_eq!(access.udp_connection_count(), 0); + + access + .get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 10003)), + None, + ) + .unwrap(); + assert_eq!(access.udp_connection_count(), 1); +} diff --git a/vm/devices/net/net_consomme/src/egress.rs b/vm/devices/net/net_consomme/src/egress.rs new file mode 100644 index 00000000000..c41d7fc44c2 --- /dev/null +++ b/vm/devices/net/net_consomme/src/egress.rs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy enforcement of the Consomme endpoint. +//! +//! The endpoint accepts a run-scoped [`EgressPolicy`] while no queue is +//! active. The queue's transmit path assembles each guest frame from guest +//! memory and drops the frames that the policy denies before they reach the +//! Consomme stack. + +use crate::ConsommeEndpoint; +use crate::ConsommeQueue; +use anyhow::Context as _; +use consomme::ChecksumState; +use net_backend::BufferAccess; +use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicy; + +impl ConsommeEndpoint { + /// Implements `set_egress_policy`. + pub(crate) fn install_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.endpoint_state + .lock() + .as_mut() + .context("cannot configure Consomme egress policy while a queue is active")? + .egress_policy = Some(policy); + Ok(()) + } +} + +impl ConsommeQueue { + /// Sends the queued guest frames that the egress policy allows to the + /// Consomme stack and completes all of them. + pub(crate) fn process_tx(&mut self, pool: &mut dyn BufferAccess) { + while let Some(head) = self.state.tx_avail.front() { + let TxSegmentType::Head(metadata) = &head.ty else { + unreachable!() + }; + let tx_id = metadata.id; + let checksum = ChecksumState { + ipv4: metadata.flags.offload_ip_header_checksum(), + tcp: metadata.flags.offload_tcp_checksum(), + udp: metadata.flags.offload_udp_checksum(), + tso: metadata + .flags + .offload_tcp_segmentation() + .then_some(metadata.max_segment_size), + gso: metadata + .flags + .offload_udp_segmentation() + .then_some(metadata.max_segment_size), + }; + let segment_count = metadata.segment_count as usize; + let packet_len = metadata.len as usize; + + let mut buffer = std::mem::take(&mut self.state.tx_scratch); + buffer.clear(); + buffer.resize(packet_len, 0); + let guest_memory = pool.guest_memory(); + let mut offset = 0usize; + for segment in self.state.tx_avail.drain(..segment_count) { + let Some(end) = offset.checked_add(segment.len as usize) else { + tracing::error!("network TX segment length overflow"); + break; + }; + let Some(destination) = buffer.get_mut(offset..end) else { + tracing::error!(packet_len, end, "network TX segments exceed packet length"); + break; + }; + if let Err(error) = guest_memory.read_at(segment.gpa, destination) { + tracing::error!( + error = &error as &dyn std::error::Error, + "network TX guest-memory read failure" + ); + } + offset = end; + } + + let policy_result = (offset == packet_len) + .then(|| { + self.endpoint_state + .as_ref() + .and_then(|state| state.egress_policy.as_ref()) + .map(|policy| policy.authorize_frame(&buffer, buffer.len())) + }) + .flatten(); + if policy_result.is_some_and(|result| result.is_err()) { + self.stats.tx_dropped.increment(); + } else if offset == packet_len + && let Err(error) = + self.with_consomme(pool, |consomme| consomme.send(&buffer, &checksum)) + { + tracing::debug!( + error = &error as &dyn std::error::Error, + "tx packet ignored" + ); + match error { + consomme::DropReason::SendBufferFull + | consomme::DropReason::DestinationNotAllowed + | consomme::DropReason::TcpConnectionLimit + | consomme::DropReason::UdpConnectionLimit + | consomme::DropReason::IcmpConnectionLimit => { + self.stats.tx_dropped.increment() + } + consomme::DropReason::UnsupportedEthertype(_) + | consomme::DropReason::UnsupportedIpProtocol(_) + | consomme::DropReason::UnsupportedIcmpv6(_) + | consomme::DropReason::UnsupportedDhcp(_) + | consomme::DropReason::UnsupportedArp + | consomme::DropReason::UnsupportedDhcpv6(_) + | consomme::DropReason::UnsupportedNdp(_) => self.stats.tx_unknown.increment(), + consomme::DropReason::Packet(_) + | consomme::DropReason::Ipv4Checksum + | consomme::DropReason::Io(_) + | consomme::DropReason::BadTcpState(_) + | consomme::DropReason::FragmentedPacket + | consomme::DropReason::IpLengthMismatch + | consomme::DropReason::MalformedPacket => self.stats.tx_errors.increment(), + } + } + self.state.tx_scratch = buffer; + self.state.tx_ready.push_back(tx_id); + } + } +} diff --git a/vm/devices/net/net_consomme/src/lib.rs b/vm/devices/net/net_consomme/src/lib.rs index 8290df7caad..714f9349c96 100644 --- a/vm/devices/net/net_consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/src/lib.rs @@ -4,8 +4,13 @@ #![expect(missing_docs)] #![forbid(unsafe_code)] +mod egress; +mod quiesce; pub mod resolver; +#[cfg(test)] +mod tests; + use anyhow::Context as _; use async_trait::async_trait; use consomme::ChecksumState; @@ -31,10 +36,10 @@ use net_backend::TxError; use net_backend::TxId; use net_backend::TxOffloadSupport; use net_backend::TxSegment; -use net_backend::TxSegmentType; use net_backend_resources::consomme::ConsommeRequest; use net_backend_resources::consomme::HostPortConfig; use net_backend_resources::consomme::HostPortProtocol; +use net_backend_resources::egress::EgressPolicy; use pal_async::driver::Driver; use parking_lot::Mutex; use std::collections::VecDeque; @@ -112,6 +117,7 @@ struct EndpointState { recv: Option>, port_recv: Option>, port_forwards: Vec, + egress_policy: Option, } impl ConsommeEndpoint { @@ -122,6 +128,7 @@ impl ConsommeEndpoint { recv: None, port_recv: None, port_forwards: Vec::new(), + egress_policy: None, }))), } } @@ -134,6 +141,7 @@ impl ConsommeEndpoint { recv: None, port_recv: None, port_forwards: ports, + egress_policy: None, }))), } } @@ -148,6 +156,7 @@ impl ConsommeEndpoint { recv: Some(recv), port_recv: None, port_forwards: Vec::new(), + egress_policy: None, }))), }, ConsommeControl { send }, @@ -167,6 +176,7 @@ impl ConsommeEndpoint { recv: None, port_recv: Some(port_recv), port_forwards: ports, + egress_policy: None, }))), } } @@ -378,6 +388,7 @@ impl net_backend::Endpoint for ConsommeEndpoint { }, stats: Default::default(), driver: config.driver, + input_quiesced: false, }); let port_forwards = std::mem::take(&mut queue.endpoint_state.as_mut().unwrap().port_forwards); @@ -418,6 +429,10 @@ impl net_backend::Endpoint for ConsommeEndpoint { Ok(()) } + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.install_egress_policy(policy) + } + async fn stop(&mut self) { assert!(self.endpoint_state.lock().is_some()); } @@ -443,6 +458,7 @@ pub struct ConsommeQueue { state: QueueState, stats: Stats, driver: Box, + input_quiesced: bool, } impl InspectMut for ConsommeQueue { @@ -679,79 +695,12 @@ fn process_message( } } +#[async_trait] impl net_backend::Queue for ConsommeQueue { fn poll_ready(&mut self, cx: &mut Context<'_>, pool: &mut dyn BufferAccess) -> Poll<()> { - while let Some(head) = self.state.tx_avail.front() { - let TxSegmentType::Head(meta) = &head.ty else { - unreachable!() - }; - let tx_id = meta.id; - let checksum = ChecksumState { - ipv4: meta.flags.offload_ip_header_checksum(), - tcp: meta.flags.offload_tcp_checksum(), - udp: meta.flags.offload_udp_checksum(), - tso: meta - .flags - .offload_tcp_segmentation() - .then_some(meta.max_segment_size), - gso: meta - .flags - .offload_udp_segmentation() - .then_some(meta.max_segment_size), - }; - - // Reuse the scratch buffer to avoid per-packet heap allocation. - // TSO caps the assembled packet at 64 KiB; assert so a buggy - // upstream caller can't permanently inflate the scratch buffer - // (and thus the queue's steady-state memory) by feeding an - // oversized `meta.len`. - debug_assert!( - meta.len as usize <= 64 * 1024, - "tx packet len {} exceeds 64 KiB TSO bound", - meta.len - ); - let mut buf = std::mem::take(&mut self.state.tx_scratch); - buf.clear(); - buf.resize(meta.len as usize, 0); - let gm = pool.guest_memory(); - let mut offset = 0; - for segment in self.state.tx_avail.drain(..meta.segment_count as usize) { - let dest = &mut buf[offset..offset + segment.len as usize]; - if let Err(err) = gm.read_at(segment.gpa, dest) { - tracing::error!( - error = &err as &dyn std::error::Error, - "memory write failure" - ); - } - offset += segment.len as usize; - } - - if let Err(err) = self.with_consomme(pool, |c| c.send(&buf, &checksum)) { - tracing::debug!(error = &err as &dyn std::error::Error, "tx packet ignored"); - match err { - consomme::DropReason::SendBufferFull - | consomme::DropReason::DestinationNotAllowed => { - self.stats.tx_dropped.increment() - } - consomme::DropReason::UnsupportedEthertype(_) - | consomme::DropReason::UnsupportedIpProtocol(_) - | consomme::DropReason::UnsupportedIcmpv6(_) - | consomme::DropReason::UnsupportedDhcp(_) - | consomme::DropReason::UnsupportedArp - | consomme::DropReason::UnsupportedDhcpv6(_) - | consomme::DropReason::UnsupportedNdp(_) => self.stats.tx_unknown.increment(), - consomme::DropReason::Packet(_) - | consomme::DropReason::Ipv4Checksum - | consomme::DropReason::Io(_) - | consomme::DropReason::BadTcpState(_) - | consomme::DropReason::FragmentedPacket - | consomme::DropReason::IpLengthMismatch - | consomme::DropReason::MalformedPacket => self.stats.tx_errors.increment(), - } - } - self.state.tx_scratch = buf; - - self.state.tx_ready.push_back(tx_id); + self.process_tx(pool); + if self.input_quiesced { + return self.poll_ready_quiesced(); } // TODO: handle messages asynchronously from any queue processing, since @@ -770,6 +719,9 @@ impl net_backend::Queue for ConsommeQueue { } fn rx_avail(&mut self, _pool: &mut dyn BufferAccess, done: &[RxId]) { + if self.input_quiesced { + return; + } self.state.rx_avail.extend(done); } @@ -805,6 +757,18 @@ impl net_backend::Queue for ConsommeQueue { } Ok(n) } + + async fn quiesce( + &mut self, + pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.quiesce_queue(pool) + } + + fn resume(&mut self) -> anyhow::Result<()> { + self.input_quiesced = false; + Ok(()) + } } struct QueueState { diff --git a/vm/devices/net/net_consomme/src/quiesce.rs b/vm/devices/net/net_consomme/src/quiesce.rs new file mode 100644 index 00000000000..40bba9ad56f --- /dev/null +++ b/vm/devices/net/net_consomme/src/quiesce.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Quiesce of a Consomme queue. +//! +//! A quiesced queue keeps transmitting guest frames, but it drops its posted +//! RX buffers, ignores new ones, and stops polling the Consomme stack and the +//! control channels until it is resumed. + +use crate::ConsommeQueue; +use net_backend::BufferAccess; +use std::task::Poll; + +impl ConsommeQueue { + /// Implements `quiesce`. + pub(crate) fn quiesce_queue( + &mut self, + pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.input_quiesced = true; + self.process_tx(pool); + self.state.rx_avail.clear(); + Ok(net_backend::quiesce::QueueQuiesceResult { + rx_ready: self.state.rx_ready.len(), + tx_ready: self.state.tx_ready.len(), + }) + } + + /// Implements `poll_ready` while the queue is quiesced: reports the + /// pending completions without polling the stack or the control channels. + pub(crate) fn poll_ready_quiesced(&self) -> Poll<()> { + if !self.state.tx_ready.is_empty() || !self.state.rx_ready.is_empty() { + Poll::Ready(()) + } else { + Poll::Pending + } + } +} diff --git a/vm/devices/net/net_consomme/src/resolver.rs b/vm/devices/net/net_consomme/src/resolver.rs index f41e7b60f3c..dd55f08c550 100644 --- a/vm/devices/net/net_consomme/src/resolver.rs +++ b/vm/devices/net/net_consomme/src/resolver.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod addressing; + use crate::ConsommeEndpoint; use crate::IpProtocol; use crate::PortForwardConfig; @@ -29,6 +31,10 @@ pub enum ResolveConsommeError { Consomme(consomme::Error), #[error(transparent)] InvalidCidr(consomme::InvalidCidr), + #[error(transparent)] + InvalidStaticIpv4(consomme::static_ipv4::InvalidStaticIpv4), + #[error("Consomme CIDR and exact static IPv4 configuration are mutually exclusive")] + ConflictingIpv4Configuration, #[error("failed to create socket for port forward ({details})")] SocketCreation { #[source] @@ -48,6 +54,7 @@ impl ResolveResource for ConsommeResolver ) -> Result { let mut state = ConsommeParams::new().map_err(ResolveConsommeError::Consomme)?; state.client_mac.0 = input.mac_address.to_bytes(); + addressing::configure(&mut state, &resource)?; if let Some(cidr) = &resource.cidr { state .set_cidr(cidr) diff --git a/vm/devices/net/net_consomme/src/resolver/addressing.rs b/vm/devices/net/net_consomme/src/resolver/addressing.rs new file mode 100644 index 00000000000..b86ab5ea8a5 --- /dev/null +++ b/vm/devices/net/net_consomme/src/resolver/addressing.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static IPv4 addressing and host access overrides of Consomme resources. + +use super::ResolveConsommeError; +use consomme::ConsommeParams; +use net_backend_resources::consomme::ConsommeHandle; + +/// Applies the exact static IPv4 identity, which is mutually exclusive with a +/// CIDR, and the host access overrides of `resource` to `state`. +pub(super) fn configure( + state: &mut ConsommeParams, + resource: &ConsommeHandle, +) -> Result<(), ResolveConsommeError> { + if resource.cidr.is_some() && resource.static_ipv4.is_some() { + return Err(ResolveConsommeError::ConflictingIpv4Configuration); + } + if let Some(config) = &resource.static_ipv4 { + state + .set_static_ipv4( + config.guest_ipv4, + config.prefix_length, + config.gateway_ipv4, + config.gateway_mac.to_bytes(), + ) + .map_err(ResolveConsommeError::InvalidStaticIpv4)?; + } + if let Some(allow) = resource.allow_host_local_access { + state.allow_host_local_access = allow; + } + if let Some(map) = resource.map_gateway_to_host_loopback { + state.map_gateway_to_host_loopback = map; + } + state.gateway_loopback_proxy_port = resource.gateway_loopback_proxy_port; + Ok(()) +} diff --git a/vm/devices/net/net_consomme/src/tests.rs b/vm/devices/net/net_consomme/src/tests.rs new file mode 100644 index 00000000000..cddd44bf22b --- /dev/null +++ b/vm/devices/net/net_consomme/src/tests.rs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the egress policy of the Consomme endpoint. + +use super::*; +use guestmem::GuestMemory; +use net_backend::Endpoint as _; +use net_backend::QueueConfig; +use net_backend::TxMetadata; +use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use std::future::poll_fn; + +const GUEST_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); +const GATEWAY_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +const GUEST_MAC: [u8; 6] = [0x52, 0x54, 0, 0, 0, 2]; +const GATEWAY_MAC: [u8; 6] = [0x52, 0x54, 0, 0, 0, 1]; + +fn arp_request(target: Ipv4Addr) -> Vec { + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&GUEST_MAC); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&GUEST_MAC); + frame[28..32].copy_from_slice(&GUEST_IPV4.octets()); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +fn tx_segment(id: TxId, frame_length: usize) -> TxSegment { + TxSegment { + ty: TxSegmentType::Head(TxMetadata { + id, + segment_count: 1, + len: frame_length as u32, + ..Default::default() + }), + gpa: 0, + len: frame_length as u32, + } +} + +#[pal_async::async_test] +async fn endpoint_policy_proxy_arps_only_bound_on_link_targets(driver: DefaultDriver) { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4(GUEST_IPV4, 24, GATEWAY_IPV4, GATEWAY_MAC) + .unwrap(); + params.client_mac.0 = GUEST_MAC; + let mut endpoint = ConsommeEndpoint::new(params); + endpoint + .set_egress_policy( + EgressPolicy::bind( + GUEST_IPV4, + 24, + MacAddress::new(GUEST_MAC), + GATEWAY_IPV4, + EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap(), + ) + .unwrap(); + + let layout = net_backend::tests::test_layout(); + let memory = GuestMemory::allocate(layout.end_of_ram() as usize); + let mut pool = net_backend::tests::Bufs::new(memory.clone()); + let mut queues = Vec::new(); + endpoint + .get_queues( + vec![QueueConfig { + driver: Box::new(driver), + }], + None, + &mut queues, + ) + .await + .unwrap(); + let queue = &mut queues[0]; + + let allowed = arp_request(Ipv4Addr::new(10, 0, 0, 9)); + memory.write_at(0, &allowed).unwrap(); + queue.rx_avail(&mut pool, &[RxId(1)]); + assert_eq!( + queue + .tx_avail(&mut pool, &[tx_segment(TxId(7), allowed.len())]) + .unwrap(), + (false, 1) + ); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + + let mut completed = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 7); + let mut received = [RxId(0)]; + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 1); + assert_eq!(received[0].0, 1); + let metadata = pool.rx_metadata(RxId(1)).unwrap(); + assert_eq!(metadata.len, 42); + let mut reply = vec![0u8; metadata.len]; + memory.read_at(2048, &mut reply).unwrap(); + assert_eq!(&reply[..6], &GUEST_MAC); + assert_eq!(&reply[6..12], &GATEWAY_MAC); + assert_eq!(u16::from_be_bytes([reply[20], reply[21]]), 2); + assert_eq!(&reply[22..28], &GATEWAY_MAC); + assert_eq!(&reply[28..32], &Ipv4Addr::new(10, 0, 0, 9).octets()); + assert_eq!(&reply[32..38], &GUEST_MAC); + assert_eq!(&reply[38..42], &GUEST_IPV4.octets()); + + let denied = arp_request(Ipv4Addr::new(10, 0, 0, 10)); + memory.write_at(0, &denied).unwrap(); + queue.rx_avail(&mut pool, &[RxId(2)]); + queue + .tx_avail(&mut pool, &[tx_segment(TxId(8), denied.len())]) + .unwrap(); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 8); + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 0); + + let mut spoofed = allowed; + spoofed[11] ^= 1; + memory.write_at(0, &spoofed).unwrap(); + queue + .tx_avail(&mut pool, &[tx_segment(TxId(9), spoofed.len())]) + .unwrap(); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 9); + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 0); + + drop(queues); + endpoint.stop().await; +} diff --git a/vm/devices/net/net_tap/src/lib.rs b/vm/devices/net/net_tap/src/lib.rs index c1b0da9b2da..364969af6a2 100644 --- a/vm/devices/net/net_tap/src/lib.rs +++ b/vm/devices/net/net_tap/src/lib.rs @@ -6,8 +6,10 @@ #![cfg(target_os = "linux")] #![expect(missing_docs)] +mod quiesce; pub mod resolver; pub mod tap; +mod tx; use async_trait::async_trait; use futures::io::AsyncRead; @@ -26,19 +28,15 @@ use net_backend::TxId; use net_backend::TxMetadata; use net_backend::TxOffloadSupport; use net_backend::TxSegment; -use net_backend::linearize; -use net_backend::next_packet; +use net_backend_resources::egress::EgressPolicy; use pal_async::driver::Driver; use parking_lot::Mutex; use std::collections::VecDeque; -use std::io::ErrorKind; -use std::io::Write; use std::pin::Pin; use std::sync::Arc; use std::task::Context; use std::task::Poll; use zerocopy::FromBytes; -use zerocopy::IntoBytes; // TODO: These virtio net header types duplicate definitions in virtio_net. // Consider extracting a shared `virtio_net_header` crate if more consumers @@ -112,6 +110,7 @@ pub use vnet_hdr::*; /// An endpoint based on a TAP interface. pub struct TapEndpoint { tap: Arc>>, + egress_policy: Option, } impl TapEndpoint { @@ -138,6 +137,7 @@ impl TapEndpoint { Ok(Self { tap: Arc::new(Mutex::new(Some(tap))), + egress_policy: None, }) } } @@ -166,10 +166,16 @@ impl Endpoint for TapEndpoint { queues.push(Box::new(TapQueue::new( config.driver.as_ref(), self.tap.clone(), + self.egress_policy.clone(), )?)); Ok(()) } + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.egress_policy = Some(policy); + Ok(()) + } + async fn stop(&mut self) { assert!(self.tap.lock().is_some(), "queue has not been dropped"); } @@ -197,6 +203,8 @@ struct TapQueue { tap: Option, inner: Inner, buffer: Box<[u8]>, + tx: tx::TxState, + input_quiesced: bool, } struct Inner { @@ -219,7 +227,11 @@ impl Drop for TapQueue { } impl TapQueue { - fn new(driver: &dyn Driver, slot: Arc>>) -> anyhow::Result { + fn new( + driver: &dyn Driver, + slot: Arc>>, + egress_policy: Option, + ) -> anyhow::Result { let tap = slot.lock().take().expect("queue is already in use"); let tap = tap.polled(driver)?; Ok(Self { @@ -230,16 +242,25 @@ impl TapQueue { rx_ready: VecDeque::new(), }, buffer: vec![0; 65535 + size_of::()].into_boxed_slice(), + tx: tx::TxState::new(egress_policy), + input_quiesced: false, }) } } +#[async_trait] impl Queue for TapQueue { fn poll_ready(&mut self, cx: &mut Context<'_>, pool: &mut dyn BufferAccess) -> Poll<()> { + if self.poll_tx(cx).is_ready() { + return Poll::Ready(()); + } if !self.inner.rx_ready.is_empty() { return Poll::Ready(()); } + if self.input_quiesced { + return Poll::Pending; + } let tap = if let Some(tap) = self.tap.as_mut() { tap } else { @@ -287,6 +308,9 @@ impl Queue for TapQueue { } fn rx_avail(&mut self, _pool: &mut dyn BufferAccess, done: &[RxId]) { + if self.input_quiesced { + return; + } self.inner.rx_free.extend(done); } @@ -308,78 +332,27 @@ impl Queue for TapQueue { pool: &mut dyn BufferAccess, mut segments: &[TxSegment], ) -> anyhow::Result<(bool, usize)> { - let n = segments.len(); - // Synchronously send packets received from the guest to host's network. - if let Some(tap) = self.tap.as_mut() { - while !segments.is_empty() { - let (meta, _segs, _rest) = next_packet(segments); - let hdr = build_vnet_hdr(meta); - let hdr_bytes = hdr.as_bytes(); - let mut packet = linearize(pool, &mut segments)?; - - // Fix up the IPv4 header checksum when the frontend - // requested IPv4 header checksum offload. - // - // The virtio vnet header has no mechanism for IPv4 header - // checksum offload, so we compute it in software. This - // also covers NDIS/netvsp LSO packets, where the guest - // driver zeroes ip_check (NDIS convention); the kernel's - // TAP GSO engine requires a valid checksum to segment - // the packet correctly. - // Same NDIS/LSO convention for IPv6: the guest zeroes the IPv6 - // payload-length field on segmentation-offload frames. IPv6 has - // no header checksum (so the IPv4 fixup above never runs for it); - // fix the length here so the kernel TAP GSO engine can segment. - if meta.flags.offload_ip_header_checksum() && meta.flags.is_ipv4() { - fixup_ipv4_header_checksum(&mut packet, meta.l2_len as usize); - } - if meta.flags.offload_tcp_segmentation() && meta.flags.is_ipv6() { - fixup_ipv6_payload_length(&mut packet, meta.l2_len as usize); - } - - let bufs = [ - std::io::IoSlice::new(hdr_bytes), - std::io::IoSlice::new(&packet), - ]; - match tap.write_vectored(&bufs) { - Ok(bytes_written) => { - assert_eq!( - bytes_written, - hdr_bytes.len() + packet.len(), - "TAP should never partial write" - ); - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - // dropped packet: buffer is full - - // TODO: return partial transmit here. This relies on - // remembering this condition and polling for POLLOUT in - // poll_ready(). - } - Err(err) if err.raw_os_error() == Some(libc::EIO) => { - // dropped packet: interface is not up - } - Err(err) => { - tracing::warn!( - error = &err as &dyn std::error::Error, - "write to TAP interface failed" - ); - } - } - } - } - let completed_synchronously = true; - Ok((completed_synchronously, n)) + self.transmit(pool, &mut segments) } fn tx_poll( &mut self, _pool: &mut dyn BufferAccess, - _done: &mut [TxId], + done: &mut [TxId], ) -> Result { - // Packets are sent synchronously so there is no no need to check here if - // sending has been completed. - Ok(0) + self.poll_tx_done(done) + } + + async fn quiesce( + &mut self, + _pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.quiesce_queue().await + } + + fn resume(&mut self) -> anyhow::Result<()> { + self.input_quiesced = false; + Ok(()) } } diff --git a/vm/devices/net/net_tap/src/quiesce.rs b/vm/devices/net/net_tap/src/quiesce.rs new file mode 100644 index 00000000000..22c86a8453b --- /dev/null +++ b/vm/devices/net/net_tap/src/quiesce.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Quiesce of a TAP queue. +//! +//! A quiesced queue drops its free RX buffers and stops reading from the TAP +//! interface, finishes writing its pending packet, and reports the +//! completions that it still holds. + +use crate::TapQueue; +use anyhow::Context as _; +use net_backend::quiesce::QueueQuiesceResult; +use std::task::Poll; + +impl TapQueue { + /// Implements `quiesce`. + pub(crate) async fn quiesce_queue(&mut self) -> anyhow::Result { + self.input_quiesced = true; + self.inner.rx_free.clear(); + std::future::poll_fn(|cx| { + self.poll_pending_tx(cx); + if self.tx.pending.is_none() || self.tx.error.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await; + if let Some(error) = self.tx.error.take() { + self.inner.rx_ready.clear(); + return Err(error).context("failed to quiesce TAP transmit queue"); + } + Ok(QueueQuiesceResult { + rx_ready: self.inner.rx_ready.len(), + tx_ready: self.tx.ready.len(), + }) + } +} diff --git a/vm/devices/net/net_tap/src/tap.rs b/vm/devices/net/net_tap/src/tap.rs index ba15e64b536..ca925087052 100644 --- a/vm/devices/net/net_tap/src/tap.rs +++ b/vm/devices/net/net_tap/src/tap.rs @@ -6,6 +6,9 @@ // UNSAFETY: Interacting with a union in bindgen-generated code and calling an ioctl. #![expect(unsafe_code)] +mod async_write; +pub mod persist; + use crate::VirtioNetHdr; use futures::AsyncRead; use linux_net_bindings::gen_if; @@ -45,6 +48,8 @@ pub enum Error { SetVnetHdrSize(#[source] io::Error), #[error("TUNSETOFFLOAD ioctl failed")] SetOffload(#[source] io::Error), + #[error("TUNSETPERSIST ioctl failed")] + SetPersistent(#[source] io::Error), #[error("TAP name conversion to C string failed")] TapNameConversion(#[source] std::ffi::NulError), #[error("TAP interface does not have IFF_VNET_HDR set")] diff --git a/vm/devices/net/net_tap/src/tap/async_write.rs b/vm/devices/net/net_tap/src/tap/async_write.rs new file mode 100644 index 00000000000..76a1c5666a2 --- /dev/null +++ b/vm/devices/net/net_tap/src/tap/async_write.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Asynchronous writes to a TAP interface, used by the queue transmit path to +//! wait for the interface to accept a packet. + +use super::PolledTap; +use futures::AsyncWrite; +use std::io; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + +impl AsyncWrite for PolledTap { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.tap).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tap).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tap).poll_close(cx) + } + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.tap).poll_write_vectored(cx, bufs) + } +} diff --git a/vm/devices/net/net_tap/src/tap/persist.rs b/vm/devices/net/net_tap/src/tap/persist.rs new file mode 100644 index 00000000000..2fddb865826 --- /dev/null +++ b/vm/devices/net/net_tap/src/tap/persist.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Persistence of TAP interfaces. + +// UNSAFETY: Calling an ioctl. +#![expect(unsafe_code)] + +use super::Error; +use linux_net_bindings::tun_set_persist; +use std::io; +use std::os::fd::OwnedFd; +use std::os::raw::c_int; +use std::os::unix::prelude::AsRawFd; + +/// Sets whether a TAP interface persists after its last fd is closed. +pub fn set_persistent(fd: &OwnedFd, persistent: bool) -> Result<(), Error> { + // SAFETY: calling the ioctl according to implementation requirements. + unsafe { + tun_set_persist(fd.as_raw_fd(), c_int::from(persistent)) + .map_err(|_e| Error::SetPersistent(io::Error::last_os_error()))?; + } + Ok(()) +} diff --git a/vm/devices/net/net_tap/src/tx.rs b/vm/devices/net/net_tap/src/tx.rs new file mode 100644 index 00000000000..6c3b658ca02 --- /dev/null +++ b/vm/devices/net/net_tap/src/tx.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Transmit path of a TAP queue. +//! +//! Each `tx_avail` call writes at most one guest packet. When the TAP +//! interface applies backpressure, the queue keeps the packet, and with it +//! the ownership of its descriptors, until `poll_ready` writes it; `tx_poll` +//! then completes it. Packets that the run-scoped egress policy denies are +//! completed without being written. + +use crate::TapQueue; +use crate::VirtioNetHdr; +use crate::build_vnet_hdr; +use crate::fixup_ipv4_header_checksum; +use crate::fixup_ipv6_payload_length; +use anyhow::Context as _; +use futures::io::AsyncWrite; +use net_backend::BufferAccess; +use net_backend::TxError; +use net_backend::TxId; +use net_backend::TxSegment; +use net_backend::linearize; +use net_backend::next_packet; +use net_backend_resources::egress::EgressPolicy; +use std::collections::VecDeque; +use std::io::ErrorKind; +use std::io::Write; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use zerocopy::IntoBytes; + +/// Transmit state of a [`TapQueue`]. +pub(crate) struct TxState { + pub(crate) pending: Option, + pub(crate) ready: VecDeque, + pub(crate) error: Option, + egress_policy: Option, +} + +/// A packet accepted from the guest and not yet written to the TAP interface. +pub(crate) struct PendingTx { + id: TxId, + header: VirtioNetHdr, + packet: Vec, +} + +impl TxState { + pub(crate) fn new(egress_policy: Option) -> Self { + Self { + pending: None, + ready: VecDeque::new(), + error: None, + egress_policy, + } + } +} + +impl TapQueue { + /// Writes the pending packet, if any, and reports whether TX completions or a + /// TX error are ready. + pub(crate) fn poll_tx(&mut self, cx: &mut Context<'_>) -> Poll<()> { + self.poll_pending_tx(cx); + if !self.tx.ready.is_empty() || self.tx.error.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + } + + pub(crate) fn poll_pending_tx(&mut self, cx: &mut Context<'_>) { + let Some(pending) = self.tx.pending.as_ref() else { + return; + }; + let Some(tap) = self.tap.as_mut() else { + return; + }; + let header = pending.header.as_bytes(); + let bufs = [ + std::io::IoSlice::new(header), + std::io::IoSlice::new(&pending.packet), + ]; + match Pin::new(tap).poll_write_vectored(cx, &bufs) { + Poll::Ready(Ok(bytes_written)) + if bytes_written == header.len() + pending.packet.len() => + { + let pending = self.tx.pending.take().unwrap(); + self.tx.ready.push_back(pending.id); + } + Poll::Ready(Ok(bytes_written)) => { + self.tx.error = Some(std::io::Error::new( + ErrorKind::WriteZero, + format!( + "partial TAP packet write: wrote {bytes_written} of {} bytes", + header.len() + pending.packet.len() + ), + )); + } + Poll::Ready(Err(error)) => self.tx.error = Some(error), + Poll::Pending => {} + } + } + + /// Implements `tx_avail`: writes the next packet of `segments`. + pub(crate) fn transmit( + &mut self, + pool: &mut dyn BufferAccess, + segments: &mut &[TxSegment], + ) -> anyhow::Result<(bool, usize)> { + if segments.is_empty() || self.tx.pending.is_some() { + return Ok((false, 0)); + } + + let (metadata, packet_segments, _) = next_packet(segments); + let segment_count = packet_segments.len(); + let id = metadata.id; + let header = build_vnet_hdr(metadata); + let mut packet = linearize(pool, segments)?; + if metadata.flags.offload_ip_header_checksum() && metadata.flags.is_ipv4() { + fixup_ipv4_header_checksum(&mut packet, metadata.l2_len as usize); + } + if metadata.flags.offload_tcp_segmentation() && metadata.flags.is_ipv6() { + fixup_ipv6_payload_length(&mut packet, metadata.l2_len as usize); + } + if let Some(policy) = &self.tx.egress_policy + && policy.authorize_frame(&packet, packet.len()).is_err() + { + return Ok((true, segment_count)); + } + + let tap = self.tap.as_mut().context("TAP queue is unavailable")?; + let header_bytes = header.as_bytes(); + let bufs = [ + std::io::IoSlice::new(header_bytes), + std::io::IoSlice::new(&packet), + ]; + match tap.write_vectored(&bufs) { + Ok(bytes_written) if bytes_written == header_bytes.len() + packet.len() => { + Ok((true, segment_count)) + } + Ok(bytes_written) => anyhow::bail!( + "partial TAP packet write: wrote {bytes_written} of {} bytes", + header_bytes.len() + packet.len() + ), + Err(error) if error.kind() == ErrorKind::WouldBlock => { + self.tx.pending = Some(PendingTx { id, header, packet }); + Ok((false, segment_count)) + } + Err(error) => Err(error).context("failed to write TAP packet"), + } + } + + /// Implements `tx_poll`. + pub(crate) fn poll_tx_done(&mut self, done: &mut [TxId]) -> Result { + if let Some(error) = self.tx.error.take() { + return Err(TxError::Fatal(error.into())); + } + let count = done.len().min(self.tx.ready.len()); + for (destination, id) in done.iter_mut().zip(self.tx.ready.drain(..count)) { + *destination = id; + } + Ok(count) + } +} diff --git a/vm/devices/net/net_tap/tests/tap_tests.rs b/vm/devices/net/net_tap/tests/tap_tests.rs index 424b39c88df..e1328a2360e 100644 --- a/vm/devices/net/net_tap/tests/tap_tests.rs +++ b/vm/devices/net/net_tap/tests/tap_tests.rs @@ -20,6 +20,8 @@ mod tap_tests { // configuration (ioctls for link-up, address, netmask). #![expect(unsafe_code)] + mod egress; + use libtest_mimic::Arguments; use libtest_mimic::Trial; use net_backend::Endpoint; @@ -318,9 +320,9 @@ mod tap_tests { ); } - /// Validates that flooding tx_avail doesn't panic or error — packets are - /// silently dropped when the kernel buffer fills up. - async fn test_tap_tx_wouldblock_drops(driver: DefaultDriver) { + /// Validates that flooding tx_avail preserves ownership until the TAP + /// accepts each packet, including when the kernel applies backpressure. + async fn test_tap_tx_backpressure_preserves_ownership(driver: DefaultDriver) { let mut endpoint = new_endpoint("tap0").unwrap(); configure_tap("tap0", "10.0.0.1/24"); @@ -357,11 +359,17 @@ mod tap_tests { len: frame_len, }]; - // Flood with packets — should never error. + // Flood with packets. A packet accepted asynchronously must be + // returned through tx_poll before the next packet is submitted. for _ in 0..10000 { let (completed, count) = queue.tx_avail(&mut pool, &segments).unwrap(); - assert!(completed, "tx should always complete synchronously"); assert_eq!(count, 1); + if !completed { + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut done = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut done).unwrap(), 1); + assert_eq!(done[0].0, 0); + } } } @@ -633,7 +641,14 @@ mod tap_tests { async_trial("tap_get_queues", test_tap_get_queues), async_trial("tap_tx_sends_frame", test_tap_tx_sends_frame), async_trial("tap_rx_receives_packet", test_tap_rx_receives_packet), - async_trial("tap_tx_wouldblock_drops", test_tap_tx_wouldblock_drops), + async_trial( + "tap_endpoint_policy_forwards_on_link_arp", + egress::test_tap_endpoint_policy_forwards_on_link_arp, + ), + async_trial( + "tap_tx_backpressure_preserves_ownership", + test_tap_tx_backpressure_preserves_ownership, + ), async_trial("tap_tx_with_offloads", test_tap_tx_with_offloads), async_trial("tap_tso_ipv4_checksum", test_tap_tso_ipv4_checksum), ] diff --git a/vm/devices/net/net_tap/tests/tap_tests/egress.rs b/vm/devices/net/net_tap/tests/tap_tests/egress.rs new file mode 100644 index 00000000000..45c6513846a --- /dev/null +++ b/vm/devices/net/net_tap/tests/tap_tests/egress.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy tests of the TAP endpoint. + +use super::configure_tap; +use super::make_pool; +use super::new_endpoint; +use net_backend::Endpoint; +use net_backend::QueueConfig; +use net_backend::RxId; +use net_backend::TxId; +use net_backend::TxMetadata; +use net_backend::TxSegment; +use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicy; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use std::future::poll_fn; + +fn endpoint_arp_request(target: std::net::Ipv4Addr) -> Vec { + let guest_mac = [0x52, 0x54, 0, 0, 0, 2]; + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&guest_mac); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&guest_mac); + frame[28..32].copy_from_slice(&[10, 0, 0, 2]); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +/// Validates that exact endpoint policy forwards an on-link ARP request to TAP. +pub(super) async fn test_tap_endpoint_policy_forwards_on_link_arp(driver: DefaultDriver) { + let target = std::net::Ipv4Addr::new(10, 0, 0, 9); + let mut endpoint = new_endpoint("tap_policy").unwrap(); + configure_tap("tap_policy", "10.0.0.9/24"); + endpoint + .set_egress_policy( + EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["10.0.0.9:443".parse().unwrap()]), + ) + .unwrap(), + ) + .unwrap(); + + let (mut pool, mem) = make_pool(); + let initial_rx: Vec<_> = (1..128).map(RxId).collect(); + let mut queues = Vec::new(); + endpoint + .get_queues( + vec![QueueConfig { + driver: Box::new(driver), + }], + None, + &mut queues, + ) + .await + .unwrap(); + let queue = &mut queues[0]; + queue.rx_avail(&mut pool, &initial_rx); + + let frame = endpoint_arp_request(target); + mem.write_at(0, &frame).unwrap(); + let segments = [TxSegment { + ty: TxSegmentType::Head(TxMetadata { + id: TxId(0), + segment_count: 1, + len: frame.len() as u32, + ..Default::default() + }), + gpa: 0, + len: frame.len() as u32, + }]; + let (completed, count) = queue.tx_avail(&mut pool, &segments).unwrap(); + assert_eq!(count, 1); + if !completed { + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut done = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut done).unwrap(), 1); + } + + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut packets = [RxId(0); 128]; + let n = queue.rx_poll(&mut pool, &mut packets).unwrap(); + let found_reply = packets[..n].iter().any(|rx_id| { + let mut reply = [0u8; 42]; + mem.read_at(rx_id.0 as u64 * 2048, &mut reply).unwrap(); + reply[12..14] == 0x0806u16.to_be_bytes() + && reply[20..22] == 2u16.to_be_bytes() + && reply[28..32] == target.octets() + && reply[32..38] == [0x52, 0x54, 0, 0, 0, 2] + && reply[38..42] == [10, 0, 0, 2] + }); + assert!( + found_reply, + "expected TAP to answer the allowed endpoint ARP" + ); +} diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index dbd2bba8784..3ab5413aab9 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -370,7 +370,7 @@ impl VpciRelay { ready_to_remove: false, }); - state_units.start_stopped_units().await; + state_units.start_stopped_units().await?; Ok(()) } diff --git a/vm/devices/serial/serial_core/src/lib.rs b/vm/devices/serial/serial_core/src/lib.rs index dab358bfbe4..78efdf2c7a6 100644 --- a/vm/devices/serial/serial_core/src/lib.rs +++ b/vm/devices/serial/serial_core/src/lib.rs @@ -13,9 +13,39 @@ pub mod serial_io; use futures::io::AsyncRead; use futures::io::AsyncWrite; use inspect::InspectMut; +use mesh::MeshPayload; use std::task::Context; use std::task::Poll; +/// Authenticated operating-system identity of a local serial peer. +#[derive(Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum LocalPeerIdentity { + /// Effective Unix user ID obtained from the connected socket. + UnixUid(u32), + /// Windows security identifier in its self-relative binary representation. + WindowsSid { + /// Binary SID bytes, zero-padded to the maximum SID size. + bytes: [u8; 68], + /// Number of meaningful bytes in `bytes`. + length: u8, + }, + /// No peer-identity implementation exists for this platform. + Unsupported, +} + +impl LocalPeerIdentity { + /// Constructs a validated Windows SID identity. + pub fn windows_sid(bytes: [u8; 68], length: u8) -> std::io::Result { + if !(8..=68).contains(&length) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Windows SID length is outside the supported range", + )); + } + Ok(Self::WindowsSid { bytes, length }) + } +} + /// Trait for types providing serial IO. pub trait SerialIo: AsyncRead + AsyncWrite + Send + InspectMut + Unpin { /// Returns true if the backend is already connected. @@ -29,4 +59,23 @@ pub trait SerialIo: AsyncRead + AsyncWrite + Send + InspectMut + Unpin { /// Polls for the serial backend to disconnect. fn poll_disconnect(&mut self, cx: &mut Context<'_>) -> Poll>; + + /// Returns the authenticated local identity of the connected peer. + /// + /// Generic serial backends do not promise peer authentication. Control + /// endpoints must reject `None`. + fn local_peer_identity(&self) -> std::io::Result> { + Ok(None) + } + + /// Closes the current connection while preserving a reconnectable listener. + /// + /// Control endpoints use this to evict unauthenticated clients. Generic + /// serial backends may return [`std::io::ErrorKind::Unsupported`]. + fn disconnect_current(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "serial backend cannot actively disconnect its peer", + )) + } } diff --git a/vm/devices/serial/serial_socket/Cargo.toml b/vm/devices/serial/serial_socket/Cargo.toml index c9663125c07..c89e709f481 100644 --- a/vm/devices/serial/serial_socket/Cargo.toml +++ b/vm/devices/serial/serial_socket/Cargo.toml @@ -17,9 +17,21 @@ pal_async.workspace = true futures.workspace = true socket2.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true +pal.workspace = true + [target.'cfg(windows)'.dependencies] pal.workspace = true tracing.workspace = true +[target.'cfg(windows)'.dev-dependencies] +windows-sys.workspace = true + +[dev-dependencies] +getrandom.workspace = true +pal_async_test.workspace = true +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vm/devices/serial/serial_socket/src/lib.rs b/vm/devices/serial/serial_socket/src/lib.rs index 247d4a8729c..3b300544b87 100644 --- a/vm/devices/serial/serial_socket/src/lib.rs +++ b/vm/devices/serial/serial_socket/src/lib.rs @@ -9,3 +9,5 @@ pub mod net; #[cfg(windows)] pub mod windows; +#[cfg(all(test, windows))] +mod windows_tests; diff --git a/vm/devices/serial/serial_socket/src/net.rs b/vm/devices/serial/serial_socket/src/net.rs index 10cdce09e7d..a98657684f1 100644 --- a/vm/devices/serial/serial_socket/src/net.rs +++ b/vm/devices/serial/serial_socket/src/net.rs @@ -12,6 +12,7 @@ use pal_async::driver::Driver; use pal_async::interest::PollEvents; use pal_async::socket::PollReady; use pal_async::socket::PolledSocket; +use serial_core::LocalPeerIdentity; use serial_core::SerialIo; use serial_core::resources::ResolveSerialBackendParams; use serial_core::resources::ResolvedSerialBackend; @@ -172,6 +173,35 @@ impl SerialIo for SocketSerialBackend { } Poll::Ready(Ok(())) } + + fn local_peer_identity(&self) -> io::Result> { + let Some(current) = &self.current else { + return Ok(None); + }; + local_peer_identity(current.get()) + } + + fn disconnect_current(&mut self) -> io::Result<()> { + self.current = None; + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn local_peer_identity(socket: &Socket) -> io::Result> { + use std::os::fd::AsFd; + + if socket.local_addr()?.family() as i32 != libc::AF_UNIX { + return Ok(None); + } + Ok(Some(LocalPeerIdentity::UnixUid( + pal::unix::credentials::unix_socket_peer_user_id(socket.as_fd())?, + ))) +} + +#[cfg(not(target_os = "linux"))] +fn local_peer_identity(_socket: &Socket) -> io::Result> { + Ok(None) } impl AsyncRead for SocketSerialBackend { @@ -200,11 +230,11 @@ impl AsyncWrite for SocketSerialBackend { let Some(current) = &mut self.current else { return Poll::Ready(Ok(buf.len())); }; - let r = ready!(Pin::new(current).poll_write(cx, buf)); - if matches!(&r, Err(err) if err.kind() == io::ErrorKind::BrokenPipe) { - return Poll::Ready(Ok(buf.len())); + let result = ready!(Pin::new(current).poll_write(cx, buf)); + if result.is_err() { + self.current = None; } - Poll::Ready(r) + Poll::Ready(result) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -229,3 +259,19 @@ impl AsyncWrite for SocketSerialBackend { Poll::Ready(r) } } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn unix_socket_reports_same_uid_peer() { + let (left, _right) = + Socket::pair(socket2::Domain::UNIX, socket2::Type::STREAM, None).unwrap(); + assert_eq!( + local_peer_identity(&left).unwrap(), + Some(LocalPeerIdentity::UnixUid(pal::unix::effective_user_id())) + ); + } +} diff --git a/vm/devices/serial/serial_socket/src/windows.rs b/vm/devices/serial/serial_socket/src/windows.rs index 9a179a97a31..12683207280 100644 --- a/vm/devices/serial/serial_socket/src/windows.rs +++ b/vm/devices/serial/serial_socket/src/windows.rs @@ -13,6 +13,7 @@ use pal::windows::pipe::PipeExt; use pal_async::driver::Driver; use pal_async::pipe::PolledPipe; use pal_async::windows::pipe::ListeningPipe; +use serial_core::LocalPeerIdentity; use serial_core::SerialIo; use serial_core::resources::ResolveSerialBackendParams; use serial_core::resources::ResolvedSerialBackend; @@ -44,6 +45,8 @@ pub struct WindowsPipeSerialBackend { #[inspect(skip)] driver: Box, state: PipeState, + #[inspect(skip)] + peer_identity: Option, } enum PipeState { @@ -89,17 +92,28 @@ impl ResolveResource impl WindowsPipeSerialBackend { pub fn new(driver: Box, config: OpenWindowsPipeSerialConfig) -> io::Result { - let state = if let Some(file) = config.pipe { + let (state, peer_identity) = if let Some(file) = config.pipe { if file.is_pipe_connected()? { - PipeState::Connected(PolledPipe::new(&driver, file)?) + let peer_identity = Some(named_pipe_peer_identity(&file)?); + ( + PipeState::Connected(PolledPipe::new(&driver, file)?), + peer_identity, + ) } else { - PipeState::Listening(ListeningPipe::new(&driver, file)?) + ( + PipeState::Listening(ListeningPipe::new(&driver, file)?), + None, + ) } } else { - PipeState::Done + (PipeState::Done, None) }; - Ok(Self { driver, state }) + Ok(Self { + driver, + state, + peer_identity, + }) } pub fn into_config(self) -> OpenWindowsPipeSerialConfig { @@ -112,9 +126,21 @@ impl WindowsPipeSerialBackend { } fn disconnect(&mut self) -> io::Result<()> { + if !matches!(self.state, PipeState::Connected(_)) { + return Ok(()); + } if let PipeState::Connected(pipe) = std::mem::replace(&mut self.state, PipeState::Done) { + self.peer_identity = None; let pipe = pipe.into_inner(); - pipe.disconnect_pipe()?; + match pipe.disconnect_pipe() { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotConnected | io::ErrorKind::BrokenPipe + ) => {} + Err(error) => return Err(error), + } self.state = PipeState::Listening(ListeningPipe::new(&self.driver, pipe)?); } Ok(()) @@ -137,8 +163,10 @@ impl SerialIo for WindowsPipeSerialBackend { PipeState::Done => Poll::Pending, PipeState::Listening(accept) => { let file = ready!(accept.poll_unpin(cx)); + let file = file?; + self.peer_identity = Some(named_pipe_peer_identity(&file)?); self.state = PipeState::Done; - self.state = PipeState::Connected(PolledPipe::new(&self.driver, file?)?); + self.state = PipeState::Connected(PolledPipe::new(&self.driver, file)?); Poll::Ready(Ok(())) } PipeState::Connected(_) => Poll::Ready(Ok(())), @@ -160,6 +188,21 @@ impl SerialIo for WindowsPipeSerialBackend { } } } + + fn disconnect_current(&mut self) -> io::Result<()> { + self.disconnect() + } + + fn local_peer_identity(&self) -> io::Result> { + Ok(self.peer_identity.clone()) + } +} + +fn named_pipe_peer_identity(pipe: &File) -> io::Result { + let process_id = pal::windows::pipe::peer::client_process_id(pipe)?; + let sid = pal::windows::security::user_sid::process_user_sid(process_id)?; + let (bytes, length) = sid.to_fixed_bytes(); + LocalPeerIdentity::windows_sid(bytes, length) } impl AsyncRead for WindowsPipeSerialBackend { @@ -195,11 +238,16 @@ impl AsyncWrite for WindowsPipeSerialBackend { match &mut self.state { PipeState::Done | PipeState::Listening(_) => Poll::Ready(Ok(buf.len())), PipeState::Connected(pipe) => { - let r = ready!(Pin::new(pipe).poll_write(cx, buf)); - if matches!(&r, Err(err) if err.kind() == io::ErrorKind::BrokenPipe) { - return Poll::Ready(Ok(buf.len())); + let result = ready!(Pin::new(pipe).poll_write(cx, buf)); + if result.is_err() + && let Err(error) = self.disconnect() + { + tracing::error!( + error = &error as &dyn std::error::Error, + "failed to prepare named pipe after a write failure" + ); } - Poll::Ready(r) + Poll::Ready(result) } } } @@ -230,3 +278,18 @@ impl AsyncWrite for WindowsPipeSerialBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_pipe_reports_the_current_process_identity() { + let (server, _client) = pal::windows::pipe::bidirectional_pair(false).unwrap(); + let actual = named_pipe_peer_identity(&server).unwrap(); + let sid = pal::windows::security::user_sid::current_process_user_sid().unwrap(); + let (bytes, length) = sid.to_fixed_bytes(); + let expected = LocalPeerIdentity::windows_sid(bytes, length).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/vm/devices/serial/serial_socket/src/windows_tests.rs b/vm/devices/serial/serial_socket/src/windows_tests.rs new file mode 100644 index 00000000000..509ca3083b9 --- /dev/null +++ b/vm/devices/serial/serial_socket/src/windows_tests.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::windows::OpenWindowsPipeSerialConfig; +use crate::windows::WindowsPipeSerialBackend; +use futures::future::poll_fn; +use pal::windows::pipe::Disposition; +use pal::windows::pipe::PipeExt; +use pal::windows::pipe::PipeMode; +use pal::windows::pipe::new_named_pipe; +use pal_async::DefaultDriver; +use pal_async_test::async_test; +use serial_core::SerialIo; +use std::fs::OpenOptions; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; +use windows_sys::Win32::System::Pipes::PIPE_NOWAIT; + +#[async_test] +async fn reconnects_after_client_close(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + let mut backend = WindowsPipeSerialBackend::new( + Box::new(driver.clone()), + OpenWindowsPipeSerialConfig::from(server), + ) + .unwrap(); + + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + client.set_pipe_mode(PIPE_NOWAIT).unwrap(); + poll_fn(|cx| backend.poll_connect(cx)).await.unwrap(); + drop(client); + poll_fn(|cx| backend.poll_disconnect(cx)).await.unwrap(); + backend.disconnect_current().unwrap(); + + let _replacement = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + poll_fn(|cx| backend.poll_connect(cx)).await.unwrap(); +} diff --git a/vm/devices/support/fs/fuse/Cargo.toml b/vm/devices/support/fs/fuse/Cargo.toml index 37046643001..ffb5e54c224 100644 --- a/vm/devices/support/fs/fuse/Cargo.toml +++ b/vm/devices/support/fs/fuse/Cargo.toml @@ -16,6 +16,7 @@ lx.workspace = true bitfield-struct.workspace = true parking_lot.workspace = true thiserror.workspace = true +tracelimit.workspace = true tracing.workspace = true zerocopy.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/vm/devices/support/fs/fuse/src/lib.rs b/vm/devices/support/fs/fuse/src/lib.rs index 72fb3e21504..52ddf7a5717 100644 --- a/vm/devices/support/fs/fuse/src/lib.rs +++ b/vm/devices/support/fs/fuse/src/lib.rs @@ -24,6 +24,9 @@ pub use request::RequestReader; pub use request::check_name; pub use session::Session; pub use session::SessionInfo; +pub use session::saved_state::SessionInfoState; +pub use session::saved_state::SessionState; +pub use session::saved_state::SessionStateError; use lx::LxStr; use lx::LxString; diff --git a/vm/devices/support/fs/fuse/src/request.rs b/vm/devices/support/fs/fuse/src/request.rs index 7e8b40d4984..99132cefd97 100644 --- a/vm/devices/support/fs/fuse/src/request.rs +++ b/vm/devices/support/fs/fuse/src/request.rs @@ -132,7 +132,7 @@ impl Request { fn read_operation(header: &fuse_in_header, mut reader: impl RequestReader) -> FuseOperation { if header.len as usize > reader.remaining_len() + size_of_val(header) { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, header_len = header.len, @@ -150,7 +150,7 @@ impl Request { let payload_len = (header.len as usize) - size_of::(); let available = payload_len.min(size_of::()); if available < FUSE_COMPAT_INIT_IN_SIZE as usize { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, len = available, @@ -160,7 +160,7 @@ impl Request { } let mut init = fuse_init_in::new_zeroed(); if let Err(e) = reader.read_exact(&mut init.as_mut_bytes()[..available]) { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, error = &e as &dyn std::error::Error, @@ -174,7 +174,7 @@ impl Request { match FuseOperation::read(header.opcode, reader) { Ok(operation) => operation, Err(e) => { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, error = &e as &dyn std::error::Error, diff --git a/vm/devices/support/fs/fuse/src/request/macros.rs b/vm/devices/support/fs/fuse/src/request/macros.rs index 7b6cf784155..cd14391c00c 100644 --- a/vm/devices/support/fs/fuse/src/request/macros.rs +++ b/vm/devices/support/fs/fuse/src/request/macros.rs @@ -35,7 +35,7 @@ macro_rules! fuse_operations { } },)* _ => { - tracing::error!(opcode, "Invalid opcode"); + tracelimit::error_ratelimited!(opcode, "Invalid opcode"); return Err(lx::Error::EINVAL) } }; diff --git a/vm/devices/support/fs/fuse/src/session.rs b/vm/devices/support/fs/fuse/src/session.rs index 9ce3e097995..4508e79448e 100644 --- a/vm/devices/support/fs/fuse/src/session.rs +++ b/vm/devices/support/fs/fuse/src/session.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(crate) mod saved_state; + use super::Fuse; use super::Mapper; use super::protocol::*; @@ -87,7 +89,7 @@ impl Session { match result { Err(OperationError::FsError(e)) => { if let Err(e) = sender.send_error(unique, e.value()) { - tracing::error!( + tracelimit::error_ratelimited!( unique, error = &e as &dyn std::error::Error, "Failed to send reply", @@ -98,7 +100,7 @@ impl Session { if e.kind() == io::ErrorKind::NotFound { tracing::trace!(unique, "Request was interrupted."); } else { - tracing::error!( + tracelimit::error_ratelimited!( unique, error = &e as &dyn std::error::Error, "Failed to send reply", @@ -119,6 +121,7 @@ impl Session { if self.initialized.swap(false, atomic::Ordering::AcqRel) { self.fs.destroy(); } + *self.info.write() = SessionInfo::default(); } /// Perform the actual dispatch. This allows the caller to send an error reply if any operation @@ -267,7 +270,7 @@ impl Session { sender.send_empty(request.unique())?; } FuseOperation::Init { arg: _ } => { - tracing::warn!("Duplicate init message."); + tracelimit::warn_ratelimited!("Duplicate init message."); return Err(lx::Error::EIO.into()); } FuseOperation::OpenDir { arg } => { @@ -316,7 +319,7 @@ impl Session { FuseOperation::Interrupt { arg: _ } => { // Interrupt is potentially complicated, and none of the sample file systems seem // to use it, so it's left as TODO for now. - tracing::warn!("FUSE_INTERRUPT not supported."); + tracelimit::warn_ratelimited!("FUSE_INTERRUPT not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::BMap { arg } => { @@ -352,12 +355,12 @@ impl Session { // Poll is not currently needed, and complicated to support. It appears to have some // way of registering for later notifications, but I can't figure out how that // works without libfuse source. - tracing::warn!("FUSE_POLL not supported."); + tracelimit::warn_ratelimited!("FUSE_POLL not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::NotifyReply { arg: _, data: _ } => { // Not sure what this is. It has something to do with poll, I think. - tracing::warn!("FUSE_NOTIFY_REPLY not supported."); + tracelimit::warn_ratelimited!("FUSE_NOTIFY_REPLY not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::BatchForget { arg, nodes } => { @@ -447,13 +450,13 @@ impl Session { let init: &fuse_init_in = if let FuseOperation::Init { arg } = request.operation() { arg } else { - tracing::error!(opcode = request.opcode(), "Expected FUSE_INIT"); + tracelimit::error_ratelimited!(opcode = request.opcode(), "Expected FUSE_INIT"); return Err(lx::Error::EIO.into()); }; let mut info = self.info.write(); if self.is_initialized() { - tracing::error!("Racy FUSE_INIT requests."); + tracelimit::error_ratelimited!("Racy FUSE_INIT requests."); return Err(lx::Error::EIO.into()); } @@ -471,7 +474,7 @@ impl Session { // Don't bother supporting old versions. Version 7.27 is what kernel 4.19 uses, and can // be supported without needing to change the daemon's behavior for compatibility. if init.major < FUSE_KERNEL_VERSION || init.minor < 27 { - tracing::error!( + tracelimit::error_ratelimited!( major = init.major, minor = init.minor, "Got unsupported kernel version", @@ -481,7 +484,7 @@ impl Session { // Prepare the session info and call the file system to negotiate. info.major = init.major; - info.minor = init.minor; + info.minor = init.minor.min(FUSE_KERNEL_MINOR_VERSION); info.max_readahead = init.max_readahead; info.capable = init.flags; info.want = DEFAULT_FLAGS & init.flags; @@ -606,7 +609,7 @@ impl Session { } /// Provides information about a session. Public fields may be modified during `init`. -#[derive(Default)] +#[derive(Clone, Copy, Default)] pub struct SessionInfo { major: u32, minor: u32, @@ -650,6 +653,8 @@ enum OperationError { #[cfg(test)] mod tests { + mod saved_state; + use super::*; use crate::request::tests::*; use parking_lot::Mutex; diff --git a/vm/devices/support/fs/fuse/src/session/saved_state.rs b/vm/devices/support/fs/fuse/src/session/saved_state.rs new file mode 100644 index 00000000000..f4a1bc4dc8e --- /dev/null +++ b/vm/devices/support/fs/fuse/src/session/saved_state.rs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serializable FUSE session state: the negotiated protocol values, their +//! validation, and saving and restoring them on a session. + +use super::Session; +use super::SessionInfo; +use crate::protocol::FUSE_INIT_EXT; +use crate::protocol::FUSE_KERNEL_MINOR_VERSION; +use crate::protocol::FUSE_KERNEL_VERSION; +use std::sync::atomic; +use thiserror::Error; + +/// Serializable FUSE session information. +/// +/// This is intentionally independent from any transport or filesystem +/// implementation. It contains only negotiated protocol values, never a file +/// handle, task, or native resource. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SessionInfoState { + /// Negotiated FUSE major version. + pub major: u32, + /// Negotiated FUSE minor version. + pub minor: u32, + /// Guest maximum readahead. + pub max_readahead: u32, + /// Feature flags advertised by the guest. + pub capable: u32, + /// Extended feature flags advertised by the guest. + pub capable2: u32, + /// Feature flags accepted by the server. + pub want: u32, + /// Extended feature flags accepted by the server. + pub want2: u32, + /// Maximum background requests. + pub max_background: u16, + /// Congestion threshold. + pub congestion_threshold: u16, + /// Maximum write size. + pub max_write: u32, + /// Timestamp granularity. + pub time_gran: u32, +} + +/// Serializable state of a FUSE session. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SessionState { + /// Whether the session has completed FUSE_INIT. + pub initialized: bool, + /// Negotiated session information. + pub info: SessionInfoState, +} + +/// Invalid FUSE session state. +#[derive(Debug, Error)] +pub enum SessionStateError { + /// The state does not describe a supported FUSE protocol. + #[error("unsupported FUSE protocol version")] + UnsupportedVersion, + /// Accepted feature bits were not advertised by the guest. + #[error("FUSE negotiated flags are not a subset of guest capabilities")] + InvalidFlags, + /// The extended-init flags are inconsistent. + #[error("FUSE extended-init flags are inconsistent")] + InvalidExtendedFlags, + /// A negotiated protocol limit is invalid. + #[error("FUSE negotiated protocol limit is invalid")] + InvalidLimits, + /// An uninitialized session retained negotiation state. + #[error("uninitialized FUSE session retained negotiation state")] + UninitializedWithInfo, +} + +impl SessionState { + /// Validates that the state can be restored by this implementation. + pub fn validate(&self) -> Result<(), SessionStateError> { + let info = self.info; + if !self.initialized { + return if info == SessionInfoState::default() { + Ok(()) + } else { + Err(SessionStateError::UninitializedWithInfo) + }; + } + if info.major != FUSE_KERNEL_VERSION + || !(27..=FUSE_KERNEL_MINOR_VERSION).contains(&info.minor) + { + return Err(SessionStateError::UnsupportedVersion); + } + if info.want & !info.capable != 0 { + return Err(SessionStateError::InvalidFlags); + } + if info.capable & FUSE_INIT_EXT == 0 { + if info.capable2 != 0 || info.want2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + } else if info.want & FUSE_INIT_EXT == 0 { + if info.want2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + } else if info.want2 & !info.capable2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + if info.max_write == 0 || info.time_gran == 0 { + return Err(SessionStateError::InvalidLimits); + } + Ok(()) + } +} + +impl Session { + /// Saves typed negotiated protocol state for device-private migration. + pub fn save_state(&self) -> SessionState { + let initialized = self.is_initialized(); + let info = SessionInfoState::from(*self.info.read()); + SessionState { initialized, info } + } + + /// Restores a previously validated FUSE negotiation. + /// + /// The caller must ensure no request dispatch is active while invoking + /// this method. + pub fn restore_state(&self, state: SessionState) -> Result<(), SessionStateError> { + state.validate()?; + *self.info.write() = state.info.into(); + self.initialized + .store(state.initialized, atomic::Ordering::Release); + Ok(()) + } +} + +impl From for SessionInfoState { + fn from(info: SessionInfo) -> Self { + Self { + major: info.major, + minor: info.minor, + max_readahead: info.max_readahead, + capable: info.capable, + capable2: info.capable2, + want: info.want, + want2: info.want2, + max_background: info.max_background, + congestion_threshold: info.congestion_threshold, + max_write: info.max_write, + time_gran: info.time_gran, + } + } +} + +impl From for SessionInfo { + fn from(state: SessionInfoState) -> Self { + Self { + major: state.major, + minor: state.minor, + max_readahead: state.max_readahead, + capable: state.capable, + capable2: state.capable2, + want: state.want, + want2: state.want2, + max_background: state.max_background, + congestion_threshold: state.congestion_threshold, + max_write: state.max_write, + time_gran: state.time_gran, + } + } +} diff --git a/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs b/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs new file mode 100644 index 00000000000..8fa32a49979 --- /dev/null +++ b/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for saving, validating, and restoring the FUSE session state. + +use super::*; +use crate::session::saved_state::SessionInfoState; +use crate::session::saved_state::SessionState; +use crate::session::saved_state::SessionStateError; + +#[test] +fn initialized_session_state_restores_without_a_second_init() { + let mut init_sender = MockSender::default(); + let source = Session::new(TestFs::default()); + source.dispatch( + Request::new(FUSE_INIT_REQUEST).unwrap(), + &mut init_sender, + None, + ); + let saved = source.save_state(); + assert!(saved.initialized); + + let fs = TestFs::default(); + let state = Arc::clone(&fs.state); + let destination = Session::new(fs); + destination.restore_state(saved).unwrap(); + assert!(destination.is_initialized()); + + let mut sender = MockSender { state: 1 }; + destination.dispatch( + Request::new(FUSE_GETATTR_REQUEST).unwrap(), + &mut sender, + None, + ); + assert_eq!(state.lock().called, GETATTR_CALLED); +} + +#[test] +fn malformed_session_state_is_rejected() { + let state = SessionState { + initialized: true, + info: SessionInfoState { + major: FUSE_KERNEL_VERSION, + minor: 27, + capable: FUSE_ASYNC_READ, + want: FUSE_ASYNC_READ | FUSE_BIG_WRITES, + max_write: PAGE_SIZE, + time_gran: 1, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::InvalidFlags) + )); + + let state = SessionState { + initialized: false, + info: SessionInfoState { + max_write: PAGE_SIZE, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::UninitializedWithInfo) + )); + + let state = SessionState { + initialized: true, + info: SessionInfoState { + major: FUSE_KERNEL_VERSION, + minor: FUSE_KERNEL_MINOR_VERSION + 1, + max_write: PAGE_SIZE, + time_gran: 1, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::UnsupportedVersion) + )); +} + +#[test] +fn init_newer_minor_is_capped_to_the_supported_version() { + let request_data = make_init_request( + FUSE_KERNEL_VERSION, + FUSE_KERNEL_MINOR_VERSION + 1, + 131072, + 0, + 0, + ); + let session = Session::new(InitCapturingFs::default()); + let mut sender = CapturingSender::default(); + + session.dispatch( + Request::new(request_data.as_slice()).unwrap(), + &mut sender, + None, + ); + + assert_eq!(session.save_state().info.minor, FUSE_KERNEL_MINOR_VERSION); +} diff --git a/vm/devices/support/fs/lxutil/src/lib.rs b/vm/devices/support/fs/lxutil/src/lib.rs index 0b9f6805400..7b48c0d6353 100644 --- a/vm/devices/support/fs/lxutil/src/lib.rs +++ b/vm/devices/support/fs/lxutil/src/lib.rs @@ -25,12 +25,17 @@ use windows as sys; pub use path::PathBufExt; pub use path::PathExt; +#[cfg(unix)] +pub use unix::credentials::FsIdentity; +#[cfg(unix)] +pub use unix::credentials::with_fs_identity; /// A platform-independent abstraction that allows you to treat an area of the file system as if /// it has Unix semantics. /// /// N.B.: all methods take relative paths, but do not attempt to make sure the path does not escape -/// the root of the `LxVolume`, and therefore should not be relied upon for security. +/// the root of the `LxVolume`, and therefore should not be relied upon for security unless +/// [`LxVolumeOptions::confine_paths`] is enabled. /// /// Use `PathExt` and `PathBufExt` to write cross-platform code that deals only with Unix-style /// paths. @@ -54,8 +59,8 @@ pub use path::PathExt; /// # Unix /// /// All calls pass through directly to their libc equivalent. Attributes like mode are always -/// enabled if the file system supports them. `LxVolumeOptions` is entirely ignored, as are the -/// `uid` and `gid` fields of `LxCreateOptions`. +/// enabled if the file system supports them. `LxVolumeOptions` other than `confine_paths` is +/// ignored, as are the `uid` and `gid` fields of `LxCreateOptions`. pub struct LxVolume { inner: sys::LxVolume, } @@ -116,7 +121,8 @@ impl LxVolume { /// # Unix /// /// Symlinks are followed for chmod, because the `fchmodat` syscall does not offer a way to not - /// follow symlinks. + /// follow symlinks. With [`LxVolumeOptions::confine_paths`], chmod never follows a symlink and + /// fails with `EOPNOTSUPP` when the path names one. /// /// The `SetAttributes::thread_uid` field is ignored, and the thread's actual capabilities are /// are used. @@ -182,7 +188,8 @@ impl LxVolume { /// # Unix /// /// Symlinks are followed for chmod, because the `fchmodat` syscall does not offer a way to not - /// follow symlinks. + /// follow symlinks. With [`LxVolumeOptions::confine_paths`], chmod never follows a symlink and + /// fails with `EOPNOTSUPP` when the path names one. pub fn chmod(&self, path: impl AsRef, mode: lx::mode_t) -> lx::Result<()> { let mut attr = SetAttributes::default(); attr.mode = Some(mode); @@ -274,7 +281,8 @@ impl LxVolume { /// # Windows /// /// This will attempt to create an NTFS symbolic link, but will fall back to a WSL-style link - /// if this is not possible. + /// if this is not possible. With [`LxVolumeOptions::confine_paths`], it always creates a + /// WSL-style link, which Windows path resolution never follows. pub fn symlink( &self, path: impl AsRef, @@ -687,7 +695,7 @@ impl LxFile { /// /// # Unix /// -/// These options have no effect on Unix platforms. +/// Except for `confine_paths`, these options have no effect on Unix platforms. #[derive(Clone)] pub struct LxVolumeOptions { uid: Option, @@ -705,6 +713,7 @@ pub struct LxVolumeOptions { symlink_root: String, override_xattrs: HashMap>, readonly: bool, + confine_paths: bool, } impl LxVolumeOptions { @@ -726,6 +735,7 @@ impl LxVolumeOptions { symlink_root: "".to_string(), override_xattrs: HashMap::new(), readonly: false, + confine_paths: false, } } @@ -1045,6 +1055,29 @@ impl LxVolumeOptions { pub fn is_readonly(&self) -> bool { self.readonly } + + /// Never follow a symbolic link while resolving a path inside the volume. + /// + /// Use this when the paths come from an untrusted client that can also create symbolic links. + /// + /// # Unix + /// + /// Every operation opens the parent directory of its path strictly beneath the volume root + /// without following any symbolic link, and then applies the operation to the final + /// component without following it. A path that crosses a symbolic link fails with `ELOOP`. + /// Opening or truncating a symbolic link fails with `ELOOP`, and changing its mode fails with + /// `EOPNOTSUPP`. Extended attribute and file system statistics calls use the pinned parent + /// directory instead of an absolute host path. + /// + /// # Windows + /// + /// New symbolic links are always created as WSL-style links. Windows path resolution never + /// follows them, so a link created through the volume can never redirect a later host path + /// lookup. Existing NT symbolic links and junctions are not affected. + pub fn confine_paths(&mut self, enabled: bool) -> &mut Self { + self.confine_paths = enabled; + self + } } impl Default for LxVolumeOptions { @@ -2017,10 +2050,16 @@ mod tests { #[test] fn xattr() { + fn names(bytes: &[u8]) -> Vec<&[u8]> { + let mut names: Vec<_> = bytes.split_inclusive(|&byte| byte == 0).collect(); + names.sort_unstable(); + names + } + let env = TestEnv::new(); env.create_file("testfile", "test"); - // No attributes to start with. + // A new file may already carry host-managed attributes such as security.selinux. let err = env .volume .get_xattr("testfile", "user.test", None) @@ -2029,7 +2068,13 @@ mod tests { assert_eq!(err.value(), lx::ENODATA); let size = env.volume.list_xattr("testfile", None).unwrap(); - assert_eq!(size, 0); + let mut inherited = vec![0u8; size]; + assert_eq!( + env.volume + .list_xattr("testfile", Some(&mut inherited)) + .unwrap(), + size + ); let err = env .volume @@ -2046,7 +2091,7 @@ mod tests { let size = env.volume.get_xattr("testfile", "user.test", None).unwrap(); assert_eq!(size, 3); - let mut buffer = [0u8; 1024]; + let mut buffer = vec![0u8; inherited.len() + 1024]; let size = env .volume .get_xattr("testfile", "user.test", Some(&mut buffer)) @@ -2068,15 +2113,17 @@ mod tests { assert_eq!(size, 0); // List the attributes. + let mut expected = inherited.clone(); + expected.extend_from_slice(b"user.test\0user.empty\0"); let size = env.volume.list_xattr("testfile", None).unwrap(); - assert_eq!(size, 21); + assert_eq!(size, expected.len()); let size = env .volume .list_xattr("testfile", Some(&mut buffer)) .unwrap(); - assert_eq!(size, 21); - assert_eq!(&buffer[..21], b"user.test\0user.empty\0"); + assert_eq!(size, expected.len()); + assert_eq!(names(&buffer[..size]), names(&expected)); // Remove an attribute. env.volume.remove_xattr("testfile", "user.empty").unwrap(); @@ -2092,8 +2139,10 @@ mod tests { .list_xattr("testfile", Some(&mut buffer)) .unwrap(); - assert_eq!(size, 10); - assert_eq!(&buffer[..10], b"user.test\0"); + let mut expected = inherited; + expected.extend_from_slice(b"user.test\0"); + assert_eq!(size, expected.len()); + assert_eq!(names(&buffer[..size]), names(&expected)); // Test flags. let err = env @@ -2214,6 +2263,49 @@ mod tests { assert_eq!(err.value(), lx::EEXIST); } + #[test] + #[cfg(windows)] + fn confined_volume_creates_inert_lx_symlinks() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let env = TestEnv::with_options(LxVolumeOptions::new().confine_paths(true)); + env.create_file("dir/file", "data"); + + // Without confinement, targets inside the volume would become NT symlinks whenever the + // process may create them. + for (link, target) in [ + ("file_link", "dir/file"), + ("dir_link", "dir"), + ("outside_link", "../outside"), + ] { + let stat = env + .volume + .symlink_stat(link, target, LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(env.volume.read_link(link).unwrap(), target); + + let host = env.root_dir.path().join(link); + let metadata = fs::symlink_metadata(&host).unwrap(); + assert_ne!(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT, 0); + // A WSL-style link is neither an NT symlink nor something Windows follows. + assert!(fs::read_link(&host).is_err()); + assert!(fs::metadata(&host).is_err()); + } + + assert!( + env.volume + .lstat(Path::from_lx("dir_link/file").unwrap()) + .is_err() + ); + + // Renaming a link keeps its target. + env.volume.rename("file_link", "renamed_link", 0).unwrap(); + assert_eq!(env.volume.read_link("renamed_link").unwrap(), "dir/file"); + } + // This test is disabled in CI, because it requires NTFS support for setting the case sensitive // directory attribute, which is only enabled if the WSL optional component is installed. #[test] diff --git a/vm/devices/support/fs/lxutil/src/unix/confine.rs b/vm/devices/support/fs/lxutil/src/unix/confine.rs new file mode 100644 index 00000000000..9bdade77945 --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/confine.rs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resolution of volume-relative paths that never follows a symbolic link. +//! +//! A confined volume opens the parent directory of every path strictly beneath its root, refusing +//! symbolic links in every component, and then applies the requested operation to the final +//! component through that pinned directory. A concurrent rename or symbolic link therefore cannot +//! redirect an operation outside the volume. + +use super::util; +use std::ffi::CStr; +use std::ffi::CString; +use std::ffi::OsStr; +use std::fs::File; +use std::mem::MaybeUninit; +use std::os::unix::prelude::*; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +/// Flags for the `O_PATH` handle of an intermediate directory. +const DIRECTORY_FLAGS: i32 = libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC; + +/// The version 0 layout of `struct open_how` from `linux/openat2.h`. +#[repr(C)] +struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, +} + +/// How the parent directory of a confined path is opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Resolver { + /// One `openat2` call that rejects symbolic links and paths that leave the root. + Openat2, + /// One `openat(O_NOFOLLOW)` call per component, for kernels or sandboxes without `openat2`. + Walk, +} + +impl Resolver { + /// Selects `openat2` when the kernel and any seccomp policy allow it. + pub(crate) fn probe(root: &File) -> Self { + match openat2_directory(root.as_raw_fd(), c".") { + Ok(_) => Self::Openat2, + Err(error) => { + tracing::debug!( + error = error.value(), + "openat2 is unavailable; resolving confined paths one component at a time" + ); + Self::Walk + } + } + } +} + +/// The directory that holds the final component of a confined path. +pub(crate) enum ParentDirectory<'a> { + /// The volume root, for single-component paths and the root itself. + Root(&'a File), + /// An `O_PATH` handle opened without following any symbolic link. + Opened(OwnedFd), +} + +impl AsRawFd for ParentDirectory<'_> { + fn as_raw_fd(&self) -> RawFd { + match self { + Self::Root(root) => root.as_raw_fd(), + Self::Opened(directory) => directory.as_raw_fd(), + } + } +} + +/// A path split into a pinned directory and the name of its final component. +pub(crate) struct AtPath<'a> { + /// The directory that holds `name`. + pub(crate) directory: ParentDirectory<'a>, + /// The final component, or an empty string for the volume root itself. + pub(crate) name: CString, +} + +impl AtPath<'_> { + /// Returns whether this refers to the volume root rather than an entry in a directory. + pub(crate) fn is_root(&self) -> bool { + self.name.is_empty() + } + + /// Returns a `/proc` path that reaches the final component through the pinned directory. + /// + /// Path-based system calls that do not follow a final symbolic link (such as the `l*xattr` + /// family) can use it without resolving any ancestor from the host root again. The returned + /// path is only valid while `self` keeps the directory open. + pub(crate) fn proc_path(&self) -> lx::Result { + let mut path = format!("/proc/self/fd/{}/", self.directory.as_raw_fd()).into_bytes(); + if self.is_root() { + path.push(b'.'); + } else { + path.extend_from_slice(self.name.as_bytes()); + } + util::create_cstr(path) + } +} + +/// Splits a volume-relative `path` and opens its parent directory beneath `root` without following +/// any symbolic link. +/// +/// A path that crosses a symbolic link fails with `ELOOP`. The final component is not inspected. +pub(crate) fn resolve<'a>( + root: &'a File, + resolver: Resolver, + path: &Path, +) -> lx::Result> { + let mut components = Vec::new(); + for component in path.components() { + let Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + components.push(component); + } + + let Some(name) = components.pop() else { + return Ok(AtPath { + directory: ParentDirectory::Root(root), + name: CString::default(), + }); + }; + + let name = util::create_cstr(name.as_bytes())?; + let Some((first, rest)) = components.split_first() else { + return Ok(AtPath { + directory: ParentDirectory::Root(root), + name, + }); + }; + + let directory = match resolver { + Resolver::Openat2 => { + let parent: PathBuf = components.iter().collect(); + openat2_directory(root.as_raw_fd(), &util::path_to_cstr(&parent)?)? + } + Resolver::Walk => { + let mut directory = open_component(root.as_raw_fd(), first)?; + for component in rest { + directory = open_component(directory.as_raw_fd(), component)?; + } + directory + } + }; + + Ok(AtPath { + directory: ParentDirectory::Opened(directory), + name, + }) +} + +/// Opens `path` beneath `directory` as an `O_PATH` directory handle, rejecting every symbolic +/// link and any escape from `directory`. +fn openat2_directory(directory: RawFd, path: &CStr) -> lx::Result { + let how = OpenHow { + flags: DIRECTORY_FLAGS as u64, + mode: 0, + resolve: libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS, + }; + + // SAFETY: `path` is NUL-terminated and `how` uses the kernel's version 0 layout, whose size + // is passed explicitly. + let result = unsafe { + libc::syscall( + libc::SYS_openat2, + directory, + path.as_ptr(), + std::ptr::from_ref(&how), + size_of::(), + ) + }; + + let fd = util::check_lx_errno(result)?; + let fd = RawFd::try_from(fd).map_err(|_| lx::Error::EIO)?; + // SAFETY: The kernel returned a new descriptor that nothing else owns. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +/// Opens one directory component without following a symbolic link. +fn open_component(directory: RawFd, name: &OsStr) -> lx::Result { + let name = util::create_cstr(name.as_bytes())?; + + // SAFETY: Calling C API as documented, with no special requirements. + let result = + unsafe { libc::openat(directory, name.as_ptr(), DIRECTORY_FLAGS | libc::O_NOFOLLOW) }; + + match util::check_lx_errno(result) { + // SAFETY: The kernel returned a new descriptor that nothing else owns. + Ok(fd) => Ok(unsafe { OwnedFd::from_raw_fd(fd) }), + // O_NOFOLLOW reports a symbolic link as "not a directory"; report it like openat2 does. + Err(error) if error.value() == lx::ENOTDIR && is_symlink(directory, &name) => { + Err(lx::Error::ELOOP) + } + Err(error) => Err(error), + } +} + +/// Returns whether `name` in `directory` is a symbolic link. +fn is_symlink(directory: RawFd, name: &CStr) -> bool { + let mut stat = MaybeUninit::::uninit(); + + // SAFETY: `stat` is valid for writes and `name` is NUL-terminated. + let result = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + + // SAFETY: fstatat initialized `stat` because it succeeded. + result == 0 && unsafe { stat.assume_init() }.st_mode & libc::S_IFMT == libc::S_IFLNK +} diff --git a/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs b/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs new file mode 100644 index 00000000000..62f2500033e --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for volumes that resolve paths without following symbolic links. + +use super::LxVolume; +use super::confine::Resolver; +use crate::LxCreateOptions; +use crate::LxVolumeOptions; +use crate::SetAttributes; +use crate::SetTime; +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; + +/// A volume root and a sibling directory that must stay unreachable from it. +struct Fixture { + _directory: TempDir, + root: PathBuf, + outside: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + let outside = directory.path().join("outside"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&outside).unwrap(); + let secret = outside.join("secret"); + fs::write(&secret, b"secret").unwrap(); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o644)).unwrap(); + Self { + _directory: directory, + root, + outside, + } + } + + /// Returns a confined volume for every resolver that this kernel supports. + fn volumes(&self) -> Vec { + let mut options = LxVolumeOptions::new(); + options.confine_paths(true); + let mut volumes = Vec::new(); + let probed = LxVolume::new(&self.root, &options).unwrap(); + if probed.resolver == Some(Resolver::Openat2) { + volumes.push(probed); + } + let mut walk = LxVolume::new(&self.root, &options).unwrap(); + walk.resolver = Some(Resolver::Walk); + volumes.push(walk); + volumes + } + + fn assert_outside_unchanged(&self) { + let names = fs::read_dir(&self.outside) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, [OsString::from("secret")]); + let secret = self.outside.join("secret"); + assert_eq!(fs::read(&secret).unwrap(), b"secret"); + assert_eq!( + fs::metadata(&secret).unwrap().permissions().mode() & 0o7777, + 0o644 + ); + } +} + +fn errno(result: lx::Result) -> i32 { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error.value(), + } +} + +fn attributes(update: impl FnOnce(&mut SetAttributes)) -> SetAttributes { + let mut attr = SetAttributes::default(); + update(&mut attr); + attr +} + +fn name(value: &[u8]) -> &lx::LxStr { + lx::LxStr::from_bytes(value) +} + +#[test] +fn confined_paths_do_not_traverse_symlinks() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("dir")).unwrap(); + symlink(&fixture.outside, fixture.root.join("absolute")).unwrap(); + symlink("../outside", fixture.root.join("relative")).unwrap(); + symlink("../../outside", fixture.root.join("dir").join("nested")).unwrap(); + + for volume in fixture.volumes() { + for link in ["absolute", "relative", "dir/nested"] { + let link = Path::new(link); + let secret = link.join("secret"); + let created = link.join("created"); + let file = LxCreateOptions::new(0o644, 0, 0); + let fifo = LxCreateOptions::new(lx::S_IFIFO | 0o644, 0, 0); + let directory = LxCreateOptions::new(0o755, 0, 0); + + assert_eq!(errno(volume.lstat(&secret)), lx::ELOOP); + assert_eq!(errno(volume.open(&secret, lx::O_RDONLY, None)), lx::ELOOP); + assert_eq!( + errno(volume.open(&created, lx::O_WRONLY | lx::O_CREAT, Some(file))), + lx::ELOOP + ); + assert_eq!(errno(volume.mkdir(&created, directory)), lx::ELOOP); + let target = name(b"target"); + let link_options = LxCreateOptions::new(0, 0, 0); + assert_eq!( + errno(volume.symlink(&created, target, link_options)), + lx::ELOOP + ); + assert_eq!(errno(volume.mknod(&created, fifo, 0)), lx::ELOOP); + assert_eq!(errno(volume.read_link(&secret)), lx::ELOOP); + assert_eq!(errno(volume.unlink(&secret, 0)), lx::ELOOP); + assert_eq!( + errno(volume.rename(&secret, Path::new("moved"), 0)), + lx::ELOOP + ); + assert_eq!(errno(volume.link(&secret, Path::new("hard"))), lx::ELOOP); + let truncate = attributes(|attr| attr.size = Some(0)); + assert_eq!(errno(volume.set_attr(&secret, truncate)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!(errno(volume.set_attr(&secret, chmod)), lx::ELOOP); + assert_eq!(errno(volume.stat_fs(&secret)), lx::ELOOP); + let xattr = name(b"user.nvx"); + assert_eq!( + errno(volume.set_xattr(&secret, xattr, b"value", 0)), + lx::ELOOP + ); + assert_eq!(errno(volume.get_xattr(&secret, xattr, None)), lx::ELOOP); + assert_eq!(errno(volume.list_xattr(&secret, None)), lx::ELOOP); + assert_eq!(errno(volume.remove_xattr(&secret, xattr)), lx::ELOOP); + } + assert!(!fixture.root.join("moved").exists()); + assert!(!fixture.root.join("hard").exists()); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn confined_final_symlink_is_not_followed() { + let fixture = Fixture::new(); + let target = fixture.outside.join("secret"); + symlink(&target, fixture.root.join("final")).unwrap(); + symlink( + fixture.outside.join("missing"), + fixture.root.join("dangling"), + ) + .unwrap(); + let modified = fs::metadata(&target).unwrap().modified().unwrap(); + + for volume in fixture.volumes() { + let link = Path::new("final"); + assert_eq!( + u32::from(volume.lstat(link).unwrap().mode) & lx::S_IFMT, + lx::S_IFLNK + ); + assert_eq!( + volume.read_link(link).unwrap().as_bytes(), + target.as_os_str().as_bytes() + ); + assert_eq!(errno(volume.open(link, lx::O_RDONLY, None)), lx::ELOOP); + assert_eq!( + errno(volume.open(link, lx::O_WRONLY | lx::O_TRUNC, None)), + lx::ELOOP + ); + let file = LxCreateOptions::new(0o644, 0, 0); + assert_eq!( + errno(volume.open( + Path::new("dangling"), + lx::O_WRONLY | lx::O_CREAT, + Some(file) + )), + lx::ELOOP + ); + let truncate = attributes(|attr| attr.size = Some(0)); + assert_eq!(errno(volume.set_attr(link, truncate)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!(errno(volume.set_attr(link, chmod)), lx::ENOTSUP); + let touch = attributes(|attr| attr.mtime = SetTime::Now); + volume.set_attr(link, touch).unwrap(); + volume.stat_fs(link).unwrap(); + // Linux rejects user attributes on a link; either way, the target is not modified. + let _ = volume.set_xattr(link, name(b"user.nvx"), b"value", 0); + } + + assert!(!fixture.outside.join("missing").exists()); + assert_eq!(fs::metadata(&target).unwrap().modified().unwrap(), modified); + let outside = LxVolume::new(&fixture.outside, &LxVolumeOptions::new()).unwrap(); + assert!( + outside + .get_xattr(Path::new("secret"), name(b"user.nvx"), None) + .is_err() + ); + + let volume = fixture.volumes().remove(0); + volume.unlink(Path::new("final"), 0).unwrap(); + assert!(fs::symlink_metadata(fixture.root.join("final")).is_err()); + fixture.assert_outside_unchanged(); +} + +#[test] +fn confined_symlink_targets_are_verbatim() { + let fixture = Fixture::new(); + let long = vec![b'x'; 1024]; + let targets: [&[u8]; 6] = [ + b"/absolute/host/path", + b"../../escape", + b"dangling/relative", + b"with space", + b"\xff\xfe-not-utf8", + &long, + ]; + + for (volume_index, volume) in fixture.volumes().into_iter().enumerate() { + for (target_index, target) in targets.iter().enumerate() { + let link = PathBuf::from(format!("link-{volume_index}-{target_index}")); + let stat = volume + .symlink_stat(&link, name(target), LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(stat.file_size, target.len() as u64); + assert_eq!(volume.read_link(&link).unwrap().as_bytes(), *target); + let host = fs::read_link(fixture.root.join(&link)).unwrap(); + assert_eq!(host.as_os_str().as_bytes(), *target); + } + } +} + +#[test] +fn confined_volume_supports_common_operations() { + let fixture = Fixture::new(); + for (index, volume) in fixture.volumes().into_iter().enumerate() { + let base = PathBuf::from(format!("base-{index}")); + let nested = base.join("nested"); + let file = nested.join("file"); + volume + .mkdir(&base, LxCreateOptions::new(0o755, 0, 0)) + .unwrap(); + let stat = volume + .mkdir_stat(&nested, LxCreateOptions::new(0o755, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFDIR); + + let options = LxCreateOptions::new(0o644, 0, 0); + let handle = volume + .open(&file, lx::O_RDWR | lx::O_CREAT | lx::O_EXCL, Some(options)) + .unwrap(); + assert_eq!(handle.pwrite(b"data", 0, 0).unwrap(), 4); + drop(handle); + + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!( + volume.set_attr_stat(&file, chmod).unwrap().mode & 0o7777, + 0o600 + ); + let truncate = attributes(|attr| attr.size = Some(2)); + assert_eq!(volume.set_attr_stat(&file, truncate).unwrap().file_size, 2); + + let link = nested.join("link"); + volume + .symlink(&link, name(b"file"), LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(volume.read_link(&link).unwrap().as_bytes(), b"file"); + + let moved = base.join("moved"); + volume.rename(&file, &moved, 0).unwrap(); + let hard = nested.join("hard"); + assert_eq!(volume.link_stat(&moved, &hard).unwrap().link_count, 2); + let handle = volume.open(&hard, lx::O_RDONLY, None).unwrap(); + let mut buffer = [0; 8]; + let size = handle.pread(&mut buffer, 0).unwrap(); + assert_eq!(&buffer[..size], b"da"); + drop(handle); + + volume.stat_fs(&moved).unwrap(); + let xattr = name(b"user.nvx"); + match volume.set_xattr(&moved, xattr, b"value", 0) { + Ok(()) => { + let mut value = [0; 16]; + let size = volume.get_xattr(&moved, xattr, Some(&mut value)).unwrap(); + assert_eq!(&value[..size], b"value"); + assert!(volume.list_xattr(&moved, None).unwrap() > 0); + volume.remove_xattr(&moved, xattr).unwrap(); + } + // Some file systems do not support user extended attributes. + Err(error) => assert_eq!(error.value(), lx::ENOTSUP), + } + + volume.unlink(&hard, 0).unwrap(); + volume.unlink(&link, 0).unwrap(); + volume.unlink(&nested, lx::AT_REMOVEDIR).unwrap(); + volume.unlink(&moved, 0).unwrap(); + volume.unlink(&base, lx::AT_REMOVEDIR).unwrap(); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 0); + } +} + +#[test] +fn confined_root_uses_its_own_descriptor() { + let fixture = Fixture::new(); + let root = Path::new(""); + for volume in fixture.volumes() { + assert_eq!( + u32::from(volume.lstat(root).unwrap().mode) & lx::S_IFMT, + lx::S_IFDIR + ); + let handle = volume + .open(root, lx::O_RDONLY | lx::O_DIRECTORY, None) + .unwrap(); + assert_eq!( + u32::from(handle.fstat().unwrap().mode) & lx::S_IFMT, + lx::S_IFDIR + ); + let touch = attributes(|attr| attr.mtime = SetTime::Now); + volume.set_attr(root, touch).unwrap(); + volume.stat_fs(root).unwrap(); + if let Err(error) = volume.list_xattr(root, None) { + assert_eq!(error.value(), lx::ENOTSUP); + } + } +} + +#[test] +fn swapped_directory_is_not_followed() { + // A client can hold a node for `dir/secret` and, while an operation on it is in flight, + // replace `dir` with a symbolic link to a host directory. + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("dir")).unwrap(); + fs::write(fixture.root.join("dir").join("secret"), b"inside").unwrap(); + + for volume in fixture.volumes() { + let secret = Path::new("dir/secret"); + volume.lstat(secret).unwrap(); + fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + symlink(&fixture.outside, fixture.root.join("dir")).unwrap(); + + assert_eq!(errno(volume.lstat(secret)), lx::ELOOP); + assert_eq!(errno(volume.open(secret, lx::O_RDWR, None)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o666)); + assert_eq!(errno(volume.set_attr(secret, chmod)), lx::ELOOP); + assert_eq!(errno(volume.unlink(secret, 0)), lx::ELOOP); + + fs::remove_file(fixture.root.join("dir")).unwrap(); + fs::rename(fixture.root.join("saved"), fixture.root.join("dir")).unwrap(); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn unconfined_volume_still_follows_intermediate_symlinks() { + let fixture = Fixture::new(); + symlink("../outside", fixture.root.join("relative")).unwrap(); + let volume = LxVolume::new(&fixture.root, &LxVolumeOptions::new()).unwrap(); + assert!(volume.resolver.is_none()); + assert_eq!( + volume + .lstat(Path::new("relative/secret")) + .unwrap() + .file_size, + 6 + ); +} diff --git a/vm/devices/support/fs/lxutil/src/unix/credentials.rs b/vm/devices/support/fs/lxutil/src/unix/credentials.rs new file mode 100644 index 00000000000..3cd9cee0d94 --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/credentials.rs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Per-thread filesystem credentials. +//! +//! Linux keeps credentials per thread. The raw `setfsuid`, `setfsgid`, `setgroups`, and `capset` +//! system calls change only the calling thread, whereas the C library wrappers for `setgroups` and +//! the `set*id` family change every thread in the process. This module therefore issues the raw +//! system calls, so that one worker thread can perform an operation as another identity without +//! affecting the rest of the process. + +use super::util; + +/// The ID that no user or group has. `setfsuid` and `setfsgid` report the current ID without +/// changing it when passed this value. +const INVALID_ID: u32 = u32::MAX; + +/// `_LINUX_CAPABILITY_VERSION_3` from `linux/capability.h`. +const CAPABILITY_VERSION_3: u32 = 0x2008_0522; + +/// `struct __user_cap_header_struct` from `linux/capability.h`. +#[repr(C)] +struct CapabilityHeader { + version: u32, + pid: libc::c_int, +} + +/// `struct __user_cap_data_struct` from `linux/capability.h`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct CapabilityData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +/// The 64 capability bits of version 3, in two 32-bit words. +type Capabilities = [CapabilityData; 2]; + +/// A host filesystem identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FsIdentity { + /// The filesystem user ID. + pub uid: lx::uid_t, + /// The filesystem group ID. + pub gid: lx::gid_t, +} + +impl FsIdentity { + /// Returns the filesystem identity of the calling thread. + pub fn current() -> Self { + Self { + uid: set_fsuid(INVALID_ID), + gid: set_fsgid(INVALID_ID), + } + } +} + +/// Runs `operation` with the calling thread's filesystem identity set to `identity`, and then +/// restores the thread's credentials. +/// +/// The operation runs without effective capabilities and without supplementary groups other +/// than the GID of `identity`, so that it gains no access through the privileges or groups of +/// the thread's original identity. Assuming a different identity, or dropping supplementary +/// groups, requires `CAP_SETUID` and `CAP_SETGID`. A thread that already has `identity` and no +/// other supplementary groups needs no privilege. +/// +/// # Errors +/// +/// Returns `EPERM` without running `operation` if the thread cannot assume `identity` or drop +/// its other supplementary groups. The operation never falls back to the thread's original +/// identity. +/// +/// # Panics +/// +/// Panics if the thread's original credentials cannot be restored, because the thread would +/// otherwise run unrelated work under the wrong identity. +pub fn with_fs_identity(identity: FsIdentity, operation: impl FnOnce() -> T) -> lx::Result { + let credentials = SwitchedCredentials::switch(identity).map_err(|_| lx::Error::EPERM)?; + let result = operation(); + drop(credentials); + Ok(result) +} + +/// The original credentials of a thread whose filesystem identity was switched. +/// +/// Dropping the value restores them on the same thread. It is created and dropped within +/// [`with_fs_identity`], so it never moves to another thread. +struct SwitchedCredentials { + original: FsIdentity, + capabilities: Capabilities, + /// The supplementary groups, if they were cleared. + groups: Option>, + /// Whether the filesystem UID and GID may have changed. + switched_identity: bool, + /// Whether the effective capabilities were cleared. + cleared_capabilities: bool, +} + +impl SwitchedCredentials { + fn switch(identity: FsIdentity) -> lx::Result { + if identity.uid == INVALID_ID || identity.gid == INVALID_ID { + return Err(lx::Error::EPERM); + } + let mut credentials = Self { + original: FsIdentity::current(), + capabilities: capabilities()?, + groups: None, + switched_identity: false, + cleared_capabilities: false, + }; + // On an error below, dropping `credentials` restores whatever was already changed. + let groups = groups()?; + if groups.iter().any(|&group| group != identity.gid) { + set_groups(&[])?; + credentials.groups = Some(groups); + } + if identity != credentials.original { + credentials.switched_identity = true; + set_fsgid(identity.gid); + set_fsuid(identity.uid); + // Neither call reports failure, so read back the identity actually in effect. + if FsIdentity::current() != identity { + return Err(lx::Error::EPERM); + } + } + if credentials + .capabilities + .iter() + .any(|data| data.effective != 0) + { + let cleared = credentials.capabilities.map(|data| CapabilityData { + effective: 0, + ..data + }); + set_capabilities(&cleared)?; + credentials.cleared_capabilities = true; + } + Ok(credentials) + } +} + +impl Drop for SwitchedCredentials { + fn drop(&mut self) { + // Restore the capabilities first, because resetting the groups and the identity may need + // CAP_SETGID and CAP_SETUID. + if self.cleared_capabilities || self.switched_identity || self.groups.is_some() { + set_capabilities(&self.capabilities) + .expect("failed to restore the thread's effective capabilities"); + } + if self.switched_identity { + set_fsgid(self.original.gid); + set_fsuid(self.original.uid); + assert_eq!( + FsIdentity::current(), + self.original, + "failed to restore the thread's filesystem identity" + ); + } + if let Some(groups) = &self.groups { + set_groups(groups).expect("failed to restore the thread's supplementary groups"); + } + if self.switched_identity { + // Changing the filesystem UID to or from 0 adjusts the effective capabilities. + set_capabilities(&self.capabilities) + .expect("failed to restore the thread's effective capabilities"); + } + } +} + +/// Sets the calling thread's filesystem UID and returns the previous one. +fn set_fsuid(uid: lx::uid_t) -> lx::uid_t { + // SAFETY: setfsuid takes an integer and changes only the calling thread's credentials. + let previous = unsafe { libc::syscall(libc::SYS_setfsuid, libc::c_long::from(uid)) }; + previous as lx::uid_t +} + +/// Sets the calling thread's filesystem GID and returns the previous one. +fn set_fsgid(gid: lx::gid_t) -> lx::gid_t { + // SAFETY: setfsgid takes an integer and changes only the calling thread's credentials. + let previous = unsafe { libc::syscall(libc::SYS_setfsgid, libc::c_long::from(gid)) }; + previous as lx::gid_t +} + +/// Returns the calling thread's supplementary groups. +fn groups() -> lx::Result> { + // SAFETY: A zero size only queries the number of groups. + let count = util::check_lx_errno(unsafe { libc::getgroups(0, std::ptr::null_mut()) })?; + let mut groups = vec![0; count as usize]; + // SAFETY: `groups` has room for `count` IDs. + let count = util::check_lx_errno(unsafe { libc::getgroups(count, groups.as_mut_ptr()) })?; + groups.truncate(count as usize); + Ok(groups) +} + +/// Sets the calling thread's supplementary groups. +fn set_groups(groups: &[lx::gid_t]) -> lx::Result<()> { + let count = libc::c_long::try_from(groups.len()).map_err(|_| lx::Error::EINVAL)?; + // SAFETY: `groups` holds `count` IDs. The raw system call changes only the calling thread. + util::check_lx_errno(unsafe { libc::syscall(libc::SYS_setgroups, count, groups.as_ptr()) })?; + Ok(()) +} + +/// Returns the calling thread's capabilities. +fn capabilities() -> lx::Result { + let mut header = CapabilityHeader { + version: CAPABILITY_VERSION_3, + pid: 0, + }; + let mut data = Capabilities::default(); + // SAFETY: The header and both data structures are valid for writes. + util::check_lx_errno(unsafe { + libc::syscall(libc::SYS_capget, &raw mut header, data.as_mut_ptr()) + })?; + Ok(data) +} + +/// Sets the calling thread's capabilities. +fn set_capabilities(data: &Capabilities) -> lx::Result<()> { + let mut header = CapabilityHeader { + version: CAPABILITY_VERSION_3, + pid: 0, + }; + // SAFETY: The header and both data structures are valid. A zero PID selects the calling + // thread, the only one whose capabilities capset changes. + util::check_lx_errno(unsafe { + libc::syscall(libc::SYS_capset, &raw mut header, data.as_ptr()) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + + const CAP_SETGID: u32 = 6; + const CAP_SETUID: u32 = 7; + + fn has_capability(capability: u32) -> bool { + let data = capabilities().unwrap(); + data[(capability / 32) as usize].effective & (1 << (capability % 32)) != 0 + } + + /// Returns an identity that differs from every ID of the calling thread. + fn foreign_identity() -> FsIdentity { + // SAFETY: These calls have no preconditions. + let ids = unsafe { + [ + libc::getuid(), + libc::geteuid(), + libc::getgid(), + libc::getegid(), + ] + }; + let current = FsIdentity::current(); + let unused = |mut candidate: u32| { + while ids.contains(&candidate) || candidate == current.uid || candidate == current.gid { + candidate += 1; + } + candidate + }; + FsIdentity { + uid: unused(4242), + gid: unused(4343), + } + } + + fn assert_restored(original: FsIdentity, groups_before: &[u32], before: &Capabilities) { + assert_eq!(FsIdentity::current(), original); + assert_eq!(groups().unwrap(), groups_before); + assert_eq!(&capabilities().unwrap(), before); + } + + /// Returns whether the calling thread has supplementary groups other than `gid`. + fn has_other_groups(gid: u32) -> bool { + groups().unwrap().iter().any(|&group| group != gid) + } + + /// Runs `test` on a new thread that has no effective capabilities, after giving that thread + /// exactly the supplementary `groups` if it may. + fn without_capabilities(groups: Vec, test: impl FnOnce() + Send + 'static) { + std::thread::spawn(move || { + if has_capability(CAP_SETGID) { + set_groups(&groups).unwrap(); + } + let mut data = capabilities().unwrap(); + for word in &mut data { + word.effective = 0; + } + set_capabilities(&data).unwrap(); + test(); + }) + .join() + .unwrap(); + } + + #[test] + fn current_identity_is_the_effective_identity() { + // SAFETY: These calls have no preconditions. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + assert_eq!(FsIdentity::current(), FsIdentity { uid, gid }); + } + + #[test] + fn same_identity_without_other_groups_needs_no_privilege() { + without_capabilities(Vec::new(), || { + let original = FsIdentity::current(); + if has_other_groups(original.gid) { + // An unprivileged thread cannot drop the groups that it was given. + return; + } + let directory = tempfile::tempdir().unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let path = directory.path().join("file"); + let (identity, groups_inside, inside) = with_fs_identity(original, || { + std::fs::write(&path, b"data").unwrap(); + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + ) + }) + .unwrap(); + + assert_eq!(identity, original); + assert_eq!(groups_inside, groups_before); + assert!(inside.iter().all(|data| data.effective == 0)); + let metadata = std::fs::metadata(&path).unwrap(); + assert_eq!(metadata.uid(), original.uid); + assert_restored(original, &groups_before, &before); + }); + } + + #[test] + fn other_groups_fail_closed_without_privilege() { + // A group that the thread is given only if the test process is privileged. + const OTHER_GROUP: u32 = 4444; + // SAFETY: getegid has no preconditions. + let gid = unsafe { libc::getegid() }; + without_capabilities(vec![gid, OTHER_GROUP], || { + let original = FsIdentity::current(); + if !has_other_groups(original.gid) { + // An unprivileged thread without other groups cannot be given one. + return; + } + let directory = tempfile::tempdir().unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let ran = AtomicBool::new(false); + let path = directory.path().join("file"); + let result = with_fs_identity(original, || { + ran.store(true, Ordering::Relaxed); + std::fs::write(&path, b"data") + }); + + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert!(!path.exists()); + assert_restored(original, &groups_before, &before); + }); + } + + #[test] + fn foreign_identity_fails_closed_without_privilege() { + // Capabilities are per thread, so drop them on a dedicated thread. The test then behaves + // the same whether or not the test process is privileged. + std::thread::spawn(|| { + let mut data = capabilities().unwrap(); + for word in &mut data { + word.effective = 0; + } + set_capabilities(&data).unwrap(); + + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)) + .unwrap(); + let original = FsIdentity::current(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let ran = AtomicBool::new(false); + let path = directory.path().join("file"); + let result = with_fs_identity(foreign_identity(), || { + ran.store(true, Ordering::Relaxed); + std::fs::write(&path, b"data") + }); + + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert!(!path.exists()); + assert_restored(original, &groups_before, &before); + }) + .join() + .unwrap(); + } + + #[test] + fn invalid_identity_fails_closed() { + let original = FsIdentity::current(); + for identity in [ + FsIdentity { + uid: INVALID_ID, + gid: original.gid, + }, + FsIdentity { + uid: original.uid, + gid: INVALID_ID, + }, + ] { + let ran = AtomicBool::new(false); + let result = with_fs_identity(identity, || ran.store(true, Ordering::Relaxed)); + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert_eq!(FsIdentity::current(), original); + } + } + + /// Run with `CAP_SETUID` and `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETUID and CAP_SETGID"] + fn foreign_identity_switches_one_thread_with_privilege() { + assert!( + has_capability(CAP_SETUID) && has_capability(CAP_SETGID), + "this test requires CAP_SETUID and CAP_SETGID" + ); + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let original = FsIdentity::current(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let identity = foreign_identity(); + + // Another thread keeps its identity while this one is switched. + let (request, requests) = std::sync::mpsc::channel::<()>(); + let (reply, replies) = std::sync::mpsc::channel(); + let observer = std::thread::spawn(move || { + for () in requests { + reply.send(FsIdentity::current()).unwrap(); + } + }); + + let (inside, groups_inside, capabilities_inside, other_thread) = + with_fs_identity(identity, || { + std::fs::write(directory.path().join("file"), b"data").unwrap(); + std::fs::create_dir(directory.path().join("directory")).unwrap(); + std::fs::write(directory.path().join("directory/nested"), b"data").unwrap(); + request.send(()).unwrap(); + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + replies.recv().unwrap(), + ) + }) + .unwrap(); + drop(request); + observer.join().unwrap(); + + assert_eq!(inside, identity); + assert!(groups_inside.is_empty()); + assert!(capabilities_inside.iter().all(|data| data.effective == 0)); + assert_eq!(other_thread, original); + for name in ["file", "directory", "directory/nested"] { + let metadata = std::fs::symlink_metadata(directory.path().join(name)).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (identity.uid, identity.gid), + "{name}" + ); + } + assert_restored(original, &groups_before, &before); + + // The switched identity is subject to ordinary permission checks. + let private = tempfile::tempdir().unwrap(); + std::fs::set_permissions(private.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let denied = with_fs_identity(identity, || { + std::fs::write(private.path().join("file"), b"data") + }) + .unwrap(); + assert_eq!( + denied.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_restored(original, &groups_before, &before); + + // Only the switched identity can empty the directory it created. + with_fs_identity(identity, || { + std::fs::remove_dir_all(directory.path().join("directory")) + }) + .unwrap() + .unwrap(); + } + + /// Run with `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETGID"] + fn same_identity_drops_other_groups_with_privilege() { + assert!(has_capability(CAP_SETGID), "this test requires CAP_SETGID"); + let other_group = foreign_identity().gid; + // Only this thread's groups change. + std::thread::spawn(move || { + let original = FsIdentity::current(); + set_groups(&[original.gid, other_group]).unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("file"); + std::fs::write(&path, b"data").unwrap(); + + let (identity, groups_inside, inside, chgrp) = with_fs_identity(original, || { + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + std::os::unix::fs::chown(&path, None, Some(other_group)), + ) + }) + .unwrap(); + + assert_eq!(identity, original); + assert!(groups_inside.iter().all(|&group| group == original.gid)); + assert!(inside.iter().all(|data| data.effective == 0)); + // The operation cannot give a file a group that only the thread has. + assert_eq!( + chgrp.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_restored(original, &groups_before, &before); + std::os::unix::fs::chown(&path, None, Some(other_group)).unwrap(); + }) + .join() + .unwrap(); + } +} diff --git a/vm/devices/support/fs/lxutil/src/unix/mod.rs b/vm/devices/support/fs/lxutil/src/unix/mod.rs index d94f66a7bed..a58e8690e4c 100644 --- a/vm/devices/support/fs/lxutil/src/unix/mod.rs +++ b/vm/devices/support/fs/lxutil/src/unix/mod.rs @@ -5,10 +5,16 @@ #![expect(unsafe_code)] #![expect(clippy::undocumented_unsafe_blocks)] +mod confine; +#[cfg(test)] +mod confine_tests; +pub(crate) mod credentials; pub(crate) mod path; mod util; use crate::SetAttributes; +use confine::AtPath; +use confine::ParentDirectory; use lx::StatEx; use std::ffi; use std::mem; @@ -22,23 +28,44 @@ const STATX_BTIME: u32 = 0x00000800; // See crate::LxVolume for more detailed comments. pub struct LxVolume { root: std::fs::File, + /// Set when paths must be resolved without following symbolic links. + resolver: Option, } impl LxVolume { - pub fn new(root_path: &Path, _options: &super::LxVolumeOptions) -> lx::Result { + pub fn new(root_path: &Path, options: &super::LxVolumeOptions) -> lx::Result { let path = util::path_to_cstr(root_path)?; // SAFETY: Calling C API as documented, with no special requirements. - unsafe { + let root = unsafe { // Open a file descriptor to the root to use with "*at" functions. let fd = util::check_lx_errno(libc::open( path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY, ))?; - Ok(Self { - root: std::fs::File::from_raw_fd(fd), - }) + std::fs::File::from_raw_fd(fd) + }; + + let resolver = options + .confine_paths + .then(|| confine::Resolver::probe(&root)); + + Ok(Self { root, resolver }) + } + + /// Splits `path` into the directory and name used by "*at" functions. + /// + /// Without confinement, the directory is the root and the name is the whole relative path, + /// so the kernel follows symbolic links in every component except the last. + fn at_path(&self, path: &Path) -> lx::Result> { + assert!(path.is_relative()); + match self.resolver { + Some(resolver) => confine::resolve(&self.root, resolver, path), + None => Ok(AtPath { + directory: ParentDirectory::Root(&self.root), + name: util::path_to_cstr(path)?, + }), } } @@ -47,16 +74,15 @@ impl LxVolume { } pub fn lstat(&self, path: &Path) -> lx::Result { - assert!(path.is_relative()); - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling syscall as documented, with no special requirements. let statx = unsafe { let mut statx: StatEx = mem::zeroed(); util::check_lx_errno(libc::syscall( libc::SYS_statx as ffi::c_long, - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), libc::AT_SYMLINK_NOFOLLOW | libc::AT_EMPTY_PATH, STATX_BASIC_STATS | STATX_BTIME, &mut statx, @@ -67,11 +93,20 @@ impl LxVolume { } pub fn set_attr(&self, path: &Path, attr: SetAttributes) -> lx::Result<()> { - util::set_attr(&self.root, Some(path), &attr) + if self.resolver.is_none() { + return util::set_attr(&self.root, Some(path), &attr); + } + + let path = self.at_path(path)?; + if path.is_root() { + util::set_attr(&self.root, None, &attr) + } else { + util::set_attr_confined(path.directory.as_raw_fd(), &path.name, &attr) + } } pub fn set_attr_stat(&self, path: &Path, attr: SetAttributes) -> lx::Result { - util::set_attr(&self.root, Some(path), &attr)?; + self.set_attr(path, attr)?; self.lstat(path).map(|x| x.into()) } @@ -83,7 +118,24 @@ impl LxVolume { ) -> lx::Result { assert!(path.is_relative()); - let fd = util::openat(&self.root, path, flags, options)?; + let fd = if self.resolver.is_some() { + // A confined open never follows the final component, and reopens the root through + // its own descriptor rather than its host path. + let path = self.at_path(path)?; + let name = if path.is_root() { + c"." + } else { + path.name.as_c_str() + }; + util::openat_name( + path.directory.as_raw_fd(), + name, + flags | libc::O_NOFOLLOW, + options, + )? + } else { + util::openat(&self.root, path, flags, options)? + }; Ok(LxFile { fd, @@ -92,15 +144,13 @@ impl LxVolume { } pub fn mkdir(&self, path: &Path, options: super::LxCreateOptions) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::mkdirat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), options.mode, ))?; } @@ -121,17 +171,15 @@ impl LxVolume { target: &lx::LxStr, _: super::LxCreateOptions, ) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; let target = util::create_cstr(target.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::symlinkat( target.as_ptr(), - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), ))?; } @@ -149,16 +197,14 @@ impl LxVolume { } pub fn read_link(&self, path: &Path) -> lx::Result { - assert!(path.is_relative()); - let mut buffer = [0u8; libc::PATH_MAX as usize]; - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { util::check_lx_errno(libc::readlinkat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), buffer.as_mut_ptr().cast(), buffer.len(), ))? @@ -169,13 +215,15 @@ impl LxVolume { } pub fn unlink(&self, path: &Path, flags: i32) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - util::check_lx_errno(libc::unlinkat(self.root.as_raw_fd(), path.as_ptr(), flags))?; + util::check_lx_errno(libc::unlinkat( + path.directory.as_raw_fd(), + path.name.as_ptr(), + flags, + ))?; } Ok(()) @@ -187,15 +235,13 @@ impl LxVolume { options: super::LxCreateOptions, device_id: lx::dev_t, ) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::mknodat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), options.mode, device_id as u64, ))?; @@ -215,10 +261,8 @@ impl LxVolume { } pub fn rename(&self, path: &Path, new_path: &Path, flags: u32) -> lx::Result<()> { - assert!(path.is_relative()); - assert!(new_path.is_relative()); - let path = util::path_to_cstr(path)?; - let new_path = util::path_to_cstr(new_path)?; + let path = self.at_path(path)?; + let new_path = self.at_path(new_path)?; // renameat2 does not have a wrapper in musl, though it does in glibc. Call it using syscall directly instead. // SAFETY: Our arguments are valid for this syscall. We are passing arguments in the @@ -226,10 +270,10 @@ impl LxVolume { unsafe { util::check_lx_errno(libc::syscall( libc::SYS_renameat2, - self.root.as_raw_fd(), - path.as_ptr(), - self.root.as_raw_fd(), - new_path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), + new_path.directory.as_raw_fd(), + new_path.name.as_ptr(), flags, ) as libc::c_int)?; } @@ -238,18 +282,16 @@ impl LxVolume { } pub fn link(&self, path: &Path, new_path: &Path) -> lx::Result<()> { - assert!(path.is_relative()); - assert!(new_path.is_relative()); - let path = util::path_to_cstr(path)?; - let new_path = util::path_to_cstr(new_path)?; + let path = self.at_path(path)?; + let new_path = self.at_path(new_path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::linkat( - self.root.as_raw_fd(), - path.as_ptr(), - self.root.as_raw_fd(), - new_path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), + new_path.directory.as_raw_fd(), + new_path.name.as_ptr(), 0, ))?; } @@ -264,16 +306,41 @@ impl LxVolume { pub fn stat_fs(&self, path: &Path) -> lx::Result { assert!(path.is_relative()); - let path = self.full_path(path)?; - // SAFETY: Calling C API as documented, with no special requirements. - let stat_fs = unsafe { - let mut stat_fs = mem::zeroed(); - util::check_lx_errno(libc::statfs(path.as_ptr(), &mut stat_fs))?; - stat_fs - }; + let mut stat_fs = mem::MaybeUninit::::uninit(); + if self.resolver.is_some() { + // Inspect the object itself through an O_PATH handle rather than following the path. + let path = self.at_path(path)?; + let object; + let fd = if path.is_root() { + self.root.as_raw_fd() + } else { + object = util::openat_name( + path.directory.as_raw_fd(), + &path.name, + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + object.as_raw_fd() + }; + + // SAFETY: `stat_fs` is valid for writes and `fd` is open. + unsafe { + util::check_lx_errno(libc::fstatfs(fd, stat_fs.as_mut_ptr()))?; + } + } else { + let path = self.full_path(path)?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + util::check_lx_errno(libc::statfs(path.as_ptr(), stat_fs.as_mut_ptr()))?; + } + } - Ok(util::libc_stat_fs_to_lx_stat_fs(stat_fs)) + // SAFETY: statfs and fstatfs initialize the buffer when they succeed. + Ok(util::libc_stat_fs_to_lx_stat_fs(unsafe { + stat_fs.assume_init() + })) } pub fn set_xattr( @@ -283,16 +350,14 @@ impl LxVolume { value: &[u8], flags: i32, ) -> lx::Result<()> { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::lsetxattr( - path.as_ptr(), + path.path.as_ptr(), name.as_ptr(), value.as_ptr().cast::(), value.len(), @@ -309,10 +374,8 @@ impl LxVolume { name: &lx::LxStr, value: Option<&mut [u8]>, ) -> lx::Result { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // Set the pointer to NULL if no value buffer is provided, to query the attribute's size. @@ -325,7 +388,7 @@ impl LxVolume { // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { util::check_lx_errno(libc::lgetxattr( - path.as_ptr(), + path.path.as_ptr(), name.as_ptr(), value_ptr.cast::(), size, @@ -337,10 +400,8 @@ impl LxVolume { } pub fn list_xattr(&self, path: &Path, list: Option<&mut [u8]>) -> lx::Result { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; // Set the list pointer to NULL if no list buffer was provided, to query the size. let (list_ptr, size) = if let Some(list) = list { @@ -351,7 +412,7 @@ impl LxVolume { // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { - util::check_lx_errno(libc::llistxattr(path.as_ptr(), list_ptr.cast(), size))? + util::check_lx_errno(libc::llistxattr(path.path.as_ptr(), list_ptr.cast(), size))? }; // Size is guaranteed positive after the check. @@ -359,15 +420,13 @@ impl LxVolume { } pub fn remove_xattr(&self, path: &Path, name: &lx::LxStr) -> lx::Result<()> { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - util::check_lx_errno(libc::lremovexattr(path.as_ptr(), name.as_ptr()))?; + util::check_lx_errno(libc::lremovexattr(path.path.as_ptr(), name.as_ptr()))?; } Ok(()) @@ -378,6 +437,33 @@ impl LxVolume { full_path.push(path); util::path_to_cstr(&full_path) } + + /// Returns a path for the `l*xattr` functions, which act on a final symbolic link itself. + /// + /// A confined volume reaches the object through its pinned parent directory in `/proc`, so no + /// ancestor is resolved from the host root again. + fn xattr_path(&self, path: &Path) -> lx::Result> { + assert!(path.is_relative()); + if self.resolver.is_none() { + return Ok(XattrPath { + path: self.full_path(path)?, + _directory: None, + }); + } + + let path = self.at_path(path)?; + Ok(XattrPath { + path: path.proc_path()?, + _directory: Some(path), + }) + } +} + +/// A host path for an extended attribute call and the directory it depends on. +struct XattrPath<'a> { + path: ffi::CString, + /// Keeps the pinned directory named by `path` open until the call completes. + _directory: Option>, } // Unix implementation of LxFile. diff --git a/vm/devices/support/fs/lxutil/src/unix/util.rs b/vm/devices/support/fs/lxutil/src/unix/util.rs index 546623343e1..1fe525886f7 100644 --- a/vm/devices/support/fs/lxutil/src/unix/util.rs +++ b/vm/devices/support/fs/lxutil/src/unix/util.rs @@ -148,12 +148,21 @@ pub fn openat( return reopen(dirfd, flags); } + openat_name(dirfd.as_raw_fd(), &path_to_cstr(path)?, flags, options) +} + +// Open a name relative to a directory descriptor. +pub fn openat_name( + dirfd: RawFd, + name: &ffi::CStr, + flags: i32, + options: Option, +) -> lx::Result { let mode = options.unwrap_or_default().mode; - let path = path_to_cstr(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - let fd = check_lx_errno(libc::openat(dirfd.as_raw_fd(), path.as_ptr(), flags, mode))?; + let fd = check_lx_errno(libc::openat(dirfd, name.as_ptr(), flags, mode))?; Ok(std::fs::File::from_raw_fd(fd)) } @@ -296,6 +305,119 @@ pub fn set_attr(fd: &std::fs::File, path: Option<&Path>, attr: &SetAttributes) - } } +/// Apply attributes to `name` in the directory `dirfd` without following a final symbolic link. +/// +/// Changing the mode of a symbolic link fails with `EOPNOTSUPP`, as `lchmod` does on Linux. +/// +/// Linux will remove the set-user-ID and set-group-ID bits as appropriate, so unlike the Windows +/// version this doesn't need to be done explicitly. +pub fn set_attr_confined(dirfd: RawFd, name: &ffi::CStr, attr: &SetAttributes) -> lx::Result<()> { + // Ctime is updated by most of the operations below, so don't explicitly + // update it if not needed. + let mut need_ctime_update = !attr.ctime.is_omit(); + + if let Some(size) = attr.size { + let file = openat_name( + dirfd, + name, + libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::ftruncate(file.as_raw_fd(), size))?; + } + + need_ctime_update = false; + } + + if let Some(mode) = attr.mode { + // `fchmodat` cannot be told not to follow a symbolic link. Pin the object without + // following it, and change the mode of exactly that object through its /proc link. + let object = openat_name( + dirfd, + name, + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + let mut stat = mem::MaybeUninit::::uninit(); + + // SAFETY: `stat` is valid for writes and `object` is open. + unsafe { + check_lx_errno(libc::fstat(object.as_raw_fd(), stat.as_mut_ptr()))?; + } + + // SAFETY: fstat initialized `stat` because it succeeded. + if unsafe { stat.assume_init() }.st_mode & libc::S_IFMT == libc::S_IFLNK { + return Err(lx::Error::ENOTSUP); + } + + let path = path_to_cstr(&get_proc_fd_path(&object))?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::chmod(path.as_ptr(), mode))?; + } + + need_ctime_update = false; + } + + if attr.uid.is_some() || attr.gid.is_some() { + let uid = attr.uid.unwrap_or(lx::UID_INVALID); + let gid = attr.gid.unwrap_or(lx::GID_INVALID); + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::fchownat( + dirfd, + name.as_ptr(), + uid, + gid, + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + + need_ctime_update = false; + } + + if !attr.atime.is_omit() || !attr.mtime.is_omit() { + let times = [ + set_time_to_timespec(&attr.atime), + set_time_to_timespec(&attr.mtime), + ]; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::utimensat( + dirfd, + name.as_ptr(), + times.as_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + + need_ctime_update = false; + } + + // If a ctime update was requested and didn't already happen, perform a no-op operation that + // has a ctime update as a side-effect. + if need_ctime_update { + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::fchownat( + dirfd, + name.as_ptr(), + lx::UID_INVALID, + lx::GID_INVALID, + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + } + + Ok(()) +} + /// Create a timespec with either omit, now or a value. fn set_time_to_timespec(time: &SetTime) -> libc::timespec { match time { diff --git a/vm/devices/support/fs/lxutil/src/windows/mod.rs b/vm/devices/support/fs/lxutil/src/windows/mod.rs index 082d32d40cc..4842dc8aa6a 100644 --- a/vm/devices/support/fs/lxutil/src/windows/mod.rs +++ b/vm/devices/support/fs/lxutil/src/windows/mod.rs @@ -757,8 +757,11 @@ impl LxVolume { let win_target = Path::from_lx(target); // Determine whether a NT symlink can be created, and if so whether it should be a file or - // directory. - let link_type = if let Ok(win_target) = &win_target { + // directory. A confined volume only creates LX symlinks, which Windows path resolution + // never follows, so a link can never redirect a later lookup outside the volume. + let link_type = if self.state.options.confine_paths { + SymlinkType::Lx + } else if let Ok(win_target) = &win_target { self.determine_symlink_type(path.parent().unwrap(), win_target) } else { SymlinkType::Lx diff --git a/vm/devices/virtio/virtio/Cargo.toml b/vm/devices/virtio/virtio/Cargo.toml index 058f45cd1d9..d017a88ce27 100644 --- a/vm/devices/virtio/virtio/Cargo.toml +++ b/vm/devices/virtio/virtio/Cargo.toml @@ -29,6 +29,7 @@ async-trait.workspace = true futures.workspace = true parking_lot.workspace = true thiserror.workspace = true +tracing.workspace = true zerocopy.workspace = true [dev-dependencies] mesh.workspace = true diff --git a/vm/devices/virtio/virtio/src/device.rs b/vm/devices/virtio/virtio/src/device.rs index 598e513204f..67eabec9322 100644 --- a/vm/devices/virtio/virtio/src/device.rs +++ b/vm/devices/virtio/virtio/src/device.rs @@ -4,6 +4,8 @@ //! Per-queue virtio device trait (`VirtioDevice`) and object-safe wrapper //! (`DynVirtioDevice`). +pub mod saved_state; + use crate::DEFAULT_QUEUE_SIZE; use crate::DeviceTraits; use crate::QueueResources; @@ -11,9 +13,15 @@ use crate::queue::QueueState; use crate::spec::VirtioDeviceFeatures; use guestmem::MappedMemoryRegion; use inspect::InspectMut; +use saved_state::DeviceStateValidator; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; /// Per-queue virtio device trait. Ergonomic async fn — not object-safe. /// @@ -23,6 +31,13 @@ pub trait VirtioDevice: InspectMut + Send { /// Device identity and capabilities. fn traits(&self) -> DeviceTraits; + /// Whether the transport should register accelerated queue doorbells. + /// Devices that opt out receive queue notifications through transport + /// emulation instead. + fn supports_accelerated_doorbells(&self) -> bool { + true + } + /// The queue size for the given queue index. /// /// This is the initial value the transport advertises to the guest @@ -105,6 +120,16 @@ pub trait VirtioDevice: InspectMut + Send { async {} } + /// Stops accepting new host input while preserving device and queue state. + fn quiesce_input(&mut self) -> impl Future> + Send { + async { Ok(()) } + } + + /// Resumes host input after a failed save transaction. + fn resume_input(&mut self) -> impl Future> + Send { + async { Ok(()) } + } + /// Whether the device supports save/restore. /// /// Devices that return `false` will cause the transport's `save()` to @@ -114,6 +139,31 @@ pub trait VirtioDevice: InspectMut + Send { fn supports_save_restore(&self) -> bool { false } + + /// Save device-private state after all queues have stopped. + /// + /// The transport saves queue and feature-negotiation state separately. + /// Devices with additional state should return a typed [`SavedStateBlob`]. + fn save_device(&mut self) -> Result, SaveError> { + saved_state::default_save_device(self.supports_save_restore()) + } + + /// Restore device-private state before any queues are restarted. + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + saved_state::default_restore_device(self.supports_save_restore(), state) + } + + /// Return an immutable validator that the transport can retain after the + /// device moves into its async task. + fn device_state_validator(&self) -> DeviceStateValidator { + saved_state::default_device_state_validator(self.supports_save_restore()) + } + + /// Polls host-only device control and reports whether guest-visible + /// configuration changed. + fn poll_config_change(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } } /// Object-safe wrapper for [`VirtioDevice`]. @@ -126,6 +176,9 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Device identity and capabilities. fn traits(&self) -> DeviceTraits; + /// Whether the transport should register accelerated queue doorbells. + fn supports_accelerated_doorbells(&self) -> bool; + /// The queue size for the given queue index. fn queue_size(&self, queue_index: u16) -> u16; @@ -164,8 +217,25 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Reset device-internal state. fn reset(&mut self) -> Pin + Send + '_>>; + /// Stops accepting new host input while preserving device state. + fn quiesce_input(&mut self) -> Pin> + Send + '_>>; + + /// Resumes host input after a failed save transaction. + fn resume_input(&mut self) -> Pin> + Send + '_>>; + /// Whether the device supports save/restore. fn supports_save_restore(&self) -> bool; + + /// Save device-private state after all queues have stopped. + fn save_device(&mut self) -> Result, SaveError>; + + /// Restore device-private state before any queues are restarted. + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError>; + + /// Return immutable validation for device-private saved state. + fn device_state_validator(&self) -> DeviceStateValidator; + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll>; } impl DynVirtioDevice for T { @@ -173,6 +243,10 @@ impl DynVirtioDevice for T { VirtioDevice::traits(self) } + fn supports_accelerated_doorbells(&self) -> bool { + VirtioDevice::supports_accelerated_doorbells(self) + } + fn queue_size(&self, queue_index: u16) -> u16 { VirtioDevice::queue_size(self, queue_index) } @@ -226,7 +300,31 @@ impl DynVirtioDevice for T { Box::pin(VirtioDevice::reset(self)) } + fn quiesce_input(&mut self) -> Pin> + Send + '_>> { + Box::pin(VirtioDevice::quiesce_input(self)) + } + + fn resume_input(&mut self) -> Pin> + Send + '_>> { + Box::pin(VirtioDevice::resume_input(self)) + } + fn supports_save_restore(&self) -> bool { VirtioDevice::supports_save_restore(self) } + + fn save_device(&mut self) -> Result, SaveError> { + VirtioDevice::save_device(self) + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + VirtioDevice::restore_device(self, state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + VirtioDevice::device_state_validator(self) + } + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll> { + VirtioDevice::poll_config_change(self, cx) + } } diff --git a/vm/devices/virtio/virtio/src/device/saved_state.rs b/vm/devices/virtio/virtio/src/device/saved_state.rs new file mode 100644 index 00000000000..308a18d624f --- /dev/null +++ b/vm/devices/virtio/virtio/src/device/saved_state.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private saved state for [`VirtioDevice`](super::VirtioDevice) +//! implementations. +//! +//! The transport saves queue and feature-negotiation state itself. Devices with +//! additional state implement `save_device`, `restore_device`, and +//! `device_state_validator`, using the types and helpers in this module. + +use crate::queue::QueueError; +use crate::queue::QueueParams; +use crate::queue::QueueState; +use crate::queue::new_queue; +use crate::spec::VirtioDeviceFeatures; +use guestmem::GuestMemory; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Restored queue metadata available to device-private state validation. +#[derive(Clone, Copy, Debug)] +pub struct DeviceQueueState { + pub params: QueueParams, + pub queue_state: Option, +} + +/// Synchronous validation for opaque device-private saved state and its queue +/// dependencies. +pub type DeviceStateValidator = Box< + dyn Fn( + Option<&SavedStateBlob>, + &VirtioDeviceFeatures, + &[DeviceQueueState], + &GuestMemory, + ) -> Result<(), RestoreError> + + Send + + Sync, +>; + +/// Inspect the readable length of the front restored descriptor without +/// advancing the queue. +pub fn restored_queue_front_readable_length( + features: VirtioDeviceFeatures, + params: QueueParams, + mem: GuestMemory, + initial_state: Option, +) -> Result, QueueError> { + let (mut queue, _) = new_queue(features, mem, params, initial_state)?; + Ok(queue.try_peek_work()?.map(|work| { + work.payload + .iter() + .filter(|payload| !payload.writeable) + .map(|payload| u64::from(payload.length)) + .sum() + })) +} + +/// The default `VirtioDevice::save_device`: devices that support save/restore +/// have no private state, and other devices cannot be saved. +pub(super) fn default_save_device( + supports_save_restore: bool, +) -> Result, SaveError> { + if supports_save_restore { + Ok(None) + } else { + Err(SaveError::NotSupported) + } +} + +/// The default `VirtioDevice::restore_device`: devices that support +/// save/restore accept only an absent private state. +pub(super) fn default_restore_device( + supports_save_restore: bool, + state: Option, +) -> Result<(), RestoreError> { + if !supports_save_restore { + return Err(RestoreError::SavedStateNotSupported); + } + if state.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "device does not accept private saved state" + ))); + } + Ok(()) +} + +/// The default `VirtioDevice::device_state_validator`, which applies the same +/// rules as [`default_restore_device`]. +pub(super) fn default_device_state_validator(supports_save_restore: bool) -> DeviceStateValidator { + Box::new(move |state, _features, _queues, _guest_memory| { + if !supports_save_restore { + return Err(RestoreError::SavedStateNotSupported); + } + if state.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "device does not accept private saved state" + ))); + } + Ok(()) + }) +} diff --git a/vm/devices/virtio/virtio/src/tests.rs b/vm/devices/virtio/virtio/src/tests.rs index 78024238bc4..2abcdc69dac 100644 --- a/vm/devices/virtio/virtio/src/tests.rs +++ b/vm/devices/virtio/virtio/src/tests.rs @@ -6,6 +6,9 @@ #![expect(unsafe_code)] #![cfg(test)] +mod interrupt; +mod saved_state; + use crate::DeviceTraits; use crate::DynVirtioDevice; use crate::PciInterruptModel; @@ -94,20 +97,6 @@ async fn must_recv_in_timeout( .unwrap() } -async fn assert_no_recv_in_timeout( - recv: &mut mesh::Receiver, - timeout: Duration, -) { - if mesh::CancelContext::new() - .with_timeout(timeout) - .until_cancelled(recv.next()) - .await - .is_ok() - { - panic!("Expected timeout, but received a value"); - } -} - /// Yield execution to the async executor, allowing spawned tasks to run. async fn yield_now() { let mut yielded = false; @@ -145,6 +134,7 @@ struct VirtioTestMemoryAccess { /// Behind an `Arc` so each handed-out registration can remove its own entry /// when it is dropped. live_doorbells: Arc>>, + doorbell_events: Arc>>, } /// A doorbell registration as the transport asked for it. @@ -310,12 +300,22 @@ unsafe impl GuestMemoryAccess for VirtioTestMemoryAccess { }; Ok(()) } + + fn compare_exchange_fallback( + &self, + address: u64, + current: &mut [u8], + new: &[u8], + ) -> Result { + self.compare_exchange_memory(address, current, new) + } } /// The handle a doorbell registration hands back. Dropping it is what /// UNINSTALLS the doorbell, so it de-registers itself from `live_doorbells`. struct DoorbellEntry { live: Arc>>, + _event: interrupt::DoorbellEvent, spec: DoorbellSpec, } @@ -334,7 +334,7 @@ impl DoorbellRegistration for VirtioTestMemoryAccess { address: u64, value: Option, length: Option, - _: &Event, + event: &Event, ) -> io::Result> { self.doorbell_count.fetch_add(1, Ordering::Relaxed); let spec = DoorbellSpec { @@ -345,6 +345,7 @@ impl DoorbellRegistration for VirtioTestMemoryAccess { self.live_doorbells.lock().push(spec); Ok(Box::new(DoorbellEntry { live: self.live_doorbells.clone(), + _event: interrupt::DoorbellEvent::register(&self.doorbell_events, spec, event), spec, })) } @@ -2558,16 +2559,21 @@ async fn verify_packed_queue_simple(driver: DefaultDriver) { } async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, with_index: bool) { + const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); + let (tx, mut rx) = mesh::mpsc_channel(); + let (completed, mut completions) = mesh::mpsc_channel(); let event = Event::new(); let mut queues = guest.create_direct_queues(|i| { let tx = tx.clone(); + let completed = completed.clone(); CreateDirectQueueParams { process_work: Box::new( move |queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { assert_eq!(work.payload.len(), 1); assert_eq!(work.payload[0].length, 0x1000); queue.complete(work, 123); + completed.send(()); }, ), notify: Interrupt::from_fn(move || { @@ -2582,10 +2588,13 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.enable_interrupt(0, Some(1)); guest.add_to_avail_queue(0); event.signal(); - assert_no_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); guest.add_to_avail_queue(0); event.signal(); - must_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert_eq!(rx.try_recv().unwrap(), 0); + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); @@ -2597,7 +2606,9 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.enable_interrupt(0, None); guest.add_to_avail_queue(0); event.signal(); - must_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert_eq!(rx.try_recv().unwrap(), 0); + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); assert_eq!(guest.get_next_completed(0).is_none(), true); @@ -2607,7 +2618,9 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.add_to_avail_queue(0); guest.add_to_avail_queue(0); event.signal(); - assert_no_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); let (_, len) = guest.get_next_completed(0).unwrap(); @@ -4745,12 +4758,19 @@ async fn pci_save_restore_round_trip(driver: DefaultDriver) { #[async_test] async fn mmio_save_restore_round_trip(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_save_restore_round_trip_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_save_restore_round_trip_inner(driver: DefaultDriver, register_doorbells: bool) { use crate::spec::mmio::VirtioMmioRegister; use vmcore::device_state::ChangeDeviceState; use vmcore::save_restore::SaveRestore; let test_mem = VirtioTestMemoryAccess::new(); - let doorbell_registration: Arc = test_mem.clone(); + let doorbell_registration: Option> = + register_doorbells.then(|| test_mem.clone() as _); let mem = GuestMemory::new("test", test_mem.clone()); let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); @@ -4773,7 +4793,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { &driver_source.simple(), mem.clone(), interrupt, - Some(doorbell_registration.clone()), + doorbell_registration.clone(), 0, 1, ) @@ -4787,9 +4807,12 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { dev.stop().await; // Save state. - let saved = dev.save().expect("save should succeed"); + let mut saved = dev.save().expect("save should succeed"); assert_eq!(saved.queues.len(), 1); assert!(saved.queues[0].common.enable); + saved.interrupt_status = VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER; + saved_state::check_restored_queue_validation(&mut saved, guest.queue_features(), &mem); + drop(dev); // Create a new device and restore into it. let interrupt2 = LineInterrupt::detached(); @@ -4809,7 +4832,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { &driver_source.simple(), mem.clone(), interrupt2, - Some(doorbell_registration), + doorbell_registration, 0, 1, ) @@ -4821,6 +4844,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { dev2.read_u32(VirtioMmioRegister::STATUS.0 as u64) & VIRTIO_DRIVER_OK, 0 ); + interrupt::check_used_buffer_ack(&test_mem, &mut dev2, register_doorbells); // Stop and clean up. dev2.stop().await; diff --git a/vm/devices/virtio/virtio/src/tests/interrupt.rs b/vm/devices/virtio/virtio/src/tests/interrupt.rs new file mode 100644 index 00000000000..ee9c6884089 --- /dev/null +++ b/vm/devices/virtio/virtio/src/tests/interrupt.rs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for virtio-mmio interrupt-status delivery: used-buffer acknowledgement +//! through the `INTERRUPT_ACK` doorbell, the shared interrupt-status mode, and +//! devices that opt out of accelerated doorbells. + +use super::*; +use crate::transport::VirtioMmioInterruptMode; +use test_with_tracing::test; + +/// The event of a doorbell registration, recorded so that tests can signal +/// the doorbell. Dropping it removes the record. +pub(super) struct DoorbellEvent { + events: Arc>>, + spec: DoorbellSpec, +} + +impl DoorbellEvent { + pub(super) fn register( + events: &Arc>>, + spec: DoorbellSpec, + event: &Event, + ) -> Self { + events.lock().push((spec, event.clone())); + Self { + events: events.clone(), + spec, + } + } +} + +impl Drop for DoorbellEvent { + fn drop(&mut self) { + let mut events = self.events.lock(); + if let Some(i) = events.iter().position(|(spec, _)| *spec == self.spec) { + events.remove(i); + } + } +} + +impl VirtioTestMemoryAccess { + fn signal_doorbell(&self, spec: DoorbellSpec) -> bool { + let event = self + .doorbell_events + .lock() + .iter() + .find_map(|(registered, event)| (*registered == spec).then(|| event.clone())); + if let Some(event) = event { + event.signal(); + true + } else { + false + } + } + + /// The `compare_exchange_fallback` of the test memory, which the shared + /// interrupt-status word uses. + pub(super) fn compare_exchange_memory( + &self, + address: u64, + current: &mut [u8], + new: &[u8], + ) -> Result { + let mut map = self.memory_map.lock(); + let Some((true, value)) = map.get(address, current.len()) else { + panic!("Unexpected compare exchange request at address {address:x}"); + }; + if value == current { + value.copy_from_slice(new); + Ok(true) + } else { + current.copy_from_slice(value); + Ok(false) + } + } +} + +/// Acknowledges the pending used-buffer interrupt of a restored device, through +/// the `INTERRUPT_ACK` doorbell when it is accelerated, and checks that the +/// status bit clears. +pub(super) fn check_used_buffer_ack( + test_mem: &VirtioTestMemoryAccess, + dev2: &mut VirtioMmioDevice, + register_doorbells: bool, +) { + use crate::spec::mmio::VirtioMmioRegister; + + let ack = DoorbellSpec { + address: VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if accelerated { + assert!(test_mem.signal_doorbell(ack)); + } else { + dev2.write_u32( + VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + ); + } + assert_eq!( + dev2.read_u32(VirtioMmioRegister::INTERRUPT_STATUS.0 as u64) + & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + 0 + ); +} + +/// A [`TestDevice`] that opts out of accelerated queue doorbells. +#[derive(InspectMut)] +#[inspect(skip)] +struct NoAcceleratedDoorbells(TestDevice); + +impl VirtioDevice for NoAcceleratedDoorbells { + fn traits(&self) -> DeviceTraits { + VirtioDevice::traits(&self.0) + } + + fn supports_accelerated_doorbells(&self) -> bool { + false + } + + async fn read_registers_u32(&mut self, offset: u16) -> u32 { + VirtioDevice::read_registers_u32(&mut self.0, offset).await + } + + async fn write_registers_u32(&mut self, offset: u16, val: u32) { + VirtioDevice::write_registers_u32(&mut self.0, offset, val).await + } + + async fn start_queue( + &mut self, + idx: u16, + resources: QueueResources, + features: &VirtioDeviceFeatures, + initial_state: Option, + ) -> anyhow::Result<()> { + VirtioDevice::start_queue(&mut self.0, idx, resources, features, initial_state).await + } + + async fn stop_queue(&mut self, idx: u16) -> Option { + VirtioDevice::stop_queue(&mut self.0, idx).await + } + + async fn reset(&mut self) { + VirtioDevice::reset(&mut self.0).await + } + + fn supports_save_restore(&self) -> bool { + VirtioDevice::supports_save_restore(&self.0) + } +} +#[async_test] +async fn mmio_used_buffer_ack_doorbell_deasserts_interrupt(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_used_buffer_ack_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_used_buffer_ack_inner(driver: DefaultDriver, register_doorbells: bool) { + const MMIO_BASE: u64 = 0x1000; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 2, true); + let target = TestLineInterruptTarget::new_arc(); + let interrupt = LineInterrupt::new_with_target("test", target.clone(), 0); + let base_addr = guest.get_queue_descriptor_backing_memory_address(0); + let queue_work = Arc::new( + move |_: u16, queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { + assert_eq!(work.payload[0].address, base_addr); + queue.complete(work, 123); + }, + ); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mut dev = VirtioMmioDevice::new( + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_EVENT_IDX | 2) + .with_bank(1, VIRTIO_F_VERSION_1), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + Some(queue_work), + )), + &driver, + guest.mem(), + interrupt, + if register_doorbells { + Some(test_mem.clone()) + } else { + None + }, + MMIO_BASE, + 0x1000, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + poll_fn(|cx| target.poll_high(cx, 0)).await; + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + + let ack = DoorbellSpec { + address: MMIO_BASE + 100, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if accelerated { + assert!(test_mem.signal_doorbell(ack)); + } else { + dev.write_u32(100, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + } + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + 0 + ); + assert!(!target.is_high(0)); + dev.stop().await; +} + +#[async_test] +async fn mmio_ack_before_completion_keeps_new_interrupt_asserted(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_ack_before_completion_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_ack_before_completion_inner(driver: DefaultDriver, register_doorbells: bool) { + const MMIO_BASE: u64 = 0x2000; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let target = TestLineInterruptTarget::new_arc(); + let interrupt = LineInterrupt::new_with_target("test", target.clone(), 0); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let (completed, mut completions) = mesh::channel(); + let mut dev = VirtioMmioDevice::new( + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_EVENT_IDX | 2) + .with_bank(1, VIRTIO_F_VERSION_1), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + Some(Arc::new( + move |_: u16, queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { + queue.complete(work, 123); + completed.send(()); + }, + )), + )), + &driver, + guest.mem(), + interrupt, + if register_doorbells { + Some(test_mem.clone()) + } else { + None + }, + MMIO_BASE, + 0x1000, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + poll_fn(|cx| target.poll_high(cx, 0)).await; + must_recv_in_timeout(&mut completions, Duration::from_secs(5)).await; + assert_eq!(guest.get_next_completed(0), Some((0, 123))); + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + let ack = DoorbellSpec { + address: MMIO_BASE + 100, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if !accelerated { + dev.write_u32(100, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + assert!(!target.is_high(0)); + } + + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + must_recv_in_timeout(&mut completions, Duration::from_secs(5)).await; + assert_eq!(guest.get_next_completed(0), Some((0, 123))); + if accelerated { + // A deferred ACK must not clear a completion newer than the status read. + assert!(test_mem.signal_doorbell(ack)); + } + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + assert!(target.is_high(0)); + dev.stop().await; +} + +#[async_test] +async fn mmio_shared_status_save_restore_and_reset(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const STATUS_GPA: u64 = 0xfeed_0000; + + let test_mem = VirtioTestMemoryAccess::new(); + test_mem.modify_memory_map(STATUS_GPA, &0u32.to_ne_bytes(), true); + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let device = || { + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, 2 | VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + None, + )) + }; + + let source_target = TestLineInterruptTarget::new_arc(); + let mut source = VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( + device(), + &driver_source.simple(), + mem.clone(), + LineInterrupt::new_with_target("shared-status-source", source_target.clone(), 0), + Some(doorbell_registration.clone()), + 0, + 0x1000, + 0, + VirtioMmioInterruptMode::SharedStatus { + status_gpa: STATUS_GPA, + }, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + assert_eq!(test_mem.memory_map_get_u32(STATUS_GPA), 0); + test_mem.modify_memory_map( + STATUS_GPA, + &VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE.to_ne_bytes(), + true, + ); + assert_eq!( + test_mem.memory_map_get_u32(STATUS_GPA), + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + assert!(!source_target.is_high(0)); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved.interrupt_status, + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + drop(source); + + let destination_target = TestLineInterruptTarget::new_arc(); + let mut destination = VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( + device(), + &driver_source.simple(), + mem, + LineInterrupt::new_with_target("shared-status-destination", destination_target.clone(), 0), + Some(doorbell_registration), + 0, + 0x1000, + 0, + VirtioMmioInterruptMode::SharedStatus { + status_gpa: STATUS_GPA, + }, + ) + .unwrap(); + destination.restore(saved).unwrap(); + assert_eq!( + test_mem.memory_map_get_u32(STATUS_GPA), + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + assert!(!destination_target.is_high(0)); + + destination.reset().await; + assert_eq!(test_mem.memory_map_get_u32(STATUS_GPA), 0); + assert!(!destination_target.is_high(0)); +} + +#[async_test] +async fn mmio_respects_accelerated_doorbell_opt_out(driver: DefaultDriver) { + let test_mem = VirtioTestMemoryAccess::new(); + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + + let device = NoAcceleratedDoorbells(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, 2 | VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + None, + )); + let mut dev = VirtioMmioDevice::new( + Box::new(device), + &driver, + mem, + LineInterrupt::detached(), + Some(doorbell_registration), + 0, + 1, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + + assert!(test_mem.installed_doorbells().is_empty()); + assert_eq!(test_mem.doorbell_count.load(Ordering::Relaxed), 0); + dev.stop().await; +} diff --git a/vm/devices/virtio/virtio/src/tests/saved_state.rs b/vm/devices/virtio/virtio/src/tests/saved_state.rs new file mode 100644 index 00000000000..5c975145e1e --- /dev/null +++ b/vm/devices/virtio/virtio/src/tests/saved_state.rs @@ -0,0 +1,1176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for device-private saved state and restore: private-state round trips, +//! restore validation, the activation of staged private state, and staged queue +//! kicks. + +use super::*; +use std::sync::atomic::AtomicBool; +use test_with_tracing::test; +mod private_state { + use mesh::payload::Protobuf; + use vmcore::save_restore::SavedStateRoot; + + #[derive(Protobuf, SavedStateRoot)] + #[mesh(package = "virtio.test_private_state")] + pub struct SavedState { + #[mesh(1)] + pub value: u64, + } +} + +#[derive(InspectMut)] +#[inspect(skip)] +struct PrivateStateTestDevice { + probe: PrivateStateProbe, + max_queues: u16, +} + +#[derive(Clone)] +struct PrivateStateProbe { + value: Arc, + restore_count: Arc, + start_count: Arc, + queue_event: Arc>>, + initial_avail: Arc, + initial_used: Arc, + value_at_start: Arc, + fail_restore: Arc, +} + +impl PrivateStateTestDevice { + fn new(value: usize, max_queues: u16, fail_restore: bool) -> (Self, PrivateStateProbe) { + let probe = PrivateStateProbe { + value: Arc::new(AtomicUsize::new(value)), + restore_count: Arc::new(AtomicUsize::new(0)), + start_count: Arc::new(AtomicUsize::new(0)), + queue_event: Arc::new(Mutex::new(None)), + initial_avail: Arc::new(AtomicUsize::new(usize::MAX)), + initial_used: Arc::new(AtomicUsize::new(usize::MAX)), + value_at_start: Arc::new(AtomicUsize::new(usize::MAX)), + fail_restore: Arc::new(AtomicBool::new(fail_restore)), + }; + ( + Self { + probe: probe.clone(), + max_queues, + }, + probe, + ) + } +} + +impl PrivateStateProbe { + fn take_queue_kick(&self) -> bool { + self.queue_event + .lock() + .as_ref() + .is_some_and(Event::try_wait) + } +} + +impl VirtioDevice for PrivateStateTestDevice { + fn traits(&self) -> DeviceTraits { + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new(), + max_queues: self.max_queues, + device_register_length: 4, + ..Default::default() + } + } + + async fn read_registers_u32(&mut self, _offset: u16) -> u32 { + self.probe.value.load(Ordering::Relaxed) as u32 + } + + async fn write_registers_u32(&mut self, _offset: u16, val: u32) { + self.probe.value.store(val as usize, Ordering::Relaxed); + } + + async fn start_queue( + &mut self, + _idx: u16, + resources: QueueResources, + _features: &VirtioDeviceFeatures, + initial_state: Option, + ) -> anyhow::Result<()> { + self.probe.start_count.fetch_add(1, Ordering::Relaxed); + *self.probe.queue_event.lock() = Some(resources.event.clone()); + self.probe + .value_at_start + .store(self.probe.value.load(Ordering::Relaxed), Ordering::Relaxed); + if let Some(initial_state) = initial_state { + self.probe + .initial_avail + .store(initial_state.avail_index as usize, Ordering::Relaxed); + self.probe + .initial_used + .store(initial_state.used_index as usize, Ordering::Relaxed); + } + Ok(()) + } + + async fn stop_queue(&mut self, _idx: u16) -> Option { + (self.probe.start_count.load(Ordering::Relaxed) != 0).then_some(QueueState { + avail_index: 3, + used_index: 1, + }) + } + + fn supports_save_restore(&self) -> bool { + true + } + + fn save_device( + &mut self, + ) -> Result, vmcore::save_restore::SaveError> { + Ok(Some(vmcore::save_restore::SavedStateBlob::new( + private_state::SavedState { + value: self.probe.value.load(Ordering::Relaxed) as u64, + }, + ))) + } + + fn restore_device( + &mut self, + state: Option, + ) -> Result<(), vmcore::save_restore::RestoreError> { + self.probe.restore_count.fetch_add(1, Ordering::Relaxed); + if self.probe.fail_restore.load(Ordering::Relaxed) { + return Err(vmcore::save_restore::RestoreError::Other(anyhow::anyhow!( + "intentional private-state restore failure" + ))); + } + let state = state.ok_or_else(|| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "missing private test state" + )) + })?; + let state: private_state::SavedState = state.parse()?; + let value = usize::try_from(state.value).map_err(|_| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "private test state is out of range" + )) + })?; + self.probe.value.store(value, Ordering::Relaxed); + Ok(()) + } + + fn device_state_validator(&self) -> crate::device::saved_state::DeviceStateValidator { + Box::new(|state, _features, _queues, _guest_memory| { + let state = state.ok_or_else(|| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "missing private test state" + )) + })?; + let _: private_state::SavedState = state.parse()?; + Ok(()) + }) + } +} + +impl VirtioPciTestDevice { + /// Like `new_with_register_length`, for a caller-provided device. + fn new_with_device( + driver: &DefaultDriver, + test_mem: &Arc, + device: Box, + ) -> Self { + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let msi_conn = MsiConnection::new(); + + let dev = VirtioPciDevice::new( + device, + driver, + mem.clone(), + PciInterruptModel::Msix(&msi_conn.target()), + Some(doorbell_registration), + &mut ExternallyManagedMmioIntercepts, + None, + ) + .unwrap(); + + let test_intc = Arc::new(TestPciInterruptController::new()); + msi_conn.connect(test_intc.signal_msi()); + + Self { + pci_device: dev, + test_intc, + } + } +} + +/// Checks that restore validation accepts the saved queue and rejects +/// corrupted copies of it, leaving the saved state unchanged. +pub(super) fn check_restored_queue_validation( + saved: &mut ::SavedState, + features: VirtioDeviceFeatures, + mem: &GuestMemory, +) { + use crate::transport::core::restore::validate_restored_queue; + + let queue = &mut saved.queues[0].common; + validate_restored_queue(0, queue, features, mem, &mut Vec::new()) + .expect("valid queue was rejected"); + + let original_size = queue.size; + queue.size = 3; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.size = original_size; + + let original_avail = queue.avail_addr; + queue.avail_addr = queue.desc_addr; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.avail_addr = original_avail; + + let original_desc = queue.desc_addr; + queue.desc_addr = u64::MAX - 15; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.desc_addr = 1 << 60; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.desc_addr = original_desc; + + let original_progress = queue.queue_state; + queue.queue_state = Some(QueueState { + avail_index: original_size + 1, + used_index: 0, + }); + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.queue_state = original_progress; +} +#[async_test] +async fn mmio_restore_rejects_missing_active_queue_progress(driver: DefaultDriver) { + use vmcore::device_state::ChangeDeviceState; + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let traits = DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }; + let mut source = VirtioMmioDevice::new( + Box::new(TestDevice::new(&driver_source, traits.clone(), None)), + &driver, + mem.clone(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + source.stop().await; + let mut saved = source.save().unwrap(); + assert!(saved.queues[0].common.enable); + assert!(saved.queues[0].common.queue_state.take().is_some()); + + let mut destination = VirtioMmioDevice::new( + Box::new(TestDevice::new(&driver_source, traits, None)), + &driver, + mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(destination.restore(saved).is_err()); +} + +#[async_test] +async fn mmio_private_state_round_trip(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::device_state::ChangeDeviceState; + use vmcore::save_restore::SaveRestore; + + let (source_device, _source_probe) = PrivateStateTestDevice::new(42, 0, false); + let mut source = VirtioMmioDevice::new( + Box::new(source_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let saved = source.save().expect("private state save should succeed"); + assert!(saved.device_state.is_some()); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination + .restore(saved) + .expect("private state restore should validate"); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.value.load(Ordering::Relaxed), 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + destination.stop().await; + let resaved = destination.save().unwrap(); + assert_eq!( + resaved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + for expected_count in [1, 1] { + let mut data = [0; 4]; + match destination.mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), expected_count); + } + + destination.stop().await; + let mut invalid = destination.save().unwrap(); + invalid.device_state = Some(vmcore::save_restore::SavedStateBlob::new( + vmcore::save_restore::NoSavedState, + )); + assert!(destination.restore(invalid).is_err()); +} + +#[async_test] +async fn mmio_private_state_config_write_triggers_restore(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let value = 7u32; + match destination + .dev + .mmio_write(VirtioMmioRegister::CONFIG.0 as u64, &value.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await.unwrap(), + other => panic!("expected deferred config write, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), value as usize); +} + +#[async_test] +async fn pci_private_state_config_triggers_restore(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(u32::from_ne_bytes(data), 42); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_private_state_config_write_triggers_restore(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let value = 7u32; + match destination + .dev + .mmio_write(BAR0_BASE + DEVICE_CONFIG_OFFSET, &value.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await.unwrap(), + other => panic!("expected deferred config write, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), value as usize); +} + +async fn save_active_private_mmio( + driver: &DefaultDriver, + test_mem: &Arc, +) -> ::SavedState { + use vmcore::save_restore::SaveRestore; + + let guest = VirtioTestGuest::new_split(driver, test_mem, 1, 4, true); + let (device, probe) = PrivateStateTestDevice::new(42, 1, false); + let mut source = VirtioMmioDevice::new( + Box::new(device), + driver, + GuestMemory::new("test", test_mem.clone()), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + saved +} + +async fn save_active_private_pci( + driver: &DefaultDriver, + test_mem: &Arc, +) -> ::SavedState { + use vmcore::save_restore::SaveRestore; + + let guest = VirtioTestGuest::new_split(driver, test_mem, 1, 4, true); + let (device, probe) = PrivateStateTestDevice::new(42, 1, false); + let mut source = VirtioPciTestDevice::new_with_device(driver, test_mem, Box::new(device)); + guest + .setup_pci_device(&mut source, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + source.pci_device.stop().await; + let saved = source.pci_device.save().unwrap(); + assert_eq!( + saved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + saved +} + +#[async_test] +async fn mmio_active_private_state_restores_before_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_mmio(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value_at_start.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_active_private_state_restores_before_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_pci(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value_at_start.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn mmio_active_zero_queue_device_restores_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.write_driver_ok(); + yield_and_poll(&mut source).await; + assert_ne!(source.read_status() & VIRTIO_DRIVER_OK, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_active_zero_queue_device_restores_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.write_driver_ok(); + yield_and_poll(&mut source).await; + assert_ne!(source.read_status() & VIRTIO_DRIVER_OK, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn mmio_staged_kick_starts_after_driver_ok(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + assert!(!probe.take_queue_kick()); + + destination.write_u32(112, VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); + assert!(probe.take_queue_kick()); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_staged_kick_starts_after_driver_ok(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + assert!(!probe.take_queue_kick()); + + let current = destination.pci_device.read_u32(20); + destination + .pci_device + .write_u32(20, (current & !0xff) | VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination.pci_device).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); + assert!(probe.take_queue_kick()); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_reset_discards_pending_private_state(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + destination.write_status_zero(); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(u32::from_ne_bytes(data), 0); +} + +#[async_test] +async fn pci_reset_discards_pending_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + destination.write_status_zero(); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(u32::from_ne_bytes(data), 0); +} + +#[async_test] +async fn mmio_config_restore_failure_completes_and_retains_state(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, true); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let mut data = [0; 4]; + let read_result = match destination + .dev + .mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await, + other => panic!("expected deferred config read, got {other:?}"), + }; + assert!(matches!(read_result, Err(IoError::NoResponse))); + + let write_result = match destination + .dev + .mmio_write(VirtioMmioRegister::CONFIG.0 as u64, &7u32.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await, + other => panic!("expected deferred config write, got {other:?}"), + }; + assert!(matches!(write_result, Err(IoError::NoResponse))); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn pci_config_restore_failure_completes_and_retains_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, true); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let mut data = [0; 4]; + let read_result = match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await, + other => panic!("expected deferred config read, got {other:?}"), + }; + assert!(matches!(read_result, Err(IoError::NoResponse))); + + let write_result = match destination + .dev + .mmio_write(BAR0_BASE + DEVICE_CONFIG_OFFSET, &7u32.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await, + other => panic!("expected deferred config write, got {other:?}"), + }; + assert!(matches!(write_result, Err(IoError::NoResponse))); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn mmio_active_start_failure_preserves_saved_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_mmio(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + assert!(destination.start_fallible().await.is_err()); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + destination.stop().await; + let resaved = destination.save().unwrap(); + assert_eq!( + resaved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn mmio_failed_kick_does_not_signal_restored_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + probe.fail_restore.store(false, Ordering::Relaxed); + destination.write_u32(112, VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_failed_kick_does_not_signal_restored_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + probe.fail_restore.store(false, Ordering::Relaxed); + let current = destination.pci_device.read_u32(20); + destination + .pci_device + .write_u32(20, (current & !0xff) | VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination.pci_device).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_driver_ok_restore_failure_releases_write(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + + let token = match destination.mmio_write(112, &VIRTIO_DRIVER_OK.to_ne_bytes()) { + IoResult::Defer(token) => token, + other => panic!("expected deferred status write, got {other:?}"), + }; + yield_and_poll_device(&mut destination).await; + token.write_future().await.unwrap(); + assert_eq!(destination.read_u32(112), 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination.mmio_read(0x100, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_driver_ok_restore_failure_releases_write(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + + let token = match destination + .pci_device + .mmio_write(BAR0_BASE + 20, &[VIRTIO_DRIVER_OK as u8]) + { + IoResult::Defer(token) => token, + other => panic!("expected deferred status write, got {other:?}"), + }; + yield_and_poll_device(&mut destination.pci_device).await; + token.write_future().await.unwrap(); + assert_eq!(destination.pci_device.read_u32(20) & 0xff, 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination.pci_device.mmio_read(BAR0_BASE + 64, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn mmio_reset_discards_staged_kick(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + destination.write_u32(112, 0); + guest + .setup_chipset_device(&mut destination, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), usize::MAX); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), usize::MAX); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_reset_discards_staged_kick(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let current = destination.pci_device.read_u32(20); + destination.pci_device.write_u32(20, current & !0xff); + guest + .setup_pci_device(&mut destination, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), usize::MAX); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), usize::MAX); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_restore_skips_disabled_zero_address_ring(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let queue = &mut saved.queues[0].common; + queue.enable = false; + queue.desc_addr = 0; + queue.avail_addr = 0; + queue.used_addr = 0; + queue.queue_state = None; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_restore_skips_disabled_zero_address_ring(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let queue = &mut saved.queues[0].common; + queue.enable = false; + queue.desc_addr = 0; + queue.avail_addr = 0; + queue.used_addr = 0; + queue.queue_state = None; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_restore_rejects_invalid_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let mut saved = source.dev.save().unwrap(); + saved.device_state = Some(vmcore::save_restore::SavedStateBlob::new( + vmcore::save_restore::NoSavedState, + )); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + assert!(destination.dev.restore(saved).is_err()); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); +} diff --git a/vm/devices/virtio/virtio/src/transport/core.rs b/vm/devices/virtio/virtio/src/transport/core.rs index 0ef24ccc726..c248f049412 100644 --- a/vm/devices/virtio/virtio/src/transport/core.rs +++ b/vm/devices/virtio/virtio/src/transport/core.rs @@ -3,6 +3,8 @@ //! Shared virtio transport core — state and logic common to PCI and MMIO. +pub(crate) mod restore; + use super::StalledIo; use super::task::DeviceCommand; use super::task::StartParams; @@ -17,6 +19,7 @@ use crate::queue::QueueState; use crate::spec::VirtioDeviceFeatures; use crate::spec::VirtioDeviceStatus; use chipset_device::io::deferred::DeferredWrite; +use futures::StreamExt; use guestmem::DoorbellRegistration; use guestmem::GuestMemory; use inspect::Inspect; @@ -69,6 +72,8 @@ pub(crate) struct VirtioTransportCore { pub device_sender: mesh::Sender, #[inspect(skip)] pub _device_task: Task<()>, + #[inspect(skip)] + pub config_change_recv: mesh::Receiver<()>, pub state: TransportState, pub device_feature: VirtioDeviceFeatures, #[inspect(hex)] @@ -88,6 +93,8 @@ pub(crate) struct VirtioTransportCore { pub doorbells: VirtioDoorbells, pub supports_save_restore: bool, #[inspect(skip)] + pub restore: restore::TransportRestore, + #[inspect(skip)] pub guest_memory: GuestMemory, #[inspect(with = "Option::is_some")] pub pending_status_deferred: Option, @@ -104,6 +111,8 @@ impl VirtioTransportCore { doorbell_registration: Option>, ) -> std::io::Result { let traits = device.traits(); + let accelerated_doorbells = device.supports_accelerated_doorbells(); + let doorbell_registration = doorbell_registration.filter(|_| accelerated_doorbells); let queues: Vec = (0..traits.max_queues) .map(|i| { let size = device.queue_size(i); @@ -129,15 +138,18 @@ impl VirtioTransportCore { .with_version_1(true) .with_access_platform(true); let supports_save_restore = device.supports_save_restore(); + let restore = restore::TransportRestore::new(&*device, traits.max_queues); let (sender, receiver) = mesh::channel(); + let (config_change_send, config_change_recv) = mesh::channel(); let _device_task = driver.spawn("virtio-device-task", async move { - run_device_task(device, receiver).await; + run_device_task(device, receiver, config_change_send).await; }); Ok(Self { device_sender: sender, _device_task, + config_change_recv, state: TransportState::Ready, device_feature, device_feature_select: 0, @@ -150,6 +162,7 @@ impl VirtioTransportCore { config_generation: 0, doorbells: VirtioDoorbells::new(doorbell_registration), supports_save_restore, + restore, guest_memory, pending_status_deferred: None, stalled_io: Vec::new(), @@ -187,6 +200,7 @@ impl VirtioTransportCore { // Immutable / long-lived — not reset. device_sender: _, _device_task: _, + config_change_recv: _, device_feature: _, supports_save_restore: _, guest_memory: _, @@ -208,10 +222,12 @@ impl VirtioTransportCore { driver_feature_select, queue_select, queues, + restore, } = self; drop(pending_status_deferred.take()); stalled_io.clear(); + restore.reset(); doorbells.clear(); *device_status = VirtioDeviceStatus::new(); @@ -277,6 +293,7 @@ impl VirtioTransportCore { ); if val == 0 { + self.reset_inactive_device(); if self.device_status.as_u32() == 0 { return; } @@ -327,6 +344,7 @@ impl VirtioTransportCore { event: qd.event.clone(), guest_memory: self.guest_memory.clone(), }, + qd.saved_state, ) }) .collect(); @@ -345,6 +363,10 @@ impl VirtioTransportCore { pub fn poll_device(&mut self, ops: &mut dyn TransportOps, cx: &mut std::task::Context<'_>) { self.poll_waker = Some(cx.waker().clone()); + while let Poll::Ready(Some(())) = self.config_change_recv.poll_next_unpin(cx) { + self.update_config_generation(ops); + } + if let Poll::Ready(result) = self.state.poll(cx) { // Complete the deferred STATUS write before applying the // result, since apply_transport_result may call reset_status @@ -358,6 +380,15 @@ impl VirtioTransportCore { /// `ChangeDeviceState::start()` implementation. pub fn start(&mut self, ops: &mut dyn TransportOps) { + if let Some(params) = self.start_params(ops) { + // Fire and forget — start() is sync, can't await. + self.device_sender + .send(DeviceCommand::Start(Rpc::detached(params))); + } + } + + fn start_params(&mut self, ops: &mut dyn TransportOps) -> Option { + let device_state = self.restore.take_start_state(); if self.device_status.driver_ok() { let features = self.driver_feature; let mut queues = Vec::new(); @@ -365,7 +396,7 @@ impl VirtioTransportCore { if !qd.params.enable { continue; } - let initial_state = qd.saved_state.take(); + let initial_state = qd.saved_state; queues.push(( i, qd.params, @@ -391,12 +422,14 @@ impl VirtioTransportCore { }) .collect(); - let params = StartParams { queues, features }; - - // Fire and forget — start() is sync, can't await. - self.device_sender - .send(DeviceCommand::Start(Rpc::detached(params))); + return Some(StartParams { + queues, + features, + device_state, + active: true, + }); } + restore::inactive_start_params(self.driver_feature, device_state) } /// `ChangeDeviceState::stop()` implementation. @@ -412,6 +445,7 @@ impl VirtioTransportCore { .call(DeviceCommand::Stop, ()) .await .expect("device task is gone"); + let states = self.restore.capture_stop(states, &self.queues); for (i, state) in states.into_iter().enumerate() { self.queues[i].saved_state = state; } @@ -427,7 +461,7 @@ impl VirtioTransportCore { /// Signal a queue notification by index. pub fn notify_queue(&self, queue_index: u32) { if let Some(qd) = self.queues.get(queue_index as usize) { - qd.event.signal(); + self.kick_queue(queue_index, &qd.event); } } @@ -469,6 +503,7 @@ impl VirtioTransportCore { &mut self, ops: &mut dyn TransportOps, common: &super::saved_state::state::CommonSavedState, + device_state: Option, queue_states: impl Iterator, saved_queue_count: usize, ) -> Result<(), RestoreError> { @@ -477,6 +512,7 @@ impl VirtioTransportCore { } let queue_items: Vec<_> = queue_states.collect(); + self.validate_restored_state(common, &device_state, &queue_items, saved_queue_count)?; super::saved_state::validate_restore( common, @@ -515,6 +551,7 @@ impl VirtioTransportCore { } self.device_status = VirtioDeviceStatus::from(common.device_status); + self.restore.stage(device_state); // Verify ephemeral runtime state. assert!(!self.state.is_busy()); diff --git a/vm/devices/virtio/virtio/src/transport/core/restore.rs b/vm/devices/virtio/virtio/src/transport/core/restore.rs new file mode 100644 index 00000000000..0e19c67669a --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/core/restore.rs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private state, restore validation, and queue kicks for the shared +//! transport core. +//! +//! `stop()` captures the device-private state next to the queue states, and +//! `save()` takes it. `restore_common()` validates the restored rings and the +//! private state before it changes any transport state, then stages the +//! private state, which the next start hands to the device task. + +use super::QueueData; +use super::TransportOps; +use super::VirtioTransportCore; +use crate::DynVirtioDevice; +use crate::device::saved_state::DeviceQueueState; +use crate::device::saved_state::DeviceStateValidator; +use crate::queue::QueueCoreCompleteWork; +use crate::queue::QueueCoreGetWork; +use crate::queue::QueueParams; +use crate::queue::QueueState; +use crate::spec::VirtioDeviceFeatures; +use crate::spec::VirtioDeviceStatus; +use crate::transport::saved_state::state::CommonQueueState; +use crate::transport::saved_state::state::CommonSavedState; +use crate::transport::task::DeviceCommand; +use crate::transport::task::StartParams; +use crate::transport::task::kick::Kick; +use crate::transport::task::kick::mark_kick_queued; +use crate::transport::task::restore::DeviceRestoreState; +use crate::transport::task::restore::StopResult; +use guestmem::GuestMemory; +use mesh::rpc::Rpc; +use mesh::rpc::RpcSend; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Device-private state and queue kick state of the transport core. +pub(crate) struct TransportRestore { + /// Validates restored private state; see VirtioDevice::device_state_validator. + validator: DeviceStateValidator, + /// Private state captured by the last `stop()`, taken by `save()`. + captured: Option, SaveError>>, + /// Private state restored by `restore_common()`, handed to the next start. + restored: DeviceRestoreState, + /// Per queue, whether a kick is queued to the device task. + kick_queued: Vec>, +} + +impl TransportRestore { + pub(super) fn new(device: &dyn DynVirtioDevice, max_queues: u16) -> Self { + Self { + validator: device.device_state_validator(), + captured: None, + restored: DeviceRestoreState::NotRestored, + kick_queued: (0..max_queues) + .map(|_| Arc::new(AtomicBool::new(false))) + .collect(), + } + } + + /// Resets the state for a transport reset. + pub(super) fn reset(&mut self) { + self.captured = None; + self.restored = DeviceRestoreState::NotRestored; + for kick_queued in &self.kick_queued { + kick_queued.store(false, Ordering::Release); + } + } + + /// Drops the captured private state and takes the restored private state + /// for a start. + pub(super) fn take_start_state(&mut self) -> DeviceRestoreState { + self.captured = None; + std::mem::replace(&mut self.restored, DeviceRestoreState::NotRestored) + } + + /// Stages restored private state for the next start. + pub(super) fn stage(&mut self, device_state: Option) { + self.restored = DeviceRestoreState::Restored(device_state); + } + + /// Captures the device-private state of a stop result and returns the + /// queue states to save. Queues that were not started keep their saved + /// state. + pub(super) fn capture_stop( + &mut self, + result: StopResult, + queues: &[QueueData], + ) -> Vec> { + self.captured = Some(result.device_state); + result + .queues + .into_iter() + .zip(queues) + .map(|((was_started, state), qd)| if was_started { state } else { qd.saved_state }) + .collect() + } +} + +/// Start parameters that only stage restored private state, for a device +/// that is not DRIVER_OK. +pub(super) fn inactive_start_params( + features: VirtioDeviceFeatures, + device_state: DeviceRestoreState, +) -> Option { + device_state.is_restored().then(|| StartParams { + queues: Vec::new(), + features, + device_state, + active: false, + }) +} + +impl VirtioTransportCore { + /// Starts the device and waits for private-state restore and queue startup. + pub async fn start_fallible(&mut self, ops: &mut dyn TransportOps) -> anyhow::Result<()> { + if let Some(params) = self.start_params(ops) { + self.device_sender + .call_failable(DeviceCommand::Start, params) + .await?; + } + Ok(()) + } + + /// Stops device-specific host input before a snapshot boundary. + pub async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.device_sender + .call_failable(DeviceCommand::QuiesceInput, ()) + .await?; + Ok(()) + } + + /// Resumes device-specific host input after a failed snapshot. + pub async fn resume_input(&mut self) -> anyhow::Result<()> { + self.device_sender + .call_failable(DeviceCommand::ResumeInput, ()) + .await?; + Ok(()) + } + + /// Take the device-private state captured when the queues stopped. + pub fn take_device_state(&mut self) -> Result, SaveError> { + self.restore + .captured + .take() + .ok_or_else(|| SaveError::Other(anyhow::anyhow!("device state was not captured")))? + } + + /// Sends a queue kick to the device task, unless one is already queued. + pub(super) fn kick_queue(&self, queue_index: u32, event: &pal_event::Event) { + if let Some(queued) = self.restore.kick_queued.get(queue_index as usize) { + if mark_kick_queued(queued) { + self.device_sender.send(DeviceCommand::Kick(Kick { + idx: queue_index as u16, + event: event.clone(), + queued: queued.clone(), + })); + } + } + } + + /// Resets the device task when the guest resets a device that is not + /// DRIVER_OK, which discards staged private state and kicks. + pub(super) fn reset_inactive_device(&self) { + if !self.device_status.driver_ok() { + self.device_sender + .send(DeviceCommand::Reset(Rpc::detached(()))); + } + } + + /// Validates restored state before `restore_common()` applies it. + /// + /// This is stricter than `saved_state::validate_restore()`, which + /// `restore_common()` runs afterwards: there must be exactly two feature + /// banks, queue sizes are bounded by each queue's device size, enabled + /// rings must be valid in guest memory, and the device must accept its + /// private state. + pub(super) fn validate_restored_state( + &self, + common: &CommonSavedState, + device_state: &Option, + queue_items: &[(CommonQueueState, u16)], + saved_queue_count: usize, + ) -> Result<(), RestoreError> { + let saved_banks = &common.driver_feature_banks; + if saved_banks.len() != 2 { + return Err(RestoreError::InvalidSavedState( + RestoreValidationError::FeatureBankCountMismatch { + saved: saved_banks.len(), + expected: 2, + } + .into(), + )); + } + // Check the feature banks and the queue count. The queue sizes are + // checked below, against each queue's device size. + crate::transport::saved_state::validate_restore( + common, + &self.device_feature, + std::iter::empty(), + self.queues.len(), + saved_queue_count, + crate::MAX_QUEUE_SIZE, + )?; + for (i, (q, _)) in queue_items.iter().enumerate() { + let max = self.queues[i].initial_size; + if q.size > max { + return Err(RestoreError::InvalidSavedState( + RestoreValidationError::QueueSizeTooLarge { + index: i, + size: q.size, + max, + } + .into(), + )); + } + } + + let mut restored_features = VirtioDeviceFeatures::new(); + for (index, &bank) in common.driver_feature_banks.iter().enumerate() { + restored_features.set_bank(index, bank); + } + let mut ring_ranges = Vec::new(); + for (index, (queue, _)) in queue_items.iter().enumerate() { + if !queue.enable { + continue; + } + if VirtioDeviceStatus::from(common.device_status).driver_ok() + && queue.queue_state.is_none() + { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "queue {index}: enabled DRIVER_OK queue is missing progress state" + ))); + } + validate_restored_queue( + index, + queue, + restored_features, + &self.guest_memory, + &mut ring_ranges, + )?; + } + let device_queues = queue_items + .iter() + .map(|(queue, _)| DeviceQueueState { + params: QueueParams { + size: queue.size, + enable: queue.enable, + desc_addr: queue.desc_addr, + avail_addr: queue.avail_addr, + used_addr: queue.used_addr, + }, + queue_state: queue.queue_state, + }) + .collect::>(); + (self.restore.validator)( + device_state.as_ref(), + &restored_features, + &device_queues, + &self.guest_memory, + ) + } +} + +#[derive(Debug, thiserror::Error)] +enum RestoreValidationError { + #[error("saved state has {saved} feature banks, expected {expected}")] + FeatureBankCountMismatch { saved: usize, expected: usize }, + #[error("queue {index}: saved size {size} exceeds device maximum {max}")] + QueueSizeTooLarge { index: usize, size: u16, max: u16 }, +} + +/// Validates the layout and progress of an enabled restored queue. Its rings +/// must not overlap previous_ranges, to which they are added. +pub(crate) fn validate_restored_queue( + index: usize, + queue: &CommonQueueState, + features: VirtioDeviceFeatures, + guest_memory: &GuestMemory, + previous_ranges: &mut Vec<(usize, &'static str, std::ops::Range)>, +) -> Result<(), RestoreError> { + let invalid = |message: String| { + RestoreError::InvalidSavedState(anyhow::anyhow!("queue {index}: {message}")) + }; + if queue.size == 0 || (!features.ring_packed() && !queue.size.is_power_of_two()) { + return Err(invalid(format!( + "invalid {} ring size {}", + if features.ring_packed() { + "packed" + } else { + "split" + }, + queue.size + ))); + } + + let descriptor_length = u64::from(queue.size) + .checked_mul(16) + .ok_or_else(|| invalid("descriptor length overflow".to_owned()))?; + let (available_length, used_length) = if features.ring_packed() { + (4, 4) + } else { + ( + u64::from(queue.size) + .checked_mul(2) + .and_then(|length| length.checked_add(6)) + .ok_or_else(|| invalid("available ring length overflow".to_owned()))?, + u64::from(queue.size) + .checked_mul(8) + .and_then(|length| length.checked_add(6)) + .ok_or_else(|| invalid("used ring length overflow".to_owned()))?, + ) + }; + let specifications = [ + ("descriptor", queue.desc_addr, descriptor_length, 16), + ("available", queue.avail_addr, available_length, 2), + ("used", queue.used_addr, used_length, 4), + ]; + let mut queue_ranges = Vec::new(); + for (name, address, length, alignment) in specifications { + if address % alignment != 0 { + return Err(invalid(format!( + "{name} ring address {address:#x} is not {alignment}-byte aligned" + ))); + } + let end = address + .checked_add(length) + .ok_or_else(|| invalid(format!("{name} ring range overflows")))?; + guest_memory + .subrange(address, length, true) + .map_err(|error| invalid(format!("{name} ring is outside guest RAM: {error}")))?; + let range = address..end; + for (other_index, other_name, other_range) in + previous_ranges.iter().chain(queue_ranges.iter()) + { + if range.start < other_range.end && other_range.start < range.end { + return Err(invalid(format!( + "{name} ring overlaps queue {other_index} {other_name} ring" + ))); + } + } + queue_ranges.push((index, name, range)); + } + + let params = QueueParams { + size: queue.size, + enable: true, + desc_addr: queue.desc_addr, + avail_addr: queue.avail_addr, + used_addr: queue.used_addr, + }; + QueueCoreGetWork::new(features, guest_memory.clone(), params, queue.queue_state) + .map_err(|error| invalid(format!("invalid available progress: {error}")))?; + QueueCoreCompleteWork::new(features, guest_memory.clone(), params, queue.queue_state) + .map_err(|error| invalid(format!("invalid used progress: {error}")))?; + previous_ranges.extend(queue_ranges); + Ok(()) +} diff --git a/vm/devices/virtio/virtio/src/transport/mmio.rs b/vm/devices/virtio/virtio/src/transport/mmio.rs index b418e8209e7..3dfd82d35cd 100644 --- a/vm/devices/virtio/virtio/src/transport/mmio.rs +++ b/vm/devices/virtio/virtio/src/transport/mmio.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(super) mod interrupt; + use super::StalledIo; use super::core::TransportOps; use super::core::VirtioTransportCore; @@ -25,7 +27,7 @@ use guestmem::DoorbellRegistration; use guestmem::GuestMemory; use inspect::Inspect; use inspect::InspectMut; -use pal_async::task::Spawn; +use interrupt::MmioDriver; use parking_lot::Mutex; use std::fmt; use std::ops::RangeInclusive; @@ -44,16 +46,23 @@ struct MmioTransport { #[inspect(hex)] vendor_id: u32, interrupt_state: Arc>, + #[inspect(skip)] + interrupt_ack: interrupt::InterruptAck, } #[derive(Inspect)] struct InterruptState { interrupt: LineInterrupt, status: u32, + #[inspect(flatten)] + delivery: interrupt::StatusDelivery, } impl InterruptState { fn update(&mut self, is_set: bool, bits: u32) { + if self.delivery.update(&self.interrupt, is_set, bits) { + return; + } if is_set { self.status |= bits; } else { @@ -80,7 +89,7 @@ impl TransportOps for MmioTransport { } fn reset_interrupts(&mut self) { - self.interrupt_state.lock().update(false, !0); + self.reset_interrupt_state(); } fn doorbell_region(&mut self) -> Option<(u64, u32)> { @@ -108,20 +117,50 @@ impl fmt::Debug for VirtioMmioDevice { impl VirtioMmioDevice { pub fn new( device: Box, - driver: &impl Spawn, + driver: &impl MmioDriver, + guest_memory: GuestMemory, + interrupt: LineInterrupt, + doorbell_registration: Option>, + mmio_gpa: u64, + mmio_len: u64, + ) -> std::io::Result { + Self::new_with_disabled_features_and_interrupt_mode( + device, + driver, + guest_memory, + interrupt, + doorbell_registration, + mmio_gpa, + mmio_len, + 0, + interrupt::VirtioMmioInterruptMode::Legacy, + ) + } + + /// Creates an MMIO transport with explicit interrupt-status delivery. + pub fn new_with_disabled_features_and_interrupt_mode( + device: Box, + driver: &impl MmioDriver, guest_memory: GuestMemory, interrupt: LineInterrupt, doorbell_registration: Option>, mmio_gpa: u64, mmio_len: u64, + disabled_features: u64, + interrupt_mode: interrupt::VirtioMmioInterruptMode, ) -> std::io::Result { let traits = device.traits(); + let (delivery, ack) = interrupt::setup(&*device, &guest_memory, interrupt_mode)?; let interrupt_state = Arc::new(Mutex::new(InterruptState { interrupt, status: 0, + delivery, })); + let ack = ack.register(driver, &doorbell_registration, mmio_gpa, &interrupt_state); - let core = VirtioTransportCore::new(device, driver, guest_memory, doorbell_registration)?; + let mut core = + VirtioTransportCore::new(device, driver, guest_memory, doorbell_registration)?; + core.device_feature = core.device_feature.without_bits(disabled_features); Ok(Self { core, @@ -130,6 +169,7 @@ impl VirtioMmioDevice { device_id: traits.device_id.0 as u32, vendor_id: 0x1af4, interrupt_state, + interrupt_ack: ack, }, }) } @@ -183,7 +223,7 @@ impl VirtioMmioDevice { .is_some_and(|qd| qd.params.enable) as u32 } VirtioMmioRegister::QUEUE_NOTIFY => 0, - VirtioMmioRegister::INTERRUPT_STATUS => self.mmio.interrupt_state.lock().status, + VirtioMmioRegister::INTERRUPT_STATUS => self.mmio.read_interrupt_status(), VirtioMmioRegister::INTERRUPT_ACK => 0, VirtioMmioRegister::STATUS => self.core.device_status.as_u32(), VirtioMmioRegister::QUEUE_DESC_LOW => self @@ -359,6 +399,18 @@ impl ChangeDeviceState for VirtioMmioDevice { self.core.start(&mut self.mmio); } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.core.start_fallible(&mut self.mmio).await + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.core.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.core.resume_input().await + } + async fn stop(&mut self) { self.core.stop(&mut self.mmio).await; } @@ -392,6 +444,7 @@ mod saved_state { use crate::transport::saved_state::state::CommonQueueState; use crate::transport::saved_state::state::CommonSavedState; use mesh::payload::Protobuf; + use vmcore::save_restore::SavedStateBlob; use vmcore::save_restore::SavedStateRoot; #[derive(Protobuf)] @@ -410,6 +463,8 @@ mod saved_state { pub queues: Vec, #[mesh(3)] pub interrupt_status: u32, + #[mesh(4)] + pub device_state: Option, } } @@ -427,7 +482,8 @@ mod saved_state { common: self.core.save_queue_common(i), }) .collect(), - interrupt_status: self.mmio.interrupt_state.lock().status, + device_state: self.core.take_device_state()?, + interrupt_status: self.mmio.read_interrupt_status(), }) } @@ -435,10 +491,12 @@ mod saved_state { &mut self, state: Self::SavedState, ) -> Result<(), vmcore::save_restore::RestoreError> { + self.mmio.reset_interrupt_state(); let saved_queue_count = state.queues.len(); self.core.restore_common( &mut self.mmio, &state.common, + state.device_state, state.queues.into_iter().map(|sq| (sq.common, 0)), saved_queue_count, )?; @@ -446,8 +504,7 @@ mod saved_state { // Restore MMIO-specific interrupt state. { let mut is = self.mmio.interrupt_state.lock(); - is.status = state.interrupt_status; - is.interrupt.set_level(is.status != 0); + is.restore_status(state.interrupt_status)?; } Ok(()) diff --git a/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs b/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs new file mode 100644 index 00000000000..cc297d4681e --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs @@ -0,0 +1,564 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Interrupt-status delivery of the virtio-mmio transport. +//! +//! In [`VirtioMmioInterruptMode::Legacy`] mode the guest reads +//! `INTERRUPT_STATUS` and acknowledges it through `INTERRUPT_ACK`. On Linux, +//! used-buffer acknowledgements may also arrive through an accelerated +//! doorbell on `INTERRUPT_ACK`; a used-buffer generation count keeps such a +//! deferred acknowledgement from clearing a completion that the guest has not +//! read yet. In [`VirtioMmioInterruptMode::SharedStatus`] mode the status lives +//! in a guest-memory word, and each zero-to-nonzero transition pulses the +//! interrupt line. + +use super::InterruptState; +use super::MmioTransport; +use super::VirtioMmioDevice; +use crate::DynVirtioDevice; +use crate::spec::VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER; +#[cfg(target_os = "linux")] +use crate::spec::mmio::VirtioMmioRegister; +use guestmem::DoorbellRegistration; +use guestmem::GuestMemory; +use guestmem::GuestMemoryError; +use inspect::Inspect; +#[cfg(target_os = "linux")] +use pal_async::driver::PollImpl; +#[cfg(target_os = "linux")] +use pal_async::fd::PollFdReady; +#[cfg(target_os = "linux")] +use pal_async::interest::InterestSlot; +#[cfg(target_os = "linux")] +use pal_async::interest::PollEvents; +#[cfg(target_os = "linux")] +use pal_async::task::Task; +#[cfg(target_os = "linux")] +use pal_event::Event; +use parking_lot::Mutex; +#[cfg(target_os = "linux")] +use std::future::poll_fn; +#[cfg(target_os = "linux")] +use std::os::fd::AsFd; +#[cfg(target_os = "linux")] +use std::os::fd::AsRawFd; +use std::sync::Arc; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::RestoreError; + +/// The driver bound of the MMIO transport constructors. On Linux the driver +/// must also wait for the accelerated `INTERRUPT_ACK` doorbell. +#[cfg(target_os = "linux")] +pub(super) use pal_async::driver::SpawnDriver as MmioDriver; +#[cfg(not(target_os = "linux"))] +pub(super) use pal_async::task::Spawn as MmioDriver; + +/// Interrupt-status delivery used by a virtio-mmio transport. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum VirtioMmioInterruptMode { + /// Virtio MMIO status reads, acknowledgement writes, and a level interrupt. + #[default] + Legacy, + /// A shared atomic status word and a pulse on each zero-to-nonzero transition. + SharedStatus { status_gpa: u64 }, +} + +impl VirtioMmioDevice { + /// Creates an MMIO transport after masking guest-visible device features. + pub fn new_with_disabled_features( + device: Box, + driver: &impl MmioDriver, + guest_memory: GuestMemory, + interrupt: LineInterrupt, + doorbell_registration: Option>, + mmio_gpa: u64, + mmio_len: u64, + disabled_features: u64, + ) -> std::io::Result { + Self::new_with_disabled_features_and_interrupt_mode( + device, + driver, + guest_memory, + interrupt, + doorbell_registration, + mmio_gpa, + mmio_len, + disabled_features, + VirtioMmioInterruptMode::Legacy, + ) + } +} + +/// Interrupt-status state in addition to the legacy status word. +#[derive(Inspect)] +pub(super) struct StatusDelivery { + used_buffer_generation: u64, + observed_used_buffer_generation: Option, + shared_status: Option, +} + +impl StatusDelivery { + /// Applies a status update in shared-status mode, or tracks used-buffer + /// generations in legacy mode. Returns `true` if the update is complete. + pub(super) fn update(&mut self, interrupt: &LineInterrupt, is_set: bool, bits: u32) -> bool { + if let Some(shared_status) = &self.shared_status { + if is_set { + let old_status = shared_status + .fetch_or(bits) + .expect("validated shared interrupt-status memory became inaccessible"); + if old_status == 0 { + interrupt.set_level(true); + interrupt.set_level(false); + } + } + return true; + } + + if bits & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0 { + if is_set { + self.used_buffer_generation = self.used_buffer_generation.wrapping_add(1); + } else { + self.observed_used_buffer_generation = None; + } + } + false + } +} + +#[derive(Inspect)] +struct SharedInterruptStatus { + #[inspect(skip)] + guest_memory: GuestMemory, + #[inspect(hex)] + gpa: u64, +} + +impl SharedInterruptStatus { + fn update(&self, operation: impl Fn(u32) -> u32) -> Result { + let mut current = self.guest_memory.read_plain::(self.gpa)?; + loop { + let new = operation(current); + match self.guest_memory.compare_exchange(self.gpa, current, new)? { + Ok(_) => return Ok(current), + Err(actual) => current = actual, + } + } + } + + fn load(&self) -> Result { + self.update(|current| current) + } + + fn store(&self, value: u32) -> Result { + self.update(|_| value) + } + + fn fetch_or(&self, bits: u32) -> Result { + self.update(|current| current | bits) + } +} + +/// Validates the interrupt mode of a new transport and returns its +/// interrupt-status state and its `INTERRUPT_ACK` doorbell setup. +pub(super) fn setup( + device: &dyn DynVirtioDevice, + guest_memory: &GuestMemory, + interrupt_mode: VirtioMmioInterruptMode, +) -> std::io::Result<(StatusDelivery, AckDoorbellSetup)> { + #[cfg(target_os = "linux")] + let supports_accelerated_doorbells = device.supports_accelerated_doorbells(); + #[cfg(not(target_os = "linux"))] + let _ = device; + let shared_status = match interrupt_mode { + VirtioMmioInterruptMode::Legacy => None, + VirtioMmioInterruptMode::SharedStatus { status_gpa } => { + if !status_gpa.is_multiple_of(size_of::() as u64) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("shared interrupt-status GPA {status_gpa:#x} is not naturally aligned"), + )); + } + let shared_status = SharedInterruptStatus { + guest_memory: guest_memory.clone(), + gpa: status_gpa, + }; + shared_status.store(0).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("shared interrupt-status GPA {status_gpa:#x} is inaccessible: {error}"), + ) + })?; + Some(shared_status) + } + }; + Ok(( + StatusDelivery { + used_buffer_generation: 0, + observed_used_buffer_generation: None, + shared_status, + }, + AckDoorbellSetup { + #[cfg(target_os = "linux")] + enabled: interrupt_mode == VirtioMmioInterruptMode::Legacy + && supports_accelerated_doorbells, + }, + )) +} + +/// Whether a new transport accelerates used-buffer acknowledgements. +pub(super) struct AckDoorbellSetup { + #[cfg(target_os = "linux")] + enabled: bool, +} + +impl AckDoorbellSetup { + /// Registers the `INTERRUPT_ACK` doorbell, if enabled and available. + #[cfg(target_os = "linux")] + pub(super) fn register( + self, + driver: &impl MmioDriver, + doorbell_registration: &Option>, + mmio_gpa: u64, + interrupt_state: &Arc>, + ) -> InterruptAck { + let (interrupt_ack_event, interrupt_ack_task, interrupt_ack_doorbell) = if self.enabled + && let Some(registration) = doorbell_registration + { + let event = Event::new(); + match registration.register_doorbell( + mmio_gpa + VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + Some(4), + &event, + ) { + Ok(doorbell) => match driver.new_dyn_fd_ready(event.as_fd().as_raw_fd()) { + Ok(ready) => { + let interrupt_ack_event = event.clone(); + let task = driver.spawn( + "virtio-mmio-interrupt-ack", + InterruptAckWait { + ready, + event, + interrupt_state: interrupt_state.clone(), + } + .run(), + ); + (Some(interrupt_ack_event), Some(task), Some(doorbell)) + } + Err(_) => (None, None, None), + }, + Err(_) => (None, None, None), + } + } else { + (None, None, None) + }; + InterruptAck { + _task: interrupt_ack_task, + _doorbell: interrupt_ack_doorbell, + event: interrupt_ack_event, + } + } + + /// Registers the `INTERRUPT_ACK` doorbell, if enabled and available. + #[cfg(not(target_os = "linux"))] + pub(super) fn register( + self, + _driver: &impl MmioDriver, + _doorbell_registration: &Option>, + _mmio_gpa: u64, + _interrupt_state: &Arc>, + ) -> InterruptAck { + InterruptAck {} + } +} + +/// The accelerated `INTERRUPT_ACK` doorbell of a transport, if registered. +#[derive(Default)] +pub(super) struct InterruptAck { + #[cfg(target_os = "linux")] + _task: Option>, + #[cfg(target_os = "linux")] + _doorbell: Option>, + #[cfg(target_os = "linux")] + event: Option, +} + +impl InterruptAck { + /// Applies a used-buffer acknowledgement that arrived through the doorbell. + fn acknowledge(&self, state: &mut InterruptState) { + #[cfg(target_os = "linux")] + if let Some(event) = &self.event { + if event.try_wait() { + state.acknowledge_used_buffer(); + } + } + #[cfg(not(target_os = "linux"))] + let _ = state; + } +} + +#[cfg(target_os = "linux")] +struct InterruptAckWait { + ready: PollImpl, + event: Event, + interrupt_state: Arc>, +} + +#[cfg(target_os = "linux")] +impl InterruptAckWait { + async fn run(mut self) { + loop { + poll_fn(|cx| { + self.ready + .poll_fd_ready(cx, InterestSlot::Read, PollEvents::IN) + }) + .await; + self.ready.clear_fd_ready(InterestSlot::Read); + let mut state = self.interrupt_state.lock(); + if self.event.try_wait() { + state.acknowledge_used_buffer(); + } + } + } +} + +impl InterruptState { + fn read_status(&mut self) -> u32 { + if let Some(shared_status) = &self.delivery.shared_status { + return shared_status + .load() + .expect("validated shared interrupt-status memory became inaccessible"); + } + if self.status & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0 { + self.delivery.observed_used_buffer_generation = + Some(self.delivery.used_buffer_generation); + } + self.status + } + + #[cfg(target_os = "linux")] + fn acknowledge_used_buffer(&mut self) { + if self.delivery.shared_status.is_some() { + return; + } + if self.delivery.observed_used_buffer_generation + == Some(self.delivery.used_buffer_generation) + { + self.update(false, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + } else { + self.delivery.observed_used_buffer_generation = None; + } + } + + /// Restores the saved interrupt status. + pub(super) fn restore_status(&mut self, status: u32) -> Result<(), RestoreError> { + if let Some(shared_status) = &self.delivery.shared_status { + shared_status + .store(status) + .map_err(|error| RestoreError::InvalidSavedState(error.into()))?; + self.interrupt.set_level(false); + } else { + self.status = status; + self.delivery.used_buffer_generation = 0; + self.delivery.observed_used_buffer_generation = + (self.status & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0).then_some(0); + self.interrupt.set_level(self.status != 0); + } + Ok(()) + } +} + +impl MmioTransport { + fn lock_interrupt_state(&self) -> parking_lot::MutexGuard<'_, InterruptState> { + let mut state = self.interrupt_state.lock(); + self.interrupt_ack.acknowledge(&mut state); + state + } + + pub(super) fn read_interrupt_status(&self) -> u32 { + self.lock_interrupt_state().read_status() + } + + pub(super) fn reset_interrupt_state(&self) { + let mut state = self.lock_interrupt_state(); + if let Some(shared_status) = &state.delivery.shared_status { + shared_status + .store(0) + .expect("validated shared interrupt-status memory became inaccessible"); + } + state.status = 0; + state.delivery.used_buffer_generation = 0; + state.delivery.observed_used_buffer_generation = None; + state.interrupt.set_level(false); + } +} + +impl Drop for MmioTransport { + fn drop(&mut self) { + let state = self.interrupt_state.lock(); + if let Some(shared_status) = &state.delivery.shared_status { + let _ = shared_status.store(0); + } + state.interrupt.set_level(false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE; + use std::sync::Barrier; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use vmcore::line_interrupt::LineSetTarget; + + #[derive(Default)] + struct CountingInterruptTarget { + high: AtomicBool, + pulses: AtomicUsize, + } + + impl LineSetTarget for CountingInterruptTarget { + fn set_irq(&self, _vector: u32, high: bool) { + self.high.store(high, Ordering::SeqCst); + if high { + self.pulses.fetch_add(1, Ordering::SeqCst); + } + } + } + + fn shared_interrupt_state( + guest_memory: &GuestMemory, + target: Arc, + ) -> InterruptState { + InterruptState { + interrupt: LineInterrupt::new_with_target("shared-status-test", target, 0), + status: 0, + delivery: StatusDelivery { + used_buffer_generation: 0, + observed_used_buffer_generation: None, + shared_status: Some(SharedInterruptStatus { + guest_memory: guest_memory.clone(), + gpa: 0, + }), + }, + } + } + + #[test] + fn shared_status_coalesces_bits_and_pulses_on_zero_transition() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let mut state = shared_interrupt_state(&guest_memory, target.clone()); + + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + + assert_eq!(state.read_status(), 3); + assert_eq!(target.pulses.load(Ordering::SeqCst), 1); + assert!(!target.high.load(Ordering::SeqCst)); + + let consumed = state + .delivery + .shared_status + .as_ref() + .unwrap() + .store(0) + .unwrap(); + assert_eq!(consumed, 3); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE); + assert_eq!(target.pulses.load(Ordering::SeqCst), 2); + } + + #[test] + fn shared_status_completion_racing_exchange_is_not_lost() { + const ITERATIONS: usize = 1000; + + let guest_memory = GuestMemory::allocate(0x1000); + let shared_status = Arc::new(SharedInterruptStatus { + guest_memory, + gpa: 0, + }); + let barrier = Arc::new(Barrier::new(2)); + let publisher = { + let shared_status = shared_status.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + for _ in 0..ITERATIONS { + barrier.wait(); + shared_status + .fetch_or(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER) + .unwrap(); + barrier.wait(); + } + }) + }; + + for _ in 0..ITERATIONS { + shared_status.store(0).unwrap(); + barrier.wait(); + let consumed = shared_status.store(0).unwrap(); + barrier.wait(); + let pending = shared_status.load().unwrap(); + assert_eq!( + (consumed | pending) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + } + publisher.join().unwrap(); + } + + #[test] + fn shared_status_reset_clears_pending_state_without_interrupt() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let state = MmioTransport { + fixed_mmio_region: ("test", 0..=0xfff), + device_id: 0, + vendor_id: 0, + interrupt_state: Arc::new(Mutex::new(shared_interrupt_state( + &guest_memory, + target.clone(), + ))), + interrupt_ack: InterruptAck::default(), + }; + state + .interrupt_state + .lock() + .update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + let pulses = target.pulses.load(Ordering::SeqCst); + + state.reset_interrupt_state(); + + assert_eq!(state.read_interrupt_status(), 0); + assert_eq!(target.pulses.load(Ordering::SeqCst), pulses); + assert!(!target.high.load(Ordering::SeqCst)); + } + + #[test] + fn shared_status_teardown_clears_pending_state() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let transport = MmioTransport { + fixed_mmio_region: ("test", 0..=0xfff), + device_id: 0, + vendor_id: 0, + interrupt_state: Arc::new(Mutex::new(shared_interrupt_state( + &guest_memory, + target.clone(), + ))), + interrupt_ack: InterruptAck::default(), + }; + transport + .interrupt_state + .lock() + .update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + + drop(transport); + + assert_eq!(guest_memory.read_plain::(0).unwrap(), 0); + assert!(!target.high.load(Ordering::SeqCst)); + } +} diff --git a/vm/devices/virtio/virtio/src/transport/mod.rs b/vm/devices/virtio/virtio/src/transport/mod.rs index fe444f0c03f..913f06c3d52 100644 --- a/vm/devices/virtio/virtio/src/transport/mod.rs +++ b/vm/devices/virtio/virtio/src/transport/mod.rs @@ -51,5 +51,6 @@ fn validate_queue_size(queue_index: u16, size: u16) -> io::Result<()> { } pub use mmio::VirtioMmioDevice; +pub use mmio::interrupt::VirtioMmioInterruptMode; pub use pci::PciInterruptModel; pub use pci::VirtioPciDevice; diff --git a/vm/devices/virtio/virtio/src/transport/pci.rs b/vm/devices/virtio/virtio/src/transport/pci.rs index 112d129e15f..da14ba8187b 100644 --- a/vm/devices/virtio/virtio/src/transport/pci.rs +++ b/vm/devices/virtio/virtio/src/transport/pci.rs @@ -772,6 +772,18 @@ impl ChangeDeviceState for VirtioPciDevice { self.core.start(&mut self.pci); } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.core.start_fallible(&mut self.pci).await + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.core.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.core.resume_input().await + } + async fn stop(&mut self) { self.core.stop(&mut self.pci).await; } @@ -813,6 +825,7 @@ mod saved_state { use mesh::payload::Protobuf; use pci_core::cfg_space_emu::ConfigSpaceType0Emulator; use vmcore::save_restore::SaveRestore; + use vmcore::save_restore::SavedStateBlob; use vmcore::save_restore::SavedStateRoot; #[derive(Protobuf)] @@ -840,6 +853,8 @@ mod saved_state { /// the guest firmware runs) survive a save/restore cycle. #[mesh(5)] pub cfg_space: ::SavedState, + #[mesh(6)] + pub device_state: Option, } #[derive(Protobuf, SavedStateRoot)] @@ -874,8 +889,9 @@ mod saved_state { msix_vector: qd.msix_vector, }) .collect(), - interrupt_status: *self.pci.interrupt_status.lock(), cfg_space: self.pci.config_space.save()?, + device_state: self.core.take_device_state()?, + interrupt_status: *self.pci.interrupt_status.lock(), }) } @@ -900,6 +916,7 @@ mod saved_state { self.core.restore_common( &mut self.pci, &state.common, + state.device_state, state .queues .into_iter() diff --git a/vm/devices/virtio/virtio/src/transport/task.rs b/vm/devices/virtio/virtio/src/transport/task.rs index 79cb453b018..ac64a0d3182 100644 --- a/vm/devices/virtio/virtio/src/transport/task.rs +++ b/vm/devices/virtio/virtio/src/transport/task.rs @@ -8,6 +8,9 @@ //! The transports become thin MMIO/PCI forwarders that send RPCs to //! the task. +pub(super) mod kick; +pub(super) mod restore; + use crate::DynVirtioDevice; use crate::QueueResources; use crate::queue::QueueState; @@ -18,6 +21,7 @@ use chipset_device::io::deferred::DeferredWrite; use chipset_device::io::deferred::defer_read; use chipset_device::io::deferred::defer_write; use futures::StreamExt; +use futures::future::poll_fn; use inspect::Inspect; use mesh::rpc::FailableRpc; use mesh::rpc::PendingRpc; @@ -36,11 +40,15 @@ pub enum DeviceCommand { /// Guest writes status=0 — stop all queues, reset device. Disable(Rpc<(), ()>), /// ChangeDeviceState::stop() — stop queues, return states for resume. - Stop(Rpc<(), Vec>>), + Stop(Rpc<(), restore::StopResult>), /// ChangeDeviceState::start() — restart queues with saved states. Start(FailableRpc), /// ChangeDeviceState::reset() — stop queues, reset device. Reset(Rpc<(), ()>), + /// Gate host input before establishing a snapshot vCPU boundary. + QuiesceInput(FailableRpc<(), ()>), + /// Resume host input after a failed snapshot transaction. + ResumeInput(FailableRpc<(), ()>), /// Config register read at byte offset with byte length. ReadConfig { offset: u16, @@ -55,6 +63,8 @@ pub enum DeviceCommand { data: [u8; 8], deferred: DeferredWrite, }, + /// Queue notification, serialized with private-state activation. + Kick(kick::Kick), /// Inspect the device state. Inspect(inspect::Deferred), } @@ -74,7 +84,7 @@ pub enum ConfigReadCompletion { /// Parameters for the Enable command. pub struct EnableParams { - pub queues: Vec<(u16, QueueResources)>, + pub queues: Vec<(u16, QueueResources, Option)>, pub features: VirtioDeviceFeatures, } @@ -82,6 +92,8 @@ pub struct EnableParams { pub struct StartParams { pub queues: Vec<(u16, QueueResources, Option)>, pub features: VirtioDeviceFeatures, + pub device_state: restore::DeviceRestoreState, + pub active: bool, } /// Transport-side state machine tracking in-flight device operations. @@ -129,7 +141,7 @@ impl TransportState { pub fn start_enable( &mut self, sender: &mesh::Sender, - queues: Vec<(u16, QueueResources)>, + queues: Vec<(u16, QueueResources, Option)>, features: VirtioDeviceFeatures, ) { assert!(!self.is_busy()); @@ -186,14 +198,17 @@ impl TransportState { struct DeviceTask { device: Box, max_queues: u16, + restore: restore::RestoreStaging, + kicks: kick::KickState, } impl DeviceTask { async fn enable(&mut self, params: EnableParams) -> bool { - for (idx, resources) in params.queues { + let mut started = + kick::StartedQueues::new("driver-ok", ¶ms.features, params.queues.len()); + for (idx, resources, initial_state) in params.queues { if let Err(err) = self - .device - .start_queue(idx, resources, ¶ms.features, None) + .start_queue(idx, resources, initial_state, &mut started) .await { tracelimit::error_ratelimited!( @@ -202,30 +217,34 @@ impl DeviceTask { "virtio device start_queue failed" ); self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; return false; } } + self.dispatch_pending_kicks(started); true } async fn disable(&mut self) { self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; } - async fn stop(&mut self) -> Vec> { + async fn stop(&mut self) -> restore::StopResult { let mut states = vec![None; self.max_queues as usize]; for idx in 0..self.max_queues { states[idx as usize] = self.device.stop_queue(idx).await; } - states + self.stop_result(states) } async fn start(&mut self, params: StartParams) -> anyhow::Result<()> { + let mut started = + kick::StartedQueues::new("active-start", ¶ms.features, params.queues.len()); for (idx, resources, initial_state) in params.queues { - self.device - .start_queue(idx, resources, ¶ms.features, initial_state) + self.start_queue(idx, resources, initial_state, &mut started) .await .map_err(|err| { tracelimit::error_ratelimited!( @@ -236,17 +255,20 @@ impl DeviceTask { err })?; } + self.dispatch_pending_kicks(started); Ok(()) } async fn reset(&mut self) { self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; } async fn stop_all_queues(&mut self) { for idx in 0..self.max_queues { self.device.stop_queue(idx).await; + self.kicks.started[idx as usize] = false; } } } @@ -255,16 +277,53 @@ impl DeviceTask { pub async fn run_device_task( device: Box, mut recv: mesh::Receiver, + config_change_send: mesh::Sender<()>, ) { + let traits = device.traits(); let mut task = DeviceTask { - max_queues: device.traits().max_queues, + max_queues: traits.max_queues, + restore: restore::RestoreStaging::new(traits.device_id.0), + kicks: kick::KickState::new(traits.max_queues), device, }; - while let Some(cmd) = recv.next().await { + loop { + enum Event { + Command(Option), + ConfigChange(anyhow::Result), + } + + let event = poll_fn(|cx| { + if let Poll::Ready(result) = task.device.poll_config_change(cx) { + return Poll::Ready(Event::ConfigChange(result)); + } + recv.poll_next_unpin(cx).map(Event::Command) + }) + .await; + + let cmd = match event { + Event::Command(Some(cmd)) => cmd, + Event::Command(None) => break, + Event::ConfigChange(Ok(true)) => { + config_change_send.send(()); + continue; + } + Event::ConfigChange(Ok(false)) => continue, + Event::ConfigChange(Err(error)) => { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "virtio host control failed" + ); + continue; + } + }; + let Some(cmd) = task.restore_before_config(cmd) else { + continue; + }; match cmd { DeviceCommand::Enable(rpc) => { - rpc.handle(async |params| task.enable(params).await).await; + rpc.handle(async |params| task.enable_with_restore(params).await) + .await; } DeviceCommand::Disable(rpc) => { rpc.handle(async |()| task.disable().await).await; @@ -278,12 +337,14 @@ pub async fn run_device_task( // but not propagated to the transport. // TODO: update ChangeDeviceState to allow async start() // so failures can be handled by the transport. - rpc.handle_failable(async |params| task.start(params).await) + rpc.handle_failable(async |params| task.start_with_restore(params).await) .await; } DeviceCommand::Reset(rpc) => { rpc.handle(async |()| task.reset().await).await; } + DeviceCommand::QuiesceInput(rpc) => task.quiesce_input(rpc).await, + DeviceCommand::ResumeInput(rpc) => task.resume_input(rpc).await, DeviceCommand::ReadConfig { offset, len, @@ -341,6 +402,7 @@ pub async fn run_device_task( } deferred.complete(); } + DeviceCommand::Kick(kick) => task.kick(kick), DeviceCommand::Inspect(deferred) => { deferred.inspect(&mut *task.device); } diff --git a/vm/devices/virtio/virtio/src/transport/task/kick.rs b/vm/devices/virtio/virtio/src/transport/task/kick.rs new file mode 100644 index 00000000000..2b1cff43332 --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/task/kick.rs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Queue kicks serialized through the device task. +//! +//! Emulated queue notifications reach the device task as +//! [`DeviceCommand::Kick`](super::DeviceCommand::Kick), so that they are +//! ordered with device-private state activation. Kicks coalesce until the +//! device task dequeues them. A kick for a queue that has not started is staged +//! and dispatched once the queue starts. + +use super::DeviceTask; +use crate::QueueResources; +use crate::queue::QueueState; +use crate::spec::VirtioDeviceFeatures; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +/// Queue notification, serialized with private-state activation. +pub struct Kick { + pub idx: u16, + pub event: pal_event::Event, + pub queued: Arc, +} + +/// Marks a queue kick as queued. Returns `true` if the caller must send a +/// [`Kick`], or `false` if one is already queued. +pub(crate) fn mark_kick_queued(queued: &AtomicBool) -> bool { + !queued.swap(true, Ordering::AcqRel) +} + +/// Per-queue kick state of the device task. +pub(super) struct KickState { + /// The notification event of each queue that has been kicked. + events: Vec>, + /// Kicks staged for queues that have not started. + pending: Vec, + /// Queues that are started. + pub(super) started: Vec, +} + +impl KickState { + pub(super) fn new(max_queues: u16) -> Self { + Self { + events: vec![None; max_queues as usize], + pending: vec![false; max_queues as usize], + started: vec![false; max_queues as usize], + } + } +} + +/// Queues started by one `enable` or `start`, whose staged kicks are +/// dispatched once all of the queues have started. +pub(super) struct StartedQueues<'a> { + /// The trigger reported by the `queue_start` restore lifecycle trace. + trigger: &'static str, + features: &'a VirtioDeviceFeatures, + events: Vec<(u16, pal_event::Event)>, +} + +impl<'a> StartedQueues<'a> { + pub(super) fn new( + trigger: &'static str, + features: &'a VirtioDeviceFeatures, + queue_count: usize, + ) -> Self { + Self { + trigger, + features, + events: Vec::with_capacity(queue_count), + } + } +} + +impl DeviceTask { + /// Starts one queue and records it in `started`. + pub(super) async fn start_queue( + &mut self, + idx: u16, + resources: QueueResources, + initial_state: Option, + started: &mut StartedQueues<'_>, + ) -> anyhow::Result<()> { + let event = resources.event.clone(); + let restored_progress = initial_state.is_some(); + let result = self + .device + .start_queue(idx, resources, started.features, initial_state) + .await; + tracing::debug!( + target: "virtio_restore", + event = "queue_start", + device_type = self.restore.device_type, + trigger = started.trigger, + queue_index = i32::from(idx), + restored_progress, + success = result.is_ok(), + "virtio restore lifecycle" + ); + if result.is_ok() { + self.kicks.started[idx as usize] = true; + started.events.push((idx, event)); + } + result + } + + /// Handles `DeviceCommand::Kick`. + pub(super) fn kick(&mut self, kick: Kick) { + let Kick { idx, event, queued } = kick; + queued.store(false, Ordering::Release); + if let Some(slot) = self.kicks.events.get_mut(idx as usize) { + *slot = Some(event.clone()); + } + if let Err(error) = self.apply_pending_restore("kick", Some(idx)) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + idx, + "virtio device restore failed before queue kick" + ); + return; + } + if self.kicks.started.get(idx as usize) == Some(&true) { + event.signal(); + tracing::debug!( + target: "virtio_restore", + event = "kick_dispatch", + device_type = self.restore.device_type, + trigger = "kick", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } else if let Some(pending) = self.kicks.pending.get_mut(idx as usize) { + let newly_staged = !*pending; + *pending = true; + if newly_staged { + tracing::debug!( + target: "virtio_restore", + event = "kick_staged", + device_type = self.restore.device_type, + trigger = "kick", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } + } + } + + /// Drains stale queue notifications and discards staged kicks. + pub(super) fn clear_queue_events(&mut self) { + for event in self.kicks.events.iter().flatten() { + while event.try_wait() {} + } + self.kicks.pending.fill(false); + } + + /// Dispatches the kicks staged for the queues in `started`. + pub(super) fn dispatch_pending_kicks(&mut self, started: StartedQueues<'_>) { + for (idx, event) in started.events { + self.dispatch_pending_kick(idx, &event); + } + } + + fn dispatch_pending_kick(&mut self, idx: u16, event: &pal_event::Event) { + if !std::mem::take(&mut self.kicks.pending[idx as usize]) { + return; + } + event.signal(); + tracing::debug!( + target: "virtio_restore", + event = "kick_dispatch", + device_type = self.restore.device_type, + trigger = "driver-ok", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } +} + +#[cfg(test)] +mod tests { + use super::mark_kick_queued; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use test_with_tracing::test; + + #[test] + fn queue_kicks_coalesce_until_the_command_is_dequeued() { + let queued = AtomicBool::new(false); + + assert!(mark_kick_queued(&queued)); + assert!(!mark_kick_queued(&queued)); + + queued.store(false, Ordering::Release); + assert!(mark_kick_queued(&queued)); + } +} diff --git a/vm/devices/virtio/virtio/src/transport/task/restore.rs b/vm/devices/virtio/virtio/src/transport/task/restore.rs new file mode 100644 index 00000000000..214bd46b3e1 --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/task/restore.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private state capture and restore staging in the device task. +//! +//! `ChangeDeviceState::stop()` captures the device-private state together +//! with the queue states. After a restore, `start()` stages the restored +//! private state, and the device task applies it on the first activation: the +//! start of an active device, DRIVER_OK, a device-config access, or a queue +//! kick. + +use super::DeviceCommand; +use super::DeviceTask; +use super::EnableParams; +use super::StartParams; +use crate::queue::QueueState; +use chipset_device::io::IoError; +use mesh::rpc::FailableRpc; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Whether this start follows restore, and its optional private payload. +#[derive(Clone)] +pub enum DeviceRestoreState { + NotRestored, + Restored(Option), +} + +impl DeviceRestoreState { + pub fn is_restored(&self) -> bool { + matches!(self, Self::Restored(_)) + } +} + +/// Queue and device-private state captured after a device has stopped. +pub struct StopResult { + pub queues: Vec<(bool, Option)>, + pub device_state: Result, SaveError>, +} + +/// Restore staging state of the device task. +pub(super) struct RestoreStaging { + /// The device type reported by restore lifecycle traces. + pub(super) device_type: u16, + /// Restored private state that has not been applied to the device yet. + pending: DeviceRestoreState, +} + +impl RestoreStaging { + pub(super) fn new(device_type: u16) -> Self { + Self { + device_type, + pending: DeviceRestoreState::NotRestored, + } + } +} + +impl DeviceTask { + /// Handles `DeviceCommand::Enable`: applies staged private state before + /// the queues start. + pub(super) async fn enable_with_restore(&mut self, params: EnableParams) -> bool { + if let Err(error) = self.apply_pending_restore("driver-ok", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed during enable" + ); + self.stop_all_queues().await; + self.clear_queue_events(); + self.device.reset().await; + self.restore.pending = DeviceRestoreState::NotRestored; + return false; + } + self.enable(params).await + } + + /// Handles `DeviceCommand::Start`: stages restored private state and, for + /// an active device, applies it before the queues start. Stops the queues + /// again if one of them fails to start. + pub(super) async fn start_with_restore( + &mut self, + mut params: StartParams, + ) -> anyhow::Result<()> { + let device_state = + std::mem::replace(&mut params.device_state, DeviceRestoreState::NotRestored); + self.stage_restore(device_state, params.active); + if !params.active { + return Ok(()); + } + self.apply_pending_restore("active-start", None)?; + let result = self.start(params).await; + if result.is_err() { + self.stop_all_queues().await; + } + result + } + + fn stage_restore(&mut self, state: DeviceRestoreState, active: bool) { + if state.is_restored() { + tracing::debug!( + target: "virtio_restore", + event = "restore_staged", + device_type = self.restore.device_type, + trigger = if active { "active-start" } else { "inactive-start" }, + queue_index = -1, + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + self.restore.pending = state; + } + } + + pub(super) fn apply_pending_restore( + &mut self, + trigger: &'static str, + queue_index: Option, + ) -> Result<(), RestoreError> { + let DeviceRestoreState::Restored(state) = &self.restore.pending else { + return Ok(()); + }; + let result = self.device.restore_device(state.clone()); + tracing::debug!( + target: "virtio_restore", + event = "private_state_apply", + device_type = self.restore.device_type, + trigger, + queue_index = queue_index.map(i32::from).unwrap_or(-1), + restored_progress = false, + success = result.is_ok(), + "virtio restore lifecycle" + ); + if result.is_ok() { + self.restore.pending = DeviceRestoreState::NotRestored; + } + result + } + + /// Discards staged kicks and staged private state before the device is + /// reset. + pub(super) fn discard_staged(&mut self) { + self.clear_queue_events(); + self.restore.pending = DeviceRestoreState::NotRestored; + } + + /// Builds the `DeviceCommand::Stop` result from the stopped queue states. + /// + /// Only queues that were started report their state. The device-private + /// state is saved from the device, or is the staged restored state if it + /// has not been applied yet. + pub(super) fn stop_result(&mut self, states: Vec>) -> StopResult { + StopResult { + queues: self + .kicks + .started + .iter_mut() + .map(std::mem::take) + .zip(states) + .collect(), + device_state: match &self.restore.pending { + DeviceRestoreState::NotRestored => self.device.save_device(), + DeviceRestoreState::Restored(state) => Ok(state.clone()), + }, + } + } + + /// Applies staged private state before a device-config access. + /// + /// Returns the command to process, or `None` if the restore failed and the + /// access was completed with an error. + pub(super) fn restore_before_config(&mut self, cmd: DeviceCommand) -> Option { + match cmd { + DeviceCommand::ReadConfig { + offset, + len, + completion, + deferred, + } => { + if let Err(error) = self.apply_pending_restore("config-read", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed before config read" + ); + deferred.complete_error(IoError::NoResponse); + return None; + } + Some(DeviceCommand::ReadConfig { + offset, + len, + completion, + deferred, + }) + } + DeviceCommand::WriteConfig { + offset, + len, + data, + deferred, + } => { + if let Err(error) = self.apply_pending_restore("config-write", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed before config write" + ); + deferred.complete_error(IoError::NoResponse); + return None; + } + Some(DeviceCommand::WriteConfig { + offset, + len, + data, + deferred, + }) + } + cmd => Some(cmd), + } + } + + /// Handles `DeviceCommand::QuiesceInput`. + pub(super) async fn quiesce_input(&mut self, rpc: FailableRpc<(), ()>) { + rpc.handle_failable(async |()| self.device.quiesce_input().await) + .await; + } + + /// Handles `DeviceCommand::ResumeInput`. + pub(super) async fn resume_input(&mut self, rpc: FailableRpc<(), ()>) { + rpc.handle_failable(async |()| self.device.resume_input().await) + .await; + } +} diff --git a/vm/devices/virtio/virtio_blk/Cargo.toml b/vm/devices/virtio/virtio_blk/Cargo.toml index f9e453d8d3e..0ecba5fbbd1 100644 --- a/vm/devices/virtio/virtio_blk/Cargo.toml +++ b/vm/devices/virtio/virtio_blk/Cargo.toml @@ -10,10 +10,13 @@ rust-version.workspace = true vm_resource.workspace = true disk_backend.workspace = true +disk_file.workspace = true guestmem.workspace = true inspect.workspace = true inspect_counters.workspace = true +mesh.workspace = true pal_async.workspace = true +parking_lot.workspace = true scsi_buffers.workspace = true task_control.workspace = true tracelimit.workspace = true @@ -28,11 +31,13 @@ unicycle.workspace = true zerocopy.workspace = true [dev-dependencies] +async-channel.workspace = true disklayer_ram.workspace = true pal_async.workspace = true pal_event.workspace = true parking_lot.workspace = true test_with_tracing.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/vm/devices/virtio/virtio_blk/src/integration_tests.rs b/vm/devices/virtio/virtio_blk/src/integration_tests.rs index 2efb6fa15f4..86a31e74dea 100644 --- a/vm/devices/virtio/virtio_blk/src/integration_tests.rs +++ b/vm/devices/virtio/virtio_blk/src/integration_tests.rs @@ -26,6 +26,11 @@ use parking_lot::Mutex; use scsi_buffers::RequestBuffers; use std::future::Future; use std::pin::pin; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::task::Context; +use std::task::Waker; use std::time::Duration; use test_with_tracing::test; use virtio::QueueResources; @@ -90,15 +95,30 @@ impl TestHarness { disk: Disk, read_only: bool, ) -> Self { - let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); - - init_avail_ring(&mem, AVAIL_ADDR); - init_used_ring(&mem, USED_ADDR); - let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(device_driver.clone())); let device = VirtioBlkDevice::new(&driver_source, disk, read_only); + Self::with_device(driver, device) + } + fn empty_image_slot( + driver: &DefaultDriver, + ) -> (Self, mesh::Sender) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let (requests, receiver) = mesh::channel(); + ( + Self::with_device( + driver, + VirtioBlkDevice::new_image_slot(&driver_source, true, receiver), + ), + requests, + ) + } + + fn with_device(driver: &DefaultDriver, device: VirtioBlkDevice) -> Self { + let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); + init_avail_ring(&mem, AVAIL_ADDR); + init_used_ring(&mem, USED_ADDR); let queue_event = Event::new(); let interrupt_event = Event::new(); @@ -218,7 +238,7 @@ impl TestHarness { /// desc 2 (writable): 1-byte status /// /// Returns the head descriptor index. - fn post_write_request(&mut self, head_desc: u16, sector: u64, data: &[u8]) { + fn post_write_request(&mut self, head_desc: u16, sector: u64, data: &[u8]) -> u64 { let header_gpa = self.alloc_data(REQ_HEADER_SIZE); let data_gpa = self.alloc_data(data.len() as u32); let status_gpa = self.alloc_data(1); @@ -281,6 +301,7 @@ impl TestHarness { &mut self.avail_idx, ); self.queue_event.signal(); + status_gpa } /// Build a flush request descriptor chain. @@ -531,6 +552,107 @@ fn ram_disk(size: u64, read_only: bool) -> Disk { disklayer_ram::ram_disk(size, read_only).unwrap() } +#[derive(Inspect)] +struct BlockingWriteDisk { + #[inspect(skip)] + storage: Mutex>, + #[inspect(skip)] + write_calls: Arc, + #[inspect(skip)] + write_started: Mutex>>, + #[inspect(skip)] + release_write: Mutex>>, +} + +impl DiskIo for BlockingWriteDisk { + fn disk_type(&self) -> &str { + "blocking-write-test" + } + + fn sector_count(&self) -> u64 { + self.storage.lock().len() as u64 / 512 + } + + fn sector_size(&self) -> u32 { + 512 + } + + fn disk_id(&self) -> Option<[u8; 16]> { + None + } + + fn physical_sector_size(&self) -> u32 { + 512 + } + + fn is_fua_respected(&self) -> bool { + false + } + + fn is_read_only(&self) -> bool { + false + } + + async fn read_vectored( + &self, + buffers: &RequestBuffers<'_>, + sector: u64, + ) -> Result<(), DiskError> { + let offset = sector as usize * 512; + let end = offset + buffers.len(); + let storage = self.storage.lock(); + if end > storage.len() { + return Err(DiskError::IllegalBlock); + } + buffers.writer().write(&storage[offset..end])?; + Ok(()) + } + + async fn write_vectored( + &self, + buffers: &RequestBuffers<'_>, + sector: u64, + _fua: bool, + ) -> Result<(), DiskError> { + self.write_calls.fetch_add(1, Ordering::SeqCst); + if let Some(started) = self.write_started.lock().take() { + started.send(()).unwrap(); + } + let release = self + .release_write + .lock() + .take() + .expect("blocking write release receiver is present"); + release.recv().await.unwrap(); + + let offset = sector as usize * 512; + let end = offset + buffers.len(); + let mut storage = self.storage.lock(); + if end > storage.len() { + return Err(DiskError::IllegalBlock); + } + buffers.reader().read(&mut storage[offset..end])?; + Ok(()) + } + + async fn sync_cache(&self) -> Result<(), DiskError> { + Ok(()) + } + + async fn unmap( + &self, + _sector: u64, + _count: u64, + _block_level_only: bool, + ) -> Result<(), DiskError> { + Ok(()) + } + + fn unmap_behavior(&self) -> disk_backend::UnmapBehavior { + disk_backend::UnmapBehavior::Ignored + } +} + /// Awaits `fut`, panicking with `msg` if it does not complete within `timeout`. async fn with_timeout( driver: &DefaultDriver, @@ -545,6 +667,53 @@ async fn with_timeout( } } +#[async_test] +async fn stop_drains_one_accepted_write_exactly_once(driver: DefaultDriver) { + let (_device_thread, device_driver) = DefaultPool::spawn_on_thread("virtio-blk-drain"); + let write_calls = Arc::new(AtomicUsize::new(0)); + let (write_started, write_started_recv) = std::sync::mpsc::channel(); + let (release_write, release_write_recv) = async_channel::bounded(1); + let disk = Disk::new(BlockingWriteDisk { + storage: Mutex::new(vec![0_u8; 64 * 1024]), + write_calls: write_calls.clone(), + write_started: Mutex::new(Some(write_started)), + release_write: Mutex::new(Some(release_write_recv)), + }) + .unwrap(); + let mut harness = TestHarness::with_device_driver(&driver, &device_driver, disk, false); + harness.enable().await; + + let status_gpa = harness.post_write_request(0, 0, &[0xa5; 512]); + write_started_recv + .recv_timeout(Duration::from_secs(5)) + .expect("virtio-blk worker did not accept the write"); + + let stopped = { + let mut stop = pin!(harness.device.stop_queue(0)); + assert!( + stop.as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending(), + "stop completed before the accepted write was released" + ); + release_write.send(()).await.unwrap(); + with_timeout( + &driver, + Duration::from_secs(5), + "virtio-blk stop did not drain its accepted write", + stop, + ) + .await + }; + assert!(stopped.is_some()); + + let (used_id, used_len) = harness.wait_for_used().await; + assert_eq!((used_id, used_len), (0, 1)); + assert_eq!(harness.read_status(status_gpa), VIRTIO_BLK_S_OK); + assert_eq!(write_calls.load(Ordering::SeqCst), 1); + assert_eq!(harness.used_idx, 1); +} + /// Write 1 sector then read it back. Verifies basic write and read roundtrip. #[async_test] async fn write_then_read_roundtrip(driver: DefaultDriver) { @@ -577,6 +746,17 @@ async fn write_then_read_roundtrip(driver: DefaultDriver) { assert_eq!(status, VIRTIO_BLK_S_OK); } +#[async_test] +async fn empty_image_slot_read_fails(driver: DefaultDriver) { + let (mut harness, _requests) = TestHarness::empty_image_slot(&driver); + harness.enable().await; + + let data_gpa = harness.post_read_request(0, 0, 512); + let (used_id, used_len) = harness.wait_for_used().await; + assert_eq!((used_id, used_len), (0, 1)); + assert_eq!(harness.read_status(data_gpa + 512), VIRTIO_BLK_S_IOERR); +} + /// Read from a sector that was never written — should succeed with zeroes. #[async_test] async fn read_unwritten_sector_returns_zeroes(driver: DefaultDriver) { diff --git a/vm/devices/virtio/virtio_blk/src/lib.rs b/vm/devices/virtio/virtio_blk/src/lib.rs index 391c045080f..05a4cd3cfb0 100644 --- a/vm/devices/virtio/virtio_blk/src/lib.rs +++ b/vm/devices/virtio/virtio_blk/src/lib.rs @@ -12,6 +12,7 @@ mod integration_tests; use anyhow::Context as _; use disk_backend::Disk; +use disk_file::FileDisk; use futures::StreamExt; use guestmem::GuestMemory; use guestmem::ranges::PagedRange; @@ -19,10 +20,12 @@ use inspect::Inspect; use inspect::InspectMut; use inspect_counters::Counter; use pal_async::wait::PolledWait; +use parking_lot::RwLock; use scsi_buffers::RequestBuffers; use std::future::Future; use std::future::poll_fn; use std::pin::Pin; +use std::sync::Arc; use std::task::Context; use std::task::Poll; use task_control::AsyncRun; @@ -68,6 +71,22 @@ pub struct VirtioBlkDevice { read_only: bool, supports_discard: bool, config: VirtioBlkConfig, + media: Arc>, + image_slot: Option, +} + +#[derive(Inspect)] +struct MediaState { + #[inspect(with = "Option::is_some")] + disk: Option, + identity: Option, +} + +#[derive(Inspect)] +struct ImageSlot { + active: bool, + #[inspect(skip)] + requests: mesh::Receiver, } /// Persistent worker state. Survives across enable/disable cycles. @@ -79,7 +98,7 @@ pub struct VirtioBlkDevice { /// the queue state is removed. #[derive(InspectMut)] struct BlkWorker { - disk: Disk, + media: Arc>, read_only: bool, stats: WorkerStats, #[inspect(with = "FuturesUnordered::len")] @@ -168,60 +187,66 @@ impl AsyncRun for BlkWorker { stop: &mut StopTask<'_>, state: &mut BlkQueueState, ) -> Result<(), task_control::Cancelled> { - stop.until_stopped(async { - // Set once the queue can no longer produce work, because the guest - // violated the queue protocol. Fetching stops, but in-flight IOs - // keep draining so their descriptors are still completed; the - // worker exits once they are done. - let mut queue_done = false; - - while !(queue_done && self.ios.is_empty()) { - enum Event { - NewWork(Result), - Completed(IoCompletion), + // Set once the queue can no longer produce work, because the guest + // violated the queue protocol. Fetching stops, but in-flight IOs + // keep draining so their descriptors are still completed; the + // worker exits once they are done. + let mut queue_done = false; + + while !(queue_done && self.ios.is_empty()) { + enum Event { + NewWork(Result), + Completed(IoCompletion), + } + + let event = poll_fn(|cx| { + // Check cancellation first so a continuously ready queue + // cannot starve a save/restore or teardown stop request. + if Pin::new(&mut *stop).poll(cx).is_ready() { + return Poll::Ready(None); } - let event = poll_fn(|cx| { - // Poll for completed IOs first to free up slots. - if let Poll::Ready(Some(completion)) = self.ios.poll_next_unpin(cx) { - return Poll::Ready(Event::Completed(completion)); - } - // Accept new work if under the depth limit. - if !queue_done && self.ios.len() < MAX_IO_DEPTH { - if let Poll::Ready(item) = state.queue.poll_next_unpin(cx) { - let item = item.expect("virtio queue stream never ends"); - return Poll::Ready(Event::NewWork(item)); - } - } - Poll::Pending - }) - .await; - - match event { - Event::NewWork(Ok(work)) => { - let disk = self.disk.clone(); - let mem = state.memory.clone(); - let read_only = self.read_only; - self.ios.push(Box::pin(async move { - process_request(&disk, &mem, read_only, work).await - })); - } - Event::NewWork(Err(err)) => { - // The queue is retired: the rejected chain is never - // consumed, so retrying would fail identically forever. - tracelimit::error_ratelimited!( - error = &err as &dyn std::error::Error, - "error reading from virtio queue, stopping worker" - ); - queue_done = true; - } - Event::Completed(completion) => { - self.finish_io(&mut state.queue, completion); + // Poll for completed IOs first to free up slots. + if let Poll::Ready(Some(completion)) = self.ios.poll_next_unpin(cx) { + return Poll::Ready(Some(Event::Completed(completion))); + } + // Accept new work if under the depth limit. + if !queue_done && self.ios.len() < MAX_IO_DEPTH { + if let Poll::Ready(item) = state.queue.poll_next_unpin(cx) { + let item = item.expect("virtio queue stream never ends"); + return Poll::Ready(Some(Event::NewWork(item))); } } + Poll::Pending + }) + .await + .ok_or(task_control::Cancelled)?; + + match event { + Event::NewWork(Ok(work)) => { + let disk = self.media.read().disk.clone(); + let mem = state.memory.clone(); + let read_only = self.read_only; + self.ios.push(Box::pin(async move { + process_request(disk.as_ref(), &mem, read_only, work).await + })); + } + Event::NewWork(Err(err)) => { + // The queue is retired: the rejected chain is never + // consumed, so retrying would fail identically forever. + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "error reading from virtio queue, stopping worker" + ); + queue_done = true; + } + Event::Completed(completion) => { + self.finish_io(&mut state.queue, completion); + } } - }) - .await + } + + Ok(()) } } @@ -293,9 +318,13 @@ impl VirtioBlkDevice { let supports_discard = disk.unmap_behavior() != disk_backend::UnmapBehavior::Ignored; + let media = Arc::new(RwLock::new(MediaState { + disk: Some(disk), + identity: None, + })); Self { worker: TaskControl::new(BlkWorker { - disk, + media: media.clone(), read_only, stats: WorkerStats::default(), ios: FuturesUnordered::new(), @@ -304,8 +333,134 @@ impl VirtioBlkDevice { read_only, supports_discard, config, + media, + image_slot: None, } } + + /// Creates a fixed-geometry image slot with no media. + pub fn new_image_slot( + driver_source: &VmTaskDriverSource, + active: bool, + requests: mesh::Receiver, + ) -> Self { + let media = Arc::new(RwLock::new(MediaState { + disk: None, + identity: None, + })); + let config = VirtioBlkConfig { + capacity: 0, + size_max: 0, + seg_max: DEFAULT_SEG_MAX, + geometry: VirtioBlkGeometry { + cylinders: 0, + heads: 0, + sectors: 0, + }, + blk_size: 512, + topology: VirtioBlkTopology { + physical_block_exp: 3, + alignment_offset: 0, + min_io_size: 1, + opt_io_size: 0, + }, + writeback: 1, + unused0: 0, + num_queues: 1, + max_discard_sectors: 0, + max_discard_seg: 0, + discard_sector_alignment: 0, + max_write_zeroes_sectors: 0, + max_write_zeroes_seg: 0, + write_zeroes_may_unmap: 0, + unused1: [0; 3], + _padding: [0; 4], + }; + Self { + worker: TaskControl::new(BlkWorker { + media: media.clone(), + read_only: true, + stats: WorkerStats::default(), + ios: FuturesUnordered::new(), + }), + driver: driver_source.simple(), + read_only: true, + supports_discard: false, + config, + media, + image_slot: Some(ImageSlot { active, requests }), + } + } + + fn image_slot_state(&self) -> virtio_resources::blk::ImageSlotState { + let media = self.media.read(); + virtio_resources::blk::ImageSlotState { + identity: media.identity.clone(), + capacity_sectors: media.disk.as_ref().map_or(0, |disk| { + disk.sector_count() * (disk.sector_size() as u64 / 512) + }), + } + } + + fn bind_image_slot( + &self, + request: virtio_resources::blk::BindImageSlotRequest, + ) -> Result + { + use virtio_resources::blk::BindImageSlotError; + + if request.identity.is_empty() || request.identity.len() > 1024 { + return Err(BindImageSlotError::InvalidMedia( + "identity must contain 1 to 1024 bytes".to_owned(), + )); + } + if request.logical_block_size != 512 || request.physical_block_size != 4096 { + return Err(BindImageSlotError::GeometryMismatch); + } + let metadata = request + .media + .metadata() + .map_err(|error| BindImageSlotError::InvalidMedia(error.to_string()))?; + if !metadata.file_type().is_file() + || request.length == 0 + || !request + .length + .is_multiple_of(request.logical_block_size as u64) + || metadata.len() != request.length + { + return Err(BindImageSlotError::InvalidMedia( + "media must be a nonempty aligned regular file".to_owned(), + )); + } + + let mut media = self.media.write(); + if let Some(identity) = &media.identity { + return if identity == &request.identity { + Ok(virtio_resources::blk::ImageSlotState { + identity: Some(identity.clone()), + capacity_sectors: media.disk.as_ref().map_or(0, Disk::sector_count), + }) + } else { + Err(BindImageSlotError::AlreadyBound) + }; + } + let disk = Disk::new(FileDisk::with_metadata( + request.media, + disk_file::Metadata { + disk_size: request.length, + sector_size: request.logical_block_size, + physical_sector_size: request.physical_block_size, + read_only: true, + }, + )) + .map_err(|error| BindImageSlotError::InvalidMedia(error.to_string()))?; + media.disk = Some(disk); + media.identity = Some(request.identity); + Ok(virtio_resources::blk::ImageSlotState { + identity: media.identity.clone(), + capacity_sectors: request.length / 512, + }) + } } impl VirtioDevice for VirtioBlkDevice { @@ -326,7 +481,11 @@ impl VirtioDevice for VirtioBlkDevice { } DeviceTraits { - device_id: virtio::spec::VirtioDeviceType::BLK, + device_id: if self.image_slot.as_ref().is_some_and(|slot| !slot.active) { + virtio::spec::VirtioDeviceType(0) + } else { + virtio::spec::VirtioDeviceType::BLK + }, device_features: VirtioDeviceFeatures::new() .with_device_specific_low(features) .with_ring_event_idx(true) @@ -343,7 +502,11 @@ impl VirtioDevice for VirtioBlkDevice { // The transport reads the device config space as a sequence of u32s. // We serialize VirtioBlkConfig to bytes and return the requested // 4-byte window. Three cases: - let config_bytes = self.config.as_bytes(); + let mut config = self.config.clone(); + if self.image_slot.is_some() { + config.capacity = self.image_slot_state().capacity_sectors; + } + let config_bytes = config.as_bytes(); let offset = offset as usize; if offset + 4 <= config_bytes.len() { // Normal case: full u32 within bounds. @@ -420,6 +583,28 @@ impl VirtioDevice for VirtioBlkDevice { fn supports_save_restore(&self) -> bool { true } + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll> { + let Some(slot) = &mut self.image_slot else { + return Poll::Pending; + }; + let request = match slot.requests.poll_next_unpin(cx) { + Poll::Ready(Some(request)) => request, + Poll::Ready(None) | Poll::Pending => return Poll::Pending, + }; + match request { + virtio_resources::blk::ImageSlotRequest::Bind(rpc) => { + let was_empty = self.image_slot_state().identity.is_none(); + rpc.handle_sync(|request| self.bind_image_slot(request)); + let changed = was_empty && self.image_slot_state().identity.is_some(); + Poll::Ready(Ok(changed)) + } + virtio_resources::blk::ImageSlotRequest::Query(rpc) => { + rpc.handle_sync(|()| self.image_slot_state()); + Poll::Ready(Ok(false)) + } + } + } } /// Process a single virtio-blk request. @@ -428,11 +613,25 @@ impl VirtioDevice for VirtioBlkDevice { /// write the used ring entry. This keeps completion in the main loop, /// which simplifies future queue API changes. async fn process_request( - disk: &Disk, + disk: Option<&Disk>, mem: &GuestMemory, read_only: bool, work: VirtioQueueCallbackWork, ) -> IoCompletion { + let Some(disk) = disk else { + if let Err(err) = write_status_byte(mem, &work, VIRTIO_BLK_S_IOERR) { + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to write error status byte" + ); + } + return IoCompletion { + work, + bytes_written: 1, + stat: IoStat::Error, + bounced: false, + }; + }; match process_request_inner(disk, mem, read_only, &work).await { Ok((bytes_written, stat, bounced)) => { if let Err(err) = write_status_byte(mem, &work, VIRTIO_BLK_S_OK) { @@ -742,6 +941,11 @@ fn write_status_byte( #[cfg(test)] mod tests { use super::*; + use mesh::rpc::RpcSend; + use std::task::Context; + use std::task::Waker; + use test_with_tracing::test; + use vmcore::vm_task::SingleDriverBackend; // ---- copy_regions tests ---- @@ -795,4 +999,82 @@ mod tests { guest.read_at(0x9000, &mut buf).unwrap(); assert!(buf.iter().all(|&b| b == 0x33)); } + + fn image_slot(active: bool) -> VirtioBlkDevice { + let driver = pal_async::DefaultPool::new().driver(); + let source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let (_requests, receiver) = mesh::channel(); + VirtioBlkDevice::new_image_slot(&source, active, receiver) + } + + fn bind_request( + identity: &str, + length: u64, + logical_block_size: u32, + ) -> virtio_resources::blk::BindImageSlotRequest { + let file = tempfile::tempfile().unwrap(); + file.set_len(length).unwrap(); + virtio_resources::blk::BindImageSlotRequest { + media: file, + identity: identity.to_owned(), + length, + logical_block_size, + physical_block_size: 4096, + } + } + + #[test] + fn inactive_image_slot_is_a_placeholder() { + let device = image_slot(false); + assert_eq!(device.traits().device_id.0, 0); + assert_eq!(device.image_slot_state().capacity_sectors, 0); + } + + #[test] + fn image_slot_bind_once_is_idempotent_by_identity() { + let device = image_slot(true); + let state = device + .bind_image_slot(bind_request("image-a", 4096, 512)) + .unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert_eq!(state.identity.as_deref(), Some("image-a")); + + let state = device + .bind_image_slot(bind_request("image-a", 4096, 512)) + .unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert!(matches!( + device.bind_image_slot(bind_request("image-b", 4096, 512)), + Err(virtio_resources::blk::BindImageSlotError::AlreadyBound) + )); + } + + #[test] + fn image_slot_rejects_geometry_mismatch() { + let device = image_slot(true); + assert!(matches!( + device.bind_image_slot(bind_request("image-a", 4096, 4096)), + Err(virtio_resources::blk::BindImageSlotError::GeometryMismatch) + )); + } + + #[test] + fn image_slot_bind_reports_configuration_change() { + let driver = pal_async::DefaultPool::new().driver(); + let source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let (requests, receiver) = mesh::channel(); + let mut device = VirtioBlkDevice::new_image_slot(&source, true, receiver); + let response = requests.call( + virtio_resources::blk::ImageSlotRequest::Bind, + bind_request("image-a", 4096, 512), + ); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + device.poll_config_change(&mut context), + Poll::Ready(Ok(true)) + )); + let state = futures::executor::block_on(response).unwrap().unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert_eq!(futures::executor::block_on(device.read_registers_u32(0)), 8); + } } diff --git a/vm/devices/virtio/virtio_blk/src/resolver.rs b/vm/devices/virtio/virtio_blk/src/resolver.rs index 98c865be134..2dadbd50ba2 100644 --- a/vm/devices/virtio/virtio_blk/src/resolver.rs +++ b/vm/devices/virtio/virtio_blk/src/resolver.rs @@ -9,6 +9,7 @@ use disk_backend::resolve::ResolveDiskParameters; use virtio::resolve::ResolvedVirtioDevice; use virtio::resolve::VirtioResolveInput; use virtio_resources::blk::VirtioBlkHandle; +use virtio_resources::blk::VirtioBlkImageSlotHandle; use vm_resource::AsyncResolveResource; use vm_resource::ResourceResolver; use vm_resource::declare_static_async_resolver; @@ -20,6 +21,7 @@ pub struct VirtioBlkResolver; declare_static_async_resolver! { VirtioBlkResolver, (VirtioDeviceHandle, VirtioBlkHandle), + (VirtioDeviceHandle, VirtioBlkImageSlotHandle), } #[async_trait] @@ -46,3 +48,25 @@ impl AsyncResolveResource for VirtioBlkReso Ok(VirtioBlkDevice::new(input.driver_source, disk.0, resource.read_only).into()) } } + +#[async_trait] +impl AsyncResolveResource for VirtioBlkResolver { + type Output = ResolvedVirtioDevice; + type Error = anyhow::Error; + + async fn resolve( + &self, + _resolver: &ResourceResolver, + resource: VirtioBlkImageSlotHandle, + input: VirtioResolveInput<'_>, + ) -> Result { + Ok( + VirtioBlkDevice::new_image_slot( + input.driver_source, + resource.active, + resource.requests, + ) + .into(), + ) + } +} diff --git a/vm/devices/virtio/virtio_console/Cargo.toml b/vm/devices/virtio/virtio_console/Cargo.toml index 03f8ccdf36d..5797763a2aa 100644 --- a/vm/devices/virtio/virtio_console/Cargo.toml +++ b/vm/devices/virtio/virtio_console/Cargo.toml @@ -12,6 +12,7 @@ virtio_resources.workspace = true inspect.workspace = true guestmem.workspace = true +mesh.workspace = true vmcore.workspace = true vm_resource.workspace = true serial_core.workspace = true @@ -25,9 +26,9 @@ futures-concurrency.workspace = true parking_lot.workspace = true thiserror.workspace = true tracelimit.workspace = true -tracing.workspace = true [dev-dependencies] +chipset_device.workspace = true futures.workspace = true pal_async.workspace = true pal_event.workspace = true diff --git a/vm/devices/virtio/virtio_console/src/broker.rs b/vm/devices/virtio/virtio_console/src/broker.rs new file mode 100644 index 00000000000..18608dd96c4 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/broker.rs @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Control-session broker worker mode. +//! +//! [`VirtioConsoleDevice::new_broker`] creates the control console. Its worker +//! runs the control-session broker between the guest queues and a +//! reconnectable host endpoint: it only attaches a host whose local peer +//! identity matches the configured one, bounds host authentication with a +//! timeout, and tracks the host endpoint's connection lifecycle in +//! [`HostTransportState`]. + +use crate::BUF_SIZE; +use crate::ConsoleWorker; +use crate::ConsoleWorkerState; +use crate::VirtioConsoleDevice; +use crate::WorkerError; +use crate::control_session_broker; +use crate::direct::ConsoleWorkerMode; +use crate::spec::VirtioConsoleConfig; +use guestmem::GuestMemory; +use inspect::Inspect; +use pal_async::timer::Instant; +use pal_async::timer::PolledTimer; +use serial_core::SerialIo; +use std::collections::VecDeque; +use std::future::poll_fn; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use task_control::TaskControl; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use vmcore::vm_task::VmTaskDriverSource; + +impl VirtioConsoleDevice { + /// Creates the control console with a reconnectable host endpoint and a + /// VMM-resident session broker. + pub fn new_broker( + driver_source: &VmTaskDriverSource, + host_io: Box, + config: VirtioControlConsoleBrokerConfig, + ) -> Self { + let driver = driver_source.simple(); + let transport_state = initial_host_transport_state(&*host_io); + let broker = control_session_broker::ControlSessionBroker::new( + config.instance_id, + config.capability, + ); + let auth_timer = PolledTimer::new(&driver); + let mut worker = TaskControl::new(ConsoleWorker { + mode: ConsoleWorkerMode::Broker(Box::new(BrokerWorker { + host_io, + broker, + config, + transport_state, + host_input: VecDeque::new(), + auth_timer, + auth_deadline: None, + })), + }); + worker.insert( + &driver, + "virtio-control-console", + ConsoleWorkerState { + receiveq: None, + transmitq: None, + mem: GuestMemory::empty(), + partial_transmit: 0, + staged_rx: VecDeque::new(), + input_gated: false, + }, + ); + Self { + driver, + config: VirtioConsoleConfig::default(), + worker, + } + } +} + +pub(crate) struct BrokerWorker { + pub(crate) host_io: Box, + pub(crate) broker: control_session_broker::ControlSessionBroker, + pub(crate) config: VirtioControlConsoleBrokerConfig, + pub(crate) transport_state: HostTransportState, + pub(crate) host_input: VecDeque, + auth_timer: PolledTimer, + pub(crate) auth_deadline: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HostTransportState { + WaitingForDisconnect, + WaitingForConnect, + Connected, + Disabled, +} + +pub(crate) fn initial_host_transport_state(host_io: &dyn SerialIo) -> HostTransportState { + if host_io.is_connected() { + HostTransportState::Connected + } else { + HostTransportState::WaitingForConnect + } +} + +pub(crate) fn disconnect_host_transport(host_io: &mut dyn SerialIo) -> HostTransportState { + if !host_io.is_connected() || host_io.disconnect_current().is_ok() { + HostTransportState::WaitingForConnect + } else { + HostTransportState::WaitingForDisconnect + } +} + +impl Inspect for BrokerWorker { + fn inspect(&self, req: inspect::Request<'_>) { + let counters = self.broker.counters(); + req.respond() + .field("mode", "broker") + .field("broker_state", format!("{:?}", self.broker.state())) + .field("epoch", self.broker.epoch()) + .field("host_transport", format!("{:?}", self.transport_state)) + .field("host_authenticated", self.broker.host_is_authenticated()) + .field( + "guest_output_records", + self.broker + .output_record_count(control_session_broker::OutputLegId::Guest), + ) + .field( + "guest_output_bytes", + self.broker + .output_byte_count(control_session_broker::OutputLegId::Guest), + ) + .field( + "host_output_records", + self.broker + .output_record_count(control_session_broker::OutputLegId::Host), + ) + .field( + "host_output_bytes", + self.broker + .output_byte_count(control_session_broker::OutputLegId::Host), + ) + .field( + "guest_parser_bytes", + self.broker.guest_parser_buffered_bytes(), + ) + .field("guest_receive_window", self.broker.guest_receive_window()) + .field("guest_receive_credit", self.broker.guest_receive_credit()) + .field("host_input_bytes", self.host_input.len()) + .field("protocol_errors", counters.protocol_errors) + .field("authentication_errors", counters.authentication_errors) + .field("sequence_errors", counters.sequence_errors) + .field("ack_errors", counters.ack_errors) + .field("reset_errors", counters.reset_errors) + .field("reconnect_errors", counters.reconnect_errors) + .field("backpressure_errors", counters.backpressure_errors); + } +} + +impl BrokerWorker { + /// Applies a guest-initiated device reset to the broker and host transport. + pub(crate) fn reset_for_device(&mut self) { + let preserve_unstarted_host = self.broker.state() + == control_session_broker::BrokerState::AwaitGuestAttach + && !self.broker.host_is_connected() + && self.transport_state == HostTransportState::Connected; + self.broker.reset_for_device(); + self.host_input.clear(); + self.auth_deadline = None; + self.transport_state = if preserve_unstarted_host { + if self.host_io.is_connected() { + HostTransportState::Connected + } else { + HostTransportState::WaitingForDisconnect + } + } else { + disconnect_host_transport(&mut *self.host_io) + }; + } + + pub(crate) async fn run_loop( + &mut self, + state: &mut ConsoleWorkerState, + ) -> Result<(), WorkerError> { + if state.receiveq.is_none() && state.transmitq.is_none() { + std::future::pending::<()>().await; + } + match poll_fn(|cx| self.poll_once(state, cx)).await { + Ok(()) => Ok(()), + Err(error) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console worker faulted" + ); + if let Err(detach_error) = self.detach_host() { + tracelimit::error_ratelimited!( + error = &detach_error as &dyn std::error::Error, + "control-console host detach failed after worker fault" + ); + } + // Keep TaskControl restartable. Device reset clears the broker + // and queue state before the worker is started again. + std::future::pending().await + } + } + } + + fn poll_once( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Poll> { + let mut made_progress = false; + + match self.transport_state { + HostTransportState::WaitingForDisconnect => match self.host_io.poll_disconnect(cx) { + Poll::Ready(Ok(())) => { + self.detach_host()?; + self.transport_state = HostTransportState::WaitingForConnect; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + }, + HostTransportState::WaitingForConnect => match self.host_io.poll_connect(cx) { + Poll::Ready(Ok(())) => { + self.transport_state = HostTransportState::Connected; + self.begin_verified_host_attachment()?; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + }, + HostTransportState::Connected => { + if !self.broker.host_is_connected() { + self.begin_verified_host_attachment()?; + made_progress = true; + } + match self.host_io.poll_disconnect(cx) { + Poll::Ready(Ok(())) => { + self.detach_host()?; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + } + } + HostTransportState::Disabled => {} + } + + if self.transport_state == HostTransportState::Connected + && self.broker.host_is_connected() + && !self.broker.host_is_authenticated() + && let Some(deadline) = self.auth_deadline + && self.auth_timer.poll_until(cx, deadline).is_ready() + { + tracelimit::warn_ratelimited!("control-console host authentication timed out"); + self.detach_host()?; + made_progress = true; + } + + made_progress |= self.poll_guest_output(state, cx)?; + made_progress |= self.poll_guest_input(state, cx)?; + + if self.transport_state == HostTransportState::Connected { + made_progress |= self.poll_host_output(cx)?; + if self.transport_state == HostTransportState::Connected && !state.input_gated { + made_progress |= self.poll_host_input(cx)?; + } + if self.broker.host_is_authenticated() { + self.auth_deadline = None; + } + } + + if made_progress { + cx.waker().wake_by_ref(); + } + Poll::Pending + } + + fn poll_guest_output( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Result { + let Some(receiveq) = state.receiveq.as_mut() else { + return Ok(false); + }; + if !self + .broker + .begin_output(control_session_broker::OutputLegId::Guest) + { + return Ok(false); + } + + let work = match receiveq.try_peek().map_err(WorkerError::Virtio)? { + Some(work) => work, + None => { + return Ok(receiveq.poll_kick(cx).is_ready()); + } + }; + let writeable_len = work + .payload() + .iter() + .filter(|payload| payload.writeable) + .map(|payload| payload.length as usize) + .sum::(); + if writeable_len == 0 { + let work = work.consume(); + receiveq.complete(work, 0); + return Ok(true); + } + + let mut bytes = [0; BUF_SIZE]; + let count = { + let output = self + .broker + .peek_output( + control_session_broker::OutputLegId::Guest, + writeable_len.min(BUF_SIZE), + ) + .ok_or(control_session_broker::BrokerError::InvalidOutputProgress) + .map_err(WorkerError::Broker)?; + bytes[..output.len()].copy_from_slice(output); + output.len() + }; + let work = work.consume(); + if let Err(error) = work.write(&state.mem, &bytes[..count]) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "failed to write broker output to guest receive buffer" + ); + receiveq.complete(work, 0); + } else { + self.broker + .advance_output(control_session_broker::OutputLegId::Guest, count) + .map_err(WorkerError::Broker)?; + receiveq.complete(work, count as u32); + } + Ok(true) + } + + fn poll_guest_input( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Result { + if self.broker.has_pending_guest_record() { + let progress = self + .broker + .accept_guest_input(&[]) + .map_err(WorkerError::Broker)?; + if progress.status != control_session_broker::InputStatus::Backpressured { + return Ok(true); + } + } + + let Some(transmitq) = state.transmitq.as_mut() else { + return Ok(false); + }; + let work = match transmitq.try_peek().map_err(WorkerError::Virtio)? { + Some(work) => work, + None => { + return Ok(transmitq.poll_kick(cx).is_ready()); + } + }; + let readable_len = work.readable_length() as usize; + if state.partial_transmit >= readable_len { + state.partial_transmit = 0; + let work = work.consume(); + transmitq.complete(work, 0); + return Ok(true); + } + + let mut bytes = [0; BUF_SIZE]; + let requested = (readable_len - state.partial_transmit).min(BUF_SIZE); + let read = work + .read_at_offset( + state.partial_transmit as u64, + &state.mem, + &mut bytes[..requested], + ) + .map_err(WorkerError::GuestMemory)?; + if read == 0 { + return Ok(false); + } + + let mut offset = 0; + while offset < read { + let progress = self + .broker + .accept_guest_input(&bytes[offset..read]) + .map_err(WorkerError::Broker)?; + offset += progress.consumed; + state.partial_transmit += progress.consumed; + if progress.status == control_session_broker::InputStatus::Backpressured + || progress.consumed == 0 + { + break; + } + } + if state.partial_transmit == readable_len { + state.partial_transmit = 0; + let work = work.consume(); + transmitq.complete(work, 0); + } + Ok(offset != 0) + } + + fn poll_host_output(&mut self, cx: &mut Context<'_>) -> Result { + if !self + .broker + .begin_output(control_session_broker::OutputLegId::Host) + { + return Ok(false); + } + let mut bytes = [0; BUF_SIZE]; + let count = { + let output = self + .broker + .peek_output(control_session_broker::OutputLegId::Host, BUF_SIZE) + .ok_or(control_session_broker::BrokerError::InvalidOutputProgress) + .map_err(WorkerError::Broker)?; + bytes[..output.len()].copy_from_slice(output); + output.len() + }; + match Pin::new(&mut *self.host_io).poll_write(cx, &bytes[..count]) { + Poll::Ready(Ok(0)) => { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + "control-console host write returned EOF" + ); + self.detach_host()?; + Ok(true) + } + Poll::Ready(Ok(written)) => { + self.broker + .advance_output(control_session_broker::OutputLegId::Host, written) + .map_err(WorkerError::Broker)?; + Ok(true) + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + broker_state = ?self.broker.state(), + error = &error as &dyn std::error::Error, + "control-console host write failed" + ); + self.detach_host()?; + Ok(true) + } + Poll::Pending => Ok(false), + } + } + + fn poll_host_input(&mut self, cx: &mut Context<'_>) -> Result { + if self.broker.has_pending_host_record() || !self.host_input.is_empty() { + let progress = if self.broker.has_pending_host_record() { + self.broker.accept_host_input(&[]) + } else { + let input = self.host_input.make_contiguous(); + self.broker.accept_host_input(input) + }; + match progress { + Ok(progress) => { + self.host_input.drain(..progress.consumed); + return Ok(progress.consumed != 0 + || progress.status != control_session_broker::InputStatus::Backpressured); + } + Err(error) => { + tracelimit::warn_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host protocol input rejected" + ); + self.detach_host()?; + return Ok(true); + } + } + } + + let mut bytes = [0; BUF_SIZE]; + match Pin::new(&mut *self.host_io).poll_read(cx, &mut bytes) { + Poll::Ready(Ok(0)) => { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + "control-console host read returned EOF" + ); + self.detach_host()?; + Ok(true) + } + Poll::Ready(Ok(read)) => { + self.host_input.extend(&bytes[..read]); + Ok(true) + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + broker_state = ?self.broker.state(), + error = &error as &dyn std::error::Error, + "control-console host read failed" + ); + self.detach_host()?; + Ok(true) + } + Poll::Pending => Ok(false), + } + } + + fn detach_host(&mut self) -> Result<(), WorkerError> { + let broker_result = self.broker.host_disconnected().map_err(WorkerError::Broker); + self.host_input.clear(); + self.auth_deadline = None; + self.transport_state = disconnect_host_transport(&mut *self.host_io); + broker_result + } + + fn begin_verified_host_attachment(&mut self) -> Result<(), WorkerError> { + let identity = self.host_io.local_peer_identity(); + let identity_status = match &identity { + Ok(Some(identity)) if identity == &self.config.expected_peer_identity => "expected", + Ok(Some(serial_core::LocalPeerIdentity::Unsupported)) => "unsupported", + Ok(Some(_)) => "unexpected", + Ok(None) => "missing", + Err(_) => "error", + }; + let identity_error_kind = identity.as_ref().err().map(std::io::Error::kind); + if !matches!( + identity, + Ok(Some(ref identity)) if identity == &self.config.expected_peer_identity + ) { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + identity_status, + identity_error_kind = ?identity_error_kind, + "control-console host rejected because its local peer identity is unavailable or unexpected" + ); + self.detach_host()?; + return Ok(()); + } + match self.broker.begin_host_attachment() { + Ok(()) => { + self.auth_deadline = Some( + Instant::now() + .saturating_add(Duration::from_millis(self.config.auth_timeout_ms)), + ); + } + Err(error) => { + tracelimit::warn_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host attachment rejected" + ); + self.auth_deadline = None; + self.detach_host()?; + } + } + Ok(()) + } + + fn disable_host_transport(&mut self, error: std::io::Error) -> Result<(), WorkerError> { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host transport disabled after a lifecycle error" + ); + self.detach_host()?; + self.transport_state = HostTransportState::Disabled; + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_console/src/control_session_broker.rs b/vm/devices/virtio/virtio_console/src/control_session_broker.rs new file mode 100644 index 00000000000..a22fc474136 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/control_session_broker.rs @@ -0,0 +1,2288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pure reconnect broker for the control console. + +use crate::control_session_protocol; +use crate::control_session_protocol::Parser; +use crate::control_session_protocol::ParserSnapshot; +use crate::control_session_protocol::ProtocolError; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use std::collections::VecDeque; +use std::fmt; +use thiserror::Error; + +/// Maximum records waiting behind the one current in-flight record. +pub const MAX_QUEUED_RECORDS_PER_LEG: usize = 16; +/// Maximum encoded bytes waiting behind the one current in-flight record. +pub const MAX_QUEUED_BYTES_PER_LEG: usize = 1024 * 1024; + +/// The broker's externally meaningful state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum BrokerState { + AwaitGuestAttach = 1, + AwaitGuestAck = 2, + ReadyNoHost = 3, + Active = 4, + ResetPending = 5, + Failed = 6, +} + +impl BrokerState { + fn from_snapshot(value: u8) -> Result { + match value { + 1 => Ok(Self::AwaitGuestAttach), + 2 => Ok(Self::AwaitGuestAck), + 3 => Ok(Self::ReadyNoHost), + 4 => Ok(Self::Active), + 5 => Ok(Self::ResetPending), + 6 => Ok(Self::Failed), + _ => Err(BrokerError::InvalidSnapshot("invalid broker state")), + } + } +} + +/// An output leg driven by a later physical-I/O adapter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputLegId { + Guest, + Host, +} + +/// Result of accepting input into one broker leg. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InputProgress { + pub consumed: usize, + pub status: InputStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InputStatus { + NeedMore, + RecordAccepted, + Backpressured, +} + +/// Error counters safe to expose through inspection. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrokerCounters { + pub protocol_errors: u64, + pub authentication_errors: u64, + pub sequence_errors: u64, + pub ack_errors: u64, + pub reset_errors: u64, + pub reconnect_errors: u64, + pub backpressure_errors: u64, +} + +/// Pure broker failures. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum BrokerError { + #[error(transparent)] + Protocol(#[from] ProtocolError), + #[error("host capability authentication failed")] + Authentication, + #[error("a host attachment already occupies the broker slot")] + HostSlotBusy, + #[error("record {record_type:?} is illegal in state {state:?}")] + IllegalRecord { + state: BrokerState, + record_type: RecordType, + }, + #[error("record instance does not match the broker")] + InstanceMismatch, + #[error("record epoch {actual} does not match expected epoch {expected}")] + EpochMismatch { expected: u64, actual: u64 }, + #[error("record sequence {actual} does not match expected sequence {expected}")] + SequenceMismatch { expected: u64, actual: u64 }, + #[error("guest acknowledged a session before RESET was emitted")] + AckBeforeReset, + #[error( + "receive credit increment {increment} exceeds charged bytes with {available} available of window {window}" + )] + InvalidCredit { + available: u32, + increment: u32, + window: u32, + }, + #[error("output leg {0:?} is backpressured")] + Backpressure(OutputLegId), + #[error("control-session epoch overflow")] + EpochOverflow, + #[error("control-session sequence overflow")] + SequenceOverflow, + #[error("invalid output progress")] + InvalidOutputProgress, + #[error("invalid broker snapshot: {0}")] + InvalidSnapshot(&'static str), + #[error("broker is in a failed state")] + Failed, +} + +/// Serializable-neutral state for one partially emitted record. +#[derive(Clone, Eq, PartialEq)] +pub struct EncodedRecordSnapshot { + pub bytes: Vec, + pub offset: usize, +} + +impl fmt::Debug for EncodedRecordSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EncodedRecordSnapshot") + .field("bytes_len", &self.bytes.len()) + .field("offset", &self.offset) + .finish() + } +} + +/// Serializable-neutral state for an output leg. +#[derive(Clone, Default, Eq, PartialEq)] +pub struct OutputSnapshot { + pub current: Option, + pub queued_records: Vec>, +} + +impl fmt::Debug for OutputSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let queued_bytes = self + .queued_records + .iter() + .fold(0usize, |total, record| total.saturating_add(record.len())); + f.debug_struct("OutputSnapshot") + .field("current", &self.current) + .field("queued_record_count", &self.queued_records.len()) + .field("queued_bytes", &queued_bytes) + .finish() + } +} + +/// Serializable-neutral broker state. It intentionally contains no capability +/// or live host-attachment identity. +#[derive(Clone, Eq, PartialEq)] +pub struct BrokerSnapshot { + pub state: u8, + pub instance_id: [u8; 16], + pub drain_foreign_instance_records: bool, + pub epoch: u64, + pub guest_parser: ParserSnapshot, + pub guest_output: OutputSnapshot, + pub host_output: OutputSnapshot, + pub guest_receive_sequence: u64, + pub guest_send_sequence: u64, + pub host_receive_sequence: u64, + pub host_send_sequence: u64, + pub pending_guest_record: Option, + pub pending_host_record: Option, + pub counters: BrokerCounters, +} + +impl fmt::Debug for BrokerSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BrokerSnapshot") + .field("state", &self.state) + .field("instance_id", &self.instance_id) + .field( + "drain_foreign_instance_records", + &self.drain_foreign_instance_records, + ) + .field("epoch", &self.epoch) + .field("guest_parser", &self.guest_parser) + .field("guest_output", &self.guest_output) + .field("host_output", &self.host_output) + .field("guest_receive_sequence", &self.guest_receive_sequence) + .field("guest_send_sequence", &self.guest_send_sequence) + .field("host_receive_sequence", &self.host_receive_sequence) + .field("host_send_sequence", &self.host_send_sequence) + .field("pending_guest_record", &self.pending_guest_record) + .field("pending_host_record", &self.pending_host_record) + .field("counters", &self.counters) + .finish() + } +} + +#[derive(Clone, Debug)] +struct EncodedRecord { + bytes: Vec, + offset: usize, +} + +#[derive(Clone, Debug, Default)] +struct OutputLeg { + current: Option, + queue: VecDeque>, + queued_bytes: usize, +} + +impl OutputLeg { + fn can_enqueue(&self, encoded_len: usize) -> bool { + self.queue.len() < MAX_QUEUED_RECORDS_PER_LEG + && self + .queued_bytes + .checked_add(encoded_len) + .is_some_and(|total| total <= MAX_QUEUED_BYTES_PER_LEG) + } + + fn enqueue(&mut self, bytes: Vec) -> Result<(), BrokerError> { + if !self.can_enqueue(bytes.len()) { + return Err(BrokerError::InvalidSnapshot( + "output queue exceeded configured bound", + )); + } + self.queued_bytes += bytes.len(); + self.queue.push_back(bytes); + Ok(()) + } + + fn begin(&mut self) -> bool { + if self.current.is_some() { + return true; + } + let Some(bytes) = self.queue.pop_front() else { + return false; + }; + self.queued_bytes -= bytes.len(); + self.current = Some(EncodedRecord { bytes, offset: 0 }); + true + } + + fn peek(&self, max_bytes: usize) -> Option<&[u8]> { + let current = self.current.as_ref()?; + let end = current + .offset + .saturating_add(max_bytes) + .min(current.bytes.len()); + Some(¤t.bytes[current.offset..end]) + } + + fn advance(&mut self, count: usize) -> Result<(), BrokerError> { + let current = self + .current + .as_mut() + .ok_or(BrokerError::InvalidOutputProgress)?; + let remaining = current.bytes.len() - current.offset; + if count > remaining { + return Err(BrokerError::InvalidOutputProgress); + } + current.offset += count; + if current.offset == current.bytes.len() { + self.current = None; + } + Ok(()) + } + + fn discard_unstarted(&mut self) { + self.queue.clear(); + self.queued_bytes = 0; + if self + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + self.current = None; + } + } + + fn clear(&mut self) { + self.current = None; + self.queue.clear(); + self.queued_bytes = 0; + } + + fn snapshot(&self) -> OutputSnapshot { + OutputSnapshot { + current: self.current.as_ref().map(|current| EncodedRecordSnapshot { + bytes: current.bytes.clone(), + offset: current.offset, + }), + queued_records: self.queue.iter().cloned().collect(), + } + } + + fn restore(snapshot: OutputSnapshot) -> Result { + Self::validate_snapshot(&snapshot)?; + let current = if let Some(current) = snapshot.current { + Some(EncodedRecord { + bytes: current.bytes, + offset: current.offset, + }) + } else { + None + }; + + let mut queue = VecDeque::with_capacity(snapshot.queued_records.len()); + let mut queued_bytes = 0usize; + for bytes in snapshot.queued_records { + queued_bytes = + queued_bytes + .checked_add(bytes.len()) + .ok_or(BrokerError::InvalidSnapshot( + "queued output byte count overflow", + ))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "queued output bytes exceed configured bound", + )); + } + queue.push_back(bytes); + } + Ok(Self { + current, + queue, + queued_bytes, + }) + } + + fn validate_snapshot(snapshot: &OutputSnapshot) -> Result<(), BrokerError> { + if let Some(current) = &snapshot.current { + validate_encoded_output(¤t.bytes)?; + if current.offset >= current.bytes.len() { + return Err(BrokerError::InvalidSnapshot( + "current output offset is out of bounds", + )); + } + } + if snapshot.queued_records.len() > MAX_QUEUED_RECORDS_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "too many queued output records", + )); + } + let mut queued_bytes = 0usize; + for bytes in &snapshot.queued_records { + validate_encoded_output(bytes)?; + queued_bytes = + queued_bytes + .checked_add(bytes.len()) + .ok_or(BrokerError::InvalidSnapshot( + "queued output byte count overflow", + ))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "queued output bytes exceed configured bound", + )); + } + } + Ok(()) + } +} + +fn validate_encoded_output(bytes: &[u8]) -> Result<(), BrokerError> { + let record = control_session_protocol::decode_exact(bytes)?; + if matches!( + record.record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + return Err(BrokerError::InvalidSnapshot( + "bootstrap record cannot be broker output", + )); + } + Ok(()) +} + +/// Pure protocol and reconnect state machine. +pub struct ControlSessionBroker { + instance_id: [u8; 16], + capability: [u8; 32], + epoch: u64, + state: BrokerState, + guest_parser: Parser, + host_parser: Parser, + guest_output: OutputLeg, + host_output: OutputLeg, + guest_receive_sequence: u64, + guest_send_sequence: u64, + host_receive_sequence: u64, + host_send_sequence: u64, + guest_receive_window: u32, + guest_receive_credit: u32, + host_connected: bool, + host_authenticated: bool, + host_rejected: bool, + host_bound_epoch: u64, + pending_guest_record: Option, + pending_host_record: Option, + drain_foreign_instance_records: bool, + counters: BrokerCounters, +} + +impl ControlSessionBroker { + pub fn new(instance_id: [u8; 16], capability: [u8; 32]) -> Self { + Self { + instance_id, + capability, + epoch: 1, + state: BrokerState::AwaitGuestAttach, + guest_parser: Parser::new(), + host_parser: Parser::new(), + guest_output: OutputLeg::default(), + host_output: OutputLeg::default(), + guest_receive_sequence: 0, + guest_send_sequence: 0, + host_receive_sequence: 0, + host_send_sequence: 0, + guest_receive_window: 0, + guest_receive_credit: 0, + host_connected: false, + host_authenticated: false, + host_rejected: false, + host_bound_epoch: 0, + pending_guest_record: None, + pending_host_record: None, + drain_foreign_instance_records: false, + counters: BrokerCounters::default(), + } + } + + pub fn state(&self) -> BrokerState { + self.state + } + + #[cfg_attr(not(test), expect(dead_code, reason = "used by protocol tests"))] + pub fn instance_id(&self) -> [u8; 16] { + self.instance_id + } + + pub fn epoch(&self) -> u64 { + self.epoch + } + + pub fn counters(&self) -> &BrokerCounters { + &self.counters + } + + pub fn guest_receive_window(&self) -> u32 { + self.guest_receive_window + } + + pub fn guest_receive_credit(&self) -> u32 { + self.guest_receive_credit + } + + pub fn host_is_authenticated(&self) -> bool { + self.host_authenticated + } + + pub fn host_is_connected(&self) -> bool { + self.host_connected + } + + pub fn output_record_count(&self, leg: OutputLegId) -> usize { + let output = self.output(leg); + output.queue.len() + usize::from(output.current.is_some()) + } + + pub fn output_byte_count(&self, leg: OutputLegId) -> usize { + let output = self.output(leg); + output.queued_bytes + + output + .current + .as_ref() + .map_or(0, |current| current.bytes.len() - current.offset) + } + + pub fn guest_parser_buffered_bytes(&self) -> usize { + let snapshot = self.guest_parser.snapshot(); + snapshot.header_count + snapshot.body_bytes.len() + } + + pub fn has_pending_guest_record(&self) -> bool { + self.pending_guest_record.is_some() + } + + pub fn has_pending_host_record(&self) -> bool { + self.pending_host_record.is_some() + } + + /// Resets the device-facing protocol state without changing constructor + /// credentials. A still-open host transport must be detached separately by + /// the physical adapter before it can attach again. + pub fn reset_for_device(&mut self) { + *self = Self::new(self.instance_id, self.capability); + } + + /// Reserves the single host slot after OS-level identity verification. + pub fn begin_host_attachment(&mut self) -> Result<(), BrokerError> { + if self.host_connected { + increment(&mut self.counters.reconnect_errors); + return Err(BrokerError::HostSlotBusy); + } + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + self.host_connected = true; + self.host_authenticated = false; + self.host_rejected = false; + self.host_bound_epoch = 0; + self.host_parser = Parser::new(); + self.host_output.clear(); + self.pending_host_record = None; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + Ok(()) + } + + pub fn accept_guest_input(&mut self, input: &[u8]) -> Result { + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + if let Some(record) = self.pending_guest_record.take() { + if let Err(error) = self.handle_guest_record(record.clone()) { + if error == BrokerError::Backpressure(OutputLegId::Host) { + self.pending_guest_record = Some(record); + return Ok(InputProgress { + consumed: 0, + status: InputStatus::Backpressured, + }); + } + return Err(error); + } + if input.is_empty() { + return Ok(InputProgress { + consumed: 0, + status: InputStatus::RecordAccepted, + }); + } + } + + let progress = match self.guest_parser.accept(input) { + Ok(progress) => progress, + Err(error) => { + increment(&mut self.counters.protocol_errors); + return Err(error.into()); + } + }; + let Some(record) = progress.record else { + return Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::NeedMore, + }); + }; + match self.handle_guest_record(record.clone()) { + Ok(()) => Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::RecordAccepted, + }), + Err(BrokerError::Backpressure(OutputLegId::Host)) => { + self.pending_guest_record = Some(record); + Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::Backpressured, + }) + } + Err(error) => Err(error), + } + } + + pub fn accept_host_input(&mut self, input: &[u8]) -> Result { + if !self.host_connected || self.host_rejected { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::IllegalRecord { + state: self.state, + record_type: RecordType::HostAttach, + }); + } + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + if let Some(record) = self.pending_host_record.take() { + if let Err(error) = self.handle_host_record(record.clone()) { + if error == BrokerError::Backpressure(OutputLegId::Guest) { + self.pending_host_record = Some(record); + return Ok(InputProgress { + consumed: 0, + status: InputStatus::Backpressured, + }); + } + return Err(error); + } + if input.is_empty() { + return Ok(InputProgress { + consumed: 0, + status: InputStatus::RecordAccepted, + }); + } + } + + let progress = match self.host_parser.accept(input) { + Ok(progress) => progress, + Err(error) => { + increment(&mut self.counters.protocol_errors); + return Err(error.into()); + } + }; + let Some(record) = progress.record else { + return Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::NeedMore, + }); + }; + match self.handle_host_record(record.clone()) { + Ok(()) => Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::RecordAccepted, + }), + Err(BrokerError::Backpressure(OutputLegId::Guest)) => { + self.pending_host_record = Some(record); + Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::Backpressured, + }) + } + Err(error) => Err(error), + } + } + + /// Drops a host attachment. Only loss of an active authenticated host + /// advances the epoch. + pub fn host_disconnected(&mut self) -> Result<(), BrokerError> { + let active_loss = + self.host_connected && self.host_authenticated && self.state == BrokerState::Active; + self.host_connected = false; + self.host_authenticated = false; + self.host_rejected = false; + self.host_bound_epoch = 0; + self.host_parser = Parser::new(); + self.host_output.clear(); + self.pending_host_record = None; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + if !active_loss { + return Ok(()); + } + + self.state = BrokerState::ResetPending; + let Some(next_epoch) = self.epoch.checked_add(1) else { + increment(&mut self.counters.reset_errors); + return Err(self.fail(BrokerError::EpochOverflow)); + }; + self.epoch = next_epoch; + self.guest_output.discard_unstarted(); + self.pending_guest_record = None; + self.guest_receive_sequence = 0; + self.guest_send_sequence = 0; + self.guest_receive_window = 0; + self.guest_receive_credit = 0; + self.enqueue_guest_control(RecordType::Reset)?; + Ok(()) + } + + /// Promotes one complete record to the current physical record. + pub fn begin_output(&mut self, leg: OutputLegId) -> bool { + if self.state == BrokerState::Failed { + return false; + } + self.output_mut(leg).begin() + } + + /// Returns bytes from the current physical record without advancing them. + pub fn peek_output(&self, leg: OutputLegId, max_bytes: usize) -> Option<&[u8]> { + if self.state == BrokerState::Failed { + return None; + } + self.output(leg).peek(max_bytes) + } + + /// Commits bytes reported written by the physical adapter. + pub fn advance_output(&mut self, leg: OutputLegId, count: usize) -> Result<(), BrokerError> { + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + let reset_completed = + if leg == OutputLegId::Guest && self.state == BrokerState::ResetPending { + self.guest_output + .current + .as_ref() + .filter(|current| current.bytes.len() - current.offset == count) + .map(|current| control_session_protocol::decode_exact(¤t.bytes)) + .transpose()? + .is_some_and(|record| { + record.record_type == RecordType::Reset + && record.instance_id == self.instance_id + && record.epoch == self.epoch + }) + } else { + false + }; + self.output_mut(leg).advance(count)?; + if reset_completed { + self.state = BrokerState::AwaitGuestAck; + } + Ok(()) + } + + pub fn snapshot(&self) -> BrokerSnapshot { + let mut guest_output = self.guest_output.snapshot(); + guest_output.queued_records.clear(); + if guest_output + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + guest_output.current = None; + } + BrokerSnapshot { + state: self.state as u8, + instance_id: self.instance_id, + drain_foreign_instance_records: self.drain_foreign_instance_records, + epoch: self.epoch, + guest_parser: self.guest_parser.snapshot(), + guest_output, + host_output: OutputSnapshot::default(), + guest_receive_sequence: self.guest_receive_sequence, + guest_send_sequence: self.guest_send_sequence, + host_receive_sequence: self.host_receive_sequence, + host_send_sequence: self.host_send_sequence, + pending_guest_record: None, + pending_host_record: None, + counters: self.counters.clone(), + } + } + + /// Restores only physical guest alignment state, then starts a fresh + /// instance at epoch one and queues RESET. + pub fn restore( + snapshot: BrokerSnapshot, + new_instance_id: [u8; 16], + new_capability: [u8; 32], + ) -> Result { + Self::validate_restore(&snapshot, new_instance_id)?; + let guest_parser = Parser::restore(snapshot.guest_parser)?; + let mut saved_guest_output = OutputLeg::restore(snapshot.guest_output)?; + let _saved_host_output = OutputLeg::restore(snapshot.host_output)?; + + saved_guest_output.queue.clear(); + saved_guest_output.queued_bytes = 0; + if saved_guest_output + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + saved_guest_output.current = None; + } + + let mut broker = Self::new(new_instance_id, new_capability); + broker.state = BrokerState::ResetPending; + broker.guest_parser = guest_parser; + broker.guest_output = saved_guest_output; + broker.drain_foreign_instance_records = true; + broker.counters = snapshot.counters; + broker.enqueue_guest_control(RecordType::Reset)?; + Ok(broker) + } + + pub fn validate_restore( + snapshot: &BrokerSnapshot, + new_instance_id: [u8; 16], + ) -> Result<(), BrokerError> { + Self::validate_snapshot(snapshot)?; + if new_instance_id == [0; 16] { + return Err(BrokerError::InvalidSnapshot("instance ID is zero")); + } + if snapshot.instance_id == new_instance_id { + return Err(BrokerError::InvalidSnapshot( + "restore reused the saved instance ID", + )); + } + if let Some(current) = &snapshot.guest_output.current + && current.offset != 0 + { + let record = control_session_protocol::decode_exact(¤t.bytes)?; + if record.instance_id == [0; 16] || record.instance_id == new_instance_id { + return Err(BrokerError::InvalidSnapshot( + "partial guest output does not belong to an old instance", + )); + } + } + Ok(()) + } + + /// Validates all snapshot bounds and encoded values without constructing a + /// broker or allocating output queues. + pub fn validate_snapshot(snapshot: &BrokerSnapshot) -> Result<(), BrokerError> { + let _saved_state = BrokerState::from_snapshot(snapshot.state)?; + if snapshot.epoch == 0 { + return Err(BrokerError::InvalidSnapshot("saved epoch is zero")); + } + if snapshot.instance_id == [0; 16] { + return Err(BrokerError::InvalidSnapshot("instance ID is zero")); + } + validate_pending(snapshot.pending_guest_record.as_ref())?; + validate_pending(snapshot.pending_host_record.as_ref())?; + Parser::validate_snapshot(&snapshot.guest_parser)?; + OutputLeg::validate_snapshot(&snapshot.guest_output)?; + OutputLeg::validate_snapshot(&snapshot.host_output)?; + Ok(()) + } + + fn handle_guest_record(&mut self, record: Record) -> Result<(), BrokerError> { + if self.drain_foreign_instance_records + && matches!( + self.state, + BrokerState::ResetPending | BrokerState::AwaitGuestAck + ) + && record.instance_id != self.instance_id + { + return Ok(()); + } + match self.state { + BrokerState::AwaitGuestAttach => { + if record.record_type != RecordType::GuestAttach { + return self.illegal(record.record_type); + } + self.enqueue_guest_control(RecordType::Reset)?; + self.state = BrokerState::ResetPending; + Ok(()) + } + BrokerState::AwaitGuestAck | BrokerState::ResetPending => { + self.handle_guest_while_awaiting_ack(record) + } + BrokerState::ReadyNoHost => { + if record.record_type == RecordType::Ack { + increment(&mut self.counters.ack_errors); + } + self.illegal(record.record_type) + } + BrokerState::Active => { + if record.record_type == RecordType::Ack { + increment(&mut self.counters.ack_errors); + return self.illegal(record.record_type); + } + if record.record_type == RecordType::Credit { + self.validate_current_guest_record(&record)?; + let credit_increment = + control_session_protocol::decode_credit_payload(&record.payload)?; + let Some(available) = self.guest_receive_credit.checked_add(credit_increment) + else { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InvalidCredit { + available: self.guest_receive_credit, + increment: credit_increment, + window: self.guest_receive_window, + }); + }; + if available > self.guest_receive_window { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InvalidCredit { + available: self.guest_receive_credit, + increment: credit_increment, + window: self.guest_receive_window, + }); + } + let next_guest_receive = + self.checked_next_sequence(self.guest_receive_sequence)?; + self.guest_receive_credit = available; + self.guest_receive_sequence = next_guest_receive; + return Ok(()); + } + if record.record_type != RecordType::Data { + return self.illegal(record.record_type); + } + self.validate_current_guest_record(&record)?; + let encoded = self.make_host_data(&record.payload)?; + if !self.host_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Host)); + } + let next_guest_receive = self.checked_next_sequence(self.guest_receive_sequence)?; + let next_host_send = self.checked_next_sequence(self.host_send_sequence)?; + self.host_output.enqueue(encoded)?; + self.guest_receive_sequence = next_guest_receive; + self.host_send_sequence = next_host_send; + Ok(()) + } + BrokerState::Failed => Err(BrokerError::Failed), + } + } + + fn handle_guest_while_awaiting_ack(&mut self, record: Record) -> Result<(), BrokerError> { + if self.state == BrokerState::ResetPending + && record.record_type == RecordType::Ack + && record.instance_id == self.instance_id + && record.epoch == self.epoch + { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::AckBeforeReset); + } + if record.record_type != RecordType::Ack { + if record.instance_id == self.instance_id && record.epoch < self.epoch { + return Ok(()); + } + return self.illegal(record.record_type); + } + if record.instance_id != self.instance_id { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.epoch { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.guest_receive_sequence { + increment(&mut self.counters.ack_errors); + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.guest_receive_sequence, + actual: record.sequence, + }); + } + let initial_credit = control_session_protocol::decode_credit_payload(&record.payload)?; + let next_guest_receive = self.checked_next_sequence(self.guest_receive_sequence)?; + if self.host_authenticated { + self.enqueue_host_control(RecordType::Ready, Vec::new())?; + self.state = BrokerState::Active; + } else { + self.state = BrokerState::ReadyNoHost; + } + self.guest_receive_sequence = next_guest_receive; + self.guest_receive_window = initial_credit; + self.guest_receive_credit = initial_credit; + self.drain_foreign_instance_records = false; + Ok(()) + } + + fn handle_host_record(&mut self, record: Record) -> Result<(), BrokerError> { + if !self.host_authenticated { + if record.record_type != RecordType::HostAttach { + return self.illegal(record.record_type); + } + if !constant_time_eq_32(&self.capability, &record.payload) { + increment(&mut self.counters.authentication_errors); + self.host_rejected = true; + // Authentication rejection is close-only; the adapter detaches the host. + return Err(BrokerError::Authentication); + } + self.host_authenticated = true; + self.host_bound_epoch = self.epoch; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + self.host_output.clear(); + match self.state { + BrokerState::ReadyNoHost => { + self.enqueue_host_control(RecordType::Ready, Vec::new())?; + self.state = BrokerState::Active; + } + BrokerState::AwaitGuestAttach + | BrokerState::AwaitGuestAck + | BrokerState::ResetPending => { + self.enqueue_host_control(RecordType::Wait, Vec::new())?; + } + BrokerState::Active => { + increment(&mut self.counters.reconnect_errors); + return Err(BrokerError::HostSlotBusy); + } + BrokerState::Failed => return Err(BrokerError::Failed), + } + return Ok(()); + } + + if self.state != BrokerState::Active || record.record_type != RecordType::Data { + return self.illegal(record.record_type); + } + if record.instance_id != self.instance_id { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.host_bound_epoch || record.epoch != self.epoch { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.host_receive_sequence { + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.host_receive_sequence, + actual: record.sequence, + }); + } + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + if payload_len > self.guest_receive_credit { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let encoded = self.make_guest_data(&record.payload)?; + if !self.guest_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let next_host_receive = self.checked_next_sequence(self.host_receive_sequence)?; + let next_guest_send = self.checked_next_sequence(self.guest_send_sequence)?; + self.guest_output.enqueue(encoded)?; + self.guest_receive_credit -= payload_len; + self.host_receive_sequence = next_host_receive; + self.guest_send_sequence = next_guest_send; + Ok(()) + } + + fn validate_current_guest_record(&mut self, record: &Record) -> Result<(), BrokerError> { + if record.instance_id != self.instance_id { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.epoch { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.guest_receive_sequence { + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.guest_receive_sequence, + actual: record.sequence, + }); + } + Ok(()) + } + + fn enqueue_guest_control(&mut self, record_type: RecordType) -> Result<(), BrokerError> { + let record = Record::session( + record_type, + self.instance_id, + self.epoch, + self.guest_send_sequence, + Vec::new(), + ); + let encoded = control_session_protocol::encode(&record)?; + if !self.guest_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let next_guest_send = self.checked_next_sequence(self.guest_send_sequence)?; + self.guest_output.enqueue(encoded)?; + self.guest_send_sequence = next_guest_send; + Ok(()) + } + + fn enqueue_host_control( + &mut self, + record_type: RecordType, + payload: Vec, + ) -> Result<(), BrokerError> { + let record = Record::session( + record_type, + self.instance_id, + self.epoch, + self.host_send_sequence, + payload, + ); + let encoded = control_session_protocol::encode(&record)?; + if !self.host_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Host)); + } + let next_host_send = self.checked_next_sequence(self.host_send_sequence)?; + self.host_output.enqueue(encoded)?; + self.host_send_sequence = next_host_send; + Ok(()) + } + + fn make_guest_data(&self, payload: &[u8]) -> Result, BrokerError> { + Ok(control_session_protocol::encode(&Record::session( + RecordType::Data, + self.instance_id, + self.epoch, + self.guest_send_sequence, + payload.to_vec(), + ))?) + } + + fn make_host_data(&self, payload: &[u8]) -> Result, BrokerError> { + Ok(control_session_protocol::encode(&Record::session( + RecordType::Data, + self.instance_id, + self.epoch, + self.host_send_sequence, + payload.to_vec(), + ))?) + } + + fn checked_next_sequence(&mut self, sequence: u64) -> Result { + sequence + .checked_add(1) + .ok_or(BrokerError::SequenceOverflow) + .map_err(|error| self.fail(error)) + } + + fn fail(&mut self, error: BrokerError) -> BrokerError { + self.state = BrokerState::Failed; + self.guest_parser = Parser::new(); + self.host_parser = Parser::new(); + self.guest_output.clear(); + self.host_output.clear(); + self.pending_guest_record = None; + self.pending_host_record = None; + error + } + + fn illegal(&mut self, record_type: RecordType) -> Result { + increment(&mut self.counters.protocol_errors); + Err(BrokerError::IllegalRecord { + state: self.state, + record_type, + }) + } + + fn output(&self, leg: OutputLegId) -> &OutputLeg { + match leg { + OutputLegId::Guest => &self.guest_output, + OutputLegId::Host => &self.host_output, + } + } + + fn output_mut(&mut self, leg: OutputLegId) -> &mut OutputLeg { + match leg { + OutputLegId::Guest => &mut self.guest_output, + OutputLegId::Host => &mut self.host_output, + } + } +} + +fn validate_pending(record: Option<&Record>) -> Result<(), BrokerError> { + if let Some(record) = record { + control_session_protocol::encode(record)?; + } + Ok(()) +} + +fn increment(counter: &mut u64) { + *counter = counter.saturating_add(1); +} + +fn constant_time_eq_32(expected: &[u8; 32], actual: &[u8]) -> bool { + let mut difference = actual.len() ^ expected.len(); + for (index, expected_byte) in expected.iter().enumerate() { + let actual_byte = actual.get(index).copied().unwrap_or(0); + difference |= usize::from(*expected_byte ^ actual_byte); + } + difference == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + const INSTANCE: [u8; 16] = [0x22; 16]; + const CAPABILITY: [u8; 32] = [0x33; 32]; + + fn feed_guest( + broker: &mut ControlSessionBroker, + record: &Record, + ) -> Result { + let bytes = control_session_protocol::encode(record)?; + broker.accept_guest_input(&bytes) + } + + fn feed_host( + broker: &mut ControlSessionBroker, + record: &Record, + ) -> Result { + let bytes = control_session_protocol::encode(record)?; + broker.accept_host_input(&bytes) + } + + fn drain_record( + broker: &mut ControlSessionBroker, + leg: OutputLegId, + ) -> Result { + assert!(broker.begin_output(leg)); + let bytes = broker + .peek_output(leg, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .to_vec(); + broker.advance_output(leg, bytes.len())?; + Ok(control_session_protocol::decode_exact(&bytes)?) + } + + fn ack(broker: &ControlSessionBroker) -> Record { + ack_with_credit(broker, control_session_protocol::MIN_RECEIVE_CREDIT) + } + + fn ack_with_credit(broker: &ControlSessionBroker, credit: u32) -> Record { + Record::session( + RecordType::Ack, + broker.instance_id(), + broker.epoch(), + 0, + control_session_protocol::credit_payload(credit), + ) + } + + fn make_active() -> Result { + make_active_with_window(control_session_protocol::MIN_RECEIVE_CREDIT) + } + + fn make_active_with_window(window: u32) -> Result { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let guest_ack = ack_with_credit(&broker, window); + feed_guest(&mut broker, &guest_ack)?; + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Ready + ); + assert_eq!(broker.state(), BrokerState::Active); + Ok(broker) + } + + #[test] + fn cold_boot_authentication_and_independent_sequences() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + let reset = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!((reset.epoch, reset.sequence), (1, 0)); + + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + let wait = drain_record(&mut broker, OutputLegId::Host)?; + assert_eq!((wait.record_type, wait.sequence), (RecordType::Wait, 0)); + + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + let ready = drain_record(&mut broker, OutputLegId::Host)?; + assert_eq!((ready.record_type, ready.sequence), (RecordType::Ready, 1)); + + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"guest".to_vec()), + )?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"host".to_vec()), + )?; + let to_host = drain_record(&mut broker, OutputLegId::Host)?; + let to_guest = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!((to_host.sequence, to_host.payload), (2, b"guest".to_vec())); + assert_eq!((to_guest.sequence, to_guest.payload), (1, b"host".to_vec())); + Ok(()) + } + + #[test] + fn wrong_capability_fails_without_advancing_epoch() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + broker.begin_host_attachment()?; + let error = feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, vec![0x44; 32]), + ) + .expect_err("wrong capability must fail"); + assert_eq!(error, BrokerError::Authentication); + assert_eq!(broker.epoch(), 1); + assert!(!broker.host_is_authenticated()); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!(broker.counters().authentication_errors, 1); + Ok(()) + } + + #[test] + fn stale_future_wrong_and_duplicate_acks_are_rejected() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + let early_ack = ack(&broker); + assert_eq!( + feed_guest(&mut broker, &early_ack), + Err(BrokerError::AckBeforeReset) + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let wrong_instance = Record::session( + RecordType::Ack, + [9; 16], + 1, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert_eq!( + feed_guest(&mut broker, &wrong_instance), + Err(BrokerError::InstanceMismatch) + ); + let stale = Record::session( + RecordType::Ack, + INSTANCE, + 0, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &stale), + Err(BrokerError::EpochMismatch { actual: 0, .. }) + )); + let future = Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &future), + Err(BrokerError::EpochMismatch { actual: 2, .. }) + )); + let wrong_sequence = Record::session( + RecordType::Ack, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &wrong_sequence), + Err(BrokerError::SequenceMismatch { actual: 1, .. }) + )); + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + assert!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + INSTANCE, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ) + ) + .is_err() + ); + assert_eq!(broker.counters().ack_errors, 6); + Ok(()) + } + + #[test] + fn ready_without_host_rejects_data() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + assert_eq!(broker.state(), BrokerState::ReadyNoHost); + assert!( + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![1]) + ) + .is_err() + ); + assert!(broker.pending_guest_record.is_none()); + Ok(()) + } + + #[test] + fn partial_output_disconnect_finishes_record_then_reset() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for (sequence, payload) in [(0, b"first".as_slice()), (1, b"second".as_slice())] { + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence, payload.to_vec()), + )?; + } + assert!(broker.begin_output(OutputLegId::Guest)); + let first_len = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + broker.advance_output(OutputLegId::Guest, first_len - 1)?; + broker.host_disconnected()?; + assert_eq!(broker.state(), BrokerState::ResetPending); + assert_eq!(broker.epoch(), 2); + + let remainder = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + assert_eq!(remainder, 1); + broker.advance_output(OutputLegId::Guest, remainder)?; + let reset = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.epoch, reset.sequence), + (RecordType::Reset, 2, 0) + ); + assert!(!broker.begin_output(OutputLegId::Guest)); + Ok(()) + } + + #[test] + fn disconnect_after_last_byte_drops_unstarted_next_record() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for sequence in 0..2 { + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + sequence, + vec![sequence as u8], + ), + )?; + } + let _first = drain_record(&mut broker, OutputLegId::Guest)?; + broker.host_disconnected()?; + let next = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!(next.record_type, RecordType::Reset); + Ok(()) + } + + #[test] + fn disconnect_at_every_host_record_offset_forwards_no_partial_data() -> Result<(), BrokerError> + { + let encoded = control_session_protocol::encode(&Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + b"body-with-NVXS".to_vec(), + ))?; + for split in 0..encoded.len() { + let mut broker = make_active()?; + let progress = broker.accept_host_input(&encoded[..split])?; + assert_eq!(progress.status, InputStatus::NeedMore); + broker.host_disconnected()?; + let output = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!(output.record_type, RecordType::Reset, "split {split}"); + assert!(!broker.begin_output(OutputLegId::Guest), "split {split}"); + } + Ok(()) + } + + #[test] + fn rapid_reconnect_waits_without_another_epoch() -> Result<(), BrokerError> { + let mut broker = make_active()?; + broker.host_disconnected()?; + assert_eq!(broker.epoch(), 2); + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Wait + ); + assert_eq!(broker.epoch(), 2); + assert_eq!( + broker.begin_host_attachment(), + Err(BrokerError::HostSlotBusy) + ); + + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let current_ack = Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + feed_guest(&mut broker, ¤t_ack)?; + assert_eq!(broker.state(), BrokerState::Active); + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Ready + ); + Ok(()) + } + + #[test] + fn bounded_queue_returns_backpressure_without_data_loss() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut sequence = 0; + loop { + let progress = feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence + 1, payload.clone()), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + sequence += 1; + assert!(sequence <= MAX_QUEUED_RECORDS_PER_LEG as u64); + } + assert!(broker.pending_guest_record.is_some()); + assert_eq!(broker.counters().backpressure_errors, 1); + assert!(broker.output_byte_count(OutputLegId::Host) <= MAX_QUEUED_BYTES_PER_LEG); + let _ = drain_record(&mut broker, OutputLegId::Host)?; + let progress = broker.accept_guest_input(&[])?; + assert_eq!(progress.status, InputStatus::RecordAccepted); + assert!(broker.pending_guest_record.is_none()); + Ok(()) + } + + #[test] + fn queue_limits_exclude_the_current_in_flight_record() -> Result<(), BrokerError> { + let mut records = OutputLeg::default(); + for _ in 0..MAX_QUEUED_RECORDS_PER_LEG { + records.enqueue(vec![0])?; + } + assert!(records.begin()); + assert!(records.can_enqueue(1)); + records.enqueue(vec![0])?; + assert_eq!(records.queue.len(), MAX_QUEUED_RECORDS_PER_LEG); + assert!(records.current.is_some()); + assert!(!records.can_enqueue(1)); + + let mut bytes = OutputLeg::default(); + bytes.enqueue(vec![0])?; + assert!(bytes.begin()); + assert!(bytes.can_enqueue(MAX_QUEUED_BYTES_PER_LEG)); + bytes.enqueue(vec![0; MAX_QUEUED_BYTES_PER_LEG])?; + assert_eq!(bytes.queued_bytes, MAX_QUEUED_BYTES_PER_LEG); + assert!(bytes.current.is_some()); + assert!(!bytes.can_enqueue(1)); + Ok(()) + } + + #[test] + fn credit_gates_complete_records_and_releases_atomically() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let first_payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, first_payload.clone()), + )?; + assert_eq!(broker.guest_receive_credit(), 0); + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![0x44]) + )? + .status, + InputStatus::Backpressured + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + first_payload + ); + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::Backpressured + ); + + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(1), + ), + )?; + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::RecordAccepted + ); + assert_eq!(broker.guest_receive_credit(), 0); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + vec![0x44] + ); + Ok(()) + } + + #[test] + fn queue_backpressure_does_not_spend_pending_record_credit() -> Result<(), BrokerError> { + let mut broker = make_active_with_window(control_session_protocol::MAX_RECEIVE_CREDIT)?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut accepted = 0u32; + loop { + let progress = feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + accepted as u64, + payload.clone(), + ), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + accepted += 1; + } + let credit_before_retry = broker.guest_receive_credit(); + assert_eq!( + credit_before_retry, + control_session_protocol::MAX_RECEIVE_CREDIT + - accepted * control_session_protocol::MIN_RECEIVE_CREDIT + ); + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::Backpressured + ); + assert_eq!(broker.guest_receive_credit(), credit_before_retry); + + let _ = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::RecordAccepted + ); + assert_eq!( + broker.guest_receive_credit(), + credit_before_retry - control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn invalid_duplicate_and_overflowing_credits_do_not_mint_capacity() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let credit = Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(1), + ); + assert!(matches!( + feed_guest(&mut broker, &credit), + Err(BrokerError::InvalidCredit { .. }) + )); + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + vec![0; control_session_protocol::MAX_DATA_LEN], + ), + )?; + feed_guest(&mut broker, &credit)?; + assert_eq!(broker.guest_receive_credit(), 1); + assert!(matches!( + feed_guest(&mut broker, &credit), + Err(BrokerError::SequenceMismatch { .. }) + )); + assert_eq!(broker.guest_receive_credit(), 1); + + assert!(matches!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 2, + 2, + control_session_protocol::credit_payload(1), + ) + ), + Err(BrokerError::EpochMismatch { .. }) + )); + assert_eq!(broker.guest_receive_credit(), 1); + + broker.guest_receive_sequence = u64::MAX; + let before = broker.guest_receive_credit(); + assert_eq!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + u64::MAX, + control_session_protocol::credit_payload(1), + ) + ), + Err(BrokerError::SequenceOverflow) + ); + assert_eq!(broker.guest_receive_credit(), before); + Ok(()) + } + + #[test] + fn zero_credit_does_not_block_disconnect_or_fresh_ack() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + vec![0x6a; control_session_protocol::MAX_DATA_LEN], + ), + )?; + let _ = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![1]) + )? + .status, + InputStatus::Backpressured + ); + + broker.host_disconnected()?; + assert!(!broker.has_pending_host_record()); + assert_eq!( + (broker.guest_receive_window(), broker.guest_receive_credit()), + (0, 0) + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!( + (broker.guest_receive_window(), broker.guest_receive_credit()), + (0, 0) + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Wait + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(broker.state(), BrokerState::Active); + assert_eq!( + broker.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn disconnect_preserves_each_started_record_prefix_only() -> Result<(), BrokerError> { + let payload = b"body-with-NVXS".to_vec(); + for split in 0..control_session_protocol::HEADER_LEN + payload.len() { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, payload.clone()), + )?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"drop".to_vec()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + let encoded = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .to_vec(); + broker.advance_output(OutputLegId::Guest, split)?; + broker.host_disconnected()?; + if split != 0 { + assert_eq!( + broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)?, + &encoded[split..] + ); + broker.advance_output(OutputLegId::Guest, encoded.len() - split)?; + } + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + assert!(!broker.begin_output(OutputLegId::Guest)); + } + Ok(()) + } + + #[test] + fn reverse_backpressure_cannot_block_disconnect_recovery() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut sequence = 1; + loop { + let progress = feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence, payload.clone()), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + sequence += 1; + } + assert!(broker.has_pending_guest_record()); + broker.host_disconnected()?; + assert!(!broker.has_pending_guest_record()); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + Ok(()) + } + + #[test] + fn streams_maximum_ttrpc_message_with_bounded_credit() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let mut remaining = control_session_protocol::MAX_RECEIVE_CREDIT as usize; + let mut host_sequence = 0; + let mut guest_sequence = 1; + let mut offset = 0usize; + while remaining != 0 { + let length = remaining.min(control_session_protocol::MAX_DATA_LEN); + let payload = (offset..offset + length) + .map(|index| (index % 251) as u8) + .collect::>(); + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + host_sequence, + payload.clone(), + ), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + payload + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + guest_sequence, + control_session_protocol::credit_payload(length as u32), + ), + )?; + host_sequence += 1; + guest_sequence += 1; + offset += length; + remaining -= length; + } + assert_eq!( + offset, + control_session_protocol::MAX_RECEIVE_CREDIT as usize + ); + assert_eq!( + broker.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn epoch_overflow_fails_closed() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"queued".to_vec()), + )?; + assert!(broker.output_record_count(OutputLegId::Guest) != 0); + broker.epoch = u64::MAX; + assert_eq!(broker.host_disconnected(), Err(BrokerError::EpochOverflow)); + assert_eq!(broker.state(), BrokerState::Failed); + assert_eq!(broker.counters().reset_errors, 1); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert!(!broker.begin_output(OutputLegId::Guest)); + assert_eq!(broker.peek_output(OutputLegId::Guest, 1), None); + assert_eq!( + broker.advance_output(OutputLegId::Guest, 0), + Err(BrokerError::Failed) + ); + Ok(()) + } + + #[test] + fn sequence_overflow_discards_enqueued_output() -> Result<(), BrokerError> { + let mut broker = make_active()?; + broker.guest_send_sequence = u64::MAX; + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"queued".to_vec()) + ), + Err(BrokerError::SequenceOverflow) + ); + assert_eq!(broker.state(), BrokerState::Failed); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert!(!broker.begin_output(OutputLegId::Guest)); + Ok(()) + } + + #[test] + fn restore_preserves_only_partial_guest_physical_records() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"outbound".to_vec()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + + let old_guest_record = + Record::session(RecordType::Data, INSTANCE, 1, 1, b"inbound".to_vec()); + let old_bytes = control_session_protocol::encode(&old_guest_record)?; + let split = control_session_protocol::HEADER_LEN + 2; + let progress = broker.accept_guest_input(&old_bytes[..split])?; + assert_eq!(progress.status, InputStatus::NeedMore); + + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let new_capability = [0x88; 32]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, new_capability)?; + assert_eq!(restored.state(), BrokerState::ResetPending); + assert_eq!(restored.epoch(), 1); + assert_eq!(restored.instance_id(), new_instance); + assert!(!restored.host_is_authenticated()); + + let progress = restored.accept_guest_input(&old_bytes[split..])?; + assert_eq!(progress.status, InputStatus::RecordAccepted); + let remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, remainder)?; + let reset = drain_record(&mut restored, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, new_instance, 1) + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + assert!( + feed_guest( + &mut restored, + &Record::session(RecordType::Data, INSTANCE, 1, 4, vec![1]) + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn restore_keeps_alignment_but_requires_fresh_credit() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + ), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + assert!(broker.guest_receive_credit() < control_session_protocol::MIN_RECEIVE_CREDIT); + + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, [0x88; 32])?; + assert_eq!( + ( + restored.guest_receive_window(), + restored.guest_receive_credit() + ), + (0, 0) + ); + + let remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, remainder)?; + assert_eq!( + drain_record(&mut restored, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!( + restored.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn invalid_snapshot_fields_are_rejected() { + let broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + let mut snapshot = broker.snapshot(); + snapshot.state = 99; + assert!(ControlSessionBroker::restore(snapshot, [1; 16], [2; 32]).is_err()); + + let mut snapshot = broker.snapshot(); + snapshot.guest_output.current = Some(EncodedRecordSnapshot { + bytes: vec![1, 2, 3], + offset: 4, + }); + assert!(ControlSessionBroker::restore(snapshot, [1; 16], [2; 32]).is_err()); + } + + #[test] + fn snapshot_debug_redacts_encoded_payloads() { + let current_secret = (0x80..0xa0).collect::>(); + let queued_secret = (0xa0..0xc0).collect::>(); + let pending_secret = (0xc0..0xe0).collect::>(); + let output = OutputSnapshot { + current: Some(EncodedRecordSnapshot { + bytes: current_secret.clone(), + offset: 7, + }), + queued_records: vec![queued_secret.clone()], + }; + let mut snapshot = ControlSessionBroker::new(INSTANCE, CAPABILITY).snapshot(); + snapshot.guest_output = output.clone(); + snapshot.pending_guest_record = Some(Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + pending_secret.clone(), + )); + + let debug_outputs = [ + format!("{:?}", &output.current), + format!("{output:?}"), + format!("{snapshot:?}"), + ]; + for debug in &debug_outputs { + for secret in [¤t_secret, &queued_secret, &pending_secret] { + assert!(!debug.contains(&format!("{secret:?}"))); + } + } + assert!(debug_outputs[0].contains("bytes_len: 32")); + assert!(debug_outputs[0].contains("offset: 7")); + assert!(debug_outputs[1].contains("queued_record_count: 1")); + assert!(debug_outputs[1].contains("queued_bytes: 32")); + assert!(debug_outputs[2].contains("payload_len: 32")); + } + + #[test] + fn restore_drains_multiple_old_instance_records_and_ack() -> Result<(), BrokerError> { + let broker = make_active()?; + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, [0x88; 32])?; + + for (record_type, sequence) in [ + (RecordType::Data, 1), + (RecordType::Data, 2), + (RecordType::Ack, 3), + ] { + let payload = if record_type == RecordType::Data { + vec![sequence as u8] + } else { + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ) + }; + feed_guest( + &mut restored, + &Record::session(record_type, INSTANCE, 1, sequence, payload), + )?; + } + + assert_eq!( + drain_record(&mut restored, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + Ok(()) + } + + #[test] + fn restore_finishes_old_reset_before_new_reset() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + + let new_instance = [0x77; 16]; + let mut restored = + ControlSessionBroker::restore(broker.snapshot(), new_instance, [0x88; 32])?; + let old_remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, old_remainder)?; + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + INSTANCE, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + let reset = drain_record(&mut restored, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, new_instance, 1) + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + Ok(()) + } + + #[test] + fn repeated_completed_restores_do_not_accumulate_identity_state() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for index in 0..8u8 { + let new_instance = [0x80 + index; 16]; + broker = + ControlSessionBroker::restore(broker.snapshot(), new_instance, [0x40 + index; 32])?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(broker.state(), BrokerState::ReadyNoHost); + assert!(!broker.drain_foreign_instance_records); + } + Ok(()) + } + + #[test] + fn device_reset_discards_attachment_and_protocol_progress() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"queued".to_vec()), + )?; + assert!(broker.output_record_count(OutputLegId::Host) != 0); + + broker.reset_for_device(); + + assert_eq!(broker.state(), BrokerState::AwaitGuestAttach); + assert_eq!(broker.epoch(), 1); + assert!(!broker.host_is_connected()); + assert!(!broker.host_is_authenticated()); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!(broker.guest_parser_buffered_bytes(), 0); + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_console/src/control_session_protocol.rs b/vm/devices/virtio/virtio_console/src/control_session_protocol.rs new file mode 100644 index 00000000000..d04ab004cf3 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/control_session_protocol.rs @@ -0,0 +1,943 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Frozen outer protocol for the control-session broker. + +use core::fmt; +use thiserror::Error; + +pub const HEADER_LEN: usize = 44; +pub const MAX_DATA_LEN: usize = 65_536; +pub const MIN_RECEIVE_CREDIT: u32 = MAX_DATA_LEN as u32; +pub const MAX_RECEIVE_CREDIT: u32 = 4 * 1024 * 1024 + 10; +const MAGIC: [u8; 4] = *b"NVXS"; +const VERSION: u16 = 1; + +/// A control-session record type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum RecordType { + GuestAttach = 1, + HostAttach = 2, + Reset = 3, + Ack = 4, + Data = 5, + Wait = 6, + Ready = 7, + Error = 8, + Credit = 9, +} + +impl TryFrom for RecordType { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::GuestAttach), + 2 => Ok(Self::HostAttach), + 3 => Ok(Self::Reset), + 4 => Ok(Self::Ack), + 5 => Ok(Self::Data), + 6 => Ok(Self::Wait), + 7 => Ok(Self::Ready), + 8 => Ok(Self::Error), + 9 => Ok(Self::Credit), + _ => Err(ProtocolError::InvalidType(value)), + } + } +} + +/// A protocol-v1 Error record code. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ErrorCode { + /// The host failed capability authentication. + Authentication = 1, +} + +impl TryFrom for ErrorCode { + type Error = ProtocolError; + + fn try_from(value: u32) -> Result { + match value { + 1 => Ok(Self::Authentication), + _ => Err(ProtocolError::InvalidErrorCode(value)), + } + } +} + +/// A validated protocol-v1 record. +#[derive(Clone, Eq, PartialEq)] +pub struct Record { + pub record_type: RecordType, + pub instance_id: [u8; 16], + pub epoch: u64, + pub sequence: u64, + pub payload: Vec, +} + +impl fmt::Debug for Record { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Record") + .field("record_type", &self.record_type) + .field("instance_id", &self.instance_id) + .field("epoch", &self.epoch) + .field("sequence", &self.sequence) + .field("payload_len", &self.payload.len()) + .finish() + } +} + +impl Record { + /// Creates a bootstrap record. Bootstrap records never consume a sequence. + #[cfg_attr(not(test), expect(dead_code, reason = "used by protocol tests"))] + pub fn bootstrap(record_type: RecordType, payload: Vec) -> Self { + Self { + record_type, + instance_id: [0; 16], + epoch: 0, + sequence: 0, + payload, + } + } + + /// Creates a post-bootstrap record. + pub fn session( + record_type: RecordType, + instance_id: [u8; 16], + epoch: u64, + sequence: u64, + payload: Vec, + ) -> Self { + Self { + record_type, + instance_id, + epoch, + sequence, + payload, + } + } +} + +/// Protocol decoding and validation failures. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ProtocolError { + #[error("invalid control-session magic")] + InvalidMagic, + #[error("unsupported control-session version {0}")] + InvalidVersion(u16), + #[error("unknown control-session record type {0}")] + InvalidType(u8), + #[error("control-session flags must be zero, got {0}")] + InvalidFlags(u8), + #[error("invalid payload length {length} for {record_type:?}")] + InvalidPayloadLength { + record_type: RecordType, + length: u32, + }, + #[error("invalid control-session error code {0}")] + InvalidErrorCode(u32), + #[error("invalid control-session receive credit {0}")] + InvalidReceiveCredit(u32), + #[error("bootstrap record contains session identity")] + InvalidBootstrapIdentity, + #[error("invalid parser snapshot: {0}")] + InvalidSnapshot(&'static str), + #[error("encoded record length overflows the platform size")] + LengthOverflow, +} + +/// Serializable-neutral state for an incremental parser. +#[derive(Clone, Eq, PartialEq)] +pub struct ParserSnapshot { + pub header_bytes: Vec, + pub header_count: usize, + pub body_bytes: Vec, + pub declared_body_len: Option, +} + +impl fmt::Debug for ParserSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ParserSnapshot") + .field("header_count", &self.header_count) + .field("body_len", &self.body_bytes.len()) + .field("declared_body_len", &self.declared_body_len) + .finish() + } +} + +/// The result of accepting bytes into a parser. +#[derive(Debug, Eq, PartialEq)] +pub struct ParseProgress { + pub consumed: usize, + pub record: Option, +} + +#[derive(Clone, Copy)] +struct ParsedHeader { + record_type: RecordType, + instance_id: [u8; 16], + epoch: u64, + sequence: u64, + payload_len: usize, +} + +/// An aligned, bounded incremental protocol parser. +#[derive(Clone)] +pub struct Parser { + header: [u8; HEADER_LEN], + header_count: usize, + body: Vec, + declared_body_len: Option, +} + +impl fmt::Debug for Parser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Parser") + .field("header_count", &self.header_count) + .field("body_len", &self.body.len()) + .field("declared_body_len", &self.declared_body_len) + .finish() + } +} + +impl Default for Parser { + fn default() -> Self { + Self::new() + } +} + +impl Parser { + pub fn new() -> Self { + Self { + header: [0; HEADER_LEN], + header_count: 0, + body: Vec::new(), + declared_body_len: None, + } + } + + /// Accepts bytes up to and including one complete record. + /// + /// Any bytes after the returned record remain unconsumed with the caller. + pub fn accept(&mut self, input: &[u8]) -> Result { + let mut consumed = 0; + if self.header_count < HEADER_LEN { + let take = (HEADER_LEN - self.header_count).min(input.len()); + self.header[self.header_count..self.header_count + take] + .copy_from_slice(&input[..take]); + self.header_count += take; + consumed += take; + if self.header_count < HEADER_LEN { + return Ok(ParseProgress { + consumed, + record: None, + }); + } + + let header = parse_header(&self.header)?; + self.declared_body_len = Some(header.payload_len); + if header.payload_len == 0 { + let record = record_from_parts(header, Vec::new())?; + self.reset(); + return Ok(ParseProgress { + consumed, + record: Some(record), + }); + } + self.body = Vec::with_capacity(header.payload_len); + } + + let declared = self + .declared_body_len + .ok_or(ProtocolError::InvalidSnapshot( + "missing declared body length", + ))?; + let remaining = + declared + .checked_sub(self.body.len()) + .ok_or(ProtocolError::InvalidSnapshot( + "body exceeds declared length", + ))?; + let available = &input[consumed..]; + let take = remaining.min(available.len()); + self.body.extend_from_slice(&available[..take]); + consumed += take; + if self.body.len() != declared { + return Ok(ParseProgress { + consumed, + record: None, + }); + } + + let header = parse_header(&self.header)?; + let body = core::mem::take(&mut self.body); + let record = record_from_parts(header, body)?; + self.reset(); + Ok(ParseProgress { + consumed, + record: Some(record), + }) + } + + pub fn snapshot(&self) -> ParserSnapshot { + ParserSnapshot { + header_bytes: self.header.to_vec(), + header_count: self.header_count, + body_bytes: self.body.clone(), + declared_body_len: self.declared_body_len.map(|len| len as u32), + } + } + + pub fn validate_snapshot(snapshot: &ParserSnapshot) -> Result<(), ProtocolError> { + if snapshot.header_bytes.len() != HEADER_LEN { + return Err(ProtocolError::InvalidSnapshot( + "header storage must be exactly 44 bytes", + )); + } + if snapshot.header_count > HEADER_LEN { + return Err(ProtocolError::InvalidSnapshot( + "header count exceeds header length", + )); + } + if snapshot.header_count < HEADER_LEN { + if snapshot.declared_body_len.is_some() || !snapshot.body_bytes.is_empty() { + return Err(ProtocolError::InvalidSnapshot( + "partial header has body state", + )); + } + } else { + let mut header = [0; HEADER_LEN]; + header.copy_from_slice(&snapshot.header_bytes); + let parsed = parse_header(&header)?; + let declared = snapshot + .declared_body_len + .ok_or(ProtocolError::InvalidSnapshot( + "complete header has no declared body length", + ))?; + if declared as usize != parsed.payload_len { + return Err(ProtocolError::InvalidSnapshot( + "declared body length disagrees with header", + )); + } + if parsed.payload_len == 0 || snapshot.body_bytes.len() >= parsed.payload_len { + return Err(ProtocolError::InvalidSnapshot( + "complete record cannot remain in parser state", + )); + } + } + Ok(()) + } + + /// Restores parser state after validating all bounds before allocation. + pub fn restore(snapshot: ParserSnapshot) -> Result { + Self::validate_snapshot(&snapshot)?; + + let mut header = [0; HEADER_LEN]; + header.copy_from_slice(&snapshot.header_bytes); + let body_capacity = snapshot.declared_body_len.unwrap_or(0) as usize; + let mut body = Vec::with_capacity(body_capacity); + body.extend_from_slice(&snapshot.body_bytes); + Ok(Self { + header, + header_count: snapshot.header_count, + body, + declared_body_len: snapshot.declared_body_len.map(|len| len as usize), + }) + } + + pub fn is_aligned(&self) -> bool { + self.header_count == 0 + } + + fn reset(&mut self) { + self.header = [0; HEADER_LEN]; + self.header_count = 0; + self.body.clear(); + self.declared_body_len = None; + } +} + +/// Encodes a validated record using the frozen 44-byte header. +pub fn encode(record: &Record) -> Result, ProtocolError> { + validate_record(record)?; + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + let total_len = HEADER_LEN + .checked_add(record.payload.len()) + .ok_or(ProtocolError::LengthOverflow)?; + let mut bytes = Vec::with_capacity(total_len); + bytes.extend_from_slice(&MAGIC); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.push(record.record_type as u8); + bytes.push(0); + bytes.extend_from_slice(&record.instance_id); + bytes.extend_from_slice(&record.epoch.to_le_bytes()); + bytes.extend_from_slice(&record.sequence.to_le_bytes()); + bytes.extend_from_slice(&payload_len.to_le_bytes()); + bytes.extend_from_slice(&record.payload); + Ok(bytes) +} + +/// Decodes exactly one complete record. +pub fn decode_exact(bytes: &[u8]) -> Result { + let mut parser = Parser::new(); + let progress = parser.accept(bytes)?; + if progress.consumed != bytes.len() || !parser.is_aligned() { + return Err(ProtocolError::InvalidSnapshot( + "encoded record is truncated or contains trailing bytes", + )); + } + progress.record.ok_or(ProtocolError::InvalidSnapshot( + "encoded record is incomplete", + )) +} + +fn parse_header(bytes: &[u8; HEADER_LEN]) -> Result { + if bytes[..4] != MAGIC { + return Err(ProtocolError::InvalidMagic); + } + let version = u16::from_le_bytes([bytes[4], bytes[5]]); + if version != VERSION { + return Err(ProtocolError::InvalidVersion(version)); + } + let record_type = RecordType::try_from(bytes[6])?; + if bytes[7] != 0 { + return Err(ProtocolError::InvalidFlags(bytes[7])); + } + let mut instance_id = [0; 16]; + instance_id.copy_from_slice(&bytes[8..24]); + let epoch = u64::from_le_bytes(bytes[24..32].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("epoch field has an invalid encoded length") + })?); + let sequence = u64::from_le_bytes(bytes[32..40].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("sequence field has an invalid encoded length") + })?); + let payload_len = u32::from_le_bytes(bytes[40..44].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("payload field has an invalid encoded length") + })?); + validate_payload_length(record_type, payload_len)?; + if matches!( + record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) && (instance_id != [0; 16] || epoch != 0 || sequence != 0) + { + return Err(ProtocolError::InvalidBootstrapIdentity); + } + Ok(ParsedHeader { + record_type, + instance_id, + epoch, + sequence, + payload_len: payload_len as usize, + }) +} + +fn record_from_parts(header: ParsedHeader, payload: Vec) -> Result { + let record = Record { + record_type: header.record_type, + instance_id: header.instance_id, + epoch: header.epoch, + sequence: header.sequence, + payload, + }; + validate_record(&record)?; + Ok(record) +} + +fn validate_record(record: &Record) -> Result<(), ProtocolError> { + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + validate_payload_length(record.record_type, payload_len)?; + if matches!( + record.record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) && (record.instance_id != [0; 16] || record.epoch != 0 || record.sequence != 0) + { + return Err(ProtocolError::InvalidBootstrapIdentity); + } + if record.record_type == RecordType::Error { + let code_bytes: [u8; 4] = record.payload.as_slice().try_into().map_err(|_| { + ProtocolError::InvalidPayloadLength { + record_type: RecordType::Error, + length: payload_len, + } + })?; + ErrorCode::try_from(u32::from_le_bytes(code_bytes))?; + } + match record.record_type { + RecordType::Ack => { + let credit = decode_credit_payload(&record.payload)?; + if !(MIN_RECEIVE_CREDIT..=MAX_RECEIVE_CREDIT).contains(&credit) { + return Err(ProtocolError::InvalidReceiveCredit(credit)); + } + } + RecordType::Credit => { + let credit = decode_credit_payload(&record.payload)?; + if !(1..=MAX_RECEIVE_CREDIT).contains(&credit) { + return Err(ProtocolError::InvalidReceiveCredit(credit)); + } + } + _ => {} + } + Ok(()) +} + +fn validate_payload_length(record_type: RecordType, payload_len: u32) -> Result<(), ProtocolError> { + let valid = match record_type { + RecordType::Ack | RecordType::Credit => payload_len == 4, + RecordType::GuestAttach | RecordType::Reset | RecordType::Wait | RecordType::Ready => { + payload_len == 0 + } + RecordType::HostAttach => payload_len == 32, + RecordType::Data => (1..=MAX_DATA_LEN as u32).contains(&payload_len), + RecordType::Error => payload_len == 4, + }; + if valid { + Ok(()) + } else { + Err(ProtocolError::InvalidPayloadLength { + record_type, + length: payload_len, + }) + } +} + +#[cfg_attr( + not(test), + expect(dead_code, reason = "used by broker tests and guest codec") +)] +pub fn credit_payload(credit: u32) -> Vec { + credit.to_le_bytes().to_vec() +} + +pub fn decode_credit_payload(payload: &[u8]) -> Result { + let bytes: [u8; 4] = payload + .try_into() + .map_err(|_| ProtocolError::InvalidPayloadLength { + record_type: RecordType::Credit, + length: u32::try_from(payload.len()).unwrap_or(u32::MAX), + })?; + Ok(u32::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn sample(record_type: RecordType, payload: Vec) -> Record { + if matches!( + record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + Record::bootstrap(record_type, payload) + } else { + Record::session(record_type, [0x11; 16], 7, 9, payload) + } + } + + fn decode_hex(hex: &str) -> Result, String> { + if !hex.len().is_multiple_of(2) { + return Err("odd hex length".into()); + } + hex.as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = core::str::from_utf8(pair).map_err(|error| error.to_string())?; + u8::from_str_radix(text, 16).map_err(|error| error.to_string()) + }) + .collect() + } + + #[test] + fn golden_vectors_round_trip() -> Result<(), Box> { + let vectors = include_str!("../test_data/control_session_protocol_vectors.txt"); + let mut count = 0; + for line in vectors.lines().filter(|line| !line.is_empty()) { + let (name, hex) = line + .split_once('|') + .ok_or_else(|| format!("invalid vector line: {line}"))?; + let bytes = decode_hex(hex)?; + let record = decode_exact(&bytes)?; + let expected_type = match name { + "GUEST_ATTACH" => RecordType::GuestAttach, + "HOST_ATTACH" => RecordType::HostAttach, + "RESET" => RecordType::Reset, + "ACK_INITIAL_CREDIT" => RecordType::Ack, + "CREDIT" => RecordType::Credit, + "DATA_EMBEDDED_MAGIC" => RecordType::Data, + "WAIT" => RecordType::Wait, + "READY" => RecordType::Ready, + "ERROR_AUTHENTICATION" => RecordType::Error, + _ => return Err(format!("unknown vector name: {name}").into()), + }; + assert_eq!(record.record_type, expected_type, "{name}"); + if matches!( + expected_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + assert_eq!(record.instance_id, [0; 16], "{name}"); + assert_eq!((record.epoch, record.sequence), (0, 0), "{name}"); + } else { + assert_eq!( + record.instance_id, + core::array::from_fn(|index| index as u8), + "{name}" + ); + assert_eq!((record.epoch, record.sequence), (7, 9), "{name}"); + } + if expected_type == RecordType::HostAttach { + assert_eq!( + record.payload, + (0..32).map(|value| value as u8).collect::>() + ); + } + if expected_type == RecordType::Data { + assert_eq!(record.payload, b"NVXSNVXS"); + } + if expected_type == RecordType::Error { + let code = u32::from_le_bytes(record.payload.as_slice().try_into()?); + assert_eq!(ErrorCode::try_from(code)?, ErrorCode::Authentication); + } + if expected_type == RecordType::Ack { + assert_eq!(decode_credit_payload(&record.payload)?, MAX_RECEIVE_CREDIT); + } + if expected_type == RecordType::Credit { + assert_eq!(decode_credit_payload(&record.payload)?, MIN_RECEIVE_CREDIT); + } + assert_eq!(encode(&record)?, bytes, "{name}"); + count += 1; + } + assert_eq!(count, 9); + assert!(vectors.contains("4e5658534e565853")); + Ok(()) + } + + #[test] + fn language_neutral_boundary_and_invalid_cases() -> Result<(), Box> { + const EXPECTED_CASE_NAMES: [&str; 37] = [ + "VALID_MIN", + "VALID_MAX", + "INVALID_EMPTY", + "INVALID_OVERSIZE", + "VALID_MIN", + "VALID_MAX", + "INVALID_BELOW_MIN", + "INVALID_ABOVE_MAX", + "VALID_MIN", + "VALID_MAX", + "INVALID_ZERO", + "INVALID_ABOVE_MAX", + "INVALID_MAGIC", + "INVALID_VERSION", + "INVALID_TYPE", + "INVALID_FLAGS", + "INVALID_DATA_LENGTH", + "AUTHENTICATION", + "INVALID_GUEST_ATTACH_PAYLOAD", + "INVALID_HOST_ATTACH_SHORT", + "INVALID_HOST_ATTACH_LONG", + "INVALID_RESET_PAYLOAD", + "INVALID_ACK_PAYLOAD", + "INVALID_WAIT_PAYLOAD", + "INVALID_READY_PAYLOAD", + "INVALID_ERROR_SHORT", + "INVALID_ERROR_LONG", + "INVALID_GUEST_ATTACH_INSTANCE", + "INVALID_HOST_ATTACH_EPOCH", + "INVALID_HOST_ATTACH_SEQUENCE", + "INVALID_ERROR_CODE_0", + "INVALID_ERROR_CODE_2", + "INVALID_ERROR_CODE_3", + "INVALID_ERROR_CODE_4", + "INVALID_ERROR_CODE_5", + "INVALID_ERROR_CODE_6", + "INVALID_ERROR_CODE_MAX", + ]; + + let cases = include_str!("../test_data/control_session_protocol_cases.txt"); + let instance_id = core::array::from_fn(|index| index as u8); + let mut names = Vec::new(); + for line in cases.lines().filter(|line| !line.is_empty()) { + let fields = line.split('|').collect::>(); + match fields.as_slice() { + ["DATA_LENGTH", name, length] => { + names.push(*name); + let length = length.parse::()?; + let result = encode(&Record::session( + RecordType::Data, + instance_id, + 7, + 9, + vec![0x5a; length], + )); + match *name { + "VALID_MIN" | "VALID_MAX" => assert!(result.is_ok(), "{name}"), + "INVALID_EMPTY" => assert!(matches!( + result, + Err(ProtocolError::InvalidPayloadLength { length: 0, .. }) + )), + "INVALID_OVERSIZE" => assert!(matches!( + result, + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )), + _ => return Err(format!("unknown data-length case: {name}").into()), + } + } + ["INVALID_HEADER", name, hex] => { + names.push(*name); + let bytes = decode_hex(hex)?; + assert_eq!(bytes.len(), HEADER_LEN, "{name}"); + let mut parser = Parser::new(); + let error = parser.accept(&bytes).expect_err(name); + let intended = matches!( + (*name, &error), + ("INVALID_MAGIC", ProtocolError::InvalidMagic) + | ("INVALID_VERSION", ProtocolError::InvalidVersion(2)) + | ("INVALID_TYPE", ProtocolError::InvalidType(99)) + | ("INVALID_FLAGS", ProtocolError::InvalidFlags(1)) + | ( + "INVALID_DATA_LENGTH", + ProtocolError::InvalidPayloadLength { + record_type: RecordType::Data, + length: 65_537, + }, + ) + ); + assert!(intended, "{name}: unexpected error {error:?}"); + } + ["ERROR_CODE", name, value] => { + names.push(*name); + let value = value.parse::()?; + match *name { + "AUTHENTICATION" => { + assert_eq!(ErrorCode::try_from(value)?, ErrorCode::Authentication); + } + _ => return Err(format!("unknown error-code mapping: {name}").into()), + } + } + ["INVALID_RECORD", name, expected_error, hex] => { + names.push(*name); + let bytes = decode_hex(hex)?; + let error = decode_exact(&bytes).expect_err(name); + let intended = matches!( + (*expected_error, &error), + ( + "INVALID_PAYLOAD_LENGTH", + ProtocolError::InvalidPayloadLength { .. }, + ) | ( + "INVALID_BOOTSTRAP_IDENTITY", + ProtocolError::InvalidBootstrapIdentity, + ) | ("INVALID_ERROR_CODE", ProtocolError::InvalidErrorCode(_)) + ); + assert!(intended, "{name}: unexpected error {error:?}"); + } + ["ACK_CREDIT", name, value] => { + names.push(*name); + let value = value.parse::()?; + let result = encode(&Record::session( + RecordType::Ack, + instance_id, + 7, + 9, + credit_payload(value), + )); + assert_eq!(result.is_ok(), name.starts_with("VALID_"), "{name}"); + } + ["CREDIT_INCREMENT", name, value] => { + names.push(*name); + let value = value.parse::()?; + let result = encode(&Record::session( + RecordType::Credit, + instance_id, + 7, + 9, + credit_payload(value), + )); + assert_eq!(result.is_ok(), name.starts_with("VALID_"), "{name}"); + } + _ => return Err(format!("invalid language-neutral case: {line}").into()), + } + } + assert_eq!(names, EXPECTED_CASE_NAMES); + Ok(()) + } + + #[test] + fn every_byte_fragmentation_preserves_state() -> Result<(), ProtocolError> { + for record in [ + sample(RecordType::Ack, credit_payload(MIN_RECEIVE_CREDIT)), + sample(RecordType::Credit, credit_payload(17)), + sample(RecordType::Data, vec![0x33; 257]), + ] { + let encoded = encode(&record)?; + for split in 0..encoded.len() { + let mut parser = Parser::new(); + let first = parser.accept(&encoded[..split])?; + assert!(first.record.is_none()); + let restored = Parser::restore(parser.snapshot())?; + parser = restored; + let second = parser.accept(&encoded[split..])?; + assert_eq!(second.record, Some(record.clone())); + } + } + Ok(()) + } + + #[test] + fn coalesced_records_stop_at_exact_boundary() -> Result<(), ProtocolError> { + let first = sample(RecordType::Credit, credit_payload(17)); + let second = sample(RecordType::Data, b"xNVXSy".to_vec()); + let mut bytes = encode(&first)?; + bytes.extend_from_slice(&encode(&second)?); + let mut parser = Parser::new(); + let progress = parser.accept(&bytes)?; + assert_eq!(progress.record, Some(first)); + assert_eq!(progress.consumed, HEADER_LEN + 4); + let progress = parser.accept(&bytes[progress.consumed..])?; + assert_eq!(progress.record, Some(second)); + Ok(()) + } + + #[test] + fn payload_boundaries() -> Result<(), ProtocolError> { + assert!(encode(&sample(RecordType::Data, vec![1])).is_ok()); + assert!(encode(&sample(RecordType::Data, vec![1; MAX_DATA_LEN])).is_ok()); + assert!(matches!( + encode(&sample(RecordType::Data, Vec::new())), + Err(ProtocolError::InvalidPayloadLength { length: 0, .. }) + )); + assert!(matches!( + encode(&sample(RecordType::Data, vec![1; MAX_DATA_LEN + 1])), + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )); + Ok(()) + } + + #[test] + fn malformed_headers_are_rejected_before_body_allocation() -> Result<(), ProtocolError> { + let valid = encode(&sample(RecordType::Data, vec![1]))?; + for (offset, value) in [(0, b'X'), (4, 2), (6, 99), (7, 1)] { + let mut malformed = valid.clone(); + malformed[offset] = value; + let mut parser = Parser::new(); + assert!(parser.accept(&malformed[..HEADER_LEN]).is_err()); + assert!(parser.body.capacity() <= MAX_DATA_LEN); + } + let mut oversized = valid; + oversized[40..44].copy_from_slice(&65_537u32.to_le_bytes()); + let mut parser = Parser::new(); + assert!(matches!( + parser.accept(&oversized[..HEADER_LEN]), + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )); + assert_eq!(parser.body.capacity(), 0); + Ok(()) + } + + #[test] + fn malformed_magic_is_not_resynchronized() -> Result<(), ProtocolError> { + let valid = encode(&sample(RecordType::Data, b"NVXS".to_vec()))?; + let mut malformed = vec![0; HEADER_LEN]; + malformed.extend_from_slice(&valid); + let mut parser = Parser::new(); + assert_eq!(parser.accept(&malformed), Err(ProtocolError::InvalidMagic)); + Ok(()) + } + + #[test] + fn payload_rules_and_error_codes_are_enforced() { + assert!(encode(&sample(RecordType::GuestAttach, vec![1])).is_err()); + assert!(encode(&sample(RecordType::HostAttach, vec![0; 31])).is_err()); + assert!(encode(&sample(RecordType::Reset, vec![1])).is_err()); + assert!(encode(&sample(RecordType::Ack, Vec::new())).is_err()); + assert!(encode(&sample(RecordType::Ack, credit_payload(MIN_RECEIVE_CREDIT))).is_ok()); + assert!(encode(&sample(RecordType::Wait, vec![1])).is_err()); + assert!(encode(&sample(RecordType::Ready, vec![1])).is_err()); + assert_eq!( + ErrorCode::try_from(1), + Ok(ErrorCode::Authentication), + "the frozen authentication code must remain stable" + ); + for code in [0, 2, 3, 4, 5, 6, u32::MAX] { + assert_eq!( + ErrorCode::try_from(code), + Err(ProtocolError::InvalidErrorCode(code)) + ); + assert!(encode(&sample(RecordType::Error, code.to_le_bytes().to_vec())).is_err()); + } + assert!(encode(&sample(RecordType::Credit, credit_payload(1))).is_ok()); + assert!(encode(&sample(RecordType::Credit, credit_payload(0))).is_err()); + } + + fn assert_capability_redacted(label: &str, debug: &str, capability: &[u8; 32]) { + assert!( + !debug.contains(&format!("{capability:?}")), + "{label} contains the complete capability: {debug}" + ); + for window in capability.windows(8) { + let recognizable_sequence = window + .iter() + .map(u8::to_string) + .collect::>() + .join(", "); + assert!( + !debug.contains(&recognizable_sequence), + "{label} contains capability bytes: {debug}" + ); + } + let marker = core::str::from_utf8(capability).unwrap(); + assert!( + !debug.contains(marker), + "{label} contains the capability marker: {debug}" + ); + } + + #[test] + fn debug_output_redacts_capability_storage_transitively() -> Result<(), ProtocolError> { + let capability = *b"NVX-CAPABILITY-SECRET-0123456789"; + let record = Record::bootstrap(RecordType::HostAttach, capability.to_vec()); + let encoded = encode(&record)?; + + let mut parser = Parser::new(); + let partial = parser.accept(&encoded[..HEADER_LEN + 16])?; + assert_eq!(partial.record, None); + let snapshot = parser.snapshot(); + + let mut complete_parser = Parser::new(); + let progress = complete_parser.accept(&encoded)?; + assert_eq!(progress.record, Some(record.clone())); + + let debug_outputs = [ + ("record", format!("{record:?}")), + ("partial parser", format!("{parser:?}")), + ("parser snapshot", format!("{snapshot:?}")), + ("parse progress", format!("{progress:?}")), + ]; + for (label, debug) in &debug_outputs { + assert_capability_redacted(label, debug, &capability); + } + + assert!(debug_outputs[0].1.contains("payload_len: 32")); + assert!(debug_outputs[1].1.contains("body_len: 16")); + assert!(debug_outputs[2].1.contains("body_len: 16")); + assert!(debug_outputs[3].1.contains("payload_len: 32")); + Ok(()) + } + + #[test] + fn invalid_parser_snapshots_are_rejected() { + let mut snapshot = Parser::new().snapshot(); + snapshot.header_count = HEADER_LEN + 1; + assert!(Parser::restore(snapshot).is_err()); + + let mut snapshot = Parser::new().snapshot(); + snapshot.body_bytes.push(1); + assert!(Parser::restore(snapshot).is_err()); + + let mut snapshot = Parser::new().snapshot(); + snapshot.header_bytes.pop(); + assert!(Parser::restore(snapshot).is_err()); + } +} diff --git a/vm/devices/virtio/virtio_console/src/direct.rs b/vm/devices/virtio/virtio_console/src/direct.rs new file mode 100644 index 00000000000..9787ae20f0c --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/direct.rs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Console worker modes and the direct forwarding mode. +//! +//! The worker runs in one of two [`ConsoleWorkerMode`]s: direct forwarding +//! between the virtio queues and a [`SerialIo`] backend, or the control-session +//! broker (see [`crate::broker`]). In direct mode a +//! [`VirtioConsoleDisconnectPolicy`] selects whether guest output is discarded +//! or retained while the backend is disconnected, and host input is staged in +//! the worker state before it is copied to a guest buffer, so input already +//! accepted from the backend survives worker restarts and snapshots. + +use crate::BUF_SIZE; +use crate::ConsoleWorker; +use crate::ConsoleWorkerState; +use crate::VirtioConsoleDevice; +use crate::WorkerError; +use crate::broker::BrokerWorker; +use crate::spec::VirtioConsoleConfig; +use futures::AsyncRead; +use guestmem::GuestMemory; +use inspect::Inspect; +use inspect::InspectMut; +use serial_core::SerialIo; +use std::collections::VecDeque; +use std::future::poll_fn; +use std::pin::Pin; +use std::task::Poll; +use task_control::TaskControl; +use virtio::VirtioQueue; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::vm_task::VmTaskDriverSource; + +pub(crate) enum ConsoleWorkerMode { + Direct { + io: Box, + disconnect_policy: VirtioConsoleDisconnectPolicy, + }, + Broker(Box), +} + +impl InspectMut for ConsoleWorker { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + match &mut self.mode { + ConsoleWorkerMode::Direct { io, .. } => { + req.respond().field("mode", "direct").field_mut("io", io); + } + ConsoleWorkerMode::Broker(mode) => mode.inspect(req), + } + } +} + +impl VirtioConsoleDevice { + /// Create a console with explicit behavior while its backend is disconnected. + pub fn new_with_policy( + driver_source: &VmTaskDriverSource, + io: Box, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + Self::new_with_name_and_policy(driver_source, io, "virtio-console", disconnect_policy) + } + + /// Create a named console with explicit disconnected-backend behavior. + pub fn new_with_name_and_policy( + driver_source: &VmTaskDriverSource, + io: Box, + worker_name: &'static str, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + let driver = driver_source.simple(); + let mut worker = TaskControl::new(ConsoleWorker { + mode: ConsoleWorkerMode::Direct { + io, + disconnect_policy, + }, + }); + worker.insert( + &driver, + worker_name, + ConsoleWorkerState { + receiveq: None, + transmitq: None, + mem: GuestMemory::empty(), + partial_transmit: 0, + staged_rx: VecDeque::new(), + input_gated: false, + }, + ); + Self { + driver, + config: VirtioConsoleConfig::default(), + worker, + } + } +} + +/// Receive half of the direct forwarding loop. +/// +/// Reads host input into `staged_rx` before copying it to guest receive +/// buffers, so that accepted input survives a cancel and restart of the worker. +/// Returns `Ok(false)` when the backend disconnects. While `input_gated` is +/// set, no host input is read. +pub(crate) async fn receive( + receiveq: &mut Option, + io: &parking_lot::Mutex<&mut Box>, + mem: &GuestMemory, + staged_rx: &mut VecDeque, + input_gated: bool, +) -> Result { + if input_gated { + return std::future::pending().await; + } + 'rx: loop { + if staged_rx.is_empty() { + let mut buf = [0u8; BUF_SIZE]; + let read = poll_fn(|cx| { + if let Some(receiveq) = receiveq.as_mut() { + loop { + match receiveq.try_peek() { + Ok(Some(work)) => { + let writeable_len = work + .payload() + .iter() + .filter(|payload| payload.writeable) + .map(|payload| payload.length as usize) + .sum::(); + if writeable_len != 0 { + break; + } + let work = work.consume(); + receiveq.complete(work, 0); + } + Ok(None) => { + let _ = receiveq.poll_kick(cx); + break; + } + Err(error) => { + return Poll::Ready(Err(WorkerError::Virtio(error))); + } + } + } + } + + Pin::new(&mut **io.lock()) + .poll_read(cx, &mut buf) + .map(|result| result.map_err(WorkerError::Serial)) + }) + .await; + let read = match read { + Ok(read) => read, + Err(WorkerError::Serial(_)) => break 'rx Ok(false), + Err(error) => return Err(error), + }; + if read == 0 { + break 'rx Ok(false); + } + staged_rx.extend(&buf[..read]); + } + + let Some(receiveq) = receiveq.as_mut() else { + std::future::pending().await + }; + let work = receiveq.peek().await.map_err(WorkerError::Virtio)?; + let writeable_len = work + .payload() + .iter() + .filter(|p| p.writeable) + .map(|p| p.length as usize) + .sum::(); + if writeable_len == 0 { + // Guest posted a zero-length buffer; complete it + // immediately without calling poll_read (which + // would return Ok(0) and look like a disconnect). + let work = work.consume(); + receiveq.complete(work, 0); + continue 'rx; + } + let n = staged_rx.len().min(writeable_len); + let work = work.consume(); + if let Err(err) = work.write(mem, &staged_rx.make_contiguous()[..n]) { + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to write to guest receive buffer" + ); + receiveq.complete(work, 0); + } else { + staged_rx.drain(..n); + receiveq.complete(work, n as u32); + } + } +} diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index b740db5dc0e..40e492df665 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -29,15 +29,21 @@ //! `Ok(0)`), the worker drains any pending guest TX descriptors without //! forwarding them. Once `poll_connect` resolves, normal bidirectional //! forwarding resumes. +//! [`VirtioConsoleDevice::new_with_policy`] can instead retain pending guest +//! TX descriptors until the backend reconnects. #![forbid(unsafe_code)] +mod broker; +pub(crate) mod control_session_broker; +pub(crate) mod control_session_protocol; +mod direct; pub mod resolver; +mod saved_state; mod spec; #[cfg(test)] mod tests; -use futures::AsyncRead; use futures::AsyncWrite; use futures_concurrency::future::Race as _; use guestmem::GuestMemory; @@ -59,6 +65,10 @@ use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; @@ -74,11 +84,7 @@ pub struct VirtioConsoleDevice { impl VirtioConsoleDevice { /// Create a new virtio console device backed by the given serial I/O. pub fn new(driver_source: &VmTaskDriverSource, io: Box) -> Self { - Self { - driver: driver_source.simple(), - config: VirtioConsoleConfig::default(), - worker: TaskControl::new(ConsoleWorker { io }), - } + Self::new_with_policy(driver_source, io, VirtioConsoleDisconnectPolicy::Discard) } } @@ -114,7 +120,7 @@ impl VirtioDevice for VirtioConsoleDevice { initial_state: Option, ) -> anyhow::Result<()> { let guest_memory = resources.guest_memory.clone(); - let queue = VirtioQueue::new( + let mut queue = VirtioQueue::new( *features, resources.params, resources.guest_memory, @@ -123,40 +129,18 @@ impl VirtioDevice for VirtioConsoleDevice { initial_state, )?; - assert!(idx < 2); - - if self.worker.has_state() { - // Worker is already running with the other queue — inject this one. - // update_with cancels the current run iteration, applies the - // closure, then the worker restarts. - self.worker.update_with(move |_worker, state| { - if let Some(state) = state { - if idx == 0 { - state.receiveq = Some(queue); - } else { - state.transmitq = Some(queue); - } - } - }); + anyhow::ensure!(idx < 2, "invalid virtio-console queue index {idx}"); + + self.worker.stop().await; + let state = self.worker.state_mut().unwrap(); + saved_state::check_restored_tx_offset(idx, state.partial_transmit, &mut queue)?; + state.mem = guest_memory; + if idx == 0 { + state.receiveq = Some(queue); } else { - // First queue to start — create the worker state. - let (receiveq, transmitq) = if idx == 0 { - (Some(queue), None) - } else { - (None, Some(queue)) - }; - self.worker.insert( - &self.driver, - "virtio-console", - ConsoleWorkerState { - receiveq, - transmitq, - mem: guest_memory, - partial_transmit: 0, - }, - ); - self.worker.start(); + state.transmitq = Some(queue); } + self.worker.start(); Ok(()) } @@ -173,27 +157,49 @@ impl VirtioDevice for VirtioConsoleDevice { let queue = match idx { 0 => state.receiveq.take(), 1 => state.transmitq.take(), - _ => unreachable!(), + _ => return None, }; - // If both queues have been taken, remove the worker state entirely. - // Otherwise, restart the worker so the remaining queue stays active. - if state.receiveq.is_none() && state.transmitq.is_none() { - self.worker.remove(); - } else { + // Keep the stopped worker state when both queues are gone so private + // TX/RX progress remains available to snapshot capture. + if state.receiveq.is_some() || state.transmitq.is_some() { self.worker.start(); } queue.map(|q| q.queue_state()) } - async fn reset(&mut self) {} + async fn reset(&mut self) { + self.reset_private_state(); + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.set_input_gated(true).await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.set_input_gated(false).await + } + + fn supports_save_restore(&self) -> bool { + true + } + + fn save_device(&mut self) -> Result, SaveError> { + self.save_private_state() + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + self.restore_private_state(state) + } + + fn device_state_validator(&self) -> virtio::device::saved_state::DeviceStateValidator { + self.private_state_validator() + } } -#[derive(InspectMut)] struct ConsoleWorker { - #[inspect(mut)] - io: Box, + mode: direct::ConsoleWorkerMode, } #[derive(InspectMut)] @@ -204,6 +210,9 @@ struct ConsoleWorkerState { /// Bytes already written for the current transmitq descriptor. /// Must survive cancel/restart to avoid re-sending data. partial_transmit: usize, + #[inspect(with = "std::collections::VecDeque::len")] + staged_rx: std::collections::VecDeque, + input_gated: bool, } impl InspectTaskMut for ConsoleWorker { @@ -220,7 +229,7 @@ impl AsyncRun for ConsoleWorker { ) -> Result<(), Cancelled> { stop.until_stopped(self.run_loop(state)).await.map(|r| { if let Err(err) = r { - tracing::error!( + tracelimit::error_ratelimited!( error = &err as &dyn std::error::Error, "virtio-console worker loop failed" ); @@ -240,6 +249,8 @@ enum WorkerError { Serial(#[source] std::io::Error), #[error("guest memory error")] GuestMemory(#[source] guestmem::GuestMemoryError), + #[error("control-session broker error")] + Broker(#[source] control_session_broker::BrokerError), } impl ConsoleWorker { @@ -249,12 +260,21 @@ impl ConsoleWorker { /// So, be careful not to leave any state in a weird intermediate state across /// an await point. async fn run_loop(&mut self, state: &mut ConsoleWorkerState) -> Result<(), WorkerError> { - let mut connected: bool = self.io.is_connected(); + let (serial_io, disconnect_policy) = match &mut self.mode { + direct::ConsoleWorkerMode::Direct { + io, + disconnect_policy, + } => (io, *disconnect_policy), + direct::ConsoleWorkerMode::Broker(mode) => return mode.run_loop(state).await, + }; + let mut connected: bool = serial_io.is_connected(); let receiveq = &mut state.receiveq; let transmitq = &mut state.transmitq; - let mut io = parking_lot::Mutex::new(&mut self.io); + let mut io = parking_lot::Mutex::new(serial_io); let mem = &state.mem; let partial_transmit = &mut state.partial_transmit; + let staged_rx = &mut state.staged_rx; + let input_gated = state.input_gated; // If neither queue is present, there's nothing to do. if receiveq.is_none() && transmitq.is_none() { @@ -262,6 +282,9 @@ impl ConsoleWorker { } loop { if !connected { + poll_fn(|cx| io.get_mut().poll_disconnect(cx)) + .await + .map_err(WorkerError::Serial)?; // Wait for the backend to connect, discarding any guest tx data // in the meantime. let wait_connect = async { @@ -271,6 +294,9 @@ impl ConsoleWorker { Ok::<_, WorkerError>(true) }; let drain_tx = async { + if disconnect_policy == VirtioConsoleDisconnectPolicy::Retain { + return std::future::pending().await; + } let Some(transmitq) = transmitq.as_mut() else { std::future::pending().await }; @@ -290,54 +316,7 @@ impl ConsoleWorker { | futures::future::Either::Right((result, _)) => result?, }; } else { - let rx = async { - let Some(receiveq) = receiveq.as_mut() else { - std::future::pending().await - }; - 'rx: loop { - let work = receiveq.peek().await.map_err(WorkerError::Virtio)?; - let writeable_len = work - .payload() - .iter() - .filter(|p| p.writeable) - .map(|p| p.length as usize) - .sum::(); - if writeable_len == 0 { - // Guest posted a zero-length buffer; complete it - // immediately without calling poll_read (which - // would return Ok(0) and look like a disconnect). - let work = work.consume(); - receiveq.complete(work, 0); - continue 'rx; - } - let n = BUF_SIZE.min(writeable_len); - let mut buf = [0u8; BUF_SIZE]; - match poll_fn(|cx| Pin::new(&mut **io.lock()).poll_read(cx, &mut buf[..n])) - .await - { - Ok(0) => { - // Backend disconnected. - break 'rx Ok(false); - } - Ok(n) => { - let work = work.consume(); - if let Err(err) = work.write(mem, &buf[..n]) { - tracelimit::error_ratelimited!( - error = &err as &dyn std::error::Error, - "failed to write to guest receive buffer" - ); - receiveq.complete(work, 0); - } else { - receiveq.complete(work, n as u32); - } - } - Err(_) => { - // Disconnect on error, like other serial impls. - break 'rx Ok(false); - } - } - } - }; + let rx = direct::receive(receiveq, &io, mem, staged_rx, input_gated); let tx = async { let Some(transmitq) = transmitq.as_mut() else { std::future::pending().await @@ -358,8 +337,10 @@ impl ConsoleWorker { }) .await { + Ok(0) => { + break 'tx Ok(false); + } Ok(written) => { - assert!(written > 0); written_this_chunk += written; *partial_transmit += written; } diff --git a/vm/devices/virtio/virtio_console/src/resolver.rs b/vm/devices/virtio/virtio_console/src/resolver.rs index 69531965a50..e5025f8be0d 100644 --- a/vm/devices/virtio/virtio_console/src/resolver.rs +++ b/vm/devices/virtio/virtio_console/src/resolver.rs @@ -3,6 +3,9 @@ //! Resource resolver for virtio-console devices. +mod attachment; +pub mod control; + use crate::VirtioConsoleDevice; use async_trait::async_trait; use serial_core::resources::ResolveSerialBackendParams; @@ -33,6 +36,7 @@ impl AsyncResolveResource for VirtioCon resource: VirtioConsoleHandle, input: VirtioResolveInput<'_>, ) -> Result { + attachment::validate_attachment(resource.attachment.as_ref())?; let io = resolver .resolve( resource.backend, @@ -43,7 +47,11 @@ impl AsyncResolveResource for VirtioCon ) .await?; - let device = VirtioConsoleDevice::new(input.driver_source, io.0.into_io()); + let device = VirtioConsoleDevice::new_with_policy( + input.driver_source, + io.0.into_io(), + resource.disconnect_policy, + ); Ok(device.into()) } diff --git a/vm/devices/virtio/virtio_console/src/resolver/attachment.rs b/vm/devices/virtio/virtio_console/src/resolver/attachment.rs new file mode 100644 index 00000000000..7e0c725dbb8 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/resolver/attachment.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation of the host endpoint attachment carried by console resources. + +use virtio_resources::console::attachment::VirtioConsoleAttachment; +use virtio_resources::console::attachment::VirtioConsoleAttachmentMode; +use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + +pub(super) fn validate_attachment( + attachment: Option<&VirtioConsoleAttachment>, +) -> anyhow::Result<()> { + if let Some(attachment) = attachment { + anyhow::ensure!( + !attachment.stable_id.is_empty() && attachment.stable_id.len() <= 128, + "virtio-console attachment has an invalid stable ID" + ); + anyhow::ensure!( + !attachment.endpoint_identity.is_empty() && attachment.endpoint_identity.len() <= 4096, + "virtio-console attachment has an invalid endpoint identity" + ); + anyhow::ensure!( + matches!( + (attachment.mode, attachment.reconnect_policy), + ( + VirtioConsoleAttachmentMode::Listen, + VirtioConsoleReconnectPolicy::RecreateListener + ) | ( + VirtioConsoleAttachmentMode::Connect, + VirtioConsoleReconnectPolicy::ReconnectClient + ) | ( + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::RequireInheritedAttachment + ) | ( + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + ) + ), + "virtio-console attachment mode and reconnect policy conflict" + ); + match attachment.reconnect_policy { + VirtioConsoleReconnectPolicy::RecreateListener => anyhow::ensure!( + !attachment.required && attachment.reconnect_timeout_ms == 0, + "listener console attachments must be optional and have no reconnect timeout" + ), + VirtioConsoleReconnectPolicy::ReconnectClient => anyhow::ensure!( + attachment.required && attachment.reconnect_timeout_ms != 0, + "client console attachments must be required with a bounded timeout" + ), + VirtioConsoleReconnectPolicy::RequireInheritedAttachment => anyhow::ensure!( + attachment.required && attachment.reconnect_timeout_ms == 0, + "inherited console attachments must be required with no reconnect timeout" + ), + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected => anyhow::ensure!( + !attachment.required && attachment.reconnect_timeout_ms == 0, + "discarding console attachments must be optional with no reconnect timeout" + ), + } + } + Ok(()) +} diff --git a/vm/devices/virtio/virtio_console/src/resolver/control.rs b/vm/devices/virtio/virtio_console/src/resolver/control.rs new file mode 100644 index 00000000000..3acac5cd637 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/resolver/control.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource resolver for the control console. +//! +//! The control console is a virtio-console device whose worker runs the +//! control-session broker (see [`VirtioConsoleDevice::new_broker`]). + +use super::attachment::validate_attachment; +use crate::VirtioConsoleDevice; +use async_trait::async_trait; +use serial_core::resources::ResolveSerialBackendParams; +use virtio::resolve::ResolvedVirtioDevice; +use virtio::resolve::VirtioResolveInput; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use virtio_resources::console::control::VirtioControlConsoleHandle; +use vm_resource::AsyncResolveResource; +use vm_resource::ResourceResolver; +use vm_resource::declare_static_async_resolver; +use vm_resource::kind::VirtioDeviceHandle; + +/// Resolver for the microVM control console resource identity. +pub struct VirtioControlConsoleResolver; + +declare_static_async_resolver! { + VirtioControlConsoleResolver, + (VirtioDeviceHandle, VirtioControlConsoleHandle), +} + +#[async_trait] +impl AsyncResolveResource + for VirtioControlConsoleResolver +{ + type Output = ResolvedVirtioDevice; + type Error = anyhow::Error; + + async fn resolve( + &self, + resolver: &ResourceResolver, + resource: VirtioControlConsoleHandle, + input: VirtioResolveInput<'_>, + ) -> Result { + validate_attachment(resource.attachment.as_ref())?; + validate_broker_config(&resource.broker_config)?; + let io = resolve_backend(resolver, &input, resource.backend).await?; + Ok(VirtioConsoleDevice::new_broker(input.driver_source, io, resource.broker_config).into()) + } +} + +fn validate_broker_config(config: &VirtioControlConsoleBrokerConfig) -> anyhow::Result<()> { + anyhow::ensure!( + config.instance_id != [0; 16], + "control-console broker instance ID must not be zero" + ); + anyhow::ensure!( + config.capability != [0; 32], + "control-console broker capability must not be zero" + ); + anyhow::ensure!( + (1..=60_000).contains(&config.auth_timeout_ms), + "control-console broker authentication timeout must be between 1 and 60000 ms" + ); + Ok(()) +} + +async fn resolve_backend( + resolver: &ResourceResolver, + input: &VirtioResolveInput<'_>, + backend: vm_resource::Resource, +) -> anyhow::Result> { + let io = resolver + .resolve( + backend, + ResolveSerialBackendParams { + driver: Box::new(input.driver_source.simple()), + _async_trait_workaround: &(), + }, + ) + .await?; + Ok(io.0.into_io()) +} diff --git a/vm/devices/virtio/virtio_console/src/saved_state.rs b/vm/devices/virtio/virtio_console/src/saved_state.rs new file mode 100644 index 00000000000..4626b79de8f --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/saved_state.rs @@ -0,0 +1,803 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Save and restore of the device-private virtio-console state. +//! +//! The worker state outlives queue restarts so that the TX progress of the +//! current descriptor, staged host input, and the control-session broker can be +//! captured once both queues are stopped. This module defines the versioned +//! saved-state format ([`SavedState`]), validates it before restore, and +//! implements save, restore, input quiescing, and reset of that state. + +use crate::BUF_SIZE; +use crate::VirtioConsoleDevice; +use crate::broker::HostTransportState; +use crate::control_session_broker; +use crate::control_session_broker::BrokerCounters; +use crate::control_session_broker::BrokerSnapshot; +use crate::control_session_broker::EncodedRecordSnapshot; +use crate::control_session_broker::OutputSnapshot; +use crate::control_session_protocol; +use crate::control_session_protocol::ParserSnapshot; +use crate::control_session_protocol::Record; +use crate::direct::ConsoleWorkerMode; +use crate::spec::VirtioConsoleConfig; +use guestmem::GuestMemory; +use mesh::payload::Protobuf; +use virtio::VirtioQueue; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; +use virtio::device::saved_state::restored_queue_front_readable_length; +use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; +use vmcore::save_restore::SavedStateRoot; + +/// Maximum accepted, not-yet-delivered host input. The direct worker stages at +/// most one [`BUF_SIZE`] read. +pub(crate) const MAX_STAGED_RX_BYTES: usize = BUF_SIZE; +pub(crate) const MAX_SAVED_STATE_BYTES: usize = 512 * 1024; +pub(crate) const DIRECT_SAVED_STATE_VERSION: u32 = 1; +pub(crate) const BROKER_SAVED_STATE_VERSION: u32 = 2; +#[cfg(test)] +pub(crate) const SAVED_STATE_VERSION: u32 = DIRECT_SAVED_STATE_VERSION; + +impl VirtioConsoleDevice { + /// Resets the private state that persists across queue restarts. + pub(crate) fn reset_private_state(&mut self) { + self.config = VirtioConsoleConfig::default(); + let (worker, mut state) = self.worker.get_mut(); + let state = state.as_mut().unwrap(); + state.partial_transmit = 0; + state.staged_rx.clear(); + state.input_gated = false; + state.mem = GuestMemory::empty(); + if let ConsoleWorkerMode::Broker(mode) = &mut worker.mode { + mode.reset_for_device(); + } + } + + /// Stops or resumes accepting new host input, preserving device and queue + /// state. + pub(crate) async fn set_input_gated(&mut self, input_gated: bool) -> anyhow::Result<()> { + self.worker.stop().await; + let state = self.worker.state_mut().unwrap(); + state.input_gated = input_gated; + if state.receiveq.is_some() || state.transmitq.is_some() { + self.worker.start(); + } + Ok(()) + } + + pub(crate) fn save_private_state(&mut self) -> Result, SaveError> { + let (worker, state) = self.worker.get(); + let state = state.as_ref().unwrap(); + if state.receiveq.is_some() || state.transmitq.is_some() { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-console queues are still running" + ))); + } + if state.staged_rx.len() > MAX_STAGED_RX_BYTES { + return Err(SaveError::InvalidChildSavedState(anyhow::anyhow!( + "virtio-console staged RX exceeds its ABI bound" + ))); + } + let (schema_version, staged_rx, disconnect_policy_id, broker) = match &worker.mode { + ConsoleWorkerMode::Direct { + disconnect_policy, .. + } => ( + DIRECT_SAVED_STATE_VERSION, + state.staged_rx.iter().copied().collect(), + disconnect_policy_id(*disconnect_policy), + None, + ), + ConsoleWorkerMode::Broker(mode) => { + if !state.staged_rx.is_empty() { + return Err(SaveError::InvalidChildSavedState(anyhow::anyhow!( + "control-console broker has direct-mode staged RX" + ))); + } + let broker = SavedBrokerSnapshot::from(mode.broker.snapshot()); + (BROKER_SAVED_STATE_VERSION, Vec::new(), 0, Some(broker)) + } + }; + Ok(Some(SavedStateBlob::new(SavedState { + schema_version, + columns: self.config.cols.into(), + rows: self.config.rows.into(), + partial_transmit: state.partial_transmit as u64, + staged_rx, + disconnect_policy_id, + broker, + }))) + } + + pub(crate) fn restore_private_state( + &mut self, + state: Option, + ) -> Result<(), RestoreError> { + let (worker, runtime) = self.worker.get_mut(); + let mut saved = validate_saved_state(state.as_ref(), worker.mode.validation_mode())?; + let runtime = runtime.ok_or_else(|| { + RestoreError::Other(anyhow::anyhow!( + "virtio-console worker state is unavailable" + )) + })?; + if runtime.receiveq.is_some() || runtime.transmitq.is_some() { + return Err(RestoreError::Other(anyhow::anyhow!( + "cannot restore a running virtio-console" + ))); + } + let columns = u16::try_from(saved.columns) + .map_err(|_| invalid_saved_state("console column count is out of range"))?; + let rows = u16::try_from(saved.rows) + .map_err(|_| invalid_saved_state("console row count is out of range"))?; + let partial_transmit = usize::try_from(saved.partial_transmit) + .map_err(|_| invalid_saved_state("console TX offset is out of range"))?; + + let restored_broker = match &mut worker.mode { + ConsoleWorkerMode::Direct { .. } => None, + ConsoleWorkerMode::Broker(mode) => { + let saved_broker = saved + .broker + .take() + .ok_or_else(|| invalid_saved_state("missing control-console broker state"))?; + let snapshot = saved_broker.try_into().map_err(invalid_saved_state)?; + let broker = control_session_broker::ControlSessionBroker::restore( + snapshot, + mode.config.instance_id, + mode.config.capability, + ) + .map_err(|error| invalid_saved_state(error.to_string()))?; + if mode.host_io.is_connected() { + mode.host_io.disconnect_current().map_err(|error| { + RestoreError::Other( + anyhow::Error::new(error) + .context("failed to disconnect control-console host for restore"), + ) + })?; + } + Some(broker) + } + }; + + self.config = VirtioConsoleConfig { + cols: columns, + rows, + }; + runtime.partial_transmit = partial_transmit; + runtime.staged_rx = saved.staged_rx.into(); + if let (ConsoleWorkerMode::Broker(mode), Some(broker)) = (&mut worker.mode, restored_broker) + { + mode.broker = broker; + mode.host_input.clear(); + mode.transport_state = HostTransportState::WaitingForConnect; + mode.auth_deadline = None; + } + Ok(()) + } + + pub(crate) fn private_state_validator(&self) -> DeviceStateValidator { + let mode = self.worker.get().0.mode.validation_mode(); + Box::new(move |state, features, queues, guest_memory| { + let saved = validate_saved_state(state, mode)?; + validate_saved_tx_offset(saved.partial_transmit, *features, queues, guest_memory)?; + Ok(()) + }) + } +} + +/// Checks a restored TX offset against the current descriptor when the +/// transmit queue (`idx` 1) is started. +pub(crate) fn check_restored_tx_offset( + idx: u16, + partial_transmit: usize, + queue: &mut VirtioQueue, +) -> anyhow::Result<()> { + if idx == 1 && partial_transmit != 0 { + let work = queue + .try_peek() + .map_err(|error| anyhow::anyhow!(error).context("invalid restored TX queue"))? + .ok_or_else(|| anyhow::anyhow!("restored TX offset has no current descriptor"))?; + anyhow::ensure!( + partial_transmit <= work.readable_length() as usize, + "restored TX offset {} exceeds descriptor length {}", + partial_transmit, + work.readable_length() + ); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum SavedStateMode { + Direct(VirtioConsoleDisconnectPolicy), + Broker { current_instance_id: [u8; 16] }, +} + +impl ConsoleWorkerMode { + fn validation_mode(&self) -> SavedStateMode { + match self { + Self::Direct { + disconnect_policy, .. + } => SavedStateMode::Direct(*disconnect_policy), + Self::Broker(mode) => SavedStateMode::Broker { + current_instance_id: mode.config.instance_id, + }, + } + } +} + +fn disconnect_policy_id(policy: VirtioConsoleDisconnectPolicy) -> u32 { + match policy { + VirtioConsoleDisconnectPolicy::Discard => 0, + VirtioConsoleDisconnectPolicy::Retain => 1, + } +} + +fn invalid_saved_state(message: impl Into) -> RestoreError { + RestoreError::InvalidSavedState(anyhow::anyhow!(message.into())) +} + +fn validate_saved_state( + state: Option<&SavedStateBlob>, + mode: SavedStateMode, +) -> Result { + let state = state.ok_or_else(|| invalid_saved_state("missing console private state"))?; + if state.encoded_len() > MAX_SAVED_STATE_BYTES { + return Err(invalid_saved_state( + "console private state exceeds its encoded size bound", + )); + } + let saved: SavedState = state.parse()?; + match mode { + SavedStateMode::Direct(disconnect_policy) => { + if saved.schema_version != DIRECT_SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "direct console requires schema version {DIRECT_SAVED_STATE_VERSION}, got {}", + saved.schema_version + ))); + } + if saved.broker.is_some() { + return Err(invalid_saved_state( + "direct console saved state contains broker state", + )); + } + if saved.disconnect_policy_id != disconnect_policy_id(disconnect_policy) { + return Err(invalid_saved_state( + "console disconnect policy does not match the saved policy", + )); + } + } + SavedStateMode::Broker { + current_instance_id, + } => { + if saved.schema_version != BROKER_SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "control console requires schema version {BROKER_SAVED_STATE_VERSION}, got {}", + saved.schema_version + ))); + } + if !saved.staged_rx.is_empty() { + return Err(invalid_saved_state( + "control-console saved state contains direct-mode staged RX", + )); + } + let broker = saved + .broker + .as_ref() + .ok_or_else(|| invalid_saved_state("missing control-console broker state"))?; + let snapshot = validate_saved_broker(broker)?; + control_session_broker::ControlSessionBroker::validate_restore( + &snapshot, + current_instance_id, + ) + .map_err(|error| invalid_saved_state(error.to_string()))?; + } + } + u16::try_from(saved.columns) + .map_err(|_| invalid_saved_state("console column count is out of range"))?; + u16::try_from(saved.rows) + .map_err(|_| invalid_saved_state("console row count is out of range"))?; + usize::try_from(saved.partial_transmit) + .map_err(|_| invalid_saved_state("console TX offset is out of range"))?; + if saved.staged_rx.len() > MAX_STAGED_RX_BYTES { + return Err(invalid_saved_state( + "console staged RX exceeds its ABI bound", + )); + } + Ok(saved) +} + +fn validate_saved_tx_offset( + partial_transmit: u64, + features: VirtioDeviceFeatures, + queues: &[DeviceQueueState], + guest_memory: &GuestMemory, +) -> Result<(), RestoreError> { + if partial_transmit == 0 { + return Ok(()); + } + let transmitq = queues + .get(1) + .ok_or_else(|| invalid_saved_state("console saved state has no transmit queue"))?; + if !transmitq.params.enable { + return Err(invalid_saved_state( + "console TX offset requires an enabled transmit queue", + )); + } + let readable_length = restored_queue_front_readable_length( + features, + transmitq.params, + guest_memory.clone(), + transmitq.queue_state, + ) + .map_err(|error| { + RestoreError::InvalidSavedState( + anyhow::Error::new(error).context("console transmit queue is invalid"), + ) + })? + .ok_or_else(|| invalid_saved_state("console TX offset has no current descriptor"))?; + if partial_transmit > readable_length { + return Err(invalid_saved_state(format!( + "console TX offset {partial_transmit} exceeds descriptor length {readable_length}" + ))); + } + Ok(()) +} + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.console")] +pub struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub columns: u32, + #[mesh(3)] + pub rows: u32, + #[mesh(4)] + pub partial_transmit: u64, + #[mesh(5)] + pub staged_rx: Vec, + #[mesh(6)] + pub disconnect_policy_id: u32, + #[mesh(7)] + pub broker: Option, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedBrokerSnapshot { + #[mesh(1)] + pub state: u32, + #[mesh(2)] + pub instance_id: Vec, + #[mesh(3)] + pub drain_foreign_instance_records: bool, + #[mesh(4)] + pub epoch: u64, + #[mesh(5)] + pub guest_parser: SavedParserSnapshot, + #[mesh(6)] + pub guest_output: SavedOutputSnapshot, + #[mesh(7)] + pub host_output: SavedOutputSnapshot, + #[mesh(8)] + pub guest_receive_sequence: u64, + #[mesh(9)] + pub guest_send_sequence: u64, + #[mesh(10)] + pub host_receive_sequence: u64, + #[mesh(11)] + pub host_send_sequence: u64, + #[mesh(12)] + pub pending_guest_record: Option, + #[mesh(13)] + pub pending_host_record: Option, + #[mesh(14)] + pub counters: SavedBrokerCounters, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedParserSnapshot { + #[mesh(1)] + pub header_bytes: Vec, + #[mesh(2)] + pub header_count: u32, + #[mesh(3)] + pub body_bytes: Vec, + #[mesh(4)] + pub declared_body_len: Option, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedEncodedRecord { + #[mesh(1)] + pub bytes: Vec, + #[mesh(2)] + pub offset: u64, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedOutputSnapshot { + #[mesh(1)] + pub current: Option, + #[mesh(2)] + pub queued_records: Vec>, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedRecord { + #[mesh(1)] + pub record_type: u32, + #[mesh(2)] + pub instance_id: Vec, + #[mesh(3)] + pub epoch: u64, + #[mesh(4)] + pub sequence: u64, + #[mesh(5)] + pub payload: Vec, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedBrokerCounters { + #[mesh(1)] + pub protocol_errors: u64, + #[mesh(2)] + pub authentication_errors: u64, + #[mesh(3)] + pub sequence_errors: u64, + #[mesh(4)] + pub ack_errors: u64, + #[mesh(5)] + pub reset_errors: u64, + #[mesh(6)] + pub reconnect_errors: u64, + #[mesh(7)] + pub backpressure_errors: u64, +} + +impl From for SavedBrokerSnapshot { + fn from(snapshot: BrokerSnapshot) -> Self { + Self { + state: snapshot.state.into(), + instance_id: snapshot.instance_id.to_vec(), + drain_foreign_instance_records: snapshot.drain_foreign_instance_records, + epoch: snapshot.epoch, + guest_parser: snapshot.guest_parser.into(), + guest_output: snapshot.guest_output.into(), + host_output: snapshot.host_output.into(), + guest_receive_sequence: snapshot.guest_receive_sequence, + guest_send_sequence: snapshot.guest_send_sequence, + host_receive_sequence: snapshot.host_receive_sequence, + host_send_sequence: snapshot.host_send_sequence, + pending_guest_record: snapshot.pending_guest_record.map(Into::into), + pending_host_record: snapshot.pending_host_record.map(Into::into), + counters: snapshot.counters.into(), + } + } +} + +impl TryFrom for BrokerSnapshot { + type Error = String; + + fn try_from(snapshot: SavedBrokerSnapshot) -> Result { + let state = + u8::try_from(snapshot.state).map_err(|_| "broker state is out of range".to_string())?; + let instance_id = fixed_bytes(snapshot.instance_id, "broker instance ID")?; + let broker = Self { + state, + instance_id, + drain_foreign_instance_records: snapshot.drain_foreign_instance_records, + epoch: snapshot.epoch, + guest_parser: snapshot.guest_parser.try_into()?, + guest_output: snapshot.guest_output.try_into()?, + host_output: snapshot.host_output.try_into()?, + guest_receive_sequence: snapshot.guest_receive_sequence, + guest_send_sequence: snapshot.guest_send_sequence, + host_receive_sequence: snapshot.host_receive_sequence, + host_send_sequence: snapshot.host_send_sequence, + pending_guest_record: snapshot + .pending_guest_record + .map(TryInto::try_into) + .transpose()?, + pending_host_record: snapshot + .pending_host_record + .map(TryInto::try_into) + .transpose()?, + counters: snapshot.counters.into(), + }; + control_session_broker::ControlSessionBroker::validate_snapshot(&broker) + .map_err(|error| error.to_string())?; + Ok(broker) + } +} + +impl From for SavedParserSnapshot { + fn from(snapshot: ParserSnapshot) -> Self { + Self { + header_bytes: snapshot.header_bytes, + header_count: snapshot.header_count as u32, + body_bytes: snapshot.body_bytes, + declared_body_len: snapshot.declared_body_len, + } + } +} + +impl TryFrom for ParserSnapshot { + type Error = String; + + fn try_from(snapshot: SavedParserSnapshot) -> Result { + Ok(Self { + header_bytes: snapshot.header_bytes, + header_count: snapshot.header_count as usize, + body_bytes: snapshot.body_bytes, + declared_body_len: snapshot.declared_body_len, + }) + } +} + +impl From for SavedOutputSnapshot { + fn from(snapshot: OutputSnapshot) -> Self { + Self { + current: snapshot.current.map(Into::into), + queued_records: snapshot.queued_records, + } + } +} + +impl TryFrom for OutputSnapshot { + type Error = String; + + fn try_from(snapshot: SavedOutputSnapshot) -> Result { + Ok(Self { + current: snapshot.current.map(TryInto::try_into).transpose()?, + queued_records: snapshot.queued_records, + }) + } +} + +impl From for SavedEncodedRecord { + fn from(record: EncodedRecordSnapshot) -> Self { + Self { + bytes: record.bytes, + offset: record.offset as u64, + } + } +} + +impl TryFrom for EncodedRecordSnapshot { + type Error = String; + + fn try_from(record: SavedEncodedRecord) -> Result { + Ok(Self { + bytes: record.bytes, + offset: usize::try_from(record.offset) + .map_err(|_| "broker output offset is out of range".to_string())?, + }) + } +} + +impl From for SavedRecord { + fn from(record: Record) -> Self { + Self { + record_type: record.record_type as u32, + instance_id: record.instance_id.to_vec(), + epoch: record.epoch, + sequence: record.sequence, + payload: record.payload, + } + } +} + +impl TryFrom for Record { + type Error = String; + + fn try_from(record: SavedRecord) -> Result { + let record_type = u8::try_from(record.record_type) + .map_err(|_| "broker record type is out of range".to_string())?; + Ok(Self { + record_type: control_session_protocol::RecordType::try_from(record_type) + .map_err(|error| error.to_string())?, + instance_id: fixed_bytes(record.instance_id, "broker record instance ID")?, + epoch: record.epoch, + sequence: record.sequence, + payload: record.payload, + }) + } +} + +impl From for SavedBrokerCounters { + fn from(counters: BrokerCounters) -> Self { + Self { + protocol_errors: counters.protocol_errors, + authentication_errors: counters.authentication_errors, + sequence_errors: counters.sequence_errors, + ack_errors: counters.ack_errors, + reset_errors: counters.reset_errors, + reconnect_errors: counters.reconnect_errors, + backpressure_errors: counters.backpressure_errors, + } + } +} + +impl From for BrokerCounters { + fn from(counters: SavedBrokerCounters) -> Self { + Self { + protocol_errors: counters.protocol_errors, + authentication_errors: counters.authentication_errors, + sequence_errors: counters.sequence_errors, + ack_errors: counters.ack_errors, + reset_errors: counters.reset_errors, + reconnect_errors: counters.reconnect_errors, + backpressure_errors: counters.backpressure_errors, + } + } +} + +fn fixed_bytes(bytes: Vec, name: &str) -> Result<[u8; N], String> { + bytes + .try_into() + .map_err(|_| format!("{name} must be exactly {N} bytes")) +} + +fn validate_saved_broker(saved: &SavedBrokerSnapshot) -> Result { + use control_session_protocol::HEADER_LEN; + use control_session_protocol::MAX_DATA_LEN; + + if !(1..=6).contains(&saved.state) { + return Err(invalid_saved_state("invalid broker state")); + } + if saved.instance_id.len() != 16 || saved.instance_id.iter().all(|byte| *byte == 0) { + return Err(invalid_saved_state("invalid broker instance ID")); + } + if saved.epoch == 0 { + return Err(invalid_saved_state("saved broker epoch is zero")); + } + if saved.guest_parser.header_bytes.len() != HEADER_LEN + || saved.guest_parser.header_count as usize > HEADER_LEN + || saved.guest_parser.body_bytes.len() > MAX_DATA_LEN + || saved + .guest_parser + .declared_body_len + .is_some_and(|length| length as usize > MAX_DATA_LEN) + { + return Err(invalid_saved_state("invalid broker parser bounds")); + } + validate_saved_output(&saved.guest_output)?; + validate_saved_output(&saved.host_output)?; + if let Some(record) = &saved.pending_guest_record { + validate_saved_record(record)?; + } + if let Some(record) = &saved.pending_host_record { + validate_saved_record(record)?; + } + + let snapshot = SavedBrokerSnapshot { + state: saved.state, + instance_id: saved.instance_id.clone(), + drain_foreign_instance_records: saved.drain_foreign_instance_records, + epoch: saved.epoch, + guest_parser: SavedParserSnapshot { + header_bytes: saved.guest_parser.header_bytes.clone(), + header_count: saved.guest_parser.header_count, + body_bytes: saved.guest_parser.body_bytes.clone(), + declared_body_len: saved.guest_parser.declared_body_len, + }, + guest_output: clone_saved_output(&saved.guest_output), + host_output: clone_saved_output(&saved.host_output), + guest_receive_sequence: saved.guest_receive_sequence, + guest_send_sequence: saved.guest_send_sequence, + host_receive_sequence: saved.host_receive_sequence, + host_send_sequence: saved.host_send_sequence, + pending_guest_record: saved.pending_guest_record.as_ref().map(clone_saved_record), + pending_host_record: saved.pending_host_record.as_ref().map(clone_saved_record), + counters: SavedBrokerCounters { + protocol_errors: saved.counters.protocol_errors, + authentication_errors: saved.counters.authentication_errors, + sequence_errors: saved.counters.sequence_errors, + ack_errors: saved.counters.ack_errors, + reset_errors: saved.counters.reset_errors, + reconnect_errors: saved.counters.reconnect_errors, + backpressure_errors: saved.counters.backpressure_errors, + }, + }; + snapshot.try_into().map_err(invalid_saved_state) +} + +fn validate_saved_output(saved: &SavedOutputSnapshot) -> Result<(), RestoreError> { + use control_session_broker::MAX_QUEUED_BYTES_PER_LEG; + use control_session_broker::MAX_QUEUED_RECORDS_PER_LEG; + use control_session_protocol::HEADER_LEN; + use control_session_protocol::MAX_DATA_LEN; + + if saved.queued_records.len() > MAX_QUEUED_RECORDS_PER_LEG { + return Err(invalid_saved_state("too many broker output records")); + } + let mut queued_bytes = 0usize; + for bytes in &saved.queued_records { + if !(HEADER_LEN..=HEADER_LEN + MAX_DATA_LEN).contains(&bytes.len()) { + return Err(invalid_saved_state("invalid broker output record length")); + } + control_session_protocol::decode_exact(bytes) + .map_err(|error| invalid_saved_state(error.to_string()))?; + queued_bytes = queued_bytes + .checked_add(bytes.len()) + .ok_or_else(|| invalid_saved_state("broker output byte count overflow"))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(invalid_saved_state( + "broker output bytes exceed configured bound", + )); + } + } + if let Some(current) = &saved.current { + if !(HEADER_LEN..=HEADER_LEN + MAX_DATA_LEN).contains(¤t.bytes.len()) { + return Err(invalid_saved_state( + "invalid current broker output record length", + )); + } + control_session_protocol::decode_exact(¤t.bytes) + .map_err(|error| invalid_saved_state(error.to_string()))?; + let offset = usize::try_from(current.offset) + .map_err(|_| invalid_saved_state("broker output offset is out of range"))?; + if offset >= current.bytes.len() { + return Err(invalid_saved_state( + "broker output offset exceeds record length", + )); + } + } + Ok(()) +} + +fn validate_saved_record(saved: &SavedRecord) -> Result<(), RestoreError> { + let record_type = u8::try_from(saved.record_type) + .ok() + .and_then(|record_type| control_session_protocol::RecordType::try_from(record_type).ok()) + .ok_or_else(|| invalid_saved_state("invalid pending broker record type"))?; + let payload_len = saved.payload.len(); + let payload_is_valid = match record_type { + control_session_protocol::RecordType::Ack + | control_session_protocol::RecordType::Credit => payload_len == 4, + control_session_protocol::RecordType::GuestAttach + | control_session_protocol::RecordType::Reset + | control_session_protocol::RecordType::Wait + | control_session_protocol::RecordType::Ready => payload_len == 0, + control_session_protocol::RecordType::HostAttach => payload_len == 32, + control_session_protocol::RecordType::Data => { + (1..=control_session_protocol::MAX_DATA_LEN).contains(&payload_len) + } + control_session_protocol::RecordType::Error => payload_len == 4, + }; + if saved.instance_id.len() != 16 || !payload_is_valid { + return Err(invalid_saved_state("invalid pending broker record")); + } + Ok(()) +} + +fn clone_saved_output(saved: &SavedOutputSnapshot) -> SavedOutputSnapshot { + SavedOutputSnapshot { + current: saved.current.as_ref().map(|record| SavedEncodedRecord { + bytes: record.bytes.clone(), + offset: record.offset, + }), + queued_records: saved.queued_records.clone(), + } +} + +fn clone_saved_record(saved: &SavedRecord) -> SavedRecord { + SavedRecord { + record_type: saved.record_type, + instance_id: saved.instance_id.clone(), + epoch: saved.epoch, + sequence: saved.sequence, + payload: saved.payload.clone(), + } +} diff --git a/vm/devices/virtio/virtio_console/src/tests.rs b/vm/devices/virtio/virtio_console/src/tests.rs index fd4db573a20..b9ef62271d8 100644 --- a/vm/devices/virtio/virtio_console/src/tests.rs +++ b/vm/devices/virtio/virtio_console/src/tests.rs @@ -7,6 +7,11 @@ //! serial IO backend, and real virtio queues — then drive requests through the //! descriptor rings just as a guest driver would. +mod broker; +mod direct; +mod harness; +mod saved_state; + use crate::VirtioConsoleDevice; use futures::AsyncRead; use futures::AsyncWrite; @@ -55,7 +60,7 @@ const TX_USED_ADDR: u64 = 0x12000; // Data area for payloads const DATA_BASE: u64 = 0x20000; -const TOTAL_MEM_SIZE: usize = 0x30000; +const TOTAL_MEM_SIZE: usize = 0x200000; // --- MockSerialIo --- @@ -77,6 +82,8 @@ struct MockShared { write_limit_then_disconnect: Option, /// If set, each poll_write accepts at most this many bytes (persistent). max_write_size: Option, + /// Extra state used by `harness::ControlledMockSerialIo`. + controls: harness::MockControls, } /// A mock `SerialIo` implementation backed by shared state. @@ -245,6 +252,7 @@ fn new_mock_serial() -> (MockSerialIo, MockSerialHandle) { disconnect_waker: None, write_limit_then_disconnect: None, max_write_size: None, + controls: Default::default(), })); ( MockSerialIo { @@ -310,6 +318,14 @@ impl TestHarness { /// Enable the device with both queues. async fn enable(&mut self) { + self.enable_with_state(None, None).await; + } + + async fn enable_with_state( + &mut self, + receive_state: Option, + transmit_state: Option, + ) { let features = VirtioDeviceFeatures::new(); // Queue 0: receiveq (host→guest) @@ -329,7 +345,7 @@ impl TestHarness { guest_memory: self.mem.clone(), }, &features, - None, + receive_state, ) .await .unwrap(); @@ -351,7 +367,7 @@ impl TestHarness { guest_memory: self.mem.clone(), }, &features, - None, + transmit_state, ) .await .unwrap(); diff --git a/vm/devices/virtio/virtio_console/src/tests/broker.rs b/vm/devices/virtio/virtio_console/src/tests/broker.rs new file mode 100644 index 00000000000..50f0ae946bf --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/broker.rs @@ -0,0 +1,755 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the control-session broker worker mode. + +use super::QUEUE_SIZE; +use super::TestHarness; +use super::harness::BROKER_CAPABILITY; +use super::harness::BROKER_INSTANCE; +use super::harness::ack; +use super::harness::decode; +use super::harness::encode; +use super::harness::yield_now; +use super::harness::yield_until; +use crate::broker::HostTransportState; +use crate::broker::disconnect_host_transport; +use crate::broker::initial_host_transport_state; +use crate::control_session_protocol; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use pal_async::DefaultDriver; +use pal_async::async_test; +use serial_core::LocalPeerIdentity; +use serial_core::disconnected::Disconnected; +use std::time::Duration; +use test_with_tracing::test; +use virtio::VirtioDevice; + +#[test] +fn disconnected_broker_transport_waits_for_connect() { + let mut io = Disconnected; + assert_eq!( + initial_host_transport_state(&io), + HostTransportState::WaitingForConnect + ); + assert_eq!( + disconnect_host_transport(&mut io), + HostTransportState::WaitingForConnect + ); +} + +async fn activate_broker(harness: &mut TestHarness) { + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!(reset.record_type, RecordType::Reset); + assert_eq!(reset.instance_id, BROKER_INSTANCE); + + harness + .send_guest_bytes(1, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let ready = decode(&harness.handle.take_tx_data()); + assert_eq!(ready.record_type, RecordType::Ready); +} + +#[async_test] +async fn broker_cold_attach_ready_and_bidirectional_data(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!(wait.record_type, RecordType::Wait); + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + harness + .send_guest_bytes(1, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); + + harness + .send_guest_bytes( + 2, + &encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"guest-to-host".to_vec(), + )), + ) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN + 13) + .await; + let host_record = decode(&harness.handle.take_tx_data()); + assert_eq!(host_record.payload, b"guest-to-host"); + + harness.handle.inject_rx_data(&encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"host-to-guest".to_vec(), + ))); + let guest_record = decode(&harness.receive_guest_bytes(1, 128).await); + assert_eq!(guest_record.payload, b"host-to-guest"); +} + +#[async_test] +async fn broker_rejects_wrong_identity_before_reserving_host_slot(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1001))); + harness.enable().await; + yield_until(|| !harness.handle.is_connected()).await; + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1000))); + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_rejects_wrong_capability_without_epoch_advance(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + [0x7d; 32].to_vec(), + ))); + yield_until(|| !harness.handle.is_connected()).await; + assert!(harness.handle.tx_data().is_empty()); + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_auth_timeout_frees_slot_without_epoch_advance(driver: DefaultDriver) { + let mut harness = + TestHarness::new_broker_with_timeout(&driver, BROKER_INSTANCE, BROKER_CAPABILITY, 5); + harness.enable().await; + let mut timer = pal_async::timer::PolledTimer::new(&driver); + timer.sleep(Duration::from_millis(20)).await; + yield_until(|| !harness.handle.is_connected()).await; + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_quiesce_stops_new_host_input(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.device.quiesce_input().await.unwrap(); + let host_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"after-quiesce".to_vec(), + )); + harness.handle.inject_rx_data(&host_data); + for _ in 0..20 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), host_data.len()); + + harness.device.resume_input().await.unwrap(); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + let record = decode( + &harness + .receive_guest_bytes(3, control_session_protocol::HEADER_LEN as u32 + 32) + .await, + ); + assert_eq!( + (record.record_type, record.payload), + (RecordType::Data, b"after-quiesce".to_vec()) + ); +} + +#[async_test] +async fn broker_host_lifecycle_error_does_not_stop_guest_worker(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.handle.set_disconnect_error(); + let reset = decode( + &harness + .receive_guest_bytes(3, control_session_protocol::HEADER_LEN as u32) + .await, + ); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 2)); + harness + .send_guest_bytes(4, &encode(&ack(BROKER_INSTANCE, 2))) + .await; + assert!(harness.device.stop_queue(0).await.is_some()); + assert!(harness.device.stop_queue(1).await.is_some()); + let (worker, state) = harness.device.worker.get(); + let crate::direct::ConsoleWorkerMode::Broker(mode) = &worker.mode else { + panic!("expected broker worker"); + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::ReadyNoHost + ); + assert!(state.is_some()); +} + +#[async_test] +async fn broker_disconnect_emits_reset_without_guest_eof(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.handle.disconnect(); + let reset = decode(&harness.receive_guest_bytes(1, 128).await); + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, BROKER_INSTANCE, 2) + ); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 2)); +} + +#[async_test] +async fn broker_zero_credit_disconnects_and_reconnects_without_guest_release( + driver: DefaultDriver, +) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let full_host_record = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + vec![0x5a; control_session_protocol::MAX_DATA_LEN], + )); + let full_guest_record = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + vec![0x5a; control_session_protocol::MAX_DATA_LEN], + )); + harness.handle.inject_rx_data(&full_host_record); + assert_eq!( + harness + .receive_guest_exact(2, full_guest_record.len()) + .await, + full_guest_record + ); + + let blocked = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + vec![0x44], + )); + harness.handle.inject_rx_data(&blocked); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + + harness.handle.disconnect(); + let reset = decode( + &harness + .receive_guest_exact(4, control_session_protocol::HEADER_LEN) + .await, + ); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 2)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); + harness + .send_guest_bytes(5, &encode(&ack(BROKER_INSTANCE, 2))) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); +} + +#[async_test] +async fn broker_disconnect_finishes_partial_data_before_reset(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let host_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + )); + let guest_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"partially-emitted".to_vec(), + )); + harness.handle.inject_rx_data(&host_data); + let first = harness.receive_guest_bytes(2, 13).await; + assert_eq!(first, guest_data[..13]); + + harness.handle.disconnect(); + let remainder = harness.receive_guest_exact(3, guest_data.len() - 13).await; + let mut completed = first; + completed.extend(remainder); + assert_eq!(completed, guest_data); + assert_eq!( + decode( + &harness + .receive_guest_exact(5, control_session_protocol::HEADER_LEN) + .await, + ) + .record_type, + RecordType::Reset + ); +} + +#[async_test] +async fn broker_initial_device_reset_preserves_connected_host(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + assert!(harness.handle.is_connected()); + + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + } + assert!(harness.handle.is_connected()); + + harness.enable().await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); + assert!(harness.handle.is_connected()); +} + +#[async_test] +async fn broker_device_reset_keeps_quarantined_host_in_waiting_for_disconnect( + driver: DefaultDriver, +) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(4242))); + harness.handle.set_disconnect_current_error(); + harness.enable().await; + + yield_until(|| harness.handle.disconnect_poll_count() > 0).await; + let receive_state = harness.device.stop_queue(0).await; + let transmit_state = harness.device.stop_queue(1).await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + assert_eq!( + mode.transport_state, + HostTransportState::WaitingForDisconnect + ); + } + assert!(harness.handle.is_connected()); + + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + assert_eq!( + mode.transport_state, + HostTransportState::WaitingForDisconnect + ); + } + assert!(harness.handle.is_connected()); + + harness + .enable_with_state(receive_state, transmit_state) + .await; + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1000))); + let host_attach = encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + )); + harness.handle.inject_rx_data(&host_attach); + for _ in 0..20 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), host_attach.len()); + assert!(harness.handle.tx_data().is_empty()); + + let disconnect_polls_before = harness.handle.disconnect_poll_count(); + harness.handle.disconnect(); + yield_until(|| harness.handle.disconnect_poll_count() > disconnect_polls_before).await; + harness.handle.reconnect(); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); +} + +#[async_test] +async fn broker_device_reset_requires_a_fresh_host_attachment(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.device.stop_queue(0).await; + harness.device.stop_queue(1).await; + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + } + + harness.handle.disconnect(); + harness.reset_rings(); + harness.enable().await; + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, BROKER_INSTANCE, 1) + ); +} + +#[async_test] +async fn broker_fragmentation_partial_host_writes_and_backpressure(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + + let guest_attach = encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())); + harness.send_guest_bytes(0, &guest_attach[..17]).await; + harness.send_guest_bytes(1, &guest_attach[17..]).await; + assert_eq!( + decode(&harness.receive_guest_bytes(0, 128).await).record_type, + RecordType::Reset + ); + harness + .send_guest_bytes(2, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + + harness.handle.set_max_write_size(3); + let host_attach = encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + )); + harness.handle.inject_rx_data(&host_attach[..11]); + for _ in 0..5 { + yield_now().await; + } + harness.handle.inject_rx_data(&host_attach[11..]); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); + + harness.handle.set_write_blocked(true); + for sequence in 1..=18 { + harness + .send_guest_bytes( + ((sequence + 2) % u64::from(QUEUE_SIZE)) as u16, + &encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + sequence, + vec![sequence as u8], + )), + ) + .await; + } + assert!(harness.handle.tx_data().is_empty()); + harness.handle.set_write_blocked(false); + let expected = 18 * (control_session_protocol::HEADER_LEN + 1); + yield_until(|| harness.handle.tx_data().len() >= expected).await; + let bytes = harness.handle.take_tx_data(); + let mut offset = 0; + for sequence in 1..=18 { + let end = offset + control_session_protocol::HEADER_LEN + 1; + let record = decode(&bytes[offset..end]); + assert_eq!( + (record.sequence, record.payload), + (sequence, vec![sequence as u8]) + ); + offset = end; + } + assert_eq!(offset, bytes.len()); +} + +#[async_test] +async fn broker_restore_finishes_old_output_then_uses_fresh_identity(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let old_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + )); + harness.handle.inject_rx_data(&old_data); + let first = harness.receive_guest_bytes(1, 13).await; + assert_eq!(first, old_data[..13]); + + let old_guest_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"partially-parsed".to_vec(), + )); + harness.send_guest_bytes(2, &old_guest_data[..19]).await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + const NEW_INSTANCE: [u8; 16] = [0x62; 16]; + const NEW_CAPABILITY: [u8; 32] = [0xb8; 32]; + harness.replace_with_broker(NEW_INSTANCE, NEW_CAPABILITY); + harness.device.restore_device(Some(saved)).unwrap(); + assert!(!harness.handle.is_connected()); + { + let (worker, _) = harness.device.worker.get(); + let crate::direct::ConsoleWorkerMode::Broker(mode) = &worker.mode else { + panic!("expected broker worker"); + }; + assert_eq!( + ( + mode.broker.guest_receive_window(), + mode.broker.guest_receive_credit() + ), + (0, 0) + ); + } + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + + let remainder = harness.receive_guest_bytes(2, 128).await; + let mut completed_old_output = first; + completed_old_output.extend(remainder); + let completed_old_output = decode(&completed_old_output); + assert_eq!(completed_old_output.payload, b"partially-emitted"); + assert_eq!(completed_old_output.instance_id, BROKER_INSTANCE); + let new_reset = decode(&harness.receive_guest_bytes(3, 128).await); + assert_eq!( + ( + new_reset.record_type, + new_reset.instance_id, + new_reset.epoch + ), + (RecordType::Reset, NEW_INSTANCE, 1) + ); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| !harness.handle.is_connected()).await; + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + NEW_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!( + (wait.record_type, wait.instance_id, wait.epoch), + (RecordType::Wait, NEW_INSTANCE, 1) + ); + + harness.send_guest_bytes(3, &old_guest_data[19..]).await; + harness + .send_guest_bytes(4, &encode(&ack(NEW_INSTANCE, 1))) + .await; +} + +#[async_test] +async fn malformed_host_input_detaches_without_panicking(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness + .handle + .inject_rx_data(&[0x99; control_session_protocol::HEADER_LEN]); + for _ in 0..20 { + yield_now().await; + } + yield_until(|| !harness.handle.is_connected()).await; + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); +} + +#[async_test] +async fn malformed_guest_input_recovers_after_device_reset(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + harness.post_tx_and_signal(2, &[0x99; control_session_protocol::HEADER_LEN]); + for _ in 0..20 { + yield_now().await; + } + + assert!(harness.device.stop_queue(0).await.is_some()); + assert!(harness.device.stop_queue(1).await.is_some()); + assert!(!harness.handle.is_connected()); + assert!(harness.handle.tx_data().is_empty()); + + harness.device.reset().await; + harness.handle.reconnect(); + harness.reset_rings(); + harness.enable().await; + activate_broker(&mut harness).await; +} diff --git a/vm/devices/virtio/virtio_console/src/tests/direct.rs b/vm/devices/virtio/virtio_console/src/tests/direct.rs new file mode 100644 index 00000000000..ba99ff85dc3 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/direct.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for input gating and read-error handling in the direct worker mode. + +use super::TestHarness; +use super::harness::yield_now; +use super::harness::yield_until; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; + +#[async_test] +async fn input_gate_cancels_rx_but_keeps_tx_running(driver: DefaultDriver) { + let mut harness = TestHarness::new(&driver); + harness.enable().await; + harness.device.quiesce_input().await.unwrap(); + + harness.handle.inject_rx_data(b"gated-input"); + for _ in 0..10 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), b"gated-input".len()); + + harness.post_tx_and_signal(0, b"tx-while-gated"); + let (used_id, _) = harness.wait_for_tx_used().await; + assert_eq!(used_id, 0); + assert_eq!(harness.handle.take_tx_data(), b"tx-while-gated"); + + harness.device.resume_input().await.unwrap(); + let gpa = harness.post_rx_buffer_and_signal(0, 64); + let (_, used_len) = harness.wait_for_rx_used().await; + let mut received = vec![0; used_len as usize]; + harness.mem.read_at(gpa, &mut received).unwrap(); + assert_eq!(received, b"gated-input"); +} + +#[async_test] +async fn read_error_drives_disconnect_before_reconnect(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_policy(&driver, VirtioConsoleDisconnectPolicy::Discard); + harness.enable().await; + harness.handle.set_read_error_then_disconnect(); + yield_until(|| harness.handle.disconnect_poll_count() != 0).await; + + harness.handle.reconnect(); + let gpa = harness.post_rx_buffer_and_signal(0, 64); + harness.handle.inject_rx_data(b"after-read-error"); + let (_, used_len) = harness.wait_for_rx_used().await; + let mut received = vec![0; used_len as usize]; + harness.mem.read_at(gpa, &mut received).unwrap(); + assert_eq!(received, b"after-read-error"); +} diff --git a/vm/devices/virtio/virtio_console/src/tests/harness.rs b/vm/devices/virtio/virtio_console/src/tests/harness.rs new file mode 100644 index 00000000000..551ce651a6b --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/harness.rs @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Test harness extensions for the direct-mode, broker, and save/restore tests. +//! +//! [`ControlledMockSerialIo`] wraps the mock serial backend and applies the +//! fault injection, write backpressure, and local peer identity configured in +//! [`MockControls`]. [`TestHarness`] gains policy and broker device variants +//! backed by it, device replacement for restore tests, and helpers that move +//! whole buffers through the guest queues. The remaining helpers poll the +//! executor and encode control-session records. + +use super::MockSerialHandle; +use super::MockSerialIo; +use super::QUEUE_SIZE; +use super::TestHarness; +use super::new_mock_serial; +use crate::VirtioConsoleDevice; +use crate::control_session_protocol; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use futures::AsyncRead; +use futures::AsyncWrite; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use serial_core::LocalPeerIdentity; +use serial_core::SerialIo; +use std::io; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +pub(super) const BROKER_INSTANCE: [u8; 16] = [0x51; 16]; +pub(super) const BROKER_CAPABILITY: [u8; 32] = [0xa7; 32]; + +/// Additional mock backend state, applied by [`ControlledMockSerialIo`]. +pub(super) struct MockControls { + write_blocked: bool, + write_waker: Option, + read_error_then_disconnect: bool, + connect_error: bool, + disconnect_error: bool, + disconnect_current_error: bool, + disconnect_poll_count: usize, + peer_identity: Option, +} + +impl Default for MockControls { + fn default() -> Self { + Self { + write_blocked: false, + write_waker: None, + read_error_then_disconnect: false, + connect_error: false, + disconnect_error: false, + disconnect_current_error: false, + disconnect_poll_count: 0, + peer_identity: Some(LocalPeerIdentity::UnixUid(1000)), + } + } +} + +/// The mock serial backend with [`MockControls`] applied before each call is +/// forwarded to [`MockSerialIo`]. +struct ControlledMockSerialIo(MockSerialIo); + +fn new_controlled_mock_serial() -> (ControlledMockSerialIo, MockSerialHandle) { + let (io, handle) = new_mock_serial(); + (ControlledMockSerialIo(io), handle) +} + +impl InspectMut for ControlledMockSerialIo { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.0.inspect_mut(req); + } +} + +impl SerialIo for ControlledMockSerialIo { + fn is_connected(&self) -> bool { + self.0.is_connected() + } + + fn poll_connect(&mut self, cx: &mut Context<'_>) -> Poll> { + { + let mut shared = self.0.shared.lock(); + if shared.controls.connect_error { + shared.controls.connect_error = false; + return Poll::Ready(Err(io::Error::other("injected connect error"))); + } + } + self.0.poll_connect(cx) + } + + fn poll_disconnect(&mut self, cx: &mut Context<'_>) -> Poll> { + { + let mut shared = self.0.shared.lock(); + shared.controls.disconnect_poll_count += 1; + if shared.controls.disconnect_error { + shared.controls.disconnect_error = false; + return Poll::Ready(Err(io::Error::other("injected disconnect error"))); + } + if shared.controls.read_error_then_disconnect { + shared.controls.read_error_then_disconnect = false; + shared.connected = false; + return Poll::Ready(Ok(())); + } + } + self.0.poll_disconnect(cx) + } + + fn local_peer_identity(&self) -> io::Result> { + Ok(self.0.shared.lock().controls.peer_identity.clone()) + } + + fn disconnect_current(&mut self) -> io::Result<()> { + let mut shared = self.0.shared.lock(); + if shared.controls.disconnect_current_error { + return Err(io::Error::other("injected disconnect_current error")); + } + shared.connected = false; + Ok(()) + } +} + +impl AsyncRead for ControlledMockSerialIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + { + let shared = self.0.shared.lock(); + if shared.connected && shared.controls.read_error_then_disconnect { + return Poll::Ready(Err(io::ErrorKind::ConnectionReset.into())); + } + } + Pin::new(&mut self.0).poll_read(cx, buf) + } +} + +impl AsyncWrite for ControlledMockSerialIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + { + let mut shared = self.0.shared.lock(); + if shared.connected && shared.controls.write_blocked { + shared.controls.write_waker = Some(cx.waker().clone()); + return Poll::Pending; + } + } + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_close(cx) + } +} + +impl MockSerialHandle { + pub(super) fn tx_data(&self) -> Vec { + self.shared.lock().tx_buf.clone() + } + + pub(super) fn pending_rx_len(&self) -> usize { + self.shared.lock().rx_buf.len() + } + + pub(super) fn set_write_blocked(&self, blocked: bool) { + let mut shared = self.shared.lock(); + shared.controls.write_blocked = blocked; + if !blocked { + if let Some(waker) = shared.controls.write_waker.take() { + waker.wake(); + } + } + } + + pub(super) fn set_read_error_then_disconnect(&self) { + let mut shared = self.shared.lock(); + shared.controls.read_error_then_disconnect = true; + if let Some(waker) = shared.rx_waker.take() { + waker.wake(); + } + } + + pub(super) fn disconnect_poll_count(&self) -> usize { + self.shared.lock().controls.disconnect_poll_count + } + + pub(super) fn is_connected(&self) -> bool { + self.shared.lock().connected + } + + pub(super) fn set_disconnect_error(&self) { + let mut shared = self.shared.lock(); + shared.controls.disconnect_error = true; + if let Some(waker) = shared.disconnect_waker.take() { + waker.wake(); + } + } + + pub(super) fn set_disconnect_current_error(&self) { + self.shared.lock().controls.disconnect_current_error = true; + } + + pub(super) fn set_peer_identity(&self, identity: Option) { + self.shared.lock().controls.peer_identity = identity; + } +} + +impl TestHarness { + pub(super) fn new_with_policy( + driver: &DefaultDriver, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + Self::new_with_device(driver, |driver_source, io| { + VirtioConsoleDevice::new_with_policy(driver_source, io, disconnect_policy) + }) + } + + pub(super) fn new_broker( + driver: &DefaultDriver, + instance_id: [u8; 16], + capability: [u8; 32], + ) -> Self { + Self::new_broker_with_timeout(driver, instance_id, capability, 5000) + } + + pub(super) fn new_broker_with_timeout( + driver: &DefaultDriver, + instance_id: [u8; 16], + capability: [u8; 32], + auth_timeout_ms: u64, + ) -> Self { + Self::new_with_device(driver, |driver_source, io| { + VirtioConsoleDevice::new_broker( + driver_source, + io, + VirtioControlConsoleBrokerConfig { + instance_id, + capability, + expected_peer_identity: LocalPeerIdentity::UnixUid(1000), + auth_timeout_ms, + }, + ) + }) + } + + /// Builds the standard harness, then swaps in the device returned by + /// `make_device` together with a fresh controlled mock backend. + fn new_with_device( + driver: &DefaultDriver, + make_device: impl FnOnce(&VmTaskDriverSource, Box) -> VirtioConsoleDevice, + ) -> Self { + let mut harness = Self::new(driver); + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + harness.device = make_device(&driver_source, Box::new(io)); + harness.handle = handle; + harness + } + + pub(super) fn replace_device(&mut self, disconnect_policy: VirtioConsoleDisconnectPolicy) { + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(self.driver.clone())); + self.device = + VirtioConsoleDevice::new_with_policy(&driver_source, Box::new(io), disconnect_policy); + self.handle = handle; + } + + pub(super) fn replace_with_broker(&mut self, instance_id: [u8; 16], capability: [u8; 32]) { + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(self.driver.clone())); + self.device = VirtioConsoleDevice::new_broker( + &driver_source, + Box::new(io), + VirtioControlConsoleBrokerConfig { + instance_id, + capability, + expected_peer_identity: LocalPeerIdentity::UnixUid(1000), + auth_timeout_ms: 5000, + }, + ); + self.handle = handle; + } + + pub(super) async fn send_guest_bytes(&mut self, desc_index: u16, bytes: &[u8]) { + self.post_tx_and_signal(desc_index, bytes); + let (used_id, used_len) = self.wait_for_tx_used().await; + assert_eq!(used_id, desc_index); + assert_eq!(used_len, 0); + } + + pub(super) async fn receive_guest_bytes(&mut self, desc_index: u16, size: u32) -> Vec { + let gpa = self.post_rx_buffer_and_signal(desc_index, size); + let (used_id, used_len) = self.wait_for_rx_used().await; + assert_eq!(used_id, desc_index); + let mut bytes = vec![0; used_len as usize]; + self.mem.read_at(gpa, &mut bytes).unwrap(); + bytes + } + + pub(super) async fn receive_guest_exact( + &mut self, + first_desc_index: u16, + mut size: usize, + ) -> Vec { + let mut bytes = Vec::with_capacity(size); + let mut desc_index = first_desc_index; + while size != 0 { + let chunk = size.min(crate::BUF_SIZE); + bytes.extend(self.receive_guest_bytes(desc_index, chunk as u32).await); + size -= chunk; + desc_index = (desc_index + 1) % QUEUE_SIZE; + } + bytes + } +} + +pub(super) async fn yield_now() { + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await +} + +pub(super) async fn yield_until(mut condition: impl FnMut() -> bool) { + for _ in 0..1000 { + if condition() { + return; + } + yield_now().await; + } + assert!(condition(), "condition did not become true"); +} + +pub(super) fn encode(record: &Record) -> Vec { + control_session_protocol::encode(record).unwrap() +} + +pub(super) fn decode(bytes: &[u8]) -> Record { + control_session_protocol::decode_exact(bytes).unwrap() +} + +pub(super) fn ack(instance_id: [u8; 16], epoch: u64) -> Record { + Record::session( + RecordType::Ack, + instance_id, + epoch, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ) +} diff --git a/vm/devices/virtio/virtio_console/src/tests/saved_state.rs b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs new file mode 100644 index 00000000000..20757c493dd --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs @@ -0,0 +1,395 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for saving, validating, and restoring the device-private console state. + +use super::DATA_BASE; +use super::QUEUE_SIZE; +use super::TOTAL_MEM_SIZE; +use super::TX_AVAIL_ADDR; +use super::TX_DESC_ADDR; +use super::TX_USED_ADDR; +use super::TestHarness; +use super::harness::BROKER_CAPABILITY; +use super::harness::BROKER_INSTANCE; +use super::harness::encode; +use super::harness::yield_until; +use super::new_mock_serial; +use crate::VirtioConsoleDevice; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use chipset_device::io::IoResult; +use chipset_device::mmio::MmioIntercept; +use guestmem::GuestMemory; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio::device::saved_state::DeviceQueueState; +use virtio::queue::QueueParams; +use virtio::queue::QueueState; +use virtio::spec::VirtioDeviceFeatures; +use virtio::spec::queue::DescriptorFlags; +use virtio::test_helpers::init_avail_ring; +use virtio::test_helpers::init_used_ring; +use virtio::test_helpers::make_available; +use virtio::test_helpers::write_descriptor; +use virtio::transport::VirtioMmioDevice; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::device_state::ChangeDeviceState; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::SaveRestore; +use vmcore::save_restore::SavedStateBlob; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +#[async_test] +async fn staged_rx_restores_before_new_endpoint_bytes(driver: DefaultDriver) { + let mut harness = TestHarness::new(&driver); + harness.enable().await; + + harness.handle.inject_rx_data(b"before-snapshot"); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + harness.replace_device(VirtioConsoleDisconnectPolicy::Discard); + harness.device.restore_device(Some(saved)).unwrap(); + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + harness.handle.inject_rx_data(b"after-restore"); + + let first_gpa = harness.post_rx_buffer_and_signal(0, 64); + let (_, first_len) = harness.wait_for_rx_used().await; + let mut first = vec![0; first_len as usize]; + harness.mem.read_at(first_gpa, &mut first).unwrap(); + assert_eq!(first, b"before-snapshot"); + + let second_gpa = harness.post_rx_buffer_and_signal(1, 64); + let (_, second_len) = harness.wait_for_rx_used().await; + let mut second = vec![0; second_len as usize]; + harness.mem.read_at(second_gpa, &mut second).unwrap(); + assert_eq!(second, b"after-restore"); +} + +#[async_test] +async fn partial_tx_restores_without_replay(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_policy(&driver, VirtioConsoleDisconnectPolicy::Retain); + harness.enable().await; + harness.handle.set_write_limit_then_disconnect(3); + harness.post_tx_and_signal(0, b"abcdef"); + yield_until(|| harness.handle.tx_data() == b"abc").await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + harness.replace_device(VirtioConsoleDisconnectPolicy::Retain); + harness.device.restore_device(Some(saved)).unwrap(); + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + + let (used_id, _) = harness.wait_for_tx_used().await; + assert_eq!(used_id, 0); + assert_eq!(harness.handle.take_tx_data(), b"def"); +} + +#[async_test] +async fn saved_state_validator_rejects_wrong_schema(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let validator = device.device_state_validator(); + let state = SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION + 1, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: Vec::new(), + disconnect_policy_id: 0, + broker: None, + }); + assert!( + validator( + Some(&state), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} + +#[async_test] +async fn saved_state_validator_rejects_tx_offset_past_descriptor(driver: DefaultDriver) { + let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); + init_avail_ring(&mem, TX_AVAIL_ADDR); + init_used_ring(&mem, TX_USED_ADDR); + let payload_gpa = DATA_BASE; + mem.write_at(payload_gpa, b"short").unwrap(); + write_descriptor( + &mem, + TX_DESC_ADDR, + 0, + payload_gpa, + 5, + DescriptorFlags::new(), + 0, + ); + let mut avail_index = 0; + make_available(&mem, TX_AVAIL_ADDR, QUEUE_SIZE, 0, &mut avail_index); + + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let validator = device.device_state_validator(); + let state = SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 6, + staged_rx: Vec::new(), + disconnect_policy_id: 0, + broker: None, + }); + let queues = [ + DeviceQueueState { + params: QueueParams::default(), + queue_state: None, + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: Some(QueueState::default()), + }, + ]; + assert!(validator(Some(&state), &VirtioDeviceFeatures::new(), &queues, &mem,).is_err()); +} + +#[async_test] +async fn inactive_transport_restore_defers_console_private_state(driver: DefaultDriver) { + let source_harness = TestHarness::new(&driver); + let mut source = VirtioMmioDevice::new( + Box::new(source_harness.device), + &driver, + source_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let mut saved = source.save().unwrap(); + let staged_rx = b"deferred console input".to_vec(); + saved.device_state = Some(SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 123, + rows: 45, + partial_transmit: 0, + staged_rx: staged_rx.clone(), + disconnect_policy_id: 0, + broker: None, + })); + + let destination_harness = TestHarness::new(&driver); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_harness.device), + &driver, + destination_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.stop().await; + let staged = destination.save().unwrap(); + let staged_private = staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(staged_private.columns, 123); + assert_eq!(staged_private.rows, 45); + assert_eq!(staged_private.staged_rx, staged_rx); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(u16::from_ne_bytes(config[..2].try_into().unwrap()), 123); + assert_eq!(u16::from_ne_bytes(config[2..].try_into().unwrap()), 45); + + let mut invalid = staged; + invalid.device_state = Some(SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 80, + rows: 25, + partial_transmit: 0, + staged_rx: vec![0; crate::saved_state::MAX_STAGED_RX_BYTES + 1], + disconnect_policy_id: 0, + broker: None, + })); + let invalid_harness = TestHarness::new(&driver); + let mut invalid_destination = VirtioMmioDevice::new( + Box::new(invalid_harness.device), + &driver, + invalid_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(invalid_destination.restore(invalid).is_err()); +} + +#[async_test] +async fn direct_and_broker_saved_state_schemas_are_not_interchangeable(driver: DefaultDriver) { + let mut direct = TestHarness::new(&driver); + direct.enable().await; + direct.device.stop_queue(0).await; + direct.device.stop_queue(1).await; + let direct_saved = direct.device.save_device().unwrap().unwrap(); + let direct_state: crate::saved_state::SavedState = direct_saved.parse().unwrap(); + assert_eq!( + direct_state.schema_version, + crate::saved_state::DIRECT_SAVED_STATE_VERSION + ); + assert!(direct_state.broker.is_none()); + + let mut broker = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + assert!(broker.device.restore_device(Some(direct_saved)).is_err()); + + broker.enable().await; + broker.device.stop_queue(0).await; + broker.device.stop_queue(1).await; + let broker_saved = broker.device.save_device().unwrap().unwrap(); + let broker_state: crate::saved_state::SavedState = broker_saved.parse().unwrap(); + assert_eq!( + broker_state.schema_version, + crate::saved_state::BROKER_SAVED_STATE_VERSION + ); + assert!(broker_state.broker.is_some()); + + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let mut direct_device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + assert!(direct_device.restore_device(Some(broker_saved)).is_err()); +} + +#[async_test] +async fn direct_schema_v1_allows_staged_rx_without_receive_queue(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let state = crate::saved_state::SavedState { + schema_version: crate::saved_state::DIRECT_SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: vec![1], + disconnect_policy_id: 0, + broker: None, + }; + let saved = SavedStateBlob::new(state); + let validator = device.device_state_validator(); + assert!( + validator( + Some(&saved), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_ok() + ); +} + +#[async_test] +async fn oversized_saved_state_is_rejected_before_nested_decode(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let state = crate::saved_state::SavedState { + schema_version: crate::saved_state::DIRECT_SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: vec![0; crate::saved_state::MAX_SAVED_STATE_BYTES], + disconnect_policy_id: 0, + broker: None, + }; + let saved = SavedStateBlob::new(state); + assert!(saved.encoded_len() > crate::saved_state::MAX_SAVED_STATE_BYTES); + let validator = device.device_state_validator(); + assert!( + validator( + Some(&saved), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} + +#[async_test] +async fn broker_state_validator_rejects_malformed_private_state(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness.device.stop_queue(0).await; + harness.device.stop_queue(1).await; + let saved = harness.device.save_device().unwrap().unwrap(); + let mut state: crate::saved_state::SavedState = saved.parse().unwrap(); + state.broker.as_mut().unwrap().guest_parser.header_count = u32::MAX; + let malformed = SavedStateBlob::new(state); + let validator = harness.device.device_state_validator(); + assert!( + validator( + Some(&malformed), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); + + let mut state: crate::saved_state::SavedState = saved.parse().unwrap(); + let broker = state.broker.as_mut().unwrap(); + broker.instance_id = [0x44; 16].to_vec(); + broker.guest_output.current = Some(crate::saved_state::SavedEncodedRecord { + bytes: encode(&Record::session( + RecordType::Reset, + BROKER_INSTANCE, + 1, + 0, + Vec::new(), + )), + offset: 1, + }); + let malformed = SavedStateBlob::new(state); + assert!( + validator( + Some(&malformed), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} diff --git a/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt new file mode 100644 index 00000000000..74ce5710898 --- /dev/null +++ b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt @@ -0,0 +1,37 @@ +DATA_LENGTH|VALID_MIN|1 +DATA_LENGTH|VALID_MAX|65536 +DATA_LENGTH|INVALID_EMPTY|0 +DATA_LENGTH|INVALID_OVERSIZE|65537 +ACK_CREDIT|VALID_MIN|65536 +ACK_CREDIT|VALID_MAX|4194314 +ACK_CREDIT|INVALID_BELOW_MIN|65535 +ACK_CREDIT|INVALID_ABOVE_MAX|4194315 +CREDIT_INCREMENT|VALID_MIN|1 +CREDIT_INCREMENT|VALID_MAX|4194314 +CREDIT_INCREMENT|INVALID_ZERO|0 +CREDIT_INCREMENT|INVALID_ABOVE_MAX|4194315 +INVALID_HEADER|INVALID_MAGIC|5856585301000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_VERSION|4e56585302000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_TYPE|4e56585301006300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_FLAGS|4e56585301000501000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_DATA_LENGTH|4e56585301000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000100 +ERROR_CODE|AUTHENTICATION|1 +INVALID_RECORD|INVALID_GUEST_ATTACH_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000100000000000000000000000000000000000000000000000000000000000000000001000000a1 +INVALID_RECORD|INVALID_HOST_ATTACH_SHORT|INVALID_PAYLOAD_LENGTH|4e5658530100020000000000000000000000000000000000000000000000000000000000000000001f00000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_LONG|INVALID_PAYLOAD_LENGTH|4e56585301000200000000000000000000000000000000000000000000000000000000000000000021000000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_RESET_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a2 +INVALID_RECORD|INVALID_ACK_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000400000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a3 +INVALID_RECORD|INVALID_WAIT_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000600000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a4 +INVALID_RECORD|INVALID_READY_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000700000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a5 +INVALID_RECORD|INVALID_ERROR_SHORT|INVALID_PAYLOAD_LENGTH|4e56585301000800000102030405060708090a0b0c0d0e0f0700000000000000090000000000000003000000010000 +INVALID_RECORD|INVALID_ERROR_LONG|INVALID_PAYLOAD_LENGTH|4e56585301000800000102030405060708090a0b0c0d0e0f07000000000000000900000000000000050000000100000000 +INVALID_RECORD|INVALID_GUEST_ATTACH_INSTANCE|INVALID_BOOTSTRAP_IDENTITY|4e56585301000100010000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_EPOCH|INVALID_BOOTSTRAP_IDENTITY|4e565853010002000000000000000000000000000000000001000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_SEQUENCE|INVALID_BOOTSTRAP_IDENTITY|4e565853010002000000000000000000000000000000000000000000000000000100000000000000200000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_ERROR_CODE_0|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000000000000 +INVALID_RECORD|INVALID_ERROR_CODE_2|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000002000000 +INVALID_RECORD|INVALID_ERROR_CODE_3|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000003000000 +INVALID_RECORD|INVALID_ERROR_CODE_4|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000004000000 +INVALID_RECORD|INVALID_ERROR_CODE_5|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000005000000 +INVALID_RECORD|INVALID_ERROR_CODE_6|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000006000000 +INVALID_RECORD|INVALID_ERROR_CODE_MAX|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f0700000000000000090000000000000004000000ffffffff diff --git a/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt new file mode 100644 index 00000000000..0edd125fb21 --- /dev/null +++ b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt @@ -0,0 +1,9 @@ +GUEST_ATTACH|4e56585301000100000000000000000000000000000000000000000000000000000000000000000000000000 +HOST_ATTACH|4e56585301000200000000000000000000000000000000000000000000000000000000000000000020000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +RESET|4e56585301000300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +ACK_INITIAL_CREDIT|4e56585301000400000102030405060708090a0b0c0d0e0f07000000000000000900000000000000040000000a004000 +CREDIT|4e56585301000900000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000000000100 +DATA_EMBEDDED_MAGIC|4e56585301000500000102030405060708090a0b0c0d0e0f07000000000000000900000000000000080000004e5658534e565853 +WAIT|4e56585301000600000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +READY|4e56585301000700000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +ERROR_AUTHENTICATION|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000001000000 diff --git a/vm/devices/virtio/virtio_net/Cargo.toml b/vm/devices/virtio/virtio_net/Cargo.toml index f954aac4ff1..7fb13c7c762 100644 --- a/vm/devices/virtio/virtio_net/Cargo.toml +++ b/vm/devices/virtio/virtio_net/Cargo.toml @@ -11,6 +11,7 @@ vm_resource.workspace = true inspect.workspace = true inspect_counters.workspace = true +mesh.workspace = true net_backend.workspace = true net_backend_resources.workspace = true pal_async.workspace = true @@ -30,8 +31,8 @@ tracing.workspace = true zerocopy.workspace = true [dev-dependencies] +chipset_device.workspace = true futures.workspace = true -mesh.workspace = true pal_event.workspace = true parking_lot.workspace = true test_with_tracing.workspace = true diff --git a/vm/devices/virtio/virtio_net/src/egress.rs b/vm/devices/virtio/virtio_net/src/egress.rs new file mode 100644 index 00000000000..1a8fa49e6ff --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/egress.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy enforcement for virtio-net transmit. +//! +//! [`NicBuilder::egress_policy`] configures a run-scoped [`EgressPolicy`]. The +//! device validates the policy and installs it on the endpoint, which applies +//! it to the bytes it transmits, and checks the prefix of each guest frame +//! against the policy before handing the frame to the endpoint. + +use crate::NicBuilder; +use crate::TxPacketError; +use crate::Worker; +use anyhow::Context as _; +use net_backend::Endpoint; +use net_backend_resources::egress::EgressPolicy; + +impl NicBuilder { + pub fn egress_policy(mut self, egress_policy: EgressPolicy) -> Self { + self.egress_policy = Some(egress_policy); + self + } + + /// Validates the configured egress policy and installs it on `endpoint`. + pub(crate) fn configure_egress( + &self, + mut endpoint: Box, + ) -> anyhow::Result> { + if let Some(policy) = &self.egress_policy { + policy + .validate() + .context("network device received an invalid egress policy")?; + endpoint + .set_egress_policy(policy.clone()) + .with_context(|| { + format!( + "network backend '{}' rejected egress policy", + endpoint.endpoint_type() + ) + })?; + } + Ok(endpoint) + } +} + +impl Worker { + /// Checks the prefix of a guest TX frame against the egress policy. + pub(crate) fn authorize_egress( + &self, + packet_prefix: &[u8], + packet_len: u32, + ) -> Result<(), TxPacketError> { + if let Some(policy) = &self.egress_policy { + policy + .authorize_frame(packet_prefix, packet_len as usize) + .map_err(TxPacketError::EgressDenied)?; + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_net/src/lib.rs b/vm/devices/virtio/virtio_net/src/lib.rs index e632711ac85..ae855736a00 100644 --- a/vm/devices/virtio/virtio_net/src/lib.rs +++ b/vm/devices/virtio/virtio_net/src/lib.rs @@ -12,7 +12,10 @@ #![forbid(unsafe_code)] mod buffers; +mod egress; +mod quiesce; pub mod resolver; +mod saved_state; #[cfg(test)] mod tests; @@ -36,10 +39,13 @@ use net_backend::TxMetadata; use net_backend::TxOffloadSupport; use net_backend::TxSegment; use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressDenied; +use net_backend_resources::egress::EgressPolicy; use net_backend_resources::mac_address::MacAddress; use pal_async::wait::PolledWait; use std::future::pending; use std::mem::offset_of; +use std::ops::ControlFlow; use std::sync::Arc; use std::task::Poll; use task_control::AsyncRun; @@ -53,10 +59,14 @@ use virtio::QueueResources; use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::VirtioQueueCallbackWork; +use virtio::device::saved_state::DeviceStateValidator; use virtio::in_order::InOrderCompletion; use virtio::queue::QueueCompletion; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; use zerocopy::FromBytes; @@ -226,6 +236,8 @@ struct Adapter { tx_fast_completions: bool, mac_address: MacAddress, tx_offload_support: TxOffloadSupport, + egress_policy: Option, + save_restore: Option, } pub struct Device { @@ -235,6 +247,7 @@ pub struct Device { driver_source: VmTaskDriverSource, /// Per-pair state tracking. pairs: Vec, + lifecycle: saved_state::Lifecycle, } /// Tracks the state of a queue pair through the start_queue lifecycle. @@ -248,9 +261,15 @@ enum QueuePairState { queue_size: u16, /// true if this is the RX queue (even index), false if TX (odd). is_rx: bool, + feature_banks: [u32; 2], }, /// Both queues started, worker running. Active, + /// Both queues stopped; each transport call takes its corresponding state. + Stopped { + rx: Option, + tx: Option, + }, } impl VirtioDevice for Device { @@ -342,13 +361,15 @@ impl VirtioDevice for Device { let pair_idx = (idx / 2) as usize; let is_rx = idx.is_multiple_of(2); + self.prepare_queue_start(pair_idx, is_rx, features)?; match &self.pairs[pair_idx] { - QueuePairState::Empty => { + QueuePairState::Empty | QueuePairState::Stopped { .. } => { // First queue of the pair — buffer it. self.pairs[pair_idx] = QueuePairState::HalfOpen { queue, queue_size, is_rx, + feature_banks: [features.bank(0), features.bank(1)], }; } QueuePairState::HalfOpen { @@ -373,6 +394,7 @@ impl VirtioDevice for Device { queue: pending_queue, queue_size: pending_queue_size, is_rx: pending_is_rx, + feature_banks: _, } = prev else { unreachable!() @@ -416,6 +438,9 @@ impl VirtioDevice for Device { } async fn stop_queue(&mut self, idx: u16) -> Option { + if let ControlFlow::Break(state) = self.stop_saved_queue(idx).await { + return state; + } let pair_idx = (idx / 2) as usize; if pair_idx < self.pairs.len() { @@ -447,10 +472,31 @@ impl VirtioDevice for Device { async fn reset(&mut self) { self.pairs.fill_with(|| QueuePairState::Empty); + self.lifecycle.reset(); + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.quiesce_endpoint_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.resume_endpoint_input().await } fn supports_save_restore(&self) -> bool { - true + self.adapter.save_restore.is_some() + } + + fn save_device(&mut self) -> Result, SaveError> { + self.save_private_state() + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + self.restore_private_state(state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + self.private_state_validator() } } @@ -532,6 +578,8 @@ struct PendingTxPacket { pub struct NicBuilder { max_queue_pairs: u16, + egress_policy: Option, + save_restore: Option, } impl NicBuilder { @@ -562,6 +610,7 @@ impl NicBuilder { endpoint.endpoint_type() ); } + let endpoint = self.configure_egress(endpoint)?; // TODO: Implement VIRTIO_NET_F_MQ and VIRTIO_NET_F_RSS logic based on mulitqueue support. // let multiqueue = endpoint.multiqueue_support(); @@ -576,6 +625,8 @@ impl NicBuilder { tx_fast_completions: endpoint.tx_fast_completions(), mac_address, tx_offload_support, + egress_policy: self.egress_policy, + save_restore: self.save_restore, }); let coordinator = TaskControl::new(CoordinatorState { @@ -603,6 +654,7 @@ impl NicBuilder { pairs: (0..max_queue_pairs) .map(|_| QueuePairState::Empty) .collect(), + lifecycle: Default::default(), }) } } @@ -611,6 +663,8 @@ impl Device { pub fn builder() -> NicBuilder { NicBuilder { max_queue_pairs: !0, + egress_policy: None, + save_restore: None, } } } @@ -632,6 +686,7 @@ impl Device { .collect(), num_queues, restart: true, + input_quiesced: self.lifecycle.input_quiesced, }, ); } @@ -667,11 +722,11 @@ impl Device { active_state, negotiated_features, negotiated_features_bank1, + egress_policy: self.adapter.egress_policy.clone(), }; let coordinator = self.coordinator.state_mut().unwrap(); let worker_task = &mut coordinator.workers[idx]; worker_task.insert(&driver, "virtio-net".to_string(), worker); - worker_task.start(); } } @@ -679,6 +734,7 @@ struct Coordinator { workers: Vec>, num_queues: u16, restart: bool, + input_quiesced: bool, } struct CoordinatorState { @@ -739,6 +795,7 @@ impl Coordinator { "failed to restart queues" ); } + self.establish_input_gate(stop).await?; self.restart = false; } self.start_workers(); @@ -851,8 +908,12 @@ enum TxPacketError { Empty, #[error("too many segments")] TooManySegments, + #[error("packet length {0} exceeds the 65535-byte backend bound")] + TooLarge(u32), #[error("descriptor index {0} already in use")] DuplicateIndex(u16), + #[error("egress policy denied packet")] + EgressDenied(#[source] EgressDenied), } impl From for WorkerError { @@ -869,6 +930,8 @@ struct Worker { negotiated_features: NetworkFeaturesBank0, #[inspect(skip)] negotiated_features_bank1: NetworkFeaturesBank1, + #[inspect(skip)] + egress_policy: Option, } impl Worker { @@ -1017,15 +1080,18 @@ impl Worker { .checked_sub(header_size()) .and_then(|len| u32::try_from(len).ok()) .ok_or(TxPacketError::Empty)?; + if packet_len > u16::MAX.into() { + return Err(TxPacketError::TooLarge(packet_len)); + } // Read the virtio-net header + enough of the Ethernet frame to parse // the EtherType (and a potential VLAN tag). - const ETH_PEEK: usize = 18; // 14 standard + 4 for VLAN tag - let mut peek_buf = [0u8; size_of::() + ETH_PEEK]; + const PACKET_PEEK: usize = 98; // Ethernet + VLAN + max IPv4 + TCP header. + let mut peek_buf = [0u8; size_of::() + PACKET_PEEK]; let bytes_read = work .read( self.active_state.pending_rx_packets.mem(), - &mut peek_buf[..header_size() + ETH_PEEK], + &mut peek_buf[..header_size() + PACKET_PEEK], ) .map_err(TxPacketError::ReadHeader)?; @@ -1037,6 +1103,7 @@ impl Worker { } else { &[] }; + self.authorize_egress(packet_prefix, packet_len)?; let segments = &mut self.active_state.data.tx_segments; let seg_start = segments.len(); diff --git a/vm/devices/virtio/virtio_net/src/quiesce.rs b/vm/devices/virtio/virtio_net/src/quiesce.rs new file mode 100644 index 00000000000..ccec542718f --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/quiesce.rs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Input quiesce of the virtio-net queue workers. +//! +//! Quiescing input stops the coordinator and the workers, lets each endpoint +//! queue finish the work it already accepted while it stops admitting new host +//! RX, drains the resulting completions, and restarts the workers. The +//! coordinator latches the input gate and re-establishes it whenever it +//! restarts the endpoint queues; resuming input releases it. Only devices built +//! with [`NicBuilder::save_restore`](crate::NicBuilder::save_restore) support +//! input quiesce. + +use crate::Coordinator; +use crate::Device; +use crate::EndpointQueueState; +use crate::Worker; +use crate::WorkerError; +use anyhow::Context as _; +use std::future::pending; +use task_control::StopTask; + +impl Device { + /// Implements `quiesce_input`. + pub(crate) async fn quiesce_endpoint_input(&mut self) -> anyhow::Result<()> { + anyhow::ensure!( + self.adapter.save_restore.is_some(), + "virtio-net input quiesce is unavailable without saved-state configuration" + ); + self.lifecycle.input_quiesced = true; + self.quiesce_active_workers(true).await + } + + /// Implements `resume_input`. + pub(crate) async fn resume_endpoint_input(&mut self) -> anyhow::Result<()> { + self.resume_quiesced_input().await?; + self.lifecycle.input_quiesced = false; + Ok(()) + } + + pub(crate) async fn quiesce_active_workers(&mut self, restart: bool) -> anyhow::Result<()> { + if !self.coordinator.is_running() && self.coordinator.state().is_none() { + return Ok(()); + } + self.coordinator.stop().await; + { + let coordinator = self + .coordinator + .state_mut() + .context("virtio-net coordinator state is unavailable")?; + coordinator.input_quiesced = self.lifecycle.input_quiesced; + for worker in &mut coordinator.workers { + worker.stop().await; + } + for worker in &mut coordinator.workers { + let (queue, state) = worker.get_mut(); + let state = + state.context("virtio-net worker state is unavailable during quiesce")?; + if let Some(queue) = queue.state.as_mut() { + state + .quiesce_endpoint(queue) + .await + .map_err(anyhow::Error::new)?; + } + } + if restart { + for worker in &mut coordinator.workers { + worker.start(); + } + } + } + if restart { + self.coordinator.start(); + } + Ok(()) + } + + async fn resume_quiesced_input(&mut self) -> anyhow::Result<()> { + if !self.coordinator.is_running() && self.coordinator.state().is_none() { + return Ok(()); + } + self.coordinator.stop().await; + { + let coordinator = self + .coordinator + .state_mut() + .context("virtio-net coordinator state is unavailable")?; + coordinator.input_quiesced = false; + for worker in &mut coordinator.workers { + worker.stop().await; + } + for worker in &mut coordinator.workers { + let (queue, state) = worker.get_mut(); + let state = state.context("virtio-net worker state is unavailable")?; + if let Some(queue) = queue.state.as_mut() { + state.resume_endpoint(queue)?; + } + worker.start(); + } + } + self.coordinator.start(); + Ok(()) + } +} + +impl Coordinator { + /// Re-establishes a latched input gate after the endpoint queues restart. + /// + /// If the gate cannot be established, waits until the coordinator is + /// stopped instead of starting the workers. + pub(crate) async fn establish_input_gate( + &mut self, + stop: &mut StopTask<'_>, + ) -> Result<(), task_control::Cancelled> { + if self.input_quiesced + && let Err(err) = stop.until_stopped(self.quiesce_workers()).await? + { + tracing::error!( + error = %err, + "failed to establish virtio-net input gate" + ); + stop.until_stopped(pending::<()>()).await?; + } + Ok(()) + } + + async fn quiesce_workers(&mut self) -> anyhow::Result<()> { + for worker in &mut self.workers { + let (queue, state) = worker.get_mut(); + let state = state.context("virtio-net worker state is unavailable")?; + if let Some(queue) = queue.state.as_mut() { + state + .quiesce_endpoint(queue) + .await + .map_err(anyhow::Error::new)?; + } + } + Ok(()) + } +} + +impl Worker { + async fn quiesce_endpoint( + &mut self, + queue_state: &mut EndpointQueueState, + ) -> Result<(), WorkerError> { + for _ in 0..=usize::from(self.virtio_state.tx_queue_size) { + while self.transmit_pending_segments(queue_state)? {} + + queue_state + .queue + .quiesce(&mut self.active_state.pending_rx_packets) + .await + .map_err(WorkerError::Endpoint)?; + self.process_endpoint_rx(queue_state.queue.as_mut())?; + self.process_endpoint_tx(queue_state.queue.as_mut())?; + + if self.active_state.data.tx_segments.is_empty() + && self + .active_state + .pending_tx_packets + .iter() + .all(Option::is_none) + { + let ownership = queue_state + .queue + .quiesce(&mut self.active_state.pending_rx_packets) + .await + .map_err(WorkerError::Endpoint)?; + if ownership.rx_ready != 0 || ownership.tx_ready != 0 { + return Err(WorkerError::Endpoint(anyhow::anyhow!( + "network endpoint retained completions after quiesce drain" + ))); + } + return Ok(()); + } + } + Err(WorkerError::Endpoint(anyhow::anyhow!( + "network endpoint did not drain within the queue-size bound" + ))) + } + + fn resume_endpoint(&mut self, queue_state: &mut EndpointQueueState) -> anyhow::Result<()> { + queue_state.queue.resume()?; + let count = self + .active_state + .pending_rx_packets + .fill_ready(&mut self.active_state.data.rx_ready); + queue_state.queue.rx_avail( + &mut self.active_state.pending_rx_packets, + &self.active_state.data.rx_ready[..count], + ); + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_net/src/resolver.rs b/vm/devices/virtio/virtio_net/src/resolver.rs index c91451f7477..b42503a3655 100644 --- a/vm/devices/virtio/virtio_net/src/resolver.rs +++ b/vm/devices/virtio/virtio_net/src/resolver.rs @@ -37,6 +37,10 @@ impl AsyncResolveResource for VirtioNetReso if let Some(max_queues) = resource.max_queues { builder = builder.max_queues(max_queues); } + builder = builder.save_restore_resource(&resource)?; + if let Some(egress_policy) = resource.egress_policy { + builder = builder.egress_policy(egress_policy); + } let endpoint = resolver .resolve( diff --git a/vm/devices/virtio/virtio_net/src/saved_state.rs b/vm/devices/virtio/virtio_net/src/saved_state.rs new file mode 100644 index 00000000000..6f967f2104c --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/saved_state.rs @@ -0,0 +1,543 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Saved state of a virtio-net device built for save and restore. +//! +//! [`NicBuilder::save_restore`] binds the device to an exact static IPv4 +//! identity and an effective feature contract. When the transport stops an +//! active queue pair, the device quiesces the endpoint and captures the +//! drained queue progress, which the transport then collects queue by queue. +//! The device-private [`SavedState`] records the identity, the feature +//! contract and the queue-pair lifecycle, and is validated against the +//! destination device before it is restored. + +use crate::Adapter; +use crate::Device; +use crate::NetStatus; +use crate::NicBuilder; +use crate::QueuePairState; +use crate::Worker; +use mesh::payload::Protobuf; +use net_backend::TxOffloadSupport; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; +use std::ops::ControlFlow; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; +use virtio::queue::QueueState; +use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::net::VirtioNetHandle; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; +use vmcore::save_restore::SavedStateRoot; + +const SAVED_STATE_VERSION: u32 = 1; + +/// Save and restore configuration of a device built with +/// [`NicBuilder::save_restore`]. +pub(crate) struct SaveRestoreConfig { + static_ipv4: StaticIpv4Config, + effective_features: u64, +} + +/// Queue-pair stop, quiesce and restore bookkeeping of a [`Device`]. +#[derive(Default)] +pub(crate) struct Lifecycle { + stop_error: Option, + stopped_feature_banks: Option<[u32; 2]>, + stopped_queue_count: u32, + pub(crate) endpoint_generation: u64, + pub(crate) input_quiesced: bool, +} + +impl Lifecycle { + /// Clears the queue-pair state on device reset. The endpoint generation is + /// kept. + pub(crate) fn reset(&mut self) { + self.stop_error = None; + self.stopped_feature_banks = None; + self.stopped_queue_count = 0; + self.input_quiesced = false; + } +} + +impl NicBuilder { + /// Enables save and restore for a device with the given exact static IPv4 + /// identity and effective feature contract. + pub fn save_restore(mut self, static_ipv4: StaticIpv4Config, effective_features: u64) -> Self { + self.save_restore = Some(SaveRestoreConfig { + static_ipv4, + effective_features, + }); + self + } + + /// Applies the save and restore configuration of a virtio-net resource. + pub(crate) fn save_restore_resource(self, resource: &VirtioNetHandle) -> anyhow::Result { + if !resource.save_restore { + return Ok(self); + } + Ok(self.save_restore( + resource + .static_ipv4 + .clone() + .ok_or_else(|| anyhow::anyhow!("saved virtio-net requires static IPv4 identity"))?, + resource.effective_features.ok_or_else(|| { + anyhow::anyhow!("saved virtio-net requires an effective feature contract") + })?, + )) + } +} + +impl Device { + /// Checks and resets the saved queue-pair state before `start_queue` + /// starts queue `pair_idx`/`is_rx`. + pub(crate) fn prepare_queue_start( + &mut self, + pair_idx: usize, + is_rx: bool, + features: &VirtioDeviceFeatures, + ) -> anyhow::Result<()> { + let feature_banks = [features.bank(0), features.bank(1)]; + match self.pairs.get(pair_idx) { + Some(QueuePairState::Empty) => { + self.lifecycle.stopped_queue_count = 0; + } + Some(QueuePairState::Stopped { rx, tx }) => { + anyhow::ensure!( + rx.is_none() && tx.is_none(), + "cannot restart virtio-net before all stopped queue states are collected" + ); + if let Some(previous) = self.lifecycle.stopped_feature_banks { + anyhow::ensure!( + previous == feature_banks, + "virtio-net negotiated features changed while restarting queues" + ); + } + self.lifecycle.stopped_queue_count = 0; + } + Some(QueuePairState::HalfOpen { + is_rx: pending_is_rx, + feature_banks: pending_feature_banks, + .. + }) if *pending_is_rx != is_rx => { + anyhow::ensure!( + *pending_feature_banks == feature_banks, + "virtio-net queue pair negotiated different features" + ); + } + _ => {} + } + Ok(()) + } + + /// Stops queue `idx` when its state is kept: a started half-open queue, an + /// active pair of a device built for save and restore, or a stopped pair. + /// + /// Returns [`ControlFlow::Continue`] when `stop_queue` must stop the queue + /// itself. + pub(crate) async fn stop_saved_queue(&mut self, idx: u16) -> ControlFlow> { + let pair_idx = (idx / 2) as usize; + if pair_idx >= self.pairs.len() { + return ControlFlow::Continue(()); + } + match self.pairs[pair_idx] { + QueuePairState::HalfOpen { is_rx, .. } if is_rx == idx.is_multiple_of(2) => { + let previous = std::mem::replace(&mut self.pairs[pair_idx], QueuePairState::Empty); + let QueuePairState::HalfOpen { + queue, + feature_banks, + .. + } = previous + else { + unreachable!() + }; + self.lifecycle.stopped_feature_banks = Some(feature_banks); + self.lifecycle.stopped_queue_count = 1; + ControlFlow::Break(Some(queue.queue_state())) + } + QueuePairState::Active if self.adapter.save_restore.is_some() => { + if let Err(error) = self.capture_stopped_pairs().await { + self.lifecycle.stop_error = Some(error); + return ControlFlow::Break(None); + } + ControlFlow::Break(self.take_stopped_queue(idx)) + } + QueuePairState::Stopped { .. } => ControlFlow::Break(self.take_stopped_queue(idx)), + _ => ControlFlow::Continue(()), + } + } + + fn take_stopped_queue(&mut self, idx: u16) -> Option { + let pair_idx = (idx / 2) as usize; + match self.pairs.get_mut(pair_idx) { + Some(QueuePairState::Stopped { rx, tx }) => { + if idx.is_multiple_of(2) { + rx.take() + } else { + tx.take() + } + } + _ => None, + } + } + + async fn capture_stopped_pairs(&mut self) -> anyhow::Result<()> { + self.quiesce_active_workers(false).await?; + let mut coordinator = self.coordinator.remove(); + self.lifecycle.stopped_queue_count = 0; + for (pair_index, worker) in coordinator.workers.iter_mut().enumerate() { + worker.task_mut().state.take(); + let worker = worker.remove(); + let (rx, tx, feature_banks) = worker.into_stopped_state()?; + if let Some(previous) = self.lifecycle.stopped_feature_banks { + anyhow::ensure!( + previous == feature_banks, + "virtio-net queue pairs negotiated different features" + ); + } else { + self.lifecycle.stopped_feature_banks = Some(feature_banks); + } + self.pairs[pair_index] = QueuePairState::Stopped { + rx: Some(rx), + tx: Some(tx), + }; + self.lifecycle.stopped_queue_count += 2; + } + Ok(()) + } + + /// Implements `save_device`. + pub(crate) fn save_private_state(&mut self) -> Result, SaveError> { + if let Some(error) = self.lifecycle.stop_error.take() { + return Err(SaveError::Other(error)); + } + let config = self.adapter.save_restore.as_ref().ok_or_else(|| { + SaveError::Other(anyhow::anyhow!( + "virtio-net static IPv4 identity is unavailable" + )) + })?; + let static_ipv4 = &config.static_ipv4; + let effective_features = config.effective_features; + if !self.pairs.iter().all(|pair| { + matches!( + pair, + QueuePairState::Empty | QueuePairState::Stopped { rx: None, tx: None } + ) + }) { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-net queues are still running" + ))); + } + let negotiated = match self.lifecycle.stopped_queue_count { + 0 => [0, 0], + 1 | 2 => self.lifecycle.stopped_feature_banks.ok_or_else(|| { + SaveError::Other(anyhow::anyhow!( + "virtio-net stopped queue features are unavailable" + )) + })?, + count => { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-net stopped queue count {count} is invalid" + ))); + } + }; + Ok(Some(SavedStateBlob::new(SavedState { + schema_version: SAVED_STATE_VERSION, + guest_ipv4: u32::from(static_ipv4.guest_ipv4), + prefix_length: u32::from(static_ipv4.prefix_length), + gateway_ipv4: u32::from(static_ipv4.gateway_ipv4), + guest_mac: self.adapter.mac_address.to_bytes().to_vec(), + gateway_mac: static_ipv4.gateway_mac.to_bytes().to_vec(), + link_up: NetStatus::from(self.registers.status).link_up(), + effective_feature_bank0: effective_features as u32, + effective_feature_bank1: (effective_features >> 32) as u32, + negotiated_feature_bank0: negotiated[0], + negotiated_feature_bank1: negotiated[1], + endpoint_offload_bits: endpoint_offload_bits(self.adapter.tx_offload_support), + queue_pair_lifecycle: self.lifecycle.stopped_queue_count, + rx_in_order_outstanding: 0, + tx_in_order_outstanding: 0, + pending_rx_packet_count: 0, + pending_rx_payload_bytes: 0, + pending_tx_packet_count: 0, + pending_tx_payload_bytes: 0, + endpoint_generation: self.lifecycle.endpoint_generation, + }))) + } + + /// Implements `restore_device`. + pub(crate) fn restore_private_state( + &mut self, + state: Option, + ) -> Result<(), RestoreError> { + let saved = validate_saved_state(state.as_ref(), &self.adapter, None, None)?; + self.registers.status = NetStatus::new().with_link_up(saved.link_up).into(); + self.lifecycle.endpoint_generation = saved + .endpoint_generation + .checked_add(1) + .ok_or_else(|| invalid_saved_state("virtio-net endpoint generation overflowed"))?; + self.lifecycle.stopped_feature_banks = (saved.queue_pair_lifecycle != 0).then_some([ + saved.negotiated_feature_bank0, + saved.negotiated_feature_bank1, + ]); + self.lifecycle.stopped_queue_count = saved.queue_pair_lifecycle; + Ok(()) + } + + /// Implements `device_state_validator`. + pub(crate) fn private_state_validator(&self) -> DeviceStateValidator { + let adapter = self.adapter.clone(); + Box::new(move |state, features, queues, _guest_memory| { + validate_saved_state(state, &adapter, Some(features), Some(queues))?; + Ok(()) + }) + } +} + +impl Worker { + fn into_stopped_state(mut self) -> anyhow::Result<(QueueState, QueueState, [u32; 2])> { + anyhow::ensure!( + self.active_state.data.tx_segments.is_empty() + && self + .active_state + .pending_tx_packets + .iter() + .all(Option::is_none) + && self.virtio_state.tx_in_order.outstanding() == 0, + "virtio-net TX ownership remained after endpoint quiesce" + ); + + // The number of guest RX descriptors currently owned by the device. + let pending_rx = self + .active_state + .pending_rx_packets + .fill_ready(&mut self.active_state.data.rx_ready); + anyhow::ensure!( + pending_rx == self.virtio_state.rx_in_order.outstanding(), + "virtio-net RX ownership does not match its completion cursor" + ); + let current_rx = self.virtio_state.rx_queue.queue_state(); + anyhow::ensure!( + usize::from(current_rx.avail_index.wrapping_sub(current_rx.used_index)) == pending_rx, + "virtio-net RX queue cursors do not match pending descriptor ownership" + ); + let rx = QueueState { + avail_index: current_rx.used_index, + used_index: current_rx.used_index, + }; + + let tx = self.virtio_state.tx_queue.queue_state(); + anyhow::ensure!( + tx.avail_index == tx.used_index, + "virtio-net TX queue retained an incomplete descriptor" + ); + Ok(( + rx, + tx, + [ + self.negotiated_features.into_bits(), + self.negotiated_features_bank1.into_bits(), + ], + )) + } +} + +fn endpoint_offload_bits(offloads: TxOffloadSupport) -> u32 { + u32::from(offloads.ipv4_header) + | (u32::from(offloads.tcp) << 1) + | (u32::from(offloads.udp) << 2) + | (u32::from(offloads.tso) << 3) + | (u32::from(offloads.uso) << 4) +} + +fn derived_mac(address: std::net::Ipv4Addr) -> [u8; 6] { + let [_, second, third, fourth] = address.octets(); + [0x52, 0x54, 0, second, third, fourth] +} + +fn invalid_saved_state(message: impl Into) -> RestoreError { + RestoreError::InvalidSavedState(anyhow::anyhow!(message.into())) +} + +fn validate_saved_state( + state: Option<&SavedStateBlob>, + adapter: &Adapter, + features: Option<&VirtioDeviceFeatures>, + queues: Option<&[DeviceQueueState]>, +) -> Result { + let state = state.ok_or_else(|| invalid_saved_state("missing virtio-net private state"))?; + let saved: SavedState = state.parse()?; + if saved.schema_version != SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "unsupported virtio-net schema version {}", + saved.schema_version + ))); + } + + let config = adapter + .save_restore + .as_ref() + .ok_or_else(|| invalid_saved_state("virtio-net static IPv4 identity is unavailable"))?; + let static_ipv4 = &config.static_ipv4; + let effective_features = config.effective_features; + if saved.guest_ipv4 != u32::from(static_ipv4.guest_ipv4) + || saved.prefix_length != u32::from(static_ipv4.prefix_length) + || saved.gateway_ipv4 != u32::from(static_ipv4.gateway_ipv4) + || saved.guest_mac != adapter.mac_address.to_bytes() + || saved.gateway_mac != static_ipv4.gateway_mac.to_bytes() + { + return Err(invalid_saved_state( + "virtio-net static identity does not match the destination", + )); + } + if saved.guest_mac != derived_mac(static_ipv4.guest_ipv4) + || saved.gateway_mac != derived_mac(static_ipv4.gateway_ipv4) + { + return Err(invalid_saved_state( + "virtio-net static identity is not canonical", + )); + } + if !(1..=30).contains(&static_ipv4.prefix_length) { + return Err(invalid_saved_state("virtio-net prefix is out of range")); + } + let mask = u32::MAX << (32 - static_ipv4.prefix_length); + let guest = u32::from(static_ipv4.guest_ipv4); + let network = guest & mask; + if u32::from(static_ipv4.gateway_ipv4) != network + 1 + || guest == network + || guest == (network | !mask) + || static_ipv4.guest_ipv4 == static_ipv4.gateway_ipv4 + { + return Err(invalid_saved_state( + "virtio-net IPv4 identity is internally inconsistent", + )); + } + + if saved.effective_feature_bank0 != effective_features as u32 + || saved.effective_feature_bank1 != (effective_features >> 32) as u32 + || saved.endpoint_offload_bits != endpoint_offload_bits(adapter.tx_offload_support) + { + return Err(invalid_saved_state( + "virtio-net feature or endpoint capability contract changed", + )); + } + if saved.queue_pair_lifecycle > 2 + || saved.rx_in_order_outstanding != 0 + || saved.tx_in_order_outstanding != 0 + || saved.pending_rx_packet_count != 0 + || saved.pending_rx_payload_bytes != 0 + || saved.pending_tx_packet_count != 0 + || saved.pending_tx_payload_bytes != 0 + { + return Err(invalid_saved_state( + "virtio-net saved packet ownership is not fully drained", + )); + } + + if let Some(features) = features { + if features.ring_packed() || (features.into_bits() & !effective_features) != 0 { + return Err(invalid_saved_state( + "virtio-net negotiated features violate the device contract", + )); + } + if saved.queue_pair_lifecycle == 0 { + if saved.negotiated_feature_bank0 != 0 || saved.negotiated_feature_bank1 != 0 { + return Err(invalid_saved_state( + "virtio-net inactive queues retain negotiated feature state", + )); + } + } else if saved.negotiated_feature_bank0 != features.bank(0) + || saved.negotiated_feature_bank1 != features.bank(1) + { + return Err(invalid_saved_state( + "virtio-net negotiated features do not match saved state", + )); + } + } + if let Some(queues) = queues { + if queues.len() != 2 { + return Err(invalid_saved_state( + "virtio-net saved state requires exactly one queue pair", + )); + } + let started_count = queues + .iter() + .filter(|queue| queue.params.enable && queue.queue_state.is_some()) + .count(); + if started_count != usize::try_from(saved.queue_pair_lifecycle).unwrap_or(usize::MAX) { + return Err(invalid_saved_state( + "virtio-net queue lifecycle does not match transport state", + )); + } + for queue in queues { + if !queue.params.enable && queue.queue_state.is_some() { + return Err(invalid_saved_state( + "virtio-net disabled queue retains progress state", + )); + } + if !queue.params.enable { + continue; + } + if queue.params.size == 0 || queue.params.size > 256 { + return Err(invalid_saved_state( + "virtio-net restored queue size is out of range", + )); + } + if queue + .queue_state + .is_some_and(|state| state.avail_index != state.used_index) + { + return Err(invalid_saved_state( + "virtio-net restored queue retains unrepresented ownership", + )); + } + } + } + Ok(saved) +} + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.net")] +pub struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub guest_ipv4: u32, + #[mesh(3)] + pub prefix_length: u32, + #[mesh(4)] + pub gateway_ipv4: u32, + #[mesh(5)] + pub guest_mac: Vec, + #[mesh(6)] + pub gateway_mac: Vec, + #[mesh(7)] + pub link_up: bool, + #[mesh(8)] + pub effective_feature_bank0: u32, + #[mesh(9)] + pub effective_feature_bank1: u32, + #[mesh(10)] + pub negotiated_feature_bank0: u32, + #[mesh(11)] + pub negotiated_feature_bank1: u32, + #[mesh(12)] + pub endpoint_offload_bits: u32, + #[mesh(13)] + pub queue_pair_lifecycle: u32, + #[mesh(14)] + pub rx_in_order_outstanding: u32, + #[mesh(15)] + pub tx_in_order_outstanding: u32, + #[mesh(16)] + pub pending_rx_packet_count: u32, + #[mesh(17)] + pub pending_rx_payload_bytes: u64, + #[mesh(18)] + pub pending_tx_packet_count: u32, + #[mesh(19)] + pub pending_tx_payload_bytes: u64, + #[mesh(20)] + pub endpoint_generation: u64, +} diff --git a/vm/devices/virtio/virtio_net/src/tests.rs b/vm/devices/virtio/virtio_net/src/tests.rs index d6120e86a99..09b07b1603b 100644 --- a/vm/devices/virtio/virtio_net/src/tests.rs +++ b/vm/devices/virtio/virtio_net/src/tests.rs @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod egress; +mod harness; +mod quiesce; +mod saved_state; + use async_trait::async_trait; use guestmem::GuestMemory; use inspect::InspectMut; @@ -232,6 +237,7 @@ struct MockQueueHandle { ready_waker: Arc>>, rx_avail_notify: mesh::Receiver<()>, tx_avail_notify: mesh::Receiver<()>, + ext: harness::QueueHandleExt, } impl MockQueueHandle { @@ -353,6 +359,7 @@ fn new_mock_queue() -> (MockQueue, MockQueueHandle) { ready_waker, rx_avail_notify: rx_avail_rx, tx_avail_notify: tx_avail_rx, + ext: Default::default(), }; (queue, handle) } diff --git a/vm/devices/virtio/virtio_net/src/tests/egress.rs b/vm/devices/virtio/virtio_net/src/tests/egress.rs new file mode 100644 index 00000000000..6eab3350f62 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/egress.rs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy tests: frames that the policy denies complete without +//! reaching the endpoint, and the endpoint checks the bytes it transmits. + +use super::TestHarness; +use net_backend_resources::egress::EgressPolicy; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; + +#[async_test] +async fn denied_tx_completes_without_backend_ownership(driver: DefaultDriver) { + let policy = EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["192.0.2.7:443".parse().unwrap()]), + ) + .unwrap(); + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(policy)); + let handle = harness.enable_and_get_handle().await; + + harness.post_tx_and_signal(0, 64); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} + +fn policy_tcp_frame(destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 20]; + frame[6..12].copy_from_slice(&[0x52, 0x54, 0, 0, 0, 2]); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&40u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 6; + ip[12..16].copy_from_slice(&[10, 0, 0, 2]); + ip[16..20].copy_from_slice(&[192, 0, 2, 7]); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[32] = 5 << 4; + let mut sum = ip[..20].chunks_exact(2).fold(0u32, |sum, word| { + sum + u32::from(u16::from_be_bytes([word[0], word[1]])) + }); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + ip[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes()); + frame +} + +fn policy_arp_frame(target: std::net::Ipv4Addr) -> Vec { + let guest_mac = [0x52, 0x54, 0, 0, 0, 2]; + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&guest_mac); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&guest_mac); + frame[28..32].copy_from_slice(&[10, 0, 0, 2]); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +fn endpoint_policy() -> EgressPolicy { + EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap() +} + +#[async_test] +async fn endpoint_policy_forwards_only_bound_arp_targets(driver: DefaultDriver) { + let mut allowed_harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let allowed_handle = allowed_harness.enable_and_get_handle().await; + allowed_harness + .post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 9))); + assert_eq!(allowed_harness.wait_for_used().await, (0, 0)); + assert!(!allowed_handle.take_tx_avail_log().is_empty()); + + let mut denied_harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let denied_handle = denied_harness.enable_and_get_handle().await; + denied_harness + .post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 10))); + assert_eq!(denied_harness.wait_for_used().await, (0, 0)); + assert!(denied_handle.take_tx_avail_log().is_empty()); +} + +#[async_test] +async fn endpoint_policy_checks_transmitted_bytes_after_guest_mutation(driver: DefaultDriver) { + let policy = EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["192.0.2.7:443".parse().unwrap()]), + ) + .unwrap(); + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(policy)); + let handle = harness.enable_and_get_handle().await; + handle.replace_next_tx(policy_tcp_frame(80)); + + harness.post_tx_frame_and_signal(0, &policy_tcp_frame(443)); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} + +#[async_test] +async fn endpoint_policy_rechecks_mutated_arp_target_at_backend(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let handle = harness.enable_and_get_handle().await; + handle.replace_next_tx(policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 10))); + + harness.post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 9))); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} diff --git a/vm/devices/virtio/virtio_net/src/tests/harness.rs b/vm/devices/virtio/virtio_net/src/tests/harness.rs new file mode 100644 index 00000000000..de3a95bc9e2 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/harness.rs @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Test harness extensions for the egress policy, quiesce and saved-state +//! tests. +//! +//! [`ForkMockEndpoint`] wraps the mock endpoint. It accepts an egress policy +//! and wraps each mock queue in a [`ForkMockQueue`], which applies the policy +//! to the frames it transmits, can replace the next transmitted frame (to model +//! a guest that rewrites a descriptor after the device checked it), and +//! supports quiesce. + +use super::MockEndpoint; +use super::MockQueue; +use super::MockQueueHandle; +use super::NET_HEADER_SIZE; +use super::QUEUE_SIZE; +use super::RX_AVAIL_ADDR; +use super::RX_DESC_ADDR; +use super::RX_USED_ADDR; +use super::TX_AVAIL_ADDR; +use super::TestHarness; +use super::new_mock_queue; +use super::post_tx_packet; +use crate::Device; +use async_trait::async_trait; +use futures::StreamExt; +use inspect::InspectMut; +use net_backend::Endpoint; +use net_backend::EndpointAction; +use net_backend::MultiQueueSupport; +use net_backend::QueueConfig; +use net_backend::RssConfig; +use net_backend::RxId; +use net_backend::TxError; +use net_backend::TxId; +use net_backend::TxOffloadSupport; +use net_backend::TxSegment; +use net_backend::linearize; +use net_backend::next_packet; +use net_backend::quiesce::QueueQuiesceResult; +use net_backend_resources::egress::EgressPolicy; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use parking_lot::Mutex; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use virtio::QueueResources; +use virtio::VirtioDevice; +use virtio::queue::QueueParams; +use virtio::spec::VirtioDeviceFeatures; +use virtio::test_helpers::make_available; +use vmcore::interrupt::Interrupt; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +/// Controls of a [`MockQueueHandle`] for queues created by +/// [`ForkMockEndpoint`]. +#[derive(Default)] +pub(super) struct QueueHandleExt { + replace_next_tx: Arc>>>, + quiesce_notify: mesh::Receiver<()>, +} + +impl MockQueueHandle { + pub(super) fn replace_next_tx(&self, frame: Vec) { + *self.ext.replace_next_tx.lock() = Some(frame); + } + + pub(super) async fn wait_for_quiesce(&mut self) { + mesh::CancelContext::new() + .with_timeout(Duration::from_secs(5)) + .until_cancelled(self.ext.quiesce_notify.next()) + .await + .expect("timed out waiting for queue quiesce") + .expect("channel closed"); + } +} + +/// A [`MockEndpoint`] that accepts an egress policy and creates +/// [`ForkMockQueue`]s. +struct ForkMockEndpoint { + endpoint: MockEndpoint, + egress_policy: Option, +} + +impl InspectMut for ForkMockEndpoint { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.endpoint.inspect_mut(req); + } +} + +#[async_trait] +impl Endpoint for ForkMockEndpoint { + fn endpoint_type(&self) -> &'static str { + self.endpoint.endpoint_type() + } + + async fn get_queues( + &mut self, + _config: Vec, + _rss: Option<&RssConfig<'_>>, + queues: &mut Vec>, + ) -> anyhow::Result<()> { + let (queue, mut handle) = new_mock_queue(); + let queue = ForkMockQueue { + queue, + egress_policy: self.egress_policy.clone(), + replace_next_tx: handle.ext.replace_next_tx.clone(), + quiesce_notify: handle.ext.quiesce_notify.sender(), + }; + self.endpoint.queue_tx.send(handle); + queues.push(Box::new(queue)); + Ok(()) + } + + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.egress_policy = Some(policy); + Ok(()) + } + + async fn stop(&mut self) { + self.endpoint.stop().await + } + + fn is_ordered(&self) -> bool { + self.endpoint.is_ordered() + } + + fn tx_offload_support(&self) -> TxOffloadSupport { + self.endpoint.tx_offload_support() + } + + fn multiqueue_support(&self) -> MultiQueueSupport { + self.endpoint.multiqueue_support() + } + + fn tx_fast_completions(&self) -> bool { + self.endpoint.tx_fast_completions() + } + + async fn wait_for_endpoint_action(&mut self) -> EndpointAction { + self.endpoint.wait_for_endpoint_action().await + } +} + +/// A [`MockQueue`] that applies an egress policy to transmitted frames, can +/// replace the next transmitted frame, and supports quiesce. +struct ForkMockQueue { + queue: MockQueue, + egress_policy: Option, + replace_next_tx: Arc>>>, + quiesce_notify: mesh::Sender<()>, +} + +impl InspectMut for ForkMockQueue { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.queue.inspect_mut(req); + } +} + +#[async_trait] +impl net_backend::Queue for ForkMockQueue { + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + pool: &mut dyn net_backend::BufferAccess, + ) -> Poll<()> { + self.queue.poll_ready(cx, pool) + } + + fn rx_avail(&mut self, pool: &mut dyn net_backend::BufferAccess, done: &[RxId]) { + self.queue.rx_avail(pool, done) + } + + fn rx_poll( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + packets: &mut [RxId], + ) -> anyhow::Result { + self.queue.rx_poll(pool, packets) + } + + async fn quiesce( + &mut self, + _pool: &mut dyn net_backend::BufferAccess, + ) -> anyhow::Result { + self.quiesce_notify.send(()); + self.queue.rx_pending.lock().clear(); + Ok(QueueQuiesceResult { + rx_ready: self.queue.rx_ready.lock().len(), + tx_ready: self.queue.tx_completions.lock().iter().map(Vec::len).sum(), + }) + } + + fn tx_avail( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + segments: &[TxSegment], + ) -> anyhow::Result<(bool, usize)> { + if let Some(replacement) = self.replace_next_tx.lock().take() { + let (metadata, packet_segments, _) = next_packet(segments); + anyhow::ensure!(replacement.len() == metadata.len as usize); + let mut offset = 0usize; + for segment in packet_segments { + let end = offset + segment.len as usize; + pool.guest_memory() + .write_at(segment.gpa, &replacement[offset..end])?; + offset = end; + } + anyhow::ensure!(offset == replacement.len()); + } + if let Some(policy) = &self.egress_policy { + let mut remaining = segments; + while !remaining.is_empty() { + let frame = linearize(pool, &mut remaining)?; + if policy.authorize_frame(&frame, frame.len()).is_err() { + return Ok((true, segments.len())); + } + } + } + + self.queue.tx_avail(pool, segments) + } + + fn tx_poll( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + done: &mut [TxId], + ) -> Result { + self.queue.tx_poll(pool, done) + } +} + +impl TestHarness { + /// Replaces the harness device with one built with `egress_policy` over a + /// [`ForkMockEndpoint`]. + pub(super) fn new_with_egress_policy( + driver: &DefaultDriver, + egress_policy: Option, + ) -> Self { + let mut harness = Self::new(driver); + let (queue_tx, queue_handle_rx) = mesh::channel(); + let endpoint = ForkMockEndpoint { + endpoint: MockEndpoint { + queue_tx, + is_ordered: true, + }, + egress_policy: None, + }; + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mac = MacAddress::new([0x00, 0x15, 0x5d, 0xaa, 0xbb, 0xcc]); + let mut builder = Device::builder(); + if let Some(egress_policy) = egress_policy { + builder = builder.egress_policy(egress_policy); + } + harness.device = builder + .build(&driver_source, Box::new(endpoint), mac) + .unwrap(); + harness.queue_handle_rx = queue_handle_rx; + harness + } + + /// Replaces the harness device with one built for save and restore over a + /// [`ForkMockEndpoint`]. + pub(super) fn new_save_restore(driver: &DefaultDriver) -> Self { + let mut harness = Self::new(driver); + let (queue_tx, queue_handle_rx) = mesh::channel(); + let endpoint = ForkMockEndpoint { + endpoint: MockEndpoint { + queue_tx, + is_ordered: true, + }, + egress_policy: None, + }; + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mac = MacAddress::new([0x52, 0x54, 0, 0, 0, 2]); + harness.device = Device::builder() + .save_restore( + net_backend_resources::consomme::static_ipv4::StaticIpv4Config { + guest_ipv4: std::net::Ipv4Addr::new(10, 0, 0, 2), + prefix_length: 24, + gateway_ipv4: std::net::Ipv4Addr::new(10, 0, 0, 1), + gateway_mac: MacAddress::new([0x52, 0x54, 0, 0, 0, 1]), + }, + (1 << 5) | (1 << 32), + ) + .build(&driver_source, Box::new(endpoint), mac) + .unwrap(); + harness.queue_handle_rx = queue_handle_rx; + harness + } + + pub(super) async fn start_rx_queue(&mut self, features: &VirtioDeviceFeatures) { + let rx_interrupt = Interrupt::from_event(self.rx_interrupt_event.clone()); + + // Queue 0: RX + self.device + .start_queue( + 0, + QueueResources { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + notify: rx_interrupt, + event: self.rx_event.clone(), + guest_memory: self.mem.clone(), + }, + features, + None, + ) + .await + .unwrap(); + } + + pub(super) fn post_tx_frame_and_signal(&mut self, desc_index: u16, frame: &[u8]) { + let header_gpa = self.alloc_data(NET_HEADER_SIZE); + let data_len = u32::try_from(frame.len()).unwrap(); + let data_gpa = self.alloc_data(data_len); + + // Write a zero virtio-net header + let header_bytes = vec![0u8; NET_HEADER_SIZE as usize]; + self.mem.write_at(header_gpa, &header_bytes).unwrap(); + + self.mem.write_at(data_gpa, frame).unwrap(); + + post_tx_packet( + &self.mem, + desc_index, + header_gpa, + NET_HEADER_SIZE, + &[(data_gpa, data_len)], + ); + + make_available( + &self.mem, + TX_AVAIL_ADDR, + QUEUE_SIZE, + desc_index, + &mut self.tx_avail_idx, + ); + self.tx_event.signal(); + } +} diff --git a/vm/devices/virtio/virtio_net/src/tests/quiesce.rs b/vm/devices/virtio/virtio_net/src/tests/quiesce.rs new file mode 100644 index 00000000000..363bd022046 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/quiesce.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Input quiesce tests: a quiesce requested before the queues start is latched, +//! and quiesce drains the TX work that the endpoint already accepted. + +use super::TestHarness; +use net_backend::TxId; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; + +#[async_test] +async fn input_quiesce_latches_before_queue_start(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + harness.device.quiesce_input().await.unwrap(); + + let mut handle = harness.enable_and_get_handle().await; + handle.wait_for_quiesce().await; + + harness.device.resume_input().await.unwrap(); +} + +#[async_test] +async fn snapshot_drains_accepted_pending_tx_exactly_once(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + let mut handle = harness.enable_and_get_handle().await; + handle.tx_avail_behavior.lock().sync = false; + + harness.post_tx_and_signal(0, 64); + handle.wait_for_tx_avail().await; + handle.tx_completions.lock().push_back(vec![TxId(0)]); + + harness.device.quiesce_input().await.unwrap(); + let rx_state = harness.device.stop_queue(0).await.unwrap(); + let tx_state = harness.device.stop_queue(1).await.unwrap(); + assert_eq!(rx_state.avail_index, rx_state.used_index); + assert_eq!(tx_state.avail_index, tx_state.used_index); + harness.device.save_device().unwrap().unwrap(); + assert!(handle.tx_completions.lock().is_empty()); +} diff --git a/vm/devices/virtio/virtio_net/src/tests/saved_state.rs b/vm/devices/virtio/virtio_net/src/tests/saved_state.rs new file mode 100644 index 00000000000..ea6adb05e82 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/saved_state.rs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Saved-state tests: stopping the queues rewinds unused RX descriptors, +//! partial queue lifecycles are saved and validated, and an inactive transport +//! defers the device-private state until the device is activated. + +use super::QUEUE_SIZE; +use super::RX_AVAIL_ADDR; +use super::RX_DESC_ADDR; +use super::RX_USED_ADDR; +use super::TX_AVAIL_ADDR; +use super::TX_DESC_ADDR; +use super::TX_USED_ADDR; +use super::TestHarness; +use chipset_device::io::IoResult; +use chipset_device::mmio::MmioIntercept; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio::device::saved_state::DeviceQueueState; +use virtio::queue::QueueParams; +use virtio::spec::VirtioDeviceFeatures; +use virtio::transport::VirtioMmioDevice; +use vmcore::device_state::ChangeDeviceState; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::SaveRestore; + +#[async_test] +async fn save_restore_rewinds_unused_rx_and_validates_private_state(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + let mut handle = harness.enable_and_get_handle().await; + harness.post_rx_buffer_and_signal(0, 1500); + handle.wait_for_rx_pending().await; + + harness.device.quiesce_input().await.unwrap(); + let rx_state = harness.device.stop_queue(0).await.unwrap(); + let tx_state = harness.device.stop_queue(1).await.unwrap(); + assert_eq!(rx_state.avail_index, rx_state.used_index); + assert_eq!(tx_state.avail_index, tx_state.used_index); + let saved = harness.device.save_device().unwrap().unwrap(); + + let queues = [ + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + queue_state: Some(rx_state), + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: Some(tx_state), + }, + ]; + let restored = TestHarness::new_save_restore(&driver); + restored.device.device_state_validator()( + Some(&saved), + &VirtioDeviceFeatures::new(), + &queues, + &harness.mem, + ) + .unwrap(); + let mut restored_device = restored.device; + restored_device.restore_device(Some(saved)).unwrap(); + assert_eq!(restored_device.lifecycle.endpoint_generation, 1); +} + +#[async_test] +async fn save_restore_preserves_partial_queue_lifecycle(driver: DefaultDriver) { + let features = VirtioDeviceFeatures::new().with_bank(0, 1 << 5); + let disabled_queues = [ + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: false, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + queue_state: None, + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: false, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: None, + }, + ]; + + let mut unstarted = TestHarness::new_save_restore(&driver); + let unstarted_state = unstarted.device.save_device().unwrap().unwrap(); + unstarted.device.device_state_validator()( + Some(&unstarted_state), + &features, + &disabled_queues, + &unstarted.mem, + ) + .unwrap(); + let mut configured_queues = disabled_queues; + configured_queues[0].params.enable = true; + configured_queues[1].params.enable = true; + unstarted.device.device_state_validator()( + Some(&unstarted_state), + &features, + &configured_queues, + &unstarted.mem, + ) + .unwrap(); + + let mut half_open = TestHarness::new_save_restore(&driver); + half_open.start_rx_queue(&features).await; + let rx_state = half_open.device.stop_queue(0).await.unwrap(); + let half_open_state = half_open.device.save_device().unwrap().unwrap(); + let mut partial_queues = disabled_queues; + partial_queues[0].params.enable = true; + partial_queues[0].queue_state = Some(rx_state); + half_open.device.device_state_validator()( + Some(&half_open_state), + &features, + &partial_queues, + &half_open.mem, + ) + .unwrap(); +} + +#[async_test] +async fn inactive_transport_restore_defers_net_private_state(driver: DefaultDriver) { + let source_harness = TestHarness::new_save_restore(&driver); + let mut source = VirtioMmioDevice::new( + Box::new(source_harness.device), + &driver, + source_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 0 + ); + + let destination_harness = TestHarness::new_save_restore(&driver); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_harness.device), + &driver, + destination_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.stop().await; + let staged = destination.save().unwrap(); + assert_eq!( + staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 0 + ); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + destination.stop().await; + let activated = destination.save().unwrap(); + assert_eq!( + activated + .device_state + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 1 + ); +} diff --git a/vm/devices/virtio/virtio_resources/Cargo.toml b/vm/devices/virtio/virtio_resources/Cargo.toml index 127c8f51a44..e9d79ec08f8 100644 --- a/vm/devices/virtio/virtio_resources/Cargo.toml +++ b/vm/devices/virtio/virtio_resources/Cargo.toml @@ -8,10 +8,12 @@ rust-version.workspace = true [dependencies] net_backend_resources.workspace = true +serial_core.workspace = true unix_socket = { workspace = true, features = ["mesh"] } vm_resource.workspace = true mesh.workspace = true +thiserror.workspace = true [lints] workspace = true diff --git a/vm/devices/virtio/virtio_resources/src/console/attachment.rs b/vm/devices/virtio/virtio_resources/src/console/attachment.rs new file mode 100644 index 00000000000..a21452734e1 --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/console/attachment.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host endpoint attachment and disconnect policy of virtio-console resources. +//! +//! [`VirtioConsoleAttachment`] describes how a console's host endpoint is +//! attached and reconnected, and [`VirtioConsoleDisconnectPolicy`] selects what +//! the device does with guest output while that endpoint is disconnected. + +use mesh::MeshPayload; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleDisconnectPolicy { + /// Complete and discard guest transmit descriptors while disconnected. + Discard, + /// Retain guest transmit descriptors until the backend reconnects. + Retain, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleBackendKind { + UnixSocket, + NamedPipe, + Tcp, + Inherited, + Disconnected, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleAttachmentMode { + Listen, + Connect, + Inherited, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleReconnectPolicy { + RecreateListener, + ReconnectClient, + RequireInheritedAttachment, + DiscardWhileDisconnected, +} + +#[derive(Clone, Debug, Eq, PartialEq, MeshPayload)] +pub struct VirtioConsoleAttachment { + pub stable_id: String, + pub backend_kind: VirtioConsoleBackendKind, + pub mode: VirtioConsoleAttachmentMode, + pub endpoint_identity: String, + pub reconnect_policy: VirtioConsoleReconnectPolicy, + pub required: bool, + pub reconnect_timeout_ms: u64, +} diff --git a/vm/devices/virtio/virtio_resources/src/console/control.rs b/vm/devices/virtio/virtio_resources/src/console/control.rs new file mode 100644 index 00000000000..137cf7013be --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/console/control.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Control-console resource. +//! +//! A [`VirtioControlConsoleHandle`] requests a virtio console whose guest +//! session is served by the VMM-resident control-session broker, configured by +//! [`VirtioControlConsoleBrokerConfig`], in front of a reconnectable host +//! endpoint. + +use super::attachment::VirtioConsoleAttachment; +use mesh::MeshPayload; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; + +#[derive(Clone, MeshPayload)] +pub struct VirtioControlConsoleBrokerConfig { + pub instance_id: [u8; 16], + pub capability: [u8; 32], + pub expected_peer_identity: serial_core::LocalPeerIdentity, + pub auth_timeout_ms: u64, +} + +#[derive(MeshPayload)] +pub struct VirtioControlConsoleHandle { + /// Reconnectable host endpoint used by the VMM-resident broker. + pub backend: Resource, + pub broker_config: VirtioControlConsoleBrokerConfig, + pub attachment: Option, +} + +impl ResourceId for VirtioControlConsoleHandle { + const ID: &'static str = "virtio-control-console"; +} + +#[cfg(test)] +mod tests { + use super::super::VirtioConsoleHandle; + use super::*; + + #[test] + fn console_resource_ids_are_distinct() { + assert_eq!( + >::ID, + "virtio-console" + ); + assert_eq!( + >::ID, + "virtio-control-console" + ); + } +} diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs new file mode 100644 index 00000000000..5a3aeede555 --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM profile of the virtio-fs resource. +//! +//! [`VirtioFsProfile`] selects the standard virtio-fs device or the microVM +//! device, which serves either an attached host folder or a dormant slot +//! backed by [`super::VirtioFsBackend::Dormant`]. + +use mesh::MeshPayload; + +#[derive(MeshPayload)] +pub enum VirtioFsProfile { + Standard, + Microvm { + stable_id: String, + root_identity: Vec, + read_only: bool, + /// Canonical share-relative paths hidden from the guest. + denied_paths: Vec, + /// Canonical share-relative paths inside `denied_paths` that the guest + /// can reach again. + allowed_paths: Vec, + /// Canonical share-relative paths that are the only parts of a + /// read-write share that the guest can modify; none makes the whole + /// share writable. + writable_paths: Vec, + /// Perform each guest request as the host UID and GID of its caller, + /// with root squashed to the owner of the export root, instead of as + /// the VMM. Linux only. + caller_identity: bool, + }, + MicrovmDormant { + stable_id: String, + }, +} diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 689c1b57f1f..fada105c185 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -41,6 +41,8 @@ pub mod p9 { } pub mod fs { + pub mod microvm; + use mesh::MeshPayload; use vm_resource::ResourceId; use vm_resource::kind::VirtioDeviceHandle; @@ -49,6 +51,7 @@ pub mod fs { pub struct VirtioFsHandle { pub tag: String, pub fs: VirtioFsBackend, + pub profile: microvm::VirtioFsProfile, } #[derive(MeshPayload)] @@ -66,6 +69,7 @@ pub mod fs { Aggregate { children: Vec, }, + Dormant, } /// A single host folder exposed as a named child of a [`VirtioFsBackend::Aggregate`]. @@ -114,6 +118,8 @@ pub mod rng { pub mod blk { use mesh::MeshPayload; + use mesh::rpc::Rpc; + use std::fs::File; use vm_resource::Resource; use vm_resource::ResourceId; use vm_resource::kind::DiskHandleKind; @@ -128,6 +134,47 @@ pub mod blk { impl ResourceId for VirtioBlkHandle { const ID: &'static str = "virtio-blk"; } + + #[derive(Debug, MeshPayload)] + pub struct BindImageSlotRequest { + pub media: File, + pub identity: String, + pub length: u64, + pub logical_block_size: u32, + pub physical_block_size: u32, + } + + #[derive(Clone, Debug, MeshPayload, PartialEq, Eq)] + pub struct ImageSlotState { + pub identity: Option, + pub capacity_sectors: u64, + } + + #[derive(Clone, Debug, MeshPayload, thiserror::Error, PartialEq, Eq)] + pub enum BindImageSlotError { + #[error("image slot is already bound to a different identity")] + AlreadyBound, + #[error("image slot media geometry does not match the slot")] + GeometryMismatch, + #[error("image slot media is invalid: {0}")] + InvalidMedia(String), + } + + #[derive(MeshPayload)] + pub enum ImageSlotRequest { + Bind(Rpc>), + Query(Rpc<(), ImageSlotState>), + } + + #[derive(MeshPayload)] + pub struct VirtioBlkImageSlotHandle { + pub active: bool, + pub requests: mesh::Receiver, + } + + impl ResourceId for VirtioBlkImageSlotHandle { + const ID: &'static str = "virtio-blk-image-slot"; + } } pub mod net { @@ -143,6 +190,10 @@ pub mod net { pub max_queues: Option, pub mac_address: MacAddress, pub endpoint: Resource, + pub egress_policy: Option, + pub save_restore: bool, + pub static_ipv4: Option, + pub effective_features: Option, } impl ResourceId for VirtioNetHandle { @@ -151,6 +202,9 @@ pub mod net { } pub mod console { + pub mod attachment; + pub mod control; + use mesh::MeshPayload; use vm_resource::Resource; use vm_resource::ResourceId; @@ -160,6 +214,8 @@ pub mod console { #[derive(MeshPayload)] pub struct VirtioConsoleHandle { pub backend: Resource, + pub disconnect_policy: attachment::VirtioConsoleDisconnectPolicy, + pub attachment: Option, } impl ResourceId for VirtioConsoleHandle { diff --git a/vm/devices/virtio/virtio_spec/src/lib.rs b/vm/devices/virtio/virtio_spec/src/lib.rs index f0797524944..e0fc0dfb044 100644 --- a/vm/devices/virtio/virtio_spec/src/lib.rs +++ b/vm/devices/virtio/virtio_spec/src/lib.rs @@ -85,6 +85,28 @@ impl VirtioDeviceFeatures { self.set_bank(index, val); self } + + /// Clears every feature selected by `disabled`. + pub fn without_bits(self, disabled: u64) -> Self { + Self::from_bits(self.into_bits() & !disabled) + } +} + +#[cfg(test)] +mod feature_tests { + use super::*; + + #[test] + fn disabled_feature_mask_removes_packed_ring_only() { + let features = VirtioDeviceFeatures::new() + .with_version_1(true) + .with_access_platform(true) + .with_ring_packed(true) + .without_bits(1 << 34); + assert!(features.version_1()); + assert!(features.access_platform()); + assert!(!features.ring_packed()); + } } #[bitfield(u8)] diff --git a/vm/devices/virtio/virtiofs/Cargo.toml b/vm/devices/virtio/virtiofs/Cargo.toml index 2599b4293cb..b07c564aa85 100644 --- a/vm/devices/virtio/virtiofs/Cargo.toml +++ b/vm/devices/virtio/virtiofs/Cargo.toml @@ -18,12 +18,15 @@ fuse.workspace = true inspect.workspace = true lx.workspace = true lxutil.workspace = true +mesh.workspace = true task_control.workspace = true anyhow.workspace = true futures.workspace = true pal_async.workspace = true parking_lot.workspace = true +thiserror.workspace = true +tracelimit.workspace = true tracing.workspace = true zerocopy.workspace = true [target.'cfg(windows)'.dependencies] @@ -41,6 +44,7 @@ windows-sys = { workspace = true, features = [ workspace = true [dev-dependencies] +chipset_device.workspace = true pal_async.workspace = true pal_event.workspace = true tempfile.workspace = true diff --git a/vm/devices/virtio/virtiofs/src/aggregate.rs b/vm/devices/virtio/virtiofs/src/aggregate.rs index 79a5fadbace..e560e15b1b9 100644 --- a/vm/devices/virtio/virtiofs/src/aggregate.rs +++ b/vm/devices/virtio/virtiofs/src/aggregate.rs @@ -11,8 +11,6 @@ //! the [`VirtioFs`] methods that operate on it; the core (direct-mode) file //! system lives in the crate root. -use crate::ATTRIBUTE_TIMEOUT; -use crate::ENTRY_TIMEOUT; use crate::VirtioFs; use crate::build_volume; use crate::inode::VirtioFsInode; @@ -242,11 +240,11 @@ impl VirtioFs { fn insert_child_root_entry(&self, volume: Arc) -> lx::Result { let (inode, stat) = VirtioFsInode::new(volume, PathBuf::new())?; let attr = inode.attr_from_stat(&stat); - let (_, node_id) = self.insert_inode(inode); + let (_, node_id) = self.insert_inode(inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } diff --git a/vm/devices/virtio/virtiofs/src/file.rs b/vm/devices/virtio/virtiofs/src/file.rs index 93753e66453..03312a5b822 100644 --- a/vm/devices/virtio/virtiofs/src/file.rs +++ b/vm/devices/virtio/virtiofs/src/file.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::inode::VirtioFsInode; +use crate::microvm::saved_state::SavedDirectoryEntry; use crate::util; use fuse::DirEntryWriter; use fuse::protocol::fuse_attr; @@ -12,18 +13,28 @@ use lxutil::LxFile; use parking_lot::RwLock; use std::sync::Arc; +#[derive(Default)] +pub(crate) struct DirectorySnapshot { + pub(crate) built: bool, + pub(crate) entries: Vec, +} + /// Implements file callbacks for virtio-fs. pub struct VirtioFsFile { - file: RwLock, - inode: Arc, + pub(crate) file: RwLock, + pub(crate) inode: Arc, + open_flags: u32, + pub(crate) directory_snapshot: RwLock, } impl VirtioFsFile { /// Create a new file. - pub fn new(file: LxFile, inode: Arc) -> Self { + pub fn new(file: LxFile, inode: Arc, open_flags: u32) -> Self { Self { file: RwLock::new(file), inode, + open_flags, + directory_snapshot: RwLock::new(DirectorySnapshot::default()), } } @@ -32,6 +43,41 @@ impl VirtioFsFile { &self.inode } + pub(crate) fn open_flags(&self) -> u32 { + self.open_flags + } + + pub(crate) fn directory_entries(&self) -> Vec { + self.directory_snapshot.read().entries.clone() + } + + pub(crate) fn directory_snapshot_built(&self) -> bool { + self.directory_snapshot.read().built + } + + pub(crate) fn object_stat(&self) -> lx::Result { + self.file.read().fstat().map(Into::into) + } + + pub(crate) fn restore_directory_snapshot( + &self, + built: bool, + entries: Vec, + ) -> lx::Result<()> { + Self::validate_directory_entries(&entries)?; + if !built && !entries.is_empty() { + return Err(lx::Error::EINVAL); + } + if built { + let stat = self.object_stat()?; + if stat.mode & lx::S_IFMT != lx::S_IFDIR { + return Err(lx::Error::ENOTDIR); + } + } + *self.directory_snapshot.write() = DirectorySnapshot { built, entries }; + Ok(()) + } + /// Gets the attributes of the open file. pub fn get_attr(&self) -> lx::Result { let stat = self.file.read().fstat()?.into(); @@ -76,7 +122,16 @@ impl VirtioFsFile { size: u32, plus: bool, ) -> lx::Result> { - let mut buffer = Vec::with_capacity(size as usize); + if fs.is_microvm() { + return self.read_dir_microvm(fs, offset, size, plus); + } + if size as usize > crate::MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size as usize) + .map_err(|_| lx::Error::ENOMEM)?; let mut entry_count: u32 = 0; // Report the directory's guest-visible inode number so `.`/`..` agree // with the number reported by lookup/getattr. @@ -119,7 +174,8 @@ impl VirtioFsFile { } }; - Ok(buffer.dir_entry_plus(&entry.name, entry.offset as u64, fuse_entry)) + let written = buffer.dir_entry_plus(&entry.name, entry.offset as u64, fuse_entry); + Ok(written) } else { // Use the current file's inode number for . and .. entries. // On Windows inode_nr is 0 for these; on Linux it may be @@ -138,12 +194,13 @@ impl VirtioFsFile { self.inode.guest_ino(entry.inode_nr) }; - Ok(buffer.dir_entry( + let written = buffer.dir_entry( &entry.name, inode_nr, entry.offset as u64, entry.file_type as u32, - )) + ); + Ok(written) } })?; diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index bdb35e3ee88..e9f5b37484c 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::file::VirtioFsFile; +use crate::microvm::policy::SubtreePolicy; use crate::util; use fuse::protocol::*; use lx::LxStr; @@ -10,7 +11,10 @@ use lxutil::LxCreateOptions; use lxutil::LxVolume; use lxutil::PathBufExt; use parking_lot::RwLock; +use std::collections::BTreeSet; +use std::collections::HashMap; use std::ops::Deref; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -38,9 +42,14 @@ pub(crate) fn namespace_ino(volume_id: u32, raw: lx::ino_t) -> lx::ino_t { } pub(crate) struct VirtioFsVolume { - volume: Arc, - id: u32, - readonly: bool, + pub(crate) volume: Arc, + pub(crate) id: u32, + pub(crate) readonly: bool, + pub(crate) strict_paths: bool, + pub(crate) policy: SubtreePolicy, + /// Host objects of the policy's hidden paths, by device and inode number, + /// with the only path at which the guest may reach each, if any. + pub(crate) pinned_identities: HashMap<(u64, u64), Option>, } impl VirtioFsVolume { @@ -49,6 +58,9 @@ impl VirtioFsVolume { volume: Arc::new(volume), id, readonly, + strict_paths: false, + policy: SubtreePolicy::default(), + pinned_identities: HashMap::new(), } } @@ -92,8 +104,9 @@ pub(crate) enum DedupKey { /// Implements inode callbacks for virtio-fs. pub struct VirtioFsInode { pub(crate) volume: Arc, - path: RwLock, - lookup_count: AtomicU64, + pub(crate) path: RwLock, + pub(crate) aliases: RwLock>, + pub(crate) lookup_count: AtomicU64, inode_nr: lx::ino_t, /// This inode's number as reported to the guest: its namespaced inode /// number under the shared superblock. @@ -104,6 +117,7 @@ impl VirtioFsInode { /// Create a new inode for the specified path. pub fn new(volume: Arc, path: PathBuf) -> lx::Result<(Self, lx::Stat)> { let stat = volume.lstat(&path)?; + volume.ensure_object_allowed(&path, &stat)?; let inode = Self::with_attr(volume, path, &stat); Ok((inode, stat)) } @@ -111,9 +125,12 @@ impl VirtioFsInode { /// Create a new inode for the specified path, with previously retrieved attributes. pub fn with_attr(volume: Arc, path: PathBuf, stat: &lx::Stat) -> Self { let guest_inode_nr = volume.map_inode(stat.inode_nr); + let mut aliases = BTreeSet::new(); + aliases.insert(path.clone()); Self { volume, path: RwLock::new(path), + aliases: RwLock::new(aliases), lookup_count: AtomicU64::new(1), inode_nr: stat.inode_nr, guest_inode_nr, @@ -132,9 +149,10 @@ impl VirtioFsInode { self.volume.id() } - /// Whether this inode's volume is read-only. + /// Whether this inode is read-only: its volume is, or the volume's access + /// policy does not let the guest modify it through every path it knows. pub fn readonly(&self) -> bool { - self.volume.readonly() + self.volume.readonly() || !self.policy_permits_writes() } /// This inode's own number as reported to the guest: its namespaced inode @@ -143,6 +161,19 @@ impl VirtioFsInode { self.guest_inode_nr } + pub(crate) fn lookup_count(&self) -> u64 { + self.lookup_count.load(Ordering::Acquire) + } + + pub(crate) fn volume(&self) -> Arc { + Arc::clone(&self.volume) + } + + pub(crate) fn object_stat(&self) -> lx::Result { + self.validate_confined_object()?; + self.volume.lstat(&*self.get_path()) + } + /// Maps a raw host inode number from this inode's volume for guest use. For /// numbers belonging to *other* inodes in the same volume (e.g. readdir /// child entries); for this inode's own number use [`Self::guest_inode_nr`]. @@ -172,6 +203,7 @@ impl VirtioFsInode { /// Increments the lookup count. pub fn lookup(&self, new_path: PathBuf) { self.lookup_count.fetch_add(1, Ordering::AcqRel); + self.aliases.write().insert(new_path.clone()); let mut path = self.path.write(); *path = new_path; } @@ -189,7 +221,7 @@ impl VirtioFsInode { let mut old_count = self.lookup_count.load(Ordering::Acquire); loop { let new_count = if lookup_count > old_count { - tracing::warn!(node_id, "Too many forgets for inode"); + tracelimit::warn_ratelimited!(node_id, "Too many forgets for inode"); 0 } else { old_count - lookup_count @@ -209,6 +241,7 @@ impl VirtioFsInode { /// Performs a lookup for a child of this inode. pub fn lookup_child(&self, name: &LxStr) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let (inode, stat) = VirtioFsInode::new(Arc::clone(&self.volume), path)?; let attr = inode.attr_from_stat(&stat); @@ -217,18 +250,21 @@ impl VirtioFsInode { /// Retrieves the attributes of this inode. pub fn get_attr(&self) -> lx::Result { + self.validate_confined_object()?; let stat = self.volume.lstat(&*self.get_path())?; Ok(self.attr_from_stat(&stat)) } /// Retrieves the extended attributes of this inode. pub fn get_statx(&self) -> lx::Result { + self.validate_confined_object()?; let statx = self.volume.statx(&*self.get_path())?; Ok(self.statx_from(&statx)) } /// Sets the attributes of this inode. pub fn set_attr(&self, arg: &fuse_setattr_in, request_uid: lx::uid_t) -> lx::Result { + self.validate_confined_object()?; let attr = util::fuse_set_attr_to_lxutil(arg, request_uid); // Because FUSE_HANDLE_KILLPRIV is set, set-user-ID and set-group-ID must be cleared @@ -240,9 +276,11 @@ impl VirtioFsInode { /// Opens the inode, creating a file object. pub fn open(self: Arc, flags: u32) -> lx::Result { + self.validate_confined_object()?; let flags = (flags as i32) | lx::O_NOFOLLOW; let file = self.volume.open(&*self.get_path(), flags, None)?; - Ok(VirtioFsFile::new(file, self)) + crate::microvm::inode::reject_opened_symlink(&self.volume, &file)?; + Ok(VirtioFsFile::new(file, self, flags as u32)) } /// Creates a new file as a child of this inode, and opens it. @@ -254,11 +292,13 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr, lxutil::LxFile)> { + self.validate_confined()?; let path = self.child_path(name)?; let options = LxCreateOptions::new(mode, uid, gid); let flags = (flags as i32) | lx::O_CREAT | lx::O_NOFOLLOW; let file = self.volume.open(&path, flags, Some(options))?; let stat = file.fstat()?.into(); + crate::microvm::inode::reject_symlink_stat(&self.volume, &stat)?; let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); let attr = inode.attr_from_stat(&stat); Ok((inode, attr, file)) @@ -272,6 +312,7 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self .volume @@ -291,6 +332,7 @@ impl VirtioFsInode { gid: u32, device_id: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self.volume.mknod_stat( &path, @@ -311,6 +353,7 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self.volume.symlink_stat( &path, @@ -325,6 +368,8 @@ impl VirtioFsInode { /// Creates a new hard link as a child of this inode. pub fn link(&self, name: &LxStr, target: &VirtioFsInode) -> lx::Result { + self.validate_confined()?; + target.validate_confined_object()?; if self.volume.id() != target.volume.id() { return Err(lx::Error::EXDEV); } @@ -337,11 +382,13 @@ impl VirtioFsInode { /// Reads the target of the symbolic link, if this inode is a symbolic link. pub fn read_link(&self) -> lx::Result { + self.validate_confined_object()?; self.volume.read_link(&*self.get_path()) } /// Removes a file or directory child of this inode. pub fn unlink(&self, name: &LxStr, flags: i32) -> lx::Result<()> { + self.validate_confined()?; let path = self.child_path(name)?; self.volume.unlink(path, flags) } @@ -354,6 +401,8 @@ impl VirtioFsInode { new_name: &LxStr, flags: u32, ) -> lx::Result<()> { + self.validate_confined()?; + new_dir.validate_confined()?; let path = self.child_path(name)?; let new_path = new_dir.child_path(new_name)?; self.volume.rename(path, new_path, flags) @@ -361,6 +410,7 @@ impl VirtioFsInode { /// Gets the attributes of the file system that the inode resides on. pub fn stat_fs(&self) -> lx::Result { + self.validate_confined_object()?; let stat_fs = self.volume.stat_fs(&*self.get_path())?; Ok(fuse_kstatfs::new( stat_fs.block_count, @@ -376,22 +426,26 @@ impl VirtioFsInode { /// Gets the value or the size of an extended attribute on this inode. pub fn get_xattr(&self, name: &LxStr, value: Option<&mut [u8]>) -> lx::Result { + self.validate_confined_object()?; self.volume.get_xattr(&*self.get_path(), name, value) } /// Sets an extended attribute on this inode. pub fn set_xattr(&self, name: &LxStr, value: &[u8], flags: u32) -> lx::Result<()> { + self.validate_confined_object()?; self.volume .set_xattr(&*self.get_path(), name, value, flags as i32) } /// Lists the extended attributes on this inode. pub fn list_xattr(&self, list: Option<&mut [u8]>) -> lx::Result { + self.validate_confined_object()?; self.volume.list_xattr(&*self.get_path(), list) } /// Removes an extended attribute from this inode. pub fn remove_xattr(&self, name: &LxStr) -> lx::Result<()> { + self.validate_confined_object()?; self.volume.remove_xattr(&*self.get_path(), name) } @@ -400,6 +454,66 @@ impl VirtioFsInode { self.get_path().clone() } + /// Returns all host-relative aliases currently known to the guest. + pub(crate) fn aliases(&self) -> Vec { + self.aliases.read().iter().cloned().collect() + } + + /// Adds an alias after a successful hard link. + pub(crate) fn add_alias(&self, path: PathBuf) { + self.aliases.write().insert(path); + } + + /// Removes all aliases at or below an unlinked directory path. + pub(crate) fn remove_alias_prefix(&self, path: &Path) { + let mut aliases = self.aliases.write(); + let removed_primary = self.path.read().starts_with(path); + aliases.retain(|alias| !alias.starts_with(path)); + if removed_primary { + if let Some(alias) = aliases.first() { + *self.path.write() = alias.clone(); + } + } + } + + /// Replaces an alias prefix after a successful rename. + pub(crate) fn rename_alias_prefix(&self, old: &Path, new: &Path) { + let mut aliases = self.aliases.write(); + let replacements: Vec<_> = aliases + .iter() + .filter_map(|alias| { + alias + .strip_prefix(old) + .ok() + .map(|suffix| (alias.clone(), renamed_path(new, suffix))) + }) + .collect(); + for (old_alias, new_alias) in &replacements { + aliases.remove(old_alias); + aliases.insert(new_alias.clone()); + } + drop(aliases); + + let mut path = self.path.write(); + let suffix = path.strip_prefix(old).ok().map(ToOwned::to_owned); + if let Some(suffix) = suffix { + *path = renamed_path(new, &suffix); + } + } + + /// Swaps two alias prefixes after a successful `RENAME_EXCHANGE`. + pub(crate) fn exchange_alias_prefixes(&self, first: &Path, second: &Path) { + let mut aliases = self.aliases.write(); + *aliases = aliases + .iter() + .map(|alias| exchanged_path(alias, first, second)) + .collect(); + drop(aliases); + + let mut path = self.path.write(); + *path = exchanged_path(&path, first, second); + } + /// The key used to deduplicate this inode in the `InodeMap`, so that /// repeated lookups of the same host file return one stable FUSE node id. /// @@ -416,31 +530,52 @@ impl VirtioFsInode { DedupKey::Path(self.volume_id(), self.get_path().clone()) } - /// The [`DedupKey::Path`] that a child named `name` of this inode would use, - /// for path-keyed (non-stable-id) volumes only. - pub(crate) fn child_path_dedup_key(&self, name: &LxStr) -> Option { - if self.volume.supports_stable_file_id() { - return None; - } - let path = self.child_path(name).ok()?; - Some(DedupKey::Path(self.volume_id(), path)) - } - /// Appends a child name to this inode's path. - fn child_path(&self, name: &LxStr) -> lx::Result { - // Defense in depth: the FUSE request parser already validates names, - // but assert here to catch any bypass. - assert!(!name.is_empty(), "empty child name"); - assert!(!name.as_bytes().contains(&b'/'), "child name contains '/'"); - assert!(name != "." && name != "..", "child name is '.' or '..'"); + pub(crate) fn child_path(&self, name: &LxStr) -> lx::Result { + let name = name.as_bytes(); + if name.is_empty() + || name.contains(&b'/') + || name.contains(&b'\0') + || name == b"." + || name == b".." + || crate::microvm::inode::child_name_disallowed(&self.volume, name) + { + return Err(lx::Error::EINVAL); + } let mut path = self.clone_path(); - path.push_lx(name)?; + path.push_lx(LxStr::from_bytes(name))?; + crate::microvm::inode::validate_child_path(&self.volume, &path)?; Ok(path) } /// Locks the path and returns the value. - fn get_path(&self) -> parking_lot::RwLockReadGuard<'_, PathBuf> { + pub(crate) fn get_path(&self) -> parking_lot::RwLockReadGuard<'_, PathBuf> { self.path.read() } } + +/// Returns `suffix` relocated under a renamed prefix `new`. +/// +/// An empty suffix (the renamed object itself) must not be pushed, because +/// pushing an empty path appends a trailing separator, which makes the host +/// resolve the path as a directory (and follow a symbolic link). +pub(crate) fn renamed_path(new: &Path, suffix: &Path) -> PathBuf { + let mut path = new.to_path_buf(); + if !suffix.as_os_str().is_empty() { + path.push(suffix); + } + path +} + +/// Returns `path` after a `RENAME_EXCHANGE` of `first` and `second`, which +/// swaps the objects at and below the two names. +pub(crate) fn exchanged_path(path: &Path, first: &Path, second: &Path) -> PathBuf { + if let Ok(suffix) = path.strip_prefix(first) { + renamed_path(second, suffix) + } else if let Ok(suffix) = path.strip_prefix(second) { + renamed_path(first, suffix) + } else { + path.to_path_buf() + } +} diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index b2dda91f832..9f4035e88d9 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -9,6 +9,8 @@ mod file; mod inode; #[cfg(test)] mod integration_tests; +mod microvm; +pub use microvm::profile; pub mod resolver; #[cfg(windows)] mod section; @@ -28,6 +30,7 @@ use inode::DedupKey; use inode::VirtioFsInode; use inode::VirtioFsVolume; pub use lxutil::LxVolumeOptions; +use microvm::profile::MicroVmVirtioFsProfile; use parking_lot::RwLock; use std::collections::HashMap; use std::collections::hash_map::Entry; @@ -36,7 +39,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -// TODO: Make these configurable. // FUSE likes to spam getattr a lot, so having a small timeout on the attributes avoids excessive // calls. It also means that a lookup/stat sequence can use the attributes returned by lookup // rather than having to call getattr. @@ -47,11 +49,36 @@ const ATTRIBUTE_TIMEOUT: Duration = Duration::from_millis(1); // update the path. const ENTRY_TIMEOUT: Duration = Duration::from_secs(0); +const MAX_GUEST_BUFFER_SIZE: usize = 1024 * 1024; + +/// Linux's `RENAME_EXCHANGE` flag for `renameat2`, which swaps two names. +const RENAME_EXCHANGE: u32 = 2; + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +struct FuseNegotiation { + initialized: bool, + major: u32, + minor: u32, + capable: u32, + capable2: u32, + want: u32, + want2: u32, + max_readahead: u32, + max_write: u32, + max_background: u16, + congestion_threshold: u16, + time_gran: u32, +} + /// Shared mutable state behind a [`VirtioFs`] handle. struct VirtioFsInner { inodes: RwLock, files: RwLock>>, mode: VirtioFsMode, + microvm_profile: Option, + /// Set when the microVM profile performs requests as their callers. + caller_identity: Option, + negotiation: RwLock, } /// Distinguishes a single-share device from a multi-share aggregate. @@ -97,6 +124,8 @@ pub struct VirtioFs { impl Fuse for VirtioFs { fn init(&self, info: &mut SessionInfo) { + microvm::fs::configure_session(self, info); + // Indicate we support both readdir and readdirplus. if info.capable() & FUSE_DO_READDIRPLUS != 0 { info.want |= FUSE_DO_READDIRPLUS; @@ -114,6 +143,24 @@ impl Fuse for VirtioFs { if info.capable2() & FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2 != 0 { info.want2 |= FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; } + + // The session owns the wire handshake. Keep the complete negotiated + // contract here so device-private state can validate it before a + // restore ever starts guest execution. + *self.inner.negotiation.write() = FuseNegotiation { + initialized: true, + major: info.major(), + minor: info.minor(), + capable: info.capable(), + capable2: info.capable2(), + want: info.want, + want2: info.want2, + max_readahead: info.max_readahead, + max_write: info.max_write, + max_background: info.max_background, + congestion_threshold: info.congestion_threshold, + time_gran: info.time_gran, + }; } fn get_attr(&self, request: &Request, flags: u32, fh: u64) -> lx::Result { @@ -131,7 +178,7 @@ impl Fuse for VirtioFs { inode.get_attr()? }; - Ok(fuse_attr_out::new(ATTRIBUTE_TIMEOUT, attr)) + Ok(fuse_attr_out::new(self.attribute_timeout(), attr)) } fn get_statx( @@ -158,7 +205,7 @@ impl Fuse for VirtioFs { inode.get_statx()? }; - Ok(fuse_statx_out::new(ATTRIBUTE_TIMEOUT, flags, statx)) + Ok(fuse_statx_out::new(self.attribute_timeout(), flags, statx)) } fn set_attr(&self, request: &Request, arg: &fuse_setattr_in) -> lx::Result { @@ -187,7 +234,7 @@ impl Fuse for VirtioFs { inode.set_attr(arg, request.uid())? }; - Ok(fuse_attr_out::new(ATTRIBUTE_TIMEOUT, attr)) + Ok(fuse_attr_out::new(self.attribute_timeout(), attr)) } fn lookup(&self, request: &Request, name: &lx::LxStr) -> lx::Result { @@ -213,11 +260,12 @@ impl Fuse for VirtioFs { fn open(&self, request: &Request, flags: u32) -> lx::Result { let inode = self.get_inode(request.node_id())?; self.check_open_readonly(&inode, flags)?; + self.preflight_file_insert()?; let file = inode.open(flags)?; - let fh = self.insert_file(file); + let fh = self.insert_file(file)?; // TODO: Optionally allow caching. - Ok(fuse_open_out::new(fh, FOPEN_DIRECT_IO)) + Ok(fuse_open_out::new(fh, self.open_flags())) } fn create( @@ -231,18 +279,27 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; + self.preflight_create_inode(&inode, name, &path)?; + self.preflight_file_insert()?; let (new_inode, attr, file) = inode.create(name, arg.flags, arg.mode, request.uid(), request.gid())?; // Insert the newly created inode; this can return an existing inode if it found a match // on the inode number (if this is a non-exclusive create), so make sure to associate the // file with the returned inode. - let (new_inode, node_id) = self.insert_inode(new_inode); - let file = VirtioFsFile::new(file, new_inode); - let fh = self.insert_file(file); + let (new_inode, node_id) = self.insert_inode(new_inode)?; + let file = VirtioFsFile::new(file, new_inode, arg.flags); + let fh = self.insert_file(file)?; Ok(CreateOut { - entry: fuse_entry_out::new(node_id, ENTRY_TIMEOUT, ATTRIBUTE_TIMEOUT, attr), - open: fuse_open_out::new(fh, FOPEN_DIRECT_IO), + entry: fuse_entry_out::new( + node_id, + self.entry_timeout(), + self.attribute_timeout(), + attr, + ), + open: fuse_open_out::new(fh, self.open_flags()), }) } @@ -257,12 +314,15 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mkdir(name, arg.mode, request.uid(), request.gid())?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -278,14 +338,17 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mknod(name, arg.mode, request.uid(), request.gid(), arg.rdev)?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -301,13 +364,17 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + microvm::fs::validate_symlink_target(self, target)?; + let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -319,7 +386,17 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; let target_inode = self.get_inode(target)?; self.check_writable(&inode)?; + let alias = inode.child_path(name)?; + self.check_writable_entry(&inode, &alias)?; + // A new link to an object that the guest may not modify would make it + // writable through the link, so refuse it as if the object were on + // another filesystem. + if target_inode.readonly() { + return Err(lx::Error::EXDEV); + } + self.preflight_alias_add(&target_inode, &alias)?; let attr = inode.link(name, &target_inode)?; + target_inode.add_alias(alias); // Increment the lookup count since we're returning an entry for this inode. // The kernel will send a forget for this entry later. @@ -328,8 +405,8 @@ impl Fuse for VirtioFs { // Use the target inode as the reply, with refreshed attributes. Ok(fuse_entry_out::new( target, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -341,13 +418,16 @@ impl Fuse for VirtioFs { fn read(&self, _request: &Request, arg: &fuse_read_in) -> lx::Result> { let file = self.get_file(arg.fh)?; - let mut buffer = vec![0u8; arg.size as usize]; + let mut buffer = guest_buffer(arg.size)?; let size = file.read(&mut buffer, arg.offset)?; buffer.truncate(size); Ok(buffer) } fn write(&self, request: &Request, arg: &fuse_write_in, data: &[u8]) -> lx::Result { + if data.len() > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } let file = self.get_file(arg.fh)?; self.check_writable(file.inode())?; file.write(data, arg.offset, request.uid()) @@ -417,16 +497,20 @@ impl Fuse for VirtioFs { return Err(lx::Error::EXDEV); } self.check_writable(&inode)?; + self.check_writable(&new_inode)?; + let old_path = inode.child_path(name)?; + let new_path = new_inode.child_path(new_name)?; + self.check_writable_entry(&inode, &old_path)?; + self.check_writable_entry(&new_inode, &new_path)?; + let exchange = flags & RENAME_EXCHANGE != 0; + self.preflight_rename_aliases(inode.volume_id(), &old_path, &new_path, exchange)?; inode.rename(name, &new_inode, new_name, flags)?; - // A rename doesn't preserve inode identity on path-keyed volumes, so - // evict both the vacated source path and the overwritten destination - // path from the dedup map. let mut inodes = self.inner.inodes.write(); - if let Some(key) = inode.child_path_dedup_key(name) { - inodes.evict_dedup_key(&key); - } - if let Some(key) = new_inode.child_path_dedup_key(new_name) { - inodes.evict_dedup_key(&key); + if exchange { + inodes.exchange_alias_prefixes(inode.volume_id(), &old_path, &new_path); + } else { + inodes.remove_alias_prefix(inode.volume_id(), &new_path); + inodes.rename_alias_prefix(inode.volume_id(), &old_path, &new_path); } Ok(()) } @@ -454,7 +538,7 @@ impl Fuse for VirtioFs { return Err(lx::Error::ENODATA); } let inode = self.get_inode(request.node_id())?; - let mut value = vec![0u8; size as usize]; + let mut value = guest_buffer(size)?; let size = inode.get_xattr(name, Some(&mut value))?; value.truncate(size); Ok(value) @@ -480,6 +564,9 @@ impl Fuse for VirtioFs { if self.is_synthetic_root(request.node_id()) { return Err(lx::Error::EROFS); } + if value.len() > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; inode.set_xattr(name, value, flags) @@ -490,7 +577,7 @@ impl Fuse for VirtioFs { return Ok(Vec::new()); } let inode = self.get_inode(request.node_id())?; - let mut list = vec![0u8; size as usize]; + let mut list = guest_buffer(size)?; let size = inode.list_xattr(Some(&mut list))?; list.truncate(size); Ok(list) @@ -519,6 +606,7 @@ impl Fuse for VirtioFs { // To get the file system ready for re-mount, clean out any open files and leaked inodes. self.inner.files.write().clear(); self.inner.inodes.write().clear(); + *self.inner.negotiation.write() = FuseNegotiation::default(); } } @@ -532,6 +620,18 @@ impl VirtioFs { } } + /// Check that the guest may create, remove, or replace the entry at `path` + /// in the directory `parent`, which [`Self::check_writable`] accepted, and + /// return EROFS if not. A writable directory can hold an entry that the + /// volume's access policy protects, such as a traverse-only directory. + fn check_writable_entry(&self, parent: &VirtioFsInode, path: &Path) -> lx::Result<()> { + if parent.volume.path_writable(path) { + Ok(()) + } else { + Err(lx::Error::EROFS) + } + } + /// Check whether the open flags are permitted on a read-only filesystem. fn check_open_readonly(&self, inode: &VirtioFsInode, flags: u32) -> lx::Result<()> { if !inode.readonly() { @@ -572,12 +672,17 @@ impl VirtioFs { let mut inodes = InodeMap::new(false); let volume = Arc::new(VirtioFsVolume::new(volume, 0, readonly)); let (root_inode, _) = VirtioFsInode::new(volume, PathBuf::new())?; - assert!(inodes.insert(root_inode).1 == FUSE_ROOT_ID); + if inodes.insert(root_inode)?.1 != FUSE_ROOT_ID { + return Err(lx::Error::EINVAL); + } Ok(Self { inner: Arc::new(VirtioFsInner { inodes: RwLock::new(inodes), files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, + microvm_profile: None, + caller_identity: None, + negotiation: RwLock::new(FuseNegotiation::default()), }), }) } @@ -595,21 +700,37 @@ impl VirtioFs { inodes: RwLock::new(InodeMap::new(true)), files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Aggregate(AggregateState::new()), + microvm_profile: None, + caller_identity: None, + negotiation: RwLock::new(FuseNegotiation::default()), }), } } fn lookup_helper(&self, inode: &VirtioFsInode, name: &lx::LxStr) -> lx::Result { let (new_inode, attr) = inode.lookup_child(name)?; - let (_, new_inode_nr) = self.insert_inode(new_inode); + self.preflight_inode_insert(&new_inode)?; + let (_, new_inode_nr) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( new_inode_nr, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } + fn attribute_timeout(&self) -> Duration { + microvm::fs::attribute_timeout(self) + } + + fn entry_timeout(&self) -> Duration { + microvm::fs::entry_timeout(self) + } + + fn open_flags(&self) -> u32 { + microvm::fs::open_flags(self) + } + /// Removes a file or directory. fn unlink_helper(&self, request: &Request, name: &lx::LxStr, flags: i32) -> lx::Result<()> { if self.is_synthetic_root(request.node_id()) { @@ -617,36 +738,40 @@ impl VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; inode.unlink(name, flags)?; - // On path-keyed volumes the path is the inode's identity, so evict it - // now; a later create at the same path must not alias the removed inode. - if let Some(key) = inode.child_path_dedup_key(name) { - self.inner.inodes.write().evict_dedup_key(&key); - } + self.inner + .inodes + .write() + .remove_alias_prefix(inode.volume_id(), &path); Ok(()) } /// Retrieve the inode with the specified node ID. fn get_inode(&self, node_id: u64) -> lx::Result> { - self.inner.inodes.read().get(node_id).ok_or_else(|| { - tracing::warn!(node_id, "request for unknown inode"); + let inode = self.inner.inodes.read().get(node_id).ok_or_else(|| { + tracelimit::warn_ratelimited!(node_id, "request for unknown inode"); lx::Error::EINVAL - }) + })?; + inode.validate_confined_object()?; + Ok(inode) } /// Insert a new inode, and returns the assigned node ID as well as a reference to the inode. /// /// If the file system supports stable inode numbers and an inode already existed with this /// number, the existing inode is returned, not the passed in one. - fn insert_inode(&self, inode: VirtioFsInode) -> (Arc, u64) { - self.inner.inodes.write().insert(inode) + fn insert_inode(&self, inode: VirtioFsInode) -> lx::Result<(Arc, u64)> { + let mut inodes = self.inner.inodes.write(); + microvm::fs::insert_inode(self, &mut inodes, inode) } /// Retrieve the file object with the specified file handle. fn get_file(&self, fh: u64) -> lx::Result> { let files = self.inner.files.read(); let file = files.get(fh).ok_or_else(|| { - tracing::warn!(fh, "Request for unknown file"); + tracelimit::warn_ratelimited!(fh, "Request for unknown file"); lx::Error::EBADF })?; @@ -654,8 +779,10 @@ impl VirtioFs { } /// Insert a new file object, and return the assigned file handle. - fn insert_file(&self, file: VirtioFsFile) -> u64 { - self.inner.files.write().insert(Arc::new(file)) + fn insert_file(&self, file: VirtioFsFile) -> lx::Result { + let mut files = self.inner.files.write(); + microvm::fs::validate_file_insert(self, &files)?; + files.insert(Arc::new(file)).ok_or(lx::Error::ENOSPC) } /// Remove the file with the specified node ID. @@ -664,6 +791,19 @@ impl VirtioFs { } } +fn guest_buffer(size: u32) -> lx::Result> { + let size = size as usize; + if size > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size) + .map_err(|_| lx::Error::ENOMEM)?; + buffer.resize(size, 0); + Ok(buffer) +} + /// A key/value map where the keys are automatically incremented identifiers. struct HandleMap { values: HashMap, @@ -687,14 +827,18 @@ impl HandleMap { } /// Inserts an item into the map, and returns the assigned handle. - pub fn insert(&mut self, value: T) -> u64 { - let handle = self.next_handle; - if self.values.insert(handle, value).is_some() { - panic!("Inode number reused."); + pub fn insert(&mut self, value: T) -> Option { + if !self.can_insert() { + return None; } + let handle = self.next_handle; + self.values.insert(handle, value); + self.next_handle = handle.checked_add(1).unwrap_or(0); + Some(handle) + } - self.next_handle += 1; - handle + pub fn can_insert(&self) -> bool { + self.next_handle != 0 && !self.values.contains_key(&self.next_handle) } /// Retrieves a value from the map. @@ -758,7 +902,7 @@ impl InodeMap { } /// Insert an inode into the map, returning its node ID. - pub fn insert(&mut self, inode: VirtioFsInode) -> (Arc, u64) { + pub fn insert(&mut self, inode: VirtioFsInode) -> lx::Result<(Arc, u64)> { // Reuse an existing node id for the same host file; see `DedupKey` // for how each volume type is keyed. match self.inodes_by_key.entry(inode.dedup_key()) { @@ -767,21 +911,26 @@ impl InodeMap { let new_path = inode.clone_path(); let (existing, node_id) = entry.get(); existing.lookup(new_path); - (Arc::clone(existing), *node_id) + Ok((Arc::clone(existing), *node_id)) } Entry::Vacant(entry) => { // Inode not found, so insert it into both maps. let inode = Arc::new(inode); - let node_id = self.inodes_by_node_id.insert(Arc::clone(&inode)); + let node_id = self + .inodes_by_node_id + .insert(Arc::clone(&inode)) + .ok_or(lx::Error::ENOSPC)?; entry.insert((Arc::clone(&inode), node_id)); - (inode, node_id) + Ok((inode, node_id)) } } } /// Remove an inode with the specified FUSE node ID from the map. pub fn remove(&mut self, node_id: u64) { - let inode = self.inodes_by_node_id.remove(node_id).unwrap(); + let Some(inode) = self.inodes_by_node_id.remove(node_id) else { + return; + }; // Only drop the by-key entry if it still points at THIS node: on // path-keyed volumes the path may have been repointed to a newer inode // (via delete+recreate or `evict_dedup_key`), which must not be lost. @@ -792,13 +941,49 @@ impl InodeMap { } } - /// Detach a [`DedupKey::Path`] entry from its current inode so a later - /// create at that path gets a fresh node id instead of aliasing the - /// removed/renamed file. The inode stays in `inodes_by_node_id` for any - /// live fd or watch. - pub fn evict_dedup_key(&mut self, key: &DedupKey) { - if matches!(key, DedupKey::Path(..)) { - self.inodes_by_key.remove(key); + /// Removes aliases at or below an unlinked path and refreshes path-keyed + /// deduplication for any surviving inodes. + pub fn remove_alias_prefix(&mut self, volume_id: u32, path: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.remove_alias_prefix(path); + } + } + self.rebuild_dedup_keys(); + } + + /// Rewrites aliases at or below a renamed path and refreshes path-keyed + /// deduplication for any surviving inodes. + pub fn rename_alias_prefix(&mut self, volume_id: u32, old: &Path, new: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.rename_alias_prefix(old, new); + } + } + self.rebuild_dedup_keys(); + } + + /// Swaps the aliases at or below two exchanged paths and refreshes + /// path-keyed deduplication. + pub fn exchange_alias_prefixes(&mut self, volume_id: u32, first: &Path, second: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.exchange_alias_prefixes(first, second); + } + } + self.rebuild_dedup_keys(); + } + + fn rebuild_dedup_keys(&mut self) { + self.inodes_by_key.clear(); + for (&node_id, inode) in &self.inodes_by_node_id.values { + let key = inode.dedup_key(); + if matches!(key, DedupKey::Path(..)) && inode.aliases().is_empty() { + continue; + } + self.inodes_by_key + .entry(key) + .or_insert_with(|| (Arc::clone(inode), node_id)); } } @@ -812,11 +997,20 @@ impl InodeMap { return; } - let root_inode = Arc::clone(self.inodes_by_node_id.get(FUSE_ROOT_ID).unwrap()); + let Some(root_inode) = self.inodes_by_node_id.get(FUSE_ROOT_ID).cloned() else { + self.inodes_by_node_id.clear(); + self.inodes_by_key.clear(); + return; + }; self.inodes_by_node_id.clear(); // Re-insert the root inode. - assert!(self.inodes_by_node_id.insert(Arc::clone(&root_inode)) == FUSE_ROOT_ID); + let inserted = self.inodes_by_node_id.insert(Arc::clone(&root_inode)); + if inserted != Some(FUSE_ROOT_ID) { + self.inodes_by_node_id.clear(); + self.inodes_by_key.clear(); + return; + } // Rebuild the dedup map with just the root. self.inodes_by_key.clear(); diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs new file mode 100644 index 00000000000..278ddff1a15 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -0,0 +1,558 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM virtio-fs device construction and dormant-slot save/restore. + +use super::profile::MICROVM_REQUEST_QUEUES; +use super::profile::MicroVmOwnerMode; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_mount_tag; +use super::saved_state::SavedState; +use super::state::save_dormant_microvm_state; +use super::state::validate_dormant_microvm_state; +use super::state::validate_microvm_state; +use crate::VirtioFs; +use crate::virtio::VirtioFsDevice; +use std::path::Path; +use std::sync::Arc; +use task_control::TaskControl; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; +use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; +use vmcore::vm_task::VmTaskDriverSource; + +struct DormantMicrovmFs; + +impl fuse::Fuse for DormantMicrovmFs {} + +impl VirtioFsDevice { + /// Creates the fixed no-DAX microVM virtio-fs device. + /// + /// The filesystem must have been constructed with + /// [`VirtioFs::new_microvm`], using this exact profile. This keeps the + /// device shape and host-enforced filesystem policy inseparable. + pub fn new_microvm( + driver_source: &VmTaskDriverSource, + profile: MicroVmVirtioFsProfile, + fs: VirtioFs, + notify_corruption: Option>, + ) -> anyhow::Result { + anyhow::ensure!( + fs.microvm_profile() == Some(&profile), + "microVM device profile does not match the HostFs attachment" + ); + let attachment_id = profile.attachment_id().to_owned(); + let mut device = Self::with_num_request_queues( + driver_source, + profile.mount_tag(), + fs.clone(), + 0, + notify_corruption, + MICROVM_REQUEST_QUEUES, + ); + device.microvm_attachment_id = Some(attachment_id); + device.microvm_profile = Some(profile); + device.caller_identity = fs.caller_identity(); + device.stateful_fs = Some(fs); + Ok(device) + } + + /// Creates the fixed no-DAX microVM virtio-fs device of the slot + /// identified by `stable_id`, without a host attachment. + pub fn new_microvm_dormant( + driver_source: &VmTaskDriverSource, + stable_id: String, + notify_corruption: Option>, + ) -> anyhow::Result { + let mount_tag = microvm_mount_tag(&stable_id).ok_or_else(|| { + anyhow::anyhow!("microVM virtio-fs attachment ID '{stable_id}' is not a fixed slot") + })?; + let mut device = Self::with_num_request_queues( + driver_source, + mount_tag, + DormantMicrovmFs, + 0, + notify_corruption, + MICROVM_REQUEST_QUEUES, + ); + device.microvm_attachment_id = Some(stable_id); + Ok(device) + } + + /// Creates the fixed no-DAX microVM HostFs device directly from the + /// `Microvm` resource fields and its process-local host root. + #[expect(clippy::too_many_arguments)] + pub fn new_microvm_hostfs( + driver_source: &VmTaskDriverSource, + stable_id: String, + root_identity: Vec, + read_only: bool, + denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, + owner_mode: MicroVmOwnerMode, + root_path: impl AsRef, + notify_corruption: Option>, + ) -> anyhow::Result { + let profile = MicroVmVirtioFsProfile::from_attachment_with_policy( + stable_id, + root_identity, + read_only, + denied_paths, + allowed_paths, + writable_paths, + )? + .with_owner_mode(owner_mode)?; + let fs = VirtioFs::new_microvm(root_path, profile.clone())?; + Self::new_microvm(driver_source, profile, fs, notify_corruption) + } +} + +pub(crate) fn device_features(device: &VirtioFsDevice) -> VirtioDeviceFeatures { + VirtioDeviceFeatures::new() + .with_ring_event_idx(true) + .with_ring_indirect_desc(true) + .with_ring_packed(device.microvm_attachment_id.is_none()) +} + +pub(crate) fn supports_accelerated_doorbells(device: &VirtioFsDevice) -> bool { + device.microvm_attachment_id.is_none() || device.microvm_profile.is_some() +} + +pub(crate) fn validate_shared_memory(device: &VirtioFsDevice) -> anyhow::Result<()> { + anyhow::ensure!( + device.microvm_attachment_id.is_none(), + "the microVM virtio-fs profile does not expose shared memory" + ); + Ok(()) +} + +pub(crate) fn validate_queue_features( + device: &VirtioFsDevice, + features: &VirtioDeviceFeatures, +) -> anyhow::Result<()> { + anyhow::ensure!( + device.microvm_attachment_id.is_none() || !features.ring_packed(), + "the microVM virtio-fs profile forbids packed virtqueues" + ); + Ok(()) +} + +pub(crate) fn reset(device: &mut VirtioFsDevice) { + device.admission.resume(); + device.save_error = None; +} + +pub(crate) fn quiesce_input(device: &mut VirtioFsDevice) { + if device.microvm_attachment_id.is_some() { + device.admission.quiesce(); + } +} + +pub(crate) fn resume_input(device: &mut VirtioFsDevice) { + if device.microvm_attachment_id.is_some() { + device.admission.resume(); + device.save_error = None; + } +} + +pub(crate) fn supports_save_restore(device: &VirtioFsDevice) -> bool { + device.microvm_attachment_id.is_some() +} + +pub(crate) fn save_device( + device: &mut VirtioFsDevice, +) -> Result, SaveError> { + let Some(attachment_id) = device.microvm_attachment_id.as_deref() else { + return Err(SaveError::NotSupported); + }; + if let Some(error) = device.save_error.take() { + return Err(SaveError::Other(error)); + } + if device.workers.iter().any(TaskControl::has_state) { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-fs queues are still running" + ))); + } + device + .admission + .verify_drained() + .map_err(SaveError::Other)?; + let state = match (&device.microvm_profile, &device.stateful_fs) { + (Some(profile), Some(fs)) => fs + .save_microvm_state(profile, device.fs.save_state()) + .map_err(SaveError::Other)?, + (None, None) => save_dormant_microvm_state(attachment_id, device.fs.save_state()) + .map_err(SaveError::Other)?, + _ => { + return Err(SaveError::Other(anyhow::anyhow!( + "microVM virtio-fs profile and attachment state disagree" + ))); + } + }; + Ok(Some(SavedStateBlob::new(state))) +} + +pub(crate) fn restore_device( + device: &mut VirtioFsDevice, + state: Option, +) -> Result<(), RestoreError> { + let attachment_id = device + .microvm_attachment_id + .as_deref() + .ok_or(RestoreError::SavedStateNotSupported)?; + let state = state.ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs is missing device-private saved state" + )) + })?; + let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; + if state.dormant { + let session_state = validate_dormant_microvm_state(&state, attachment_id) + .map_err(RestoreError::InvalidSavedState)?; + return device + .fs + .restore_state(session_state) + .map_err(|error| RestoreError::InvalidSavedState(error.into())); + } + let profile = device.microvm_profile.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "active microVM virtio-fs state requires a host attachment" + )) + })?; + validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState)?; + let fs = device.stateful_fs.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs filesystem attachment is unavailable" + )) + })?; + fs.restore_microvm_state(profile, state, &device.fs) + .map_err(RestoreError::InvalidSavedState) +} + +pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateValidator { + let attachment_id = device.microvm_attachment_id.clone(); + let profile = device.microvm_profile.clone(); + Box::new( + move |state, features, queues: &[DeviceQueueState], _guest_memory| { + let attachment_id = attachment_id + .as_deref() + .ok_or(RestoreError::SavedStateNotSupported)?; + if features.ring_packed() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs restored packed virtqueue" + ))); + } + if queues.len() != 2 { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs requires one hiprio queue and one request queue" + ))); + } + let state = state.ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs is missing device-private saved state" + )) + })?; + let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; + if state.dormant { + validate_dormant_microvm_state(&state, attachment_id) + .map(|_| ()) + .map_err(RestoreError::InvalidSavedState) + } else { + let profile = profile.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "active microVM virtio-fs state requires a host attachment" + )) + })?; + validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState) + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::MICROVM_SLOTS; + use crate::profile::microvm_root_identity; + use chipset_device::io::IoResult; + use chipset_device::mmio::MmioIntercept; + use guestmem::GuestMemory; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virtio::VirtioDevice; + use virtio::transport::VirtioMmioDevice; + use vmcore::device_state::ChangeDeviceState; + use vmcore::line_interrupt::LineInterrupt; + use vmcore::save_restore::SaveRestore; + use vmcore::vm_task::SingleDriverBackend; + + #[async_test] + async fn microvm_profile_has_fixed_device_shape(driver: DefaultDriver) { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + root_identity, + true, + Vec::new(), + Vec::new(), + Vec::new(), + MicroVmOwnerMode::Vmm, + temporary_directory.path(), + None, + ) + .unwrap(); + + assert_eq!(device.config.num_request_queues, MICROVM_REQUEST_QUEUES); + assert_eq!(device.traits().max_queues, 2); + assert_eq!(device.traits().shared_memory.size, 0); + assert!(!device.traits().device_features.ring_packed()); + assert!(device.supports_save_restore()); + assert!(device.supports_accelerated_doorbells()); + assert_eq!(&device.config.tag[..8], b"microvm\0"); + } + + #[async_test] + async fn microvm_slots_expose_their_own_tags(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + for (stable_id, tag) in MICROVM_SLOTS { + let temporary_directory = tempfile::tempdir().unwrap(); + let active = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + stable_id.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + false, + Vec::new(), + Vec::new(), + Vec::new(), + MicroVmOwnerMode::Vmm, + temporary_directory.path(), + None, + ) + .unwrap(); + let dormant = + VirtioFsDevice::new_microvm_dormant(&driver_source, stable_id.to_owned(), None) + .unwrap(); + for device in [active, dormant] { + assert_eq!(&device.config.tag[..tag.len()], tag.as_bytes()); + assert_eq!(device.config.tag[tag.len()], 0); + assert_eq!(device.microvm_attachment_id.as_deref(), Some(stable_id)); + } + } + assert!( + VirtioFsDevice::new_microvm_dormant(&driver_source, "fs:microvm2".to_owned(), None) + .is_err() + ); + } + + #[async_test] + async fn dormant_microvm_profile_uses_emulated_doorbells(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + + assert_eq!(device.traits().max_queues, 2); + assert!(device.supports_save_restore()); + assert!(!device.supports_accelerated_doorbells()); + } + + #[async_test] + async fn microvm_dormant_state_restores_into_active_attachment(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let mut source = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + let state = source.save_device().unwrap().unwrap(); + + let root = tempfile::tempdir().unwrap(); + let mut destination = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + Vec::new(), + Vec::new(), + Vec::new(), + MicroVmOwnerMode::Vmm, + root.path(), + None, + ) + .unwrap(); + destination.restore_device(Some(state)).unwrap(); + } + + #[async_test] + async fn microvm_active_state_rejects_dormant_destination(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let root = tempfile::tempdir().unwrap(); + let mut source = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + Vec::new(), + Vec::new(), + Vec::new(), + MicroVmOwnerMode::Vmm, + root.path(), + None, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + let state = source.save_device().unwrap().unwrap(); + + let mut destination = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + assert!(destination.restore_device(Some(state)).is_err()); + } + + #[async_test] + async fn microvm_device_rejects_a_non_profile_attachment(driver: DefaultDriver) { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + true, + Vec::new(), + ) + .unwrap(); + let fs = VirtioFs::new(temporary_directory.path(), None).unwrap(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + + assert!(VirtioFsDevice::new_microvm(&driver_source, profile, fs, None).is_err()); + } + + #[async_test] + async fn inactive_transport_restore_defers_microvm_hostfs_state(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let source_device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + let mut source = VirtioMmioDevice::new( + Box::new(source_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + source.stop().await; + let saved = source.save().unwrap(); + assert!( + saved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + let root = tempfile::tempdir().unwrap(); + let destination_device = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + Vec::new(), + Vec::new(), + Vec::new(), + MicroVmOwnerMode::Vmm, + root.path(), + None, + ) + .unwrap(); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.quiesce_input().await.unwrap(); + destination.stop().await; + let mut staged = destination.save().unwrap(); + assert!( + staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + destination.stop().await; + let activated = destination.save().unwrap(); + assert!( + !activated + .device_state + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + let mut invalid_private = staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap(); + invalid_private.attachment_id = "wrong-attachment".to_owned(); + staged.device_state = Some(SavedStateBlob::new(invalid_private)); + let invalid_device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + let mut invalid_destination = VirtioMmioDevice::new( + Box::new(invalid_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(invalid_destination.restore(staged).is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/file.rs b/vm/devices/virtio/virtiofs/src/microvm/file.rs new file mode 100644 index 00000000000..4451a5f8a80 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/file.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM directory enumeration and persisted directory snapshots. + +use super::saved_state::MAX_DIRECTORY_ENTRIES_PER_HANDLE; +use super::saved_state::MAX_DIRECTORY_ENTRY_NAME_BYTES; +use super::saved_state::MAX_DIRECTORY_SNAPSHOT_BYTES; +use super::saved_state::SavedDirectoryEntry; +use crate::VirtioFs; +use crate::file::VirtioFsFile; +use fuse::DirEntryWriter; +use fuse::protocol::fuse_entry_out; + +impl VirtioFsFile { + pub(crate) fn read_dir_microvm( + &self, + fs: &VirtioFs, + offset: u64, + size: u32, + plus: bool, + ) -> lx::Result> { + if size as usize > crate::MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + + let entries = { + let mut snapshot = self.directory_snapshot.write(); + if !snapshot.built { + if offset != 0 { + return Err(lx::Error::EINVAL); + } + snapshot.entries = self.build_microvm_directory_snapshot()?; + snapshot.built = true; + } + snapshot.entries.clone() + }; + + let start = if offset == 0 { + 0 + } else { + entries + .iter() + .position(|entry| entry.next_cookie == offset) + .map(|index| index + 1) + .ok_or(lx::Error::EINVAL)? + }; + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size as usize) + .map_err(|_| lx::Error::ENOMEM)?; + + for entry in &entries[start..] { + let name = lx::LxStr::from_bytes(&entry.name); + if plus { + if entry.name == b"." || entry.name == b".." { + if !buffer.dir_entry_plus( + name, + entry.next_cookie, + fuse_entry_out::new_dot(entry.guest_inode_id, entry.kind << 12), + ) { + break; + } + } else { + // This check must happen before lookup_helper, because a + // readdirplus lookup creates a guest lookup reference. + if !buffer.check_dir_entry_plus(name) { + break; + } + let fuse_entry = fs.lookup_helper(&self.inode, name)?; + if fuse_entry.attr.ino != entry.guest_inode_id { + return Err(lx::Error::EIO); + } + if !buffer.dir_entry_plus(name, entry.next_cookie, fuse_entry) { + return Err(lx::Error::EIO); + } + } + } else if !buffer.dir_entry(name, entry.guest_inode_id, entry.next_cookie, entry.kind) { + break; + } + } + + if !entries[start..].is_empty() && buffer.is_empty() { + return Err(lx::Error::EINVAL); + } + Ok(buffer) + } + + fn build_microvm_directory_snapshot(&self) -> lx::Result> { + let stat = self.object_stat()?; + if stat.mode & lx::S_IFMT != lx::S_IFDIR { + return Err(lx::Error::ENOTDIR); + } + + let self_inode_nr = self.inode.guest_inode_nr(); + let mut total_bytes = 0usize; + let mut entries = Vec::new(); + self.file.write().read_dir(0, |entry| { + if entries.len() == MAX_DIRECTORY_ENTRIES_PER_HANDLE { + return Err(lx::Error::E2BIG); + } + let name = entry.name.as_bytes(); + if name.is_empty() || name.len() > MAX_DIRECTORY_ENTRY_NAME_BYTES { + return Err(lx::Error::EINVAL); + } + if name != b"." && name != b".." { + match self.inode.child_path(&entry.name) { + Ok(path) if self.inode.volume.entry_listable(&path, entry.file_type) => {} + Ok(_) => return Ok(true), + Err(error) if error == lx::Error::EACCES => return Ok(true), + Err(error) => return Err(error), + } + } + total_bytes = total_bytes + .checked_add(name.len()) + .ok_or(lx::Error::E2BIG)?; + if total_bytes > MAX_DIRECTORY_SNAPSHOT_BYTES { + return Err(lx::Error::E2BIG); + } + let next_cookie = u64::try_from(entries.len()) + .ok() + .and_then(|index| index.checked_add(1)) + .ok_or(lx::Error::E2BIG)?; + entries.try_reserve(1).map_err(|_| lx::Error::ENOMEM)?; + entries.push(SavedDirectoryEntry { + name: name.to_vec(), + next_cookie, + guest_inode_id: if name == b"." || name == b".." { + self_inode_nr + } else { + self.inode.guest_ino(entry.inode_nr) + }, + kind: entry.file_type as u32, + }); + Ok(true) + })?; + Self::validate_directory_entries(&entries)?; + Ok(entries) + } + + pub(crate) fn validate_directory_entries(entries: &[SavedDirectoryEntry]) -> lx::Result<()> { + if entries.len() > MAX_DIRECTORY_ENTRIES_PER_HANDLE { + return Err(lx::Error::E2BIG); + } + let mut total_bytes = 0usize; + let mut previous_cookie = 0; + for (index, entry) in entries.iter().enumerate() { + let dot_entry = entry.name == b"." || entry.name == b".."; + if entry.name.is_empty() + || entry.name.len() > MAX_DIRECTORY_ENTRY_NAME_BYTES + || entry.kind > 15 + || (!dot_entry + && (entry.name.contains(&b'/') + || entry.name.contains(&b'\0') + || entry.name.contains(&b'\\') + || entry.name.contains(&b':'))) + { + return Err(lx::Error::EINVAL); + } + if (index == 0 && entry.next_cookie == 0) + || (index != 0 && entry.next_cookie <= previous_cookie) + { + return Err(lx::Error::EINVAL); + } + previous_cookie = entry.next_cookie; + total_bytes = total_bytes + .checked_add(entry.name.len()) + .ok_or(lx::Error::E2BIG)?; + if total_bytes > MAX_DIRECTORY_SNAPSHOT_BYTES { + return Err(lx::Error::E2BIG); + } + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs new file mode 100644 index 00000000000..10694081f7c --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM filesystem construction, policy hooks, and attachment restore. + +use super::MAX_FUSE_REQUEST_BYTES; +use super::owner::CallerIdentity; +use super::profile::MicroVmOwnerMode; +use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_DIRECTORY_BYTES; +use super::saved_state::MAX_DIRECTORY_ENTRIES; +use super::saved_state::MAX_HANDLES; +use super::saved_state::MAX_INODES; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; +use super::saved_state::SavedHandle; +use super::saved_state::SavedInode; +use super::saved_state::SavedState; +use super::state::encode_relative_path; +use super::state::fuse_negotiation_from_session; +use super::state::reopen_flags; +use super::state::saved_identity; +use super::state::saved_negotiation; +use super::state::saved_policy_paths; +use super::state::schema_version; +use super::state::session_state_from_saved; +use super::state::validate_identity; +use super::state::validate_microvm_state; +use super::state::validate_relative_path; +use super::state::validate_reopenable_alias; +use super::state::validate_saved_identity; +use crate::ATTRIBUTE_TIMEOUT; +use crate::ENTRY_TIMEOUT; +use crate::FuseNegotiation; +use crate::HandleMap; +use crate::InodeMap; +use crate::VirtioFs; +use crate::VirtioFsInner; +use crate::VirtioFsMode; +use crate::file::VirtioFsFile; +use crate::inode::VirtioFsInode; +use crate::inode::VirtioFsVolume; +use anyhow::Context; +use fuse::Session; +use fuse::SessionInfo; +use fuse::SessionState; +use fuse::protocol::FOPEN_DIRECT_IO; +use fuse::protocol::FUSE_ROOT_ID; +use lxutil::LxVolumeOptions; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +pub(crate) fn attribute_timeout(fs: &VirtioFs) -> Duration { + fs.microvm_profile().map_or( + ATTRIBUTE_TIMEOUT, + MicroVmVirtioFsProfile::attribute_cache_timeout, + ) +} + +pub(crate) fn entry_timeout(fs: &VirtioFs) -> Duration { + fs.microvm_profile() + .map_or(ENTRY_TIMEOUT, MicroVmVirtioFsProfile::entry_cache_timeout) +} + +pub(crate) fn open_flags(fs: &VirtioFs) -> u32 { + if fs + .microvm_profile() + .is_none_or(MicroVmVirtioFsProfile::direct_io) + { + FOPEN_DIRECT_IO + } else { + 0 + } +} + +pub(crate) fn configure_session(fs: &VirtioFs, info: &mut SessionInfo) { + if let Some(profile) = fs.inner.microvm_profile.as_ref() { + let policy = profile.fuse_negotiation(); + // Session has already selected its supported protocol version; + // make the profile-controlled portion of the response explicit. + info.max_write = policy.maximum_write(); + } +} + +/// The longest symbolic link target, in bytes, that Linux accepts. +const MAX_SYMLINK_TARGET_BYTES: usize = 4095; + +/// Validates a guest symbolic link target without rewriting it. +pub(crate) fn validate_symlink_target(fs: &VirtioFs, target: &lx::LxStr) -> lx::Result<()> { + // The microVM volume never follows a symbolic link while resolving a host + // path (see `LxVolumeOptions::confine_paths`), so the guest may store any + // target verbatim. Only the bounds that Linux itself enforces are applied, + // so that every host reports the same errors. + if fs.is_microvm() { + let target = target.as_bytes(); + if target.is_empty() { + return Err(lx::Error::ENOENT); + } + if target.len() > MAX_SYMLINK_TARGET_BYTES { + return Err(lx::Error::ENAMETOOLONG); + } + } + Ok(()) +} + +pub(crate) fn insert_inode( + fs: &VirtioFs, + inodes: &mut InodeMap, + inode: VirtioFsInode, +) -> lx::Result<(Arc, u64)> { + if fs.is_microvm() { + inodes.insert_microvm(inode) + } else { + inodes.insert(inode) + } +} + +pub(crate) fn validate_file_insert( + fs: &VirtioFs, + files: &HandleMap>, +) -> lx::Result<()> { + if fs.is_microvm() && (files.values.len() >= MAX_HANDLES || !files.can_insert()) { + return Err(lx::Error::ENOSPC); + } + Ok(()) +} + +impl VirtioFs { + /// Creates a filesystem attachment for the fixed microVM profile. + /// + /// `root_path` is deliberately consumed only while opening the attachment; + /// it is not retained in the filesystem state or in a saved-state blob. + pub fn new_microvm( + root_path: impl AsRef, + profile: MicroVmVirtioFsProfile, + ) -> anyhow::Result { + let root_path = root_path.as_ref(); + profile.validate_root_path(root_path)?; + let mut mount_options = LxVolumeOptions::new(); + mount_options + .readonly(profile.is_readonly()) + .sandbox(true) + .confine_paths(true); + let volume = mount_options.new_volume(root_path)?; + let mut pinned_identities = HashMap::new(); + for (path, reachable_at) in profile.subtree_policy().pinned_paths() { + let stat = volume.lstat(&path)?; + if reachable_at.is_some() && stat.mode & lx::S_IFMT != lx::S_IFDIR { + anyhow::bail!( + "microVM virtio-fs path {} leads to an allowed path but is not a directory", + path.display() + ); + } + // An object pinned at two paths, such as through a bind mount, is + // reachable at neither. + pinned_identities + .entry((stat.device_nr, stat.inode_nr)) + .and_modify(|pinned: &mut Option| { + if *pinned != reachable_at { + *pinned = None; + } + }) + .or_insert(reachable_at); + } + let mut inodes = InodeMap::new(false); + let volume = Arc::new(VirtioFsVolume::new_with_strict_paths( + volume, + 0, + profile.is_readonly(), + true, + profile.subtree_policy().clone(), + pinned_identities, + )); + let (root_inode, root_stat) = VirtioFsInode::new(Arc::clone(&volume), PathBuf::new())?; + profile.validate_opened_root(root_path, &root_stat)?; + let caller_identity = match profile.owner_mode() { + MicroVmOwnerMode::Vmm => None, + MicroVmOwnerMode::Caller => Some(CallerIdentity::for_export_root_owner( + root_stat.uid, + root_stat.gid, + )?), + }; + if inodes.insert(root_inode)?.1 != FUSE_ROOT_ID { + anyhow::bail!("microVM virtio-fs root received an invalid node ID"); + } + Ok(Self { + inner: Arc::new(VirtioFsInner { + inodes: RwLock::new(inodes), + files: RwLock::new(HandleMap::new()), + mode: VirtioFsMode::Direct, + microvm_profile: Some(profile), + caller_identity, + negotiation: RwLock::new(FuseNegotiation::default()), + }), + }) + } + + pub(crate) fn microvm_profile(&self) -> Option<&MicroVmVirtioFsProfile> { + self.inner.microvm_profile.as_ref() + } + + /// Returns the mapping of guest callers to host identities, if requests + /// run as their callers. + pub(crate) fn caller_identity(&self) -> Option { + self.inner.caller_identity + } + + pub(crate) fn is_microvm(&self) -> bool { + self.inner.microvm_profile.is_some() + } + + pub(crate) fn preflight_inode_insert(&self, inode: &VirtioFsInode) -> lx::Result<()> { + if self.is_microvm() { + self.inner.inodes.read().preflight_microvm_insert(inode) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_new_inode_path(&self, path: &Path) -> lx::Result<()> { + if self.is_microvm() { + self.inner.inodes.read().preflight_microvm_new_inode(path) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_create_inode( + &self, + parent: &VirtioFsInode, + name: &lx::LxStr, + path: &Path, + ) -> lx::Result<()> { + if !self.is_microvm() { + return Ok(()); + } + match parent.lookup_child(name) { + Ok((inode, _)) => self.preflight_inode_insert(&inode), + Err(error) if error == lx::Error::ENOENT => self.preflight_new_inode_path(path), + Err(error) => Err(error), + } + } + + pub(crate) fn preflight_alias_add(&self, inode: &VirtioFsInode, path: &Path) -> lx::Result<()> { + if self.is_microvm() { + self.inner + .inodes + .read() + .preflight_microvm_alias_add(inode, path) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_rename_aliases( + &self, + volume_id: u32, + old: &Path, + new: &Path, + exchange: bool, + ) -> lx::Result<()> { + if self.is_microvm() { + self.inner + .inodes + .read() + .preflight_microvm_rename(volume_id, old, new, exchange) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_file_insert(&self) -> lx::Result<()> { + let files = self.inner.files.read(); + validate_file_insert(self, &files) + } + + pub(crate) fn save_microvm_state( + &self, + profile: &MicroVmVirtioFsProfile, + session_state: SessionState, + ) -> anyhow::Result { + anyhow::ensure!( + self.microvm_profile() == Some(profile), + "virtio-fs attachment does not match the microVM profile" + ); + anyhow::ensure!( + self.inner.aggregate().is_none(), + "aggregate virtio-fs is not part of the microVM ABI" + ); + + let (inodes, node_ids, next_node_id) = { + let inodes = self.inner.inodes.read(); + anyhow::ensure!(inodes.inodes_by_node_id.values.len() <= MAX_INODES); + let mut saved = Vec::with_capacity(inodes.inodes_by_node_id.values.len()); + let mut node_ids = HashMap::with_capacity(inodes.inodes_by_node_id.values.len()); + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + for (&node_id, inode) in &inodes.inodes_by_node_id.values { + let aliases = inode.aliases(); + anyhow::ensure!( + !aliases.is_empty() && aliases.len() <= MAX_ALIASES_PER_INODE, + "inode has no bounded reopenable aliases" + ); + alias_count = alias_count + .checked_add(aliases.len()) + .context("saved alias count overflow")?; + anyhow::ensure!(alias_count <= MAX_ALIASES, "saved alias table is too large"); + let volume = inode.volume(); + let mut relative_aliases = Vec::with_capacity(aliases.len()); + let mut object_identity = None; + for alias in aliases { + validate_relative_path(&alias, true) + .map_err(anyhow::Error::from) + .context("inode has an unsafe relative alias")?; + let stat = validate_reopenable_alias(&volume, &alias) + .context("inode alias cannot be revalidated for save")?; + let identity = saved_identity(&stat); + if let Some(expected) = &object_identity { + validate_saved_identity(&identity, expected) + .context("inode aliases identify different objects")?; + } else { + object_identity = Some(identity); + } + let alias = encode_relative_path(&alias)?; + alias_bytes = alias_bytes + .checked_add(alias.len()) + .context("saved alias bytes overflow")?; + anyhow::ensure!( + alias_bytes <= MAX_ALIAS_BYTES, + "saved aliases exceed the aggregate byte bound" + ); + relative_aliases.push(alias); + } + saved.push(SavedInode { + node_id, + volume_id: inode.volume_id(), + relative_aliases, + lookup_count: inode.lookup_count(), + guest_inode_id: inode.guest_inode_nr(), + object_identity: object_identity.context("inode has no aliases")?, + }); + anyhow::ensure!( + node_ids + .insert(Arc::as_ptr(inode) as usize, node_id) + .is_none(), + "duplicate inode identity in inode table" + ); + } + (saved, node_ids, inodes.inodes_by_node_id.next_handle) + }; + + let handles = { + let files = self.inner.files.read(); + anyhow::ensure!(files.values.len() <= MAX_HANDLES); + let mut saved = Vec::with_capacity(files.values.len()); + let mut directory_entry_count = 0usize; + let mut directory_bytes = 0usize; + for (&handle_id, file) in &files.values { + let node_id = *node_ids + .get(&(std::ptr::from_ref(file.inode()) as usize)) + .context("open handle refers to an untracked inode")?; + let stat = file + .object_stat() + .map_err(anyhow::Error::from) + .context("open handle cannot be revalidated for save")?; + let snapshot_entries = file.directory_entries(); + let directory_snapshot_built = file.directory_snapshot_built(); + anyhow::ensure!( + directory_snapshot_built || snapshot_entries.is_empty(), + "uninitialized directory snapshot retained entries" + ); + VirtioFsFile::validate_directory_entries(&snapshot_entries) + .map_err(anyhow::Error::from) + .context("directory enumeration state is invalid")?; + directory_entry_count = directory_entry_count + .checked_add(snapshot_entries.len()) + .context("saved directory entry count overflow")?; + anyhow::ensure!( + directory_entry_count <= MAX_DIRECTORY_ENTRIES, + "saved directory entries exceed the aggregate bound" + ); + for entry in &snapshot_entries { + directory_bytes = directory_bytes + .checked_add(entry.name.len()) + .context("saved directory bytes overflow")?; + } + anyhow::ensure!( + directory_bytes <= MAX_DIRECTORY_BYTES, + "saved directory entries exceed the aggregate byte bound" + ); + saved.push(SavedHandle { + handle_id, + node_id, + open_flags: file.open_flags(), + kind: stat.mode & lx::S_IFMT, + object_identity: saved_identity(&stat), + directory_entries: snapshot_entries, + directory_snapshot_built, + }); + } + saved + }; + + let negotiation = fuse_negotiation_from_session(session_state); + anyhow::ensure!( + *self.inner.negotiation.read() == negotiation, + "FUSE session and filesystem negotiation state disagree" + ); + let schema_version = schema_version(profile); + let policy = profile.subtree_policy(); + let (denied_paths, allowed_paths, writable_paths) = + if schema_version == SUBTREE_POLICY_SCHEMA_VERSION { + ( + saved_policy_paths(policy.denied_paths()), + saved_policy_paths(policy.allowed_paths()), + saved_policy_paths(policy.writable_paths()), + ) + } else { + (Vec::new(), Vec::new(), Vec::new()) + }; + Ok(SavedState { + schema_version, + attachment_id: profile.attachment_id().to_owned(), + access_mode: match profile.access_mode() { + super::profile::MicroVmAccessMode::ReadOnly => 1, + super::profile::MicroVmAccessMode::ReadWrite => 2, + }, + request_queues: profile.request_queues(), + shared_memory_size: 0, + packed_rings: false, + direct_io: profile.direct_io(), + entry_cache_timeout_ns: profile.entry_cache_timeout().as_nanos() as u64, + attribute_cache_timeout_ns: profile.attribute_cache_timeout().as_nanos() as u64, + negotiation: saved_negotiation(session_state), + next_node_id, + next_handle_id: self.inner.files.read().next_handle, + inodes, + handles, + attachment_root_identity: profile.root_identity().to_vec(), + maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, + dormant: false, + caller_identity: profile.owner_mode() == MicroVmOwnerMode::Caller, + denied_paths, + allowed_paths, + writable_paths, + }) + } + + pub(crate) fn restore_microvm_state( + &self, + profile: &MicroVmVirtioFsProfile, + state: SavedState, + session: &Session, + ) -> anyhow::Result<()> { + anyhow::ensure!( + self.microvm_profile() == Some(profile), + "virtio-fs attachment does not match the microVM profile" + ); + validate_microvm_state(&state, profile)?; + let session_state = session_state_from_saved(&state.negotiation)?; + + let current_root = self.get_inode(FUSE_ROOT_ID).map_err(anyhow::Error::from)?; + let current_root_stat = current_root + .object_stat() + .map_err(anyhow::Error::from) + .context("restore attachment root cannot be inspected")?; + let saved_root = state + .inodes + .iter() + .find(|inode| inode.node_id == FUSE_ROOT_ID) + .context("saved state does not contain a root inode")?; + validate_identity(¤t_root_stat, &saved_root.object_identity) + .context("restore attachment root identity does not match")?; + let volume = current_root.volume(); + + let mut restored_inodes = InodeMap::new(false); + let mut node_ids = HashSet::with_capacity(state.inodes.len()); + let mut alias_paths = HashSet::new(); + for saved in &state.inodes { + anyhow::ensure!(node_ids.insert(saved.node_id), "duplicate saved inode ID"); + anyhow::ensure!(saved.volume_id == 0, "unexpected saved volume ID"); + let mut aliases = Vec::with_capacity(saved.relative_aliases.len()); + let mut first_stat = None; + for saved_alias in &saved.relative_aliases { + let path = super::state::decode_relative_path(saved_alias)?; + validate_relative_path(&path, true).map_err(anyhow::Error::from)?; + if saved.node_id == FUSE_ROOT_ID { + anyhow::ensure!( + path.as_os_str().is_empty(), + "root inode has a non-root alias" + ); + } else { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "non-root inode has a root alias" + ); + } + anyhow::ensure!( + alias_paths.insert(path.clone()), + "saved aliases are duplicated or ambiguous" + ); + let stat = validate_reopenable_alias(&volume, &path) + .context("saved inode alias cannot be reopened")?; + validate_identity(&stat, &saved.object_identity) + .context("saved inode identity does not match the attachment")?; + if first_stat.is_none() { + first_stat = Some(stat); + } + aliases.push(path); + } + let stat = first_stat.context("saved inode has no reopenable aliases")?; + let inode = + VirtioFsInode::from_saved(Arc::clone(&volume), aliases, saved.lookup_count, &stat) + .map_err(anyhow::Error::from)?; + anyhow::ensure!( + inode.guest_inode_nr() == saved.guest_inode_id, + "saved guest inode ID does not match the attachment" + ); + let key = inode.dedup_key(); + anyhow::ensure!( + !restored_inodes.inodes_by_key.contains_key(&key), + "saved inode aliases are ambiguous" + ); + let inode = Arc::new(inode); + restored_inodes + .inodes_by_node_id + .values + .insert(saved.node_id, Arc::clone(&inode)); + restored_inodes + .inodes_by_key + .insert(key, (inode, saved.node_id)); + } + restored_inodes.inodes_by_node_id.next_handle = state.next_node_id; + + let mut restored_files = HandleMap::starting_at(state.next_handle_id); + let mut handle_ids = HashSet::with_capacity(state.handles.len()); + for saved in &state.handles { + anyhow::ensure!( + handle_ids.insert(saved.handle_id), + "duplicate saved handle ID" + ); + let inode = restored_inodes + .get(saved.node_id) + .context("saved handle refers to an unknown inode")?; + let reopen_flags = reopen_flags(saved.open_flags)?; + let file = inode + .open(reopen_flags) + .map_err(anyhow::Error::from) + .context("saved handle cannot be reopened")?; + let stat = file + .object_stat() + .map_err(anyhow::Error::from) + .context("reopened handle cannot be inspected")?; + validate_identity(&stat, &saved.object_identity) + .context("reopened handle identity does not match")?; + anyhow::ensure!( + stat.mode & lx::S_IFMT == saved.kind, + "reopened handle kind does not match" + ); + file.restore_directory_snapshot( + saved.directory_snapshot_built, + saved.directory_entries.clone(), + ) + .map_err(anyhow::Error::from) + .context("saved directory snapshot is invalid")?; + restored_files + .values + .insert(saved.handle_id, Arc::new(file)); + } + + session + .restore_state(session_state) + .map_err(anyhow::Error::from) + .context("saved FUSE negotiation cannot be restored")?; + *self.inner.inodes.write() = restored_inodes; + *self.inner.files.write() = restored_files; + *self.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + Ok(()) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs new file mode 100644 index 00000000000..f7f7f3312e7 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for microVM filesystem persistence and limits. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MICROVM_FUSE_MAJOR; +use super::profile::MICROVM_FUSE_MAX_WRITE; +use super::profile::MICROVM_FUSE_MIN_MINOR; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_PATH_BYTES; +use super::state::encode_relative_path; +use super::state::fuse_negotiation_from_session; +use super::state::reopen_flags; +use super::state::validate_microvm_state; +use super::state::validate_relative_path; +use crate::VirtioFs; +use fuse::Session; +use fuse::SessionInfoState; +use fuse::SessionState; +use fuse::protocol::FUSE_ASYNC_READ; +use fuse::protocol::FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; +use fuse::protocol::FUSE_INIT_EXT; +use fuse::protocol::FUSE_ROOT_ID; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::tempdir; +use test_with_tracing::test; + +fn profile(root_path: &Path) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + true, + Vec::new(), + ) + .unwrap() +} + +#[test] +fn microvm_state_restores_against_the_same_attachment() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert!(destination.get_inode(FUSE_ROOT_ID).is_ok()); +} + +#[test] +fn initialized_microvm_negotiation_restores() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session_state = SessionState { + initialized: true, + info: SessionInfoState { + major: MICROVM_FUSE_MAJOR, + minor: MICROVM_FUSE_MIN_MINOR, + capable: FUSE_ASYNC_READ, + want: FUSE_ASYNC_READ, + max_write: MICROVM_FUSE_MAX_WRITE, + time_gran: 1, + ..Default::default() + }, + }; + *source.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + let state = source.save_microvm_state(&profile, session_state).unwrap(); + + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert_eq!(session.save_state(), session_state); + assert!(session.is_initialized()); +} + +#[test] +fn malformed_allocation_state_is_rejected() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.next_node_id = FUSE_ROOT_ID; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_request_size_mismatch_is_rejected() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.maximum_request_size = 0; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn microvm_runtime_path_and_map_limits_are_enforced() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let fs = VirtioFs::new_microvm(temporary_directory.path(), profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let oversized_name = vec![b'x'; MAX_PATH_BYTES + 1]; + assert_eq!( + root.lookup_child(lx::LxStr::from_bytes(&oversized_name)) + .err() + .unwrap(), + lx::Error::E2BIG + ); + + fs.inner.inodes.write().inodes_by_node_id.next_handle = 0; + assert_eq!( + fs.preflight_new_inode_path(Path::new("new-entry")) + .unwrap_err(), + lx::Error::ENOSPC + ); + fs.inner.files.write().next_handle = 0; + assert_eq!(fs.preflight_file_insert().unwrap_err(), lx::Error::ENOSPC); +} + +#[test] +fn microvm_denied_subtree_is_not_lookupable() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::create_dir(root_path.join("allowed")).unwrap(); + std::fs::create_dir(root_path.join("secrets")).unwrap(); + std::fs::write(root_path.join("secrets").join("token"), b"secret").unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + false, + vec!["secrets".to_owned()], + ) + .unwrap(); + let fs = VirtioFs::new_microvm(root_path, profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + + let denied = match root.lookup_child(lx::LxStr::from_bytes(b"secrets")) { + Err(error) => error, + Ok(_) => panic!("denied subtree was lookupable"), + }; + assert_eq!(denied, lx::Error::EACCES); + root.lookup_child(lx::LxStr::from_bytes(b"allowed")) + .unwrap(); + assert_eq!( + root.child_path(lx::LxStr::from_bytes(b"..")).unwrap_err(), + lx::Error::EINVAL + ); +} + +#[test] +fn microvm_alias_limit_is_enforced_before_linking() { + let temporary_directory = tempdir().unwrap(); + std::fs::write(temporary_directory.path().join("entry"), b"data").unwrap(); + let profile = profile(temporary_directory.path()); + let fs = VirtioFs::new_microvm(temporary_directory.path(), profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let (inode, _) = fs + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"entry")) + .unwrap() + .0, + ) + .unwrap(); + for index in 1..MAX_ALIASES_PER_INODE { + inode.add_alias(PathBuf::from(format!("alias-{index}"))); + } + assert_eq!( + fs.preflight_alias_add(&inode, Path::new("alias-overflow")) + .unwrap_err(), + lx::Error::ENOSPC + ); +} + +#[test] +fn saved_negotiation_must_match_the_exact_microvm_policy() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session_state = SessionState { + initialized: true, + info: SessionInfoState { + major: MICROVM_FUSE_MAJOR, + minor: MICROVM_FUSE_MIN_MINOR, + capable: FUSE_ASYNC_READ | FUSE_INIT_EXT, + capable2: FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2, + want: FUSE_ASYNC_READ | FUSE_INIT_EXT, + want2: FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2, + max_write: MICROVM_FUSE_MAX_WRITE, + time_gran: 1, + ..Default::default() + }, + }; + *source.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + let mut state = source.save_microvm_state(&profile, session_state).unwrap(); + + state.negotiation.want2 = 0; + assert!(validate_microvm_state(&state, &profile).is_err()); + state.negotiation.want2 = FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; + state.negotiation.max_background = 1; + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn reopen_flags_preserve_safe_status_bits_and_reject_effects() { + const O_NONBLOCK: u32 = 0x800; + const O_DSYNC: u32 = 0x1000; + const O_CLOEXEC: u32 = 0x80000; + const O_TMPFILE: u32 = 0x410000; + + let saved = lx::O_RDWR as u32 + | lx::O_APPEND as u32 + | O_NONBLOCK + | O_DSYNC + | O_CLOEXEC + | lx::O_CREAT as u32 + | lx::O_EXCL as u32 + | lx::O_TRUNC as u32; + let reopened = reopen_flags(saved).unwrap(); + assert_eq!( + reopened, + saved & !(lx::O_CREAT as u32 | lx::O_EXCL as u32 | lx::O_TRUNC as u32) + ); + assert!(reopen_flags(O_TMPFILE).is_err()); + assert!(reopen_flags(0x80000000).is_err()); +} + +#[test] +fn readonly_profile_rejects_a_writable_saved_handle() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + source + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.handles[0].open_flags = lx::O_RDWR as u32 | lx::O_NOFOLLOW as u32; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_parent_alias_is_rejected_before_reopen() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.inodes[0].relative_aliases = vec![b"..".to_vec()]; + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + + assert!( + destination + .restore_microvm_state(&profile, state, &session) + .is_err() + ); +} + +#[test] +fn directory_snapshot_survives_host_mutation_after_restore() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::write(root_path.join("alpha"), b"alpha").unwrap(); + std::fs::write(root_path.join("beta"), b"beta").unwrap(); + let profile = profile(root_path); + let source = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + let handle = source + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + let file = source.get_file(handle).unwrap(); + + let first_page = file.read_dir(&source, 0, 32, false).unwrap(); + assert!(!first_page.is_empty()); + let snapshot = file.directory_entries(); + assert!(snapshot.len() > 1); + let continuation_offset = snapshot[0].next_cookie; + assert_ne!(continuation_offset, 0); + let expected_continuation = file + .read_dir(&source, continuation_offset, 4096, false) + .unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + std::fs::rename(root_path.join("beta"), root_path.join("00-beta")).unwrap(); + std::fs::write(root_path.join("later"), b"later").unwrap(); + + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + let continuation = destination + .get_file(handle) + .unwrap() + .read_dir(&destination, continuation_offset, 4096, false) + .unwrap(); + assert_eq!(continuation, expected_continuation); +} + +#[test] +fn hard_link_aliases_restore_and_reject_missing_or_replaced_names() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::write(root_path.join("first"), b"data").unwrap(); + std::fs::hard_link(root_path.join("first"), root_path.join("second")).unwrap(); + let profile = profile(root_path); + let source = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + let (_, first_node_id) = source + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"first")) + .unwrap() + .0, + ) + .unwrap(); + let (_, second_node_id) = source + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"second")) + .unwrap() + .0, + ) + .unwrap(); + assert_eq!(first_node_id, second_node_id); + + let missing_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let inode = missing_state + .inodes + .iter() + .find(|inode| inode.node_id == first_node_id) + .unwrap(); + assert_eq!( + inode.relative_aliases, + vec![ + encode_relative_path(Path::new("first")).unwrap(), + encode_relative_path(Path::new("second")).unwrap(), + ] + ); + let replaced_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + std::fs::remove_file(root_path.join("second")).unwrap(); + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, missing_state, &session) + .is_err() + ); + + std::fs::write(root_path.join("second"), b"replacement").unwrap(); + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, replaced_state, &session) + .is_err() + ); +} + +#[test] +fn relative_path_validation_rejects_platform_escapes() { + assert!(validate_relative_path(Path::new("../outside"), true).is_err()); + assert!(validate_relative_path(Path::new("/outside"), true).is_err()); + assert!(validate_relative_path(Path::new("alternate:name"), true).is_err()); +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs new file mode 100644 index 00000000000..6b12497f495 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM path confinement and persisted inode reconstruction. + +use super::policy::PathVisibility; +use super::policy::SubtreePolicy; +use super::saved_state::MAX_PATH_BYTES; +use super::state::relative_path_encoded_len; +use super::state::validate_relative_path; +use crate::inode::VirtioFsInode; +use crate::inode::VirtioFsVolume; +use lxutil::LxVolume; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +impl VirtioFsVolume { + pub(crate) fn new_with_strict_paths( + volume: LxVolume, + id: u32, + readonly: bool, + strict_paths: bool, + policy: SubtreePolicy, + pinned_identities: HashMap<(u64, u64), Option>, + ) -> Self { + Self { + volume: Arc::new(volume), + id, + readonly, + strict_paths, + policy, + pinned_identities, + } + } + + pub(crate) fn strict_paths(&self) -> bool { + self.strict_paths + } + + /// Rejects a path that the access policy hides from the guest. + pub(crate) fn ensure_path_allowed(&self, path: &Path) -> lx::Result<()> { + if self.policy.visibility(path) == PathVisibility::Hidden { + return Err(lx::Error::EACCES); + } + Ok(()) + } + + /// Rejects an object that the guest reached at `path` but must not see + /// there: a hidden object pinned when the share was attached, which may + /// be reachable only at its own path or not at all, or anything but a + /// directory at a traverse-only path. + pub(crate) fn ensure_object_allowed(&self, path: &Path, stat: &lx::Stat) -> lx::Result<()> { + if let Some(reachable_at) = self.pinned_identities.get(&(stat.device_nr, stat.inode_nr)) { + if reachable_at.as_deref() != Some(path) { + return Err(lx::Error::EACCES); + } + } + if self.policy.visibility(path) == PathVisibility::TraverseOnly + && stat.mode & lx::S_IFMT != lx::S_IFDIR + { + return Err(lx::Error::EACCES); + } + Ok(()) + } + + /// Returns whether the guest may modify `path`, or an entry at `path`. + pub(crate) fn path_writable(&self, path: &Path) -> bool { + !self.readonly && self.policy.is_writable(path) + } + + /// Returns whether the access policy makes some visible path read-only. + pub(crate) fn restricts_writes(&self) -> bool { + self.policy.restricts_writes() + } + + /// Returns whether a directory entry named by `path` is one that the guest + /// may list: every visible entry, and only the directories among the + /// traverse-only entries. Filesystems may report an entry's type as + /// unknown, so the host resolves it for a traverse-only entry. + pub(crate) fn entry_listable(&self, path: &Path, file_type: u8) -> bool { + match self.policy.visibility(path) { + PathVisibility::Visible => true, + PathVisibility::TraverseOnly => match file_type { + lx::DT_DIR => true, + lx::DT_UNK => self + .lstat(path) + .is_ok_and(|stat| stat.mode & lx::S_IFMT == lx::S_IFDIR), + _ => false, + }, + PathVisibility::Hidden => false, + } + } +} + +impl VirtioFsInode { + /// Rebuilds an inode after a saved attachment identity has been + /// independently revalidated. + pub(crate) fn from_saved( + volume: Arc, + aliases: Vec, + lookup_count: u64, + stat: &lx::Stat, + ) -> lx::Result { + let Some(path) = aliases.first().cloned() else { + return Err(lx::Error::EINVAL); + }; + if lookup_count == 0 { + return Err(lx::Error::EINVAL); + } + for alias in &aliases { + volume.ensure_object_allowed(alias, stat)?; + } + let mut inode = Self::with_attr(volume, path, stat); + inode.lookup_count = AtomicU64::new(lookup_count); + let aliases: BTreeSet<_> = aliases.into_iter().collect(); + if aliases.is_empty() { + return Err(lx::Error::EINVAL); + } + *inode.aliases.write() = aliases; + Ok(inode) + } + + /// Checks that a microVM inode used as a directory is reached without + /// crossing a symbolic link, including at its final component. + /// + /// LxVolume intentionally does not promise this property for arbitrary + /// callers, so the profile applies a conservative check before each + /// namespace operation. On Linux, the confined volume additionally + /// resolves every host path without following a symbolic link, so this + /// check is not the only defense against a concurrent rename. Windows + /// guests can only create WSL-style links, which Windows path resolution + /// never follows. + pub(crate) fn validate_confined(&self) -> lx::Result<()> { + self.validate_confined_paths(false) + } + + /// Checks that a microVM inode is reached without crossing a symbolic + /// link. The inode itself may be a symbolic link, which operations on the + /// object never follow. + pub(crate) fn validate_confined_object(&self) -> lx::Result<()> { + self.validate_confined_paths(true) + } + + /// Returns whether the volume's access policy lets the guest modify this + /// object through every path that it knows for the object, so that no + /// alias, such as a hard link, makes a read-only object writable. + pub(crate) fn policy_permits_writes(&self) -> bool { + if !self.volume.restricts_writes() { + return true; + } + let aliases = self.aliases(); + let primary = self.clone_path(); + self.volume.policy.is_writable(&primary) + && aliases + .iter() + .all(|alias| self.volume.policy.is_writable(alias)) + } + + fn validate_confined_paths(&self, allow_final_link: bool) -> lx::Result<()> { + if !self.volume.strict_paths() { + return Ok(()); + } + // Operations use the primary path, which outlives the aliases when the + // object is unlinked, so it must be validated as well. + let mut paths = self.aliases(); + let primary = self.clone_path(); + if !paths.contains(&primary) { + paths.push(primary); + } + for path in paths { + validate_relative_path(&path, true)?; + self.volume.ensure_path_allowed(&path)?; + let component_count = path.components().count(); + let mut prefix = PathBuf::new(); + for (index, component) in path.components().enumerate() { + let Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + prefix.push(component); + let stat = self.volume.lstat(&prefix)?; + let final_link = allow_final_link && index + 1 == component_count; + if stat.mode & lx::S_IFMT == lx::S_IFLNK && !final_link { + return Err(lx::Error::ELOOP); + } + } + } + Ok(()) + } +} + +/// Rejects a symbolic link opened on a strict volume. +/// +/// Windows opens a link itself when asked not to follow it, so the opened +/// object is checked rather than relying on the host to refuse. +pub(crate) fn reject_opened_symlink( + volume: &VirtioFsVolume, + file: &lxutil::LxFile, +) -> lx::Result<()> { + if volume.strict_paths() { + reject_symlink_stat(volume, &file.fstat()?.into())?; + } + Ok(()) +} + +/// Rejects the attributes of an opened symbolic link on a strict volume. +pub(crate) fn reject_symlink_stat(volume: &VirtioFsVolume, stat: &lx::Stat) -> lx::Result<()> { + if volume.strict_paths() && stat.mode & lx::S_IFMT == lx::S_IFLNK { + return Err(lx::Error::ELOOP); + } + Ok(()) +} + +pub(crate) fn child_name_disallowed(volume: &VirtioFsVolume, name: &[u8]) -> bool { + volume.strict_paths() && (name.contains(&b'\\') || name.contains(&b':')) +} + +pub(crate) fn validate_child_path(volume: &VirtioFsVolume, path: &Path) -> lx::Result<()> { + validate_relative_path(path, volume.strict_paths())?; + if volume.strict_paths() && relative_path_encoded_len(path)? > MAX_PATH_BYTES { + return Err(lx::Error::E2BIG); + } + volume.ensure_path_allowed(path) +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/limits.rs b/vm/devices/virtio/virtiofs/src/microvm/limits.rs new file mode 100644 index 00000000000..b853735cb3f --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/limits.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM inode, alias, and path allocation limits. + +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_INODES; +use super::saved_state::MAX_PATH_BYTES; +use super::state::relative_path_encoded_len; +use super::state::validate_relative_path; +use crate::InodeMap; +use crate::inode::VirtioFsInode; +use crate::inode::exchanged_path; +use crate::inode::renamed_path; +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +impl InodeMap { + pub(crate) fn insert_microvm( + &mut self, + inode: VirtioFsInode, + ) -> lx::Result<(Arc, u64)> { + self.preflight_microvm_insert(&inode)?; + self.insert(inode) + } + + pub(crate) fn preflight_microvm_insert(&self, inode: &VirtioFsInode) -> lx::Result<()> { + let path = inode.clone_path(); + Self::validate_microvm_alias_path(&path)?; + if let Some((existing, _)) = self.inodes_by_key.get(&inode.dedup_key()) { + return self.preflight_microvm_alias_add(existing, &path); + } + self.preflight_microvm_new_inode(&path) + } + + pub(crate) fn preflight_microvm_new_inode(&self, path: &Path) -> lx::Result<()> { + Self::validate_microvm_alias_path(path)?; + if self.inodes_by_node_id.values.len() >= MAX_INODES || !self.inodes_by_node_id.can_insert() + { + return Err(lx::Error::ENOSPC); + } + let (alias_count, alias_bytes) = self.microvm_alias_usage()?; + if alias_count >= MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + let path_bytes = relative_path_encoded_len(path)?; + if alias_bytes + .checked_add(path_bytes) + .is_none_or(|bytes| bytes > MAX_ALIAS_BYTES) + { + return Err(lx::Error::E2BIG); + } + Ok(()) + } + + pub(crate) fn preflight_microvm_alias_add( + &self, + inode: &VirtioFsInode, + path: &Path, + ) -> lx::Result<()> { + Self::validate_microvm_alias_path(path)?; + let aliases = inode.aliases(); + if aliases.iter().any(|alias| alias == path) { + return Ok(()); + } + if aliases.len() >= MAX_ALIASES_PER_INODE { + return Err(lx::Error::ENOSPC); + } + let (alias_count, alias_bytes) = self.microvm_alias_usage()?; + if alias_count >= MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + let path_bytes = relative_path_encoded_len(path)?; + if alias_bytes + .checked_add(path_bytes) + .is_none_or(|bytes| bytes > MAX_ALIAS_BYTES) + { + return Err(lx::Error::E2BIG); + } + Ok(()) + } + + pub(crate) fn preflight_microvm_rename( + &self, + volume_id: u32, + old: &Path, + new: &Path, + exchange: bool, + ) -> lx::Result<()> { + Self::validate_microvm_alias_path(new)?; + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + for inode in self.inodes_by_node_id.values.values() { + let mut aliases: BTreeSet<_> = inode.aliases().into_iter().collect(); + if inode.volume_id() == volume_id && exchange { + aliases = aliases + .iter() + .map(|alias| exchanged_path(alias, old, new)) + .collect(); + } else if inode.volume_id() == volume_id { + aliases.retain(|alias| !alias.starts_with(new)); + let replacements: Vec<_> = aliases + .iter() + .filter_map(|alias| { + alias + .strip_prefix(old) + .ok() + .map(|suffix| (alias.clone(), renamed_path(new, suffix))) + }) + .collect(); + for (old_alias, new_alias) in replacements { + aliases.remove(&old_alias); + aliases.insert(new_alias); + } + } + if aliases.len() > MAX_ALIASES_PER_INODE { + return Err(lx::Error::ENOSPC); + } + alias_count = alias_count + .checked_add(aliases.len()) + .ok_or(lx::Error::ENOSPC)?; + if alias_count > MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + for alias in aliases { + Self::validate_microvm_alias_path(&alias)?; + alias_bytes = alias_bytes + .checked_add(relative_path_encoded_len(&alias)?) + .ok_or(lx::Error::E2BIG)?; + if alias_bytes > MAX_ALIAS_BYTES { + return Err(lx::Error::E2BIG); + } + } + } + Ok(()) + } + + fn microvm_alias_usage(&self) -> lx::Result<(usize, usize)> { + let mut count = 0usize; + let mut bytes = 0usize; + for inode in self.inodes_by_node_id.values.values() { + let aliases = inode.aliases(); + count = count.checked_add(aliases.len()).ok_or(lx::Error::ENOSPC)?; + for alias in aliases { + bytes = bytes + .checked_add(relative_path_encoded_len(&alias)?) + .ok_or(lx::Error::E2BIG)?; + } + } + Ok((count, bytes)) + } + + fn validate_microvm_alias_path(path: &Path) -> lx::Result<()> { + validate_relative_path(path, true)?; + if relative_path_encoded_len(path)? > MAX_PATH_BYTES { + return Err(lx::Error::E2BIG); + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs new file mode 100644 index 00000000000..006c60ef5d8 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM-specific virtio-fs profile, policy, persistence, and device support. + +pub(crate) mod device; +pub(crate) mod file; +pub(crate) mod fs; +#[cfg(test)] +mod fs_tests; +pub(crate) mod inode; +mod limits; +pub(crate) mod owner; +#[cfg(test)] +mod owner_tests; +pub(crate) mod policy; +#[cfg(test)] +mod policy_tests; +pub mod profile; +pub(crate) mod resolver; +pub(crate) mod saved_state; +pub(crate) mod state; +#[cfg(test)] +mod symlink_tests; + +const MAX_FUSE_REQUEST_HEADER_BYTES: usize = 4096; + +pub(crate) const MAX_FUSE_REQUEST_BYTES: usize = + profile::MICROVM_FUSE_MAX_WRITE as usize + MAX_FUSE_REQUEST_HEADER_BYTES; + +const _: () = assert!(MAX_FUSE_REQUEST_BYTES > profile::MICROVM_FUSE_MAX_WRITE as usize); diff --git a/vm/devices/virtio/virtiofs/src/microvm/owner.rs b/vm/devices/virtio/virtiofs/src/microvm/owner.rs new file mode 100644 index 00000000000..f0721887ab3 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/owner.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host identity of guest filesystem requests (`--mount-owner`). +//! +//! In caller mode, a worker thread performs each request as the host UID and +//! GID of its guest caller, as the guest kernel reports them in the FUSE +//! request header. Guest UID 0 and GID 0 are squashed to the owner of the +//! export root, so the guest can create neither root-owned nor setuid-root +//! files on the host. A request that cannot run as its caller fails with +//! `EPERM`; it never falls back to the VMM's own identity. + +use fuse::protocol::FUSE_BATCH_FORGET; +use fuse::protocol::FUSE_DESTROY; +use fuse::protocol::FUSE_FORGET; +use fuse::protocol::FUSE_INIT; +use fuse::protocol::FUSE_INTERRUPT; +use fuse::protocol::FUSE_RELEASE; +use fuse::protocol::FUSE_RELEASEDIR; + +/// Maps guest callers to the host identities that perform their requests. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CallerIdentity { + /// The owner of the export root, which replaces guest UID 0. + squash_uid: lx::uid_t, + /// The group of the export root, which replaces guest GID 0. + squash_gid: lx::gid_t, +} + +impl CallerIdentity { + /// Builds the mapping for an export root owned by `uid` and `gid`. + pub(crate) fn for_export_root_owner(uid: lx::uid_t, gid: lx::gid_t) -> anyhow::Result { + anyhow::ensure!( + uid != 0 && gid != 0, + "microVM virtio-fs caller ownership squashes guest root to the owner of the export root, which must not be UID 0 or GID 0" + ); + Ok(Self { + squash_uid: uid, + squash_gid: gid, + }) + } + + /// Returns the host UID and GID that perform a request from guest `uid` + /// and `gid`. + pub(crate) fn host_identity(&self, uid: lx::uid_t, gid: lx::gid_t) -> (lx::uid_t, lx::gid_t) { + ( + if uid == 0 { self.squash_uid } else { uid }, + if gid == 0 { self.squash_gid } else { gid }, + ) + } +} + +/// Returns whether handling a request with `opcode` may access the host +/// filesystem. +/// +/// Negotiating the session and releasing host objects never check +/// permissions, so these requests run as the VMM. The guest can therefore +/// always mount, unmount, and release what it opened, even when its other +/// requests fail closed. +fn accesses_host_files(opcode: u32) -> bool { + !matches!( + opcode, + FUSE_INIT + | FUSE_DESTROY + | FUSE_FORGET + | FUSE_BATCH_FORGET + | FUSE_INTERRUPT + | FUSE_RELEASE + | FUSE_RELEASEDIR + ) +} + +/// Dispatches `request` to `session`, performing it as the host identity of +/// its caller when `caller_identity` is set. +pub(crate) fn dispatch( + caller_identity: Option<&CallerIdentity>, + session: &fuse::Session, + request: fuse::Request, + sender: &mut impl fuse::ReplySender, + mapper: Option<&dyn fuse::Mapper>, +) { + dispatch_with(caller_identity, session, request, sender, mapper, run_as); +} + +/// Dispatches `request` like [`dispatch`], but performs a request that runs +/// as its caller through `run_as`, which receives the caller's host UID and +/// GID and either performs the operation as that identity or fails. +pub(crate) fn dispatch_with( + caller_identity: Option<&CallerIdentity>, + session: &fuse::Session, + request: fuse::Request, + sender: &mut impl fuse::ReplySender, + mapper: Option<&dyn fuse::Mapper>, + run_as: impl FnOnce(lx::uid_t, lx::gid_t, &mut dyn FnMut()) -> lx::Result<()>, +) { + match caller_identity { + Some(caller_identity) if accesses_host_files(request.opcode()) => { + let unique = request.unique(); + let (uid, gid) = caller_identity.host_identity(request.uid(), request.gid()); + let mut request = Some(request); + let result = run_as(uid, gid, &mut || { + if let Some(request) = request.take() { + session.dispatch(request, sender, mapper); + } + }); + if let Err(error) = result { + tracelimit::warn_ratelimited!( + uid, + gid, + error = error.value(), + "virtio-fs request cannot run as its caller; failing it with EPERM" + ); + if let Err(error) = sender.send_error(unique, lx::Error::EPERM.value()) { + tracelimit::error_ratelimited!( + unique, + error = &error as &dyn std::error::Error, + "Failed to send reply", + ); + } + } + } + _ => session.dispatch(request, sender, mapper), + } +} + +/// Performs `operation` as host `uid` and `gid`. +#[cfg(target_os = "linux")] +fn run_as(uid: lx::uid_t, gid: lx::gid_t, operation: &mut dyn FnMut()) -> lx::Result<()> { + lxutil::with_fs_identity(lxutil::FsIdentity { uid, gid }, operation) +} + +/// Fails, because the profile accepts caller ownership only on Linux. +#[cfg(not(target_os = "linux"))] +fn run_as(_uid: lx::uid_t, _gid: lx::gid_t, _operation: &mut dyn FnMut()) -> lx::Result<()> { + Err(lx::Error::EPERM) +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs new file mode 100644 index 00000000000..426d1a02aec --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs @@ -0,0 +1,516 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for performing microVM requests as their guest callers. + +use super::owner::CallerIdentity; +use test_with_tracing::test; + +#[test] +fn guest_root_is_squashed_to_the_export_root_owner() { + let identity = CallerIdentity::for_export_root_owner(1000, 1001).unwrap(); + assert_eq!(identity.host_identity(0, 0), (1000, 1001)); + assert_eq!(identity.host_identity(0, 5), (1000, 5)); + assert_eq!(identity.host_identity(4242, 0), (4242, 1001)); + assert_eq!(identity.host_identity(4242, 4343), (4242, 4343)); +} + +#[test] +fn root_owned_export_roots_are_rejected() { + for (uid, gid) in [(0, 1000), (1000, 0), (0, 0)] { + assert!(CallerIdentity::for_export_root_owner(uid, gid).is_err()); + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::super::owner; + use super::super::owner::CallerIdentity; + use super::super::profile::MICROVM_ATTACHMENT_ID; + use super::super::profile::MicroVmOwnerMode; + use super::super::profile::MicroVmVirtioFsProfile; + use super::super::profile::microvm_root_identity; + use super::super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; + use super::super::saved_state::PREVIOUS_SCHEMA_VERSION; + use super::super::saved_state::SCHEMA_VERSION; + use super::super::state::validate_microvm_state; + use crate::VirtioFs; + use fuse::Request; + use fuse::Session; + use fuse::SessionState; + use fuse::protocol::FUSE_GETATTR; + use fuse::protocol::FUSE_INIT; + use fuse::protocol::FUSE_MKDIR; + use fuse::protocol::FUSE_ROOT_ID; + use fuse::protocol::fuse_entry_out; + use fuse::protocol::fuse_getattr_in; + use fuse::protocol::fuse_in_header; + use fuse::protocol::fuse_init_in; + use fuse::protocol::fuse_mkdir_in; + use fuse::protocol::fuse_out_header; + use lxutil::FsIdentity; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use tempfile::TempDir; + use test_with_tracing::test; + use zerocopy::FromBytes; + use zerocopy::FromZeros; + use zerocopy::IntoBytes; + + const EACCES: i32 = -13; + const EPERM: i32 = -1; + + /// The export root owner when the tests run as root, which caller + /// ownership refuses as an owner. + const ROOT_RUN_OWNER: u32 = 65533; + + fn profile(root: &Path, owner_mode: MicroVmOwnerMode) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root).unwrap(), + false, + Vec::new(), + ) + .unwrap() + .with_owner_mode(owner_mode) + .unwrap() + } + + /// Creates a world-writable export root that root does not own. + fn export_root() -> TempDir { + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + if FsIdentity::current().uid == 0 { + std::os::unix::fs::chown(directory.path(), Some(ROOT_RUN_OWNER), Some(ROOT_RUN_OWNER)) + .unwrap(); + } + directory + } + + fn owner(path: &Path) -> (u32, u32) { + let metadata = std::fs::symlink_metadata(path).unwrap(); + (metadata.uid(), metadata.gid()) + } + + fn vmm() -> (u32, u32) { + let current = FsIdentity::current(); + (current.uid, current.gid) + } + + /// Returns whether the calling thread has supplementary groups other than + /// its filesystem GID, which an unprivileged VMM cannot drop. + fn has_other_groups() -> bool { + let gid = FsIdentity::current().gid; + let status = std::fs::read_to_string("/proc/thread-self/status").unwrap(); + let groups = status + .lines() + .find_map(|line| line.strip_prefix("Groups:")) + .unwrap(); + groups + .split_whitespace() + .any(|group| group.parse::().unwrap() != gid) + } + + /// Runs `test` without effective capabilities, as an unprivileged VMM does, + /// even if the tests run as root. + fn unprivileged(test: impl FnOnce() -> T) -> T { + let mut test = Some(test); + // Keeping the current identity clears the capabilities and the other + // supplementary groups. Only a thread without CAP_SETGID, which is + // already unprivileged, fails to drop those groups. + match lxutil::with_fs_identity(FsIdentity::current(), || test.take().unwrap()()) { + Ok(result) => result, + Err(_) => test.take().unwrap()(), + } + } + + /// Records the host identities that `dispatch_with` assumes, performing + /// each request as the test's own identity. + #[derive(Default)] + struct Recorder(Vec<(u32, u32)>); + + impl Recorder { + fn run_as(&mut self) -> impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()> + '_ { + |uid, gid, operation| { + self.0.push((uid, gid)); + operation(); + Ok(()) + } + } + } + + #[derive(Default)] + struct Reply(Vec); + + impl fuse::ReplySender for Reply { + fn send(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<()> { + self.0.clear(); + for buf in bufs { + self.0.extend_from_slice(buf); + } + Ok(()) + } + } + + /// A mounted microVM share whose requests are dispatched the way the + /// virtio-fs worker dispatches them. + struct Share { + directory: TempDir, + session: Session, + caller_identity: Option, + next_unique: u64, + } + + impl Share { + fn new(owner_mode: MicroVmOwnerMode) -> Self { + let directory = export_root(); + let fs = VirtioFs::new_microvm(directory.path(), profile(directory.path(), owner_mode)) + .unwrap(); + let mut share = Self { + session: Session::new(fs.clone()), + caller_identity: fs.caller_identity(), + directory, + next_unique: 1, + }; + // Negotiation never accesses host files, so it never runs as its + // caller, and any caller may mount. + let mut init = fuse_init_in::new_zeroed(); + init.major = 7; + init.minor = 31; + let foreign = share.foreign(); + let (error, _) = share.send_with( + FUSE_INIT, + FUSE_ROOT_ID, + foreign, + init.as_bytes(), + |_, _, _| unreachable!("negotiation never runs as its caller"), + ); + assert_eq!(error, 0); + share + } + + /// Returns an identity that is neither the VMM's nor the export root + /// owner's. + fn foreign(&self) -> (u32, u32) { + let taken = [vmm(), self.root_owner()]; + let mut candidate = (4242, 4343); + while taken + .iter() + .any(|&(uid, gid)| uid == candidate.0 || gid == candidate.1) + { + candidate = (candidate.0 + 2, candidate.1 + 2); + } + candidate + } + + fn root_owner(&self) -> (u32, u32) { + owner(self.directory.path()) + } + + fn owner(&self, name: &str) -> (u32, u32) { + owner(&self.directory.path().join(name)) + } + + /// Sends one request and returns the reply's error and payload. + fn send( + &mut self, + opcode: u32, + node_id: u64, + caller: (u32, u32), + body: &[u8], + ) -> (i32, Vec) { + self.exchange( + opcode, + node_id, + caller, + body, + |identity, session, request, reply| { + owner::dispatch(identity, session, request, reply, None); + }, + ) + } + + /// Sends one request like [`Self::send`], but performs a request that + /// runs as its caller through `run_as`. + fn send_with( + &mut self, + opcode: u32, + node_id: u64, + caller: (u32, u32), + body: &[u8], + run_as: impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()>, + ) -> (i32, Vec) { + self.exchange( + opcode, + node_id, + caller, + body, + |identity, session, request, reply| { + owner::dispatch_with(identity, session, request, reply, None, run_as); + }, + ) + } + + fn exchange( + &mut self, + opcode: u32, + node_id: u64, + (uid, gid): (u32, u32), + body: &[u8], + dispatch: impl FnOnce(Option<&CallerIdentity>, &Session, Request, &mut Reply), + ) -> (i32, Vec) { + let header = fuse_in_header { + len: (size_of::() + body.len()) as u32, + opcode, + unique: self.next_unique, + nodeid: node_id, + uid, + gid, + pid: 1, + padding: 0, + }; + self.next_unique += 1; + let mut bytes = header.as_bytes().to_vec(); + bytes.extend_from_slice(body); + let mut reply = Reply::default(); + dispatch( + self.caller_identity.as_ref(), + &self.session, + Request::new(bytes.as_slice()).unwrap(), + &mut reply, + ); + let (header, payload) = fuse_out_header::read_from_prefix(&reply.0).unwrap(); + (header.error, payload.to_vec()) + } + + /// Creates the directory `name` in `parent` as `caller` and returns + /// the reply's error and the new node ID. + fn mkdir(&mut self, parent: u64, name: &str, caller: (u32, u32)) -> (i32, u64) { + let (error, payload) = self.send(FUSE_MKDIR, parent, caller, &mkdir_body(name)); + (error, entry_node_id(error, &payload)) + } + + /// Creates a directory like [`Self::mkdir`], but performs the request + /// through `run_as`. + fn mkdir_with( + &mut self, + parent: u64, + name: &str, + caller: (u32, u32), + run_as: impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()>, + ) -> (i32, u64) { + let (error, payload) = + self.send_with(FUSE_MKDIR, parent, caller, &mkdir_body(name), run_as); + (error, entry_node_id(error, &payload)) + } + + fn getattr(&mut self, node_id: u64, caller: (u32, u32)) -> i32 { + let arg = fuse_getattr_in::new_zeroed(); + self.send(FUSE_GETATTR, node_id, caller, arg.as_bytes()).0 + } + } + + fn mkdir_body(name: &str) -> Vec { + let mut body = fuse_mkdir_in { + mode: 0o755, + umask: 0, + } + .as_bytes() + .to_vec(); + body.extend_from_slice(name.as_bytes()); + body.push(0); + body + } + + fn entry_node_id(error: i32, payload: &[u8]) -> u64 { + if error == 0 { + fuse_entry_out::read_from_prefix(payload).unwrap().0.nodeid + } else { + 0 + } + } + + #[test] + fn requests_run_as_their_callers_with_guest_root_squashed() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let foreign = share.foreign(); + // Only a privileged process can create an export root that neither it + // nor `foreign` owns, so map guest root to such an owner directly. + let mut owner = (foreign.0 + 1, foreign.1 + 1); + while owner.0 == vmm().0 || owner.1 == vmm().1 { + owner = (owner.0 + 2, owner.1 + 2); + } + share.caller_identity = + Some(CallerIdentity::for_export_root_owner(owner.0, owner.1).unwrap()); + for (name, caller, expected) in [ + ("root", (0, 0), owner), + ("root-user", (0, foreign.1), (owner.0, foreign.1)), + ("root-group", (foreign.0, 0), (foreign.0, owner.1)), + ("foreign", foreign, foreign), + ] { + let mut recorder = Recorder::default(); + let (error, _) = share.mkdir_with(FUSE_ROOT_ID, name, caller, recorder.run_as()); + assert_eq!(error, 0, "{name}"); + assert_eq!(recorder.0, [expected], "{name}"); + } + + // A request that cannot run as its caller fails without being + // performed. + let (error, _) = share.mkdir_with(FUSE_ROOT_ID, "denied", foreign, |_, _, _| { + Err(lx::Error::EPERM) + }); + assert_eq!(error, EPERM); + assert!(!share.directory.path().join("denied").exists()); + } + + #[test] + fn guest_root_creates_entries_as_the_export_root_owner() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let (uid, gid) = share.root_owner(); + if lxutil::with_fs_identity(FsIdentity { uid, gid }, || ()).is_err() { + // An unprivileged VMM with other supplementary groups cannot drop + // them, so even guest root fails closed. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, EPERM); + assert!(!share.directory.path().join("root").exists()); + return; + } + let (error, directory) = share.mkdir(FUSE_ROOT_ID, "root", (0, 0)); + assert_eq!(error, 0); + let (error, _) = share.mkdir(directory, "nested", (0, 0)); + assert_eq!(error, 0); + assert_eq!(share.owner("root"), share.root_owner()); + assert_eq!(share.owner("root/nested"), share.root_owner()); + } + + #[test] + fn callers_with_the_vmm_identity_need_no_privilege_without_other_groups() { + // Root cannot own an export root that caller ownership accepts. + if FsIdentity::current().uid == 0 { + return; + } + let mut share = Share::new(MicroVmOwnerMode::Caller); + assert_eq!(share.root_owner(), vmm()); + unprivileged(|| { + if has_other_groups() { + // An unprivileged VMM cannot drop its other supplementary + // groups, so even callers with its identity fail closed. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, EPERM); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "vmm", vmm()).0, EPERM); + assert!(!share.directory.path().join("root").exists()); + assert!(!share.directory.path().join("vmm").exists()); + } else { + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, 0); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "vmm", vmm()).0, 0); + assert_eq!(share.owner("root"), vmm()); + assert_eq!(share.owner("vmm"), vmm()); + } + }); + } + + #[test] + fn foreign_callers_fail_closed_without_privilege() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let foreign = share.foreign(); + let identity = FsIdentity::current(); + unprivileged(|| { + assert_eq!(share.getattr(FUSE_ROOT_ID, foreign), EPERM); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "foreign", foreign).0, EPERM); + assert_eq!(FsIdentity::current(), identity); + }); + assert!(!share.directory.path().join("foreign").exists()); + } + + #[test] + fn vmm_mode_performs_every_request_as_the_vmm() { + let mut share = Share::new(MicroVmOwnerMode::Vmm); + assert!(share.caller_identity.is_none()); + let foreign = share.foreign(); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "foreign", foreign).0, 0); + assert_eq!(share.owner("foreign"), vmm()); + } + + #[test] + fn root_owned_export_root_is_rejected() { + let root = Path::new("/"); + if owner(root) != (0, 0) { + return; + } + let error = VirtioFs::new_microvm(root, profile(root, MicroVmOwnerMode::Caller)) + .err() + .unwrap(); + assert!(error.to_string().contains("must not be UID 0 or GID 0")); + VirtioFs::new_microvm(root, profile(root, MicroVmOwnerMode::Vmm)).unwrap(); + } + + #[test] + fn saved_state_requires_the_same_owner_mode() { + let directory = export_root(); + let caller = profile(directory.path(), MicroVmOwnerMode::Caller); + let vmm = profile(directory.path(), MicroVmOwnerMode::Vmm); + for (saved, other) in [(&caller, &vmm), (&vmm, &caller)] { + let mut state = VirtioFs::new_microvm(directory.path(), saved.clone()) + .unwrap() + .save_microvm_state(saved, SessionState::default()) + .unwrap(); + let caller_owned = saved.owner_mode() == MicroVmOwnerMode::Caller; + assert_eq!(state.caller_identity, caller_owned); + assert_eq!( + state.schema_version, + if caller_owned { + CALLER_IDENTITY_SCHEMA_VERSION + } else { + SCHEMA_VERSION + } + ); + // Readers that predate caller ownership accept only the previous + // versions, so they refuse to restore a caller-owned attachment as + // the VMM. + assert_eq!( + matches!( + state.schema_version, + PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION + ), + !caller_owned + ); + validate_microvm_state(&state, saved).unwrap(); + assert!(validate_microvm_state(&state, other).is_err()); + + state.schema_version = if caller_owned { + SCHEMA_VERSION + } else { + CALLER_IDENTITY_SCHEMA_VERSION + }; + assert!(validate_microvm_state(&state, saved).is_err()); + } + } + + /// Run with `CAP_SETUID` and `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETUID and CAP_SETGID"] + fn foreign_callers_own_what_they_create_with_privilege() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let caller = share.foreign(); + let (error, directory) = share.mkdir(FUSE_ROOT_ID, "workload", caller); + assert_eq!(error, 0, "run with CAP_SETUID and CAP_SETGID"); + // The caller can populate the directory that it created. + assert_eq!(share.mkdir(directory, "nested", caller).0, 0); + assert_eq!(share.owner("workload"), caller); + assert_eq!(share.owner("workload/nested"), caller); + + // Host permissions apply to the caller, without the VMM's capabilities. + let intruder = (caller.0 + 1, caller.1 + 1); + assert_eq!(share.mkdir(directory, "intruder", intruder).0, EACCES); + // Guest root is squashed to the export root owner, never host root. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, 0); + assert_eq!(share.owner("root"), share.root_owner()); + + let (uid, gid) = caller; + let workload = share.directory.path().join("workload"); + lxutil::with_fs_identity(FsIdentity { uid, gid }, || { + std::fs::remove_dir_all(workload) + }) + .unwrap() + .unwrap(); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/policy.rs b/vm/devices/virtio/virtiofs/src/microvm/policy.rs new file mode 100644 index 00000000000..fc75e934ca4 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/policy.rs @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The path-scoped access policy of a microVM share. +//! +//! A share's policy consists of three sets of canonical share-relative paths: +//! +//! - A *denied* path hides itself and everything below it from the guest. +//! - An *allowed* path exposes itself and everything below it again, inside a +//! denied path. +//! - When a read-write share has *writable* paths, they are the only parts of +//! the share that the guest can modify, and the rest of the share is +//! read-only. +//! +//! The nearest denied or allowed path that contains a path decides whether the +//! guest can see it. The nearest one that contains a denied path must be an +//! allowed path, if any, and the nearest one that contains an allowed path +//! must be a denied path, so no path in the policy is redundant. +//! +//! A hidden directory that leads to an allowed path is *traverse-only*: the +//! guest can look it up and list the entries that lead to allowed paths, but +//! it can see nothing else in it, and it can modify neither the directory nor +//! its entries. + +use super::profile::MicroVmProfileError; +use std::path::Path; +use std::path::PathBuf; + +/// How the guest can reach a share-relative path. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PathVisibility { + /// The guest can reach the path, subject to the write policy. + Visible, + /// The path is hidden but leads to an allowed path, so the guest can look + /// it up as a directory and list the entries that lead to allowed paths. + TraverseOnly, + /// The guest cannot reach the path. + Hidden, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Rule { + Deny, + Allow, +} + +/// The validated access policy of a microVM share. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct SubtreePolicy { + denied: Vec, + allowed: Vec, + writable: Vec, +} + +impl SubtreePolicy { + /// Validates how the denied, allowed, and writable paths of a share relate + /// to one another and to its access mode. Each list must already contain + /// unique, canonical share-relative paths. + pub(crate) fn new( + denied: Vec, + allowed: Vec, + writable: Vec, + read_only: bool, + ) -> Result { + let policy = Self { + denied, + allowed, + writable, + }; + for denied in &policy.denied { + if denied.as_os_str().is_empty() || policy.enclosing_rule(denied) == Some(Rule::Deny) { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + } + for allowed in &policy.allowed { + if policy.denied.contains(allowed) || policy.enclosing_rule(allowed) != Some(Rule::Deny) + { + return Err(MicroVmProfileError::InvalidAllowedPaths); + } + } + if read_only && !policy.writable.is_empty() { + return Err(MicroVmProfileError::InvalidWritablePaths); + } + for (index, writable) in policy.writable.iter().enumerate() { + let overlaps = policy + .writable + .iter() + .enumerate() + .any(|(other_index, other)| other_index != index && writable.starts_with(other)); + if writable.as_os_str().is_empty() + || overlaps + || policy.visibility(writable) != PathVisibility::Visible + { + return Err(MicroVmProfileError::InvalidWritablePaths); + } + } + Ok(policy) + } + + /// Returns the denied paths. + pub(crate) fn denied_paths(&self) -> &[PathBuf] { + &self.denied + } + + /// Returns the allowed paths. + pub(crate) fn allowed_paths(&self) -> &[PathBuf] { + &self.allowed + } + + /// Returns the writable paths; none means that a read-write share is + /// writable everywhere that the guest can see. + pub(crate) fn writable_paths(&self) -> &[PathBuf] { + &self.writable + } + + /// Returns the rule of the nearest denied or allowed path that contains + /// `path`, other than `path` itself. + fn enclosing_rule(&self, path: &Path) -> Option { + self.nearest_rule(path, false) + } + + /// Returns the rule of the nearest denied or allowed path that contains + /// `path`, including `path` itself when `inclusive`. + fn nearest_rule(&self, path: &Path, inclusive: bool) -> Option { + self.denied + .iter() + .map(|rule| (rule, Rule::Deny)) + .chain(self.allowed.iter().map(|rule| (rule, Rule::Allow))) + .filter(|(rule, _)| path.starts_with(rule) && (inclusive || rule.as_path() != path)) + .max_by_key(|(rule, _)| rule.components().count()) + .map(|(_, kind)| kind) + } + + /// Returns how the guest can reach `path`. + pub(crate) fn visibility(&self, path: &Path) -> PathVisibility { + match self.nearest_rule(path, true) { + None | Some(Rule::Allow) => PathVisibility::Visible, + Some(Rule::Deny) + if self + .allowed + .iter() + .any(|allowed| allowed.as_path() != path && allowed.starts_with(path)) => + { + PathVisibility::TraverseOnly + } + Some(Rule::Deny) => PathVisibility::Hidden, + } + } + + /// Returns whether the guest may modify `path` in a read-write share. + pub(crate) fn is_writable(&self, path: &Path) -> bool { + self.visibility(path) == PathVisibility::Visible + && (self.writable.is_empty() + || self + .writable + .iter() + .any(|writable| path.starts_with(writable))) + } + + /// Returns whether some path that the guest can reach is not writable in a + /// read-write share, so that each modification must check its paths. + pub(crate) fn restricts_writes(&self) -> bool { + !self.allowed.is_empty() || !self.writable.is_empty() + } + + /// Returns the hidden paths whose host objects are pinned when the share is + /// attached, each with the only path at which the guest may reach the + /// object: none for a denied path that leads to no allowed path, and its + /// own path for a traverse-only directory. Pinning the objects keeps them + /// hidden when the host makes them reachable through another path, such + /// as a bind mount, a hard link, or a renamed ancestor. + pub(crate) fn pinned_paths(&self) -> Vec<(PathBuf, Option)> { + let mut pinned = Vec::new(); + for denied in &self.denied { + let reachable_at = match self.visibility(denied) { + PathVisibility::Hidden => None, + PathVisibility::Visible | PathVisibility::TraverseOnly => Some(denied.clone()), + }; + pinned.push((denied.clone(), reachable_at)); + } + for allowed in &self.allowed { + let mut prefix = PathBuf::new(); + for component in allowed.components() { + prefix.push(component); + if prefix != *allowed + && self.visibility(&prefix) == PathVisibility::TraverseOnly + && !pinned.iter().any(|(path, _)| *path == prefix) + { + pinned.push((prefix.clone(), Some(prefix.clone()))); + } + } + } + pinned + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> PathBuf { + value + .split('/') + .filter(|component| !component.is_empty()) + .collect() + } + + fn paths(values: &[&str]) -> Vec { + values.iter().map(|value| path(value)).collect() + } + + fn policy(denied: &[&str], allowed: &[&str], writable: &[&str]) -> SubtreePolicy { + SubtreePolicy::new(paths(denied), paths(allowed), paths(writable), false).unwrap() + } + + fn invalid( + denied: &[&str], + allowed: &[&str], + writable: &[&str], + read_only: bool, + ) -> MicroVmProfileError { + SubtreePolicy::new(paths(denied), paths(allowed), paths(writable), read_only).unwrap_err() + } + + fn visibility(policy: &SubtreePolicy, value: &str) -> PathVisibility { + policy.visibility(&path(value)) + } + + fn writable(policy: &SubtreePolicy, value: &str) -> bool { + policy.is_writable(&path(value)) + } + + #[test] + fn allowed_paths_expose_subtrees_of_denied_paths() { + let policy = policy(&["logs"], &["logs/mcp/payloads"], &[]); + assert_eq!(visibility(&policy, "workspace"), PathVisibility::Visible); + assert_eq!(visibility(&policy, "logs"), PathVisibility::TraverseOnly); + assert_eq!( + visibility(&policy, "logs/mcp"), + PathVisibility::TraverseOnly + ); + assert_eq!( + visibility(&policy, "logs/mcp/gateway"), + PathVisibility::Hidden + ); + assert_eq!(visibility(&policy, "logs/secret"), PathVisibility::Hidden); + assert_eq!( + visibility(&policy, "logs/mcp/payloads"), + PathVisibility::Visible + ); + assert_eq!( + visibility(&policy, "logs/mcp/payloads/session/1"), + PathVisibility::Visible + ); + // A name that only shares a prefix with an allowed path stays hidden. + assert_eq!(visibility(&policy, "logs/mc"), PathVisibility::Hidden); + assert_eq!( + visibility(&policy, "logs/mcp/payloads-old"), + PathVisibility::Hidden + ); + assert!(policy.restricts_writes()); + } + + #[test] + fn denied_paths_nest_inside_allowed_paths() { + let policy = policy(&["tmp", "tmp/sandbox/firewall/logs"], &["tmp/sandbox"], &[]); + assert_eq!(visibility(&policy, "tmp/sandbox"), PathVisibility::Visible); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall"), + PathVisibility::Visible + ); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall/logs"), + PathVisibility::Hidden + ); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall/logs/access.log"), + PathVisibility::Hidden + ); + assert_eq!(visibility(&policy, "tmp/other"), PathVisibility::Hidden); + assert_eq!( + policy.pinned_paths(), + [ + (path("tmp"), Some(path("tmp"))), + (path("tmp/sandbox/firewall/logs"), None), + ] + ); + } + + #[test] + fn writable_paths_narrow_writes_to_their_subtrees() { + let policy = policy(&["out/secrets"], &[], &["build/output.log", "out"]); + assert!(writable(&policy, "out")); + assert!(writable(&policy, "out/nested/file")); + assert!(writable(&policy, "build/output.log")); + assert!(!writable(&policy, "build")); + assert!(!writable(&policy, "build/output.log.tmp")); + assert!(!writable(&policy, "outside")); + assert!(!writable(&policy, "")); + assert!(!writable(&policy, "out/secrets")); + assert!(!writable(&policy, "out/secrets/token")); + assert!(policy.restricts_writes()); + } + + #[test] + fn traverse_only_directories_are_never_writable() { + // Without writable paths, every visible path of a read-write share is + // writable, but a traverse-only directory never is. + let policy = policy(&["logs"], &["logs/payloads"], &[]); + assert!(writable(&policy, "")); + assert!(writable(&policy, "logs/payloads")); + assert!(writable(&policy, "logs/payloads/file")); + assert!(!writable(&policy, "logs")); + assert!(!writable(&policy, "logs/secret")); + } + + #[test] + fn writable_paths_may_contain_allowed_and_denied_paths() { + let narrowed = policy(&["out/logs"], &["out/logs/payloads"], &["out"]); + assert!(writable(&narrowed, "out/file")); + assert!(writable(&narrowed, "out/logs/payloads/file")); + assert!(!writable(&narrowed, "out/logs")); + assert!(!writable(&narrowed, "out/logs/secret")); + + let exemption = policy(&["logs"], &["logs/payloads"], &["logs/payloads"]); + assert!(writable(&exemption, "logs/payloads/file")); + assert!(!writable(&exemption, "workspace")); + } + + #[test] + fn deny_only_policies_do_not_restrict_writes() { + let policy = policy(&["secrets"], &[], &[]); + assert!(!policy.restricts_writes()); + assert_eq!(policy.pinned_paths(), [(path("secrets"), None)]); + assert!(!SubtreePolicy::default().restricts_writes()); + } + + #[test] + fn traverse_only_directories_are_pinned_to_their_paths() { + let policy = policy(&["logs", "other"], &["logs/mcp/payloads"], &[]); + assert_eq!( + policy.pinned_paths(), + [ + (path("logs"), Some(path("logs"))), + (path("other"), None), + (path("logs/mcp"), Some(path("logs/mcp"))), + ] + ); + } + + #[test] + fn redundant_or_contradictory_policies_are_rejected() { + // A denied path directly inside another denied path is redundant. + assert!(matches!( + invalid(&["a", "a/b"], &[], &[], false), + MicroVmProfileError::InvalidDeniedPaths + )); + // An allowed path must be inside a denied path, and not directly + // inside another allowed path. + for (denied, allowed) in [ + (vec![], vec!["a"]), + (vec!["b"], vec!["a"]), + (vec!["a"], vec!["a"]), + (vec!["a"], vec!["a/b", "a/b/c"]), + ] { + assert!(matches!( + invalid(&denied, &allowed, &[], false), + MicroVmProfileError::InvalidAllowedPaths + )); + } + // Writable paths need a read-write share, must not overlap, and must + // be visible. + for (denied, allowed, writable, read_only) in [ + (vec![], vec![], vec!["a"], true), + (vec![], vec![], vec!["a", "a/b"], false), + (vec!["a"], vec![], vec!["a/b"], false), + (vec!["a"], vec![], vec!["a"], false), + (vec!["a"], vec!["a/b/c"], vec!["a/b"], false), + ] { + assert!(matches!( + invalid(&denied, &allowed, &writable, read_only), + MicroVmProfileError::InvalidWritablePaths + )); + } + // The share root itself is not a policy path. + assert!(SubtreePolicy::new(vec![PathBuf::new()], Vec::new(), Vec::new(), false).is_err()); + assert!(SubtreePolicy::new(Vec::new(), Vec::new(), vec![PathBuf::new()], false).is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs new file mode 100644 index 00000000000..2bf0384f6e7 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs @@ -0,0 +1,656 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the access policy of microVM shares: write narrowing, hidden +//! subtrees, and the allowed paths inside them. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; +use super::state::save_dormant_microvm_state; +use super::state::validate_dormant_microvm_state; +use super::state::validate_microvm_state; +use crate::RENAME_EXCHANGE; +use crate::VirtioFs; +use fuse::Fuse; +use fuse::Request; +use fuse::Session; +use fuse::SessionState; +use fuse::protocol::FATTR_MODE; +use fuse::protocol::FATTR_SIZE; +use fuse::protocol::FUSE_LOOKUP; +use fuse::protocol::FUSE_ROOT_ID; +use fuse::protocol::fuse_create_in; +use fuse::protocol::fuse_in_header; +use fuse::protocol::fuse_mkdir_in; +use fuse::protocol::fuse_read_in; +use fuse::protocol::fuse_release_in; +use fuse::protocol::fuse_setattr_in; +use fuse::protocol::fuse_write_in; +use std::path::PathBuf; +use tempfile::TempDir; +use tempfile::tempdir; +use test_with_tracing::test; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +fn request(node_id: u64) -> Request { + let header = fuse_in_header { + len: size_of::() as u32, + opcode: FUSE_LOOKUP, + unique: 1, + nodeid: node_id, + uid: 0, + gid: 0, + pid: 0, + padding: 0, + }; + Request::new(header.as_bytes()).unwrap() +} + +fn name(value: &str) -> &lx::LxStr { + lx::LxStr::from_bytes(value.as_bytes()) +} + +fn error(result: lx::Result) -> lx::Error { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error, + } +} + +fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() +} + +fn setattr(valid: u32, update: impl FnOnce(&mut fuse_setattr_in)) -> fuse_setattr_in { + let mut arg = fuse_setattr_in::new_zeroed(); + arg.valid = valid; + update(&mut arg); + arg +} + +/// Looks `path` up one component at a time, as the guest kernel does, and +/// returns its node ID. +fn lookup(fs: &VirtioFs, path: &str) -> lx::Result { + let mut node_id = FUSE_ROOT_ID; + for component in path.split('/') { + node_id = fs.lookup(&request(node_id), name(component))?.nodeid; + } + Ok(node_id) +} + +fn open(fs: &VirtioFs, node_id: u64, flags: i32) -> lx::Result { + Ok(fs.open(&request(node_id), flags as u32)?.fh) +} + +fn release(fs: &VirtioFs, node_id: u64, fh: u64) { + let mut arg = fuse_release_in::new_zeroed(); + arg.fh = fh; + fs.release(&request(node_id), &arg).unwrap(); +} + +/// Creates and opens a file, and returns its node ID and handle. +fn create(fs: &VirtioFs, parent: u64, file_name: &str) -> lx::Result<(u64, u64)> { + let arg = fuse_create_in { + flags: lx::O_RDWR as u32, + mode: lx::S_IFREG | 0o644, + umask: 0, + padding: 0, + }; + let created = fs.create(&request(parent), name(file_name), &arg)?; + Ok((created.entry.nodeid, created.open.fh)) +} + +fn mkdir(fs: &VirtioFs, parent: u64, directory_name: &str) -> lx::Result { + let arg = fuse_mkdir_in { + mode: 0o755, + umask: 0, + }; + Ok(fs + .mkdir(&request(parent), name(directory_name), &arg)? + .nodeid) +} + +fn write(fs: &VirtioFs, node_id: u64, fh: u64, data: &[u8]) -> lx::Result { + let mut arg = fuse_write_in::new_zeroed(); + arg.fh = fh; + arg.size = data.len() as u32; + fs.write(&request(node_id), &arg, data) +} + +/// Returns up to 64 bytes from the start of a file. +fn read_node(fs: &VirtioFs, node_id: u64) -> Vec { + let fh = open(fs, node_id, lx::O_RDONLY).unwrap(); + let mut arg = fuse_read_in::new_zeroed(); + arg.fh = fh; + arg.size = 64; + let data = fs.read(&request(node_id), &arg).unwrap(); + release(fs, node_id, fh); + data +} + +/// Returns the names that the guest lists in a directory, other than `.` and +/// `..`, sorted. +fn list(fs: &VirtioFs, node_id: u64) -> Vec { + let fh = fs + .open_dir(&request(node_id), lx::O_RDONLY as u32) + .unwrap() + .fh; + let mut arg = fuse_read_in::new_zeroed(); + arg.fh = fh; + arg.size = 4096; + let buffer = fs.read_dir(&request(node_id), &arg).unwrap(); + let mut names = Vec::new(); + let mut offset = 0; + while offset < buffer.len() { + // A `fuse_dirent` header is the inode number, the next offset, the + // name length, and the type, followed by the name, padded to eight + // bytes. + let header = &buffer[offset..offset + 24]; + let length = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize; + let entry = &buffer[offset + 24..offset + 24 + length]; + let entry = String::from_utf8(entry.to_vec()).unwrap(); + if entry != "." && entry != ".." { + names.push(entry); + } + offset += fuse::protocol::fuse_dirent_align(24 + length); + } + release(fs, node_id, fh); + names.sort(); + names +} + +/// A share laid out like the directories that a sandbox policy protects. +/// +/// - `marker` and `build.log` are files, and `out` is a directory with the +/// file `existing`. +/// - `logs` holds the secret file `secret` and the directory `gateway`, and +/// `logs/payloads` holds the file `payload` and the directory `private`. +struct Share { + _directory: TempDir, + root: PathBuf, +} + +impl Share { + fn new() -> Self { + let directory = tempdir().unwrap(); + let root = directory.path().join("share"); + let share = Self { + _directory: directory, + root, + }; + for path in ["out", "logs/gateway", "logs/payloads/private"] { + std::fs::create_dir_all(share.path(path)).unwrap(); + } + for (path, contents) in [ + ("marker", "host"), + ("build.log", "log"), + ("out/existing", "existing"), + ("logs/secret", "secret"), + ("logs/gateway/log", "gateway"), + ("logs/payloads/payload", "payload"), + ("logs/payloads/private/key", "key"), + ] { + std::fs::write(share.path(path), contents).unwrap(); + } + share + } + + fn path(&self, relative: &str) -> PathBuf { + relative + .split('/') + .fold(self.root.clone(), |path, component| path.join(component)) + } + + fn read(&self, relative: &str) -> String { + String::from_utf8(std::fs::read(self.path(relative)).unwrap()).unwrap() + } + + fn entries(&self, relative: &str) -> Vec { + let mut names = std::fs::read_dir(self.path(relative)) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect::>(); + names.sort(); + names + } + + fn profile( + &self, + denied: &[&str], + allowed: &[&str], + writable: &[&str], + ) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment_with_policy( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(&self.root).unwrap(), + false, + owned(denied), + owned(allowed), + owned(writable), + ) + .unwrap() + } + + fn fs(&self, denied: &[&str], allowed: &[&str], writable: &[&str]) -> VirtioFs { + VirtioFs::new_microvm(&self.root, self.profile(denied, allowed, writable)).unwrap() + } + + /// Checks that nothing outside `out` and `build.log` changed. + fn assert_protected_paths_unchanged(&self) { + assert_eq!(self.entries(""), ["build.log", "logs", "marker", "out"]); + assert_eq!(self.read("marker"), "host"); + assert_eq!(self.entries("logs"), ["gateway", "payloads", "secret"]); + assert_eq!(self.read("logs/secret"), "secret"); + assert_eq!(self.read("logs/gateway/log"), "gateway"); + assert_eq!(self.read("logs/payloads/private/key"), "key"); + } +} + +#[test] +fn writes_succeed_only_under_writable_paths() { + let share = Share::new(); + let fs = share.fs(&[], &[], &["build.log", "out"]); + let root = FUSE_ROOT_ID; + let marker = lookup(&fs, "marker").unwrap(); + let out = lookup(&fs, "out").unwrap(); + let existing = lookup(&fs, "out/existing").unwrap(); + + // Outside the writable paths, every mutation fails with EROFS. + assert_eq!(error(create(&fs, root, "new")), lx::Error::EROFS); + assert_eq!(error(mkdir(&fs, root, "new")), lx::Error::EROFS); + assert_eq!( + error(fs.symlink(&request(root), name("link"), name("marker"))), + lx::Error::EROFS + ); + assert_eq!(error(open(&fs, marker, lx::O_RDWR)), lx::Error::EROFS); + assert_eq!( + error(open(&fs, marker, lx::O_WRONLY | lx::O_TRUNC)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr(&request(marker), &setattr(FATTR_SIZE, |arg| arg.size = 0))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr( + &request(marker), + &setattr(FATTR_MODE, |arg| arg.mode = 0o600) + )), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_xattr(&request(marker), name("user.nvx"), b"x", 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.remove_xattr(&request(marker), name("user.nvx"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.unlink(&request(root), name("marker"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rmdir(&request(root), name("out"))), + lx::Error::EROFS + ); + // Moving an entry into or out of a writable path changes a read-only + // directory. + assert_eq!( + error(fs.rename(&request(root), name("marker"), out, name("marker"), 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(out), name("existing"), root, name("existing"), 0)), + lx::Error::EROFS + ); + // A hard link in a writable path would make a read-only file writable. + assert_eq!( + error(fs.link(&request(out), name("marker-link"), marker)), + lx::Error::EXDEV + ); + assert_eq!( + error(fs.link(&request(root), name("existing-link"), existing)), + lx::Error::EROFS + ); + + // Inside them, the guest can create, modify, rename, link, and remove + // entries. + let (file, fh) = create(&fs, out, "file").unwrap(); + write(&fs, file, fh, b"guest").unwrap(); + release(&fs, file, fh); + let directory = mkdir(&fs, out, "directory").unwrap(); + fs.rename(&request(out), name("file"), directory, name("renamed"), 0) + .unwrap(); + fs.link(&request(out), name("hard"), existing).unwrap(); + fs.unlink(&request(out), name("hard")).unwrap(); + fs.set_attr(&request(existing), &setattr(FATTR_SIZE, |arg| arg.size = 0)) + .unwrap(); + fs.symlink(&request(out), name("link"), name("../marker")) + .unwrap(); + // A writable file can change, but not move, because its directory is + // read-only. + let log = lookup(&fs, "build.log").unwrap(); + let fh = open(&fs, log, lx::O_RDWR).unwrap(); + write(&fs, log, fh, b"guest").unwrap(); + release(&fs, log, fh); + assert_eq!( + error(fs.unlink(&request(root), name("build.log"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(root), name("build.log"), out, name("build.log"), 0)), + lx::Error::EROFS + ); + + assert_eq!(share.read("out/directory/renamed"), "guest"); + assert_eq!(share.read("out/existing"), ""); + assert_eq!(share.entries("out"), ["directory", "existing", "link"]); + assert_eq!(share.read("build.log"), "guest"); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn allowed_paths_stay_readable_inside_a_hidden_subtree() { + let share = Share::new(); + let fs = share.fs(&["logs", "logs/payloads/private"], &["logs/payloads"], &[]); + let root = FUSE_ROOT_ID; + + // The denied directory leads to an allowed path, so the guest can list + // and traverse it, but it lists only the way to the allowed path. + assert_eq!(list(&fs, root), ["build.log", "logs", "marker", "out"]); + let logs = lookup(&fs, "logs").unwrap(); + assert_eq!(list(&fs, logs), ["payloads"]); + for hidden in [ + "logs/secret", + "logs/gateway", + "logs/gateway/log", + "logs/missing", + "logs/payloads/private", + "logs/payloads/private/key", + ] { + assert_eq!(error(lookup(&fs, hidden)), lx::Error::EACCES, "{hidden}"); + } + let payloads = lookup(&fs, "logs/payloads").unwrap(); + assert_eq!(list(&fs, payloads), ["payload"]); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + assert_eq!(read_node(&fs, payload), b"payload"); + + // The share is read-write and has no writable paths, so the allowed path + // is writable. + let (spilled, fh) = create(&fs, payloads, "spilled").unwrap(); + release(&fs, spilled, fh); + + // Nothing can change the traverse-only directory or its entries. + assert_eq!(error(create(&fs, logs, "new")), lx::Error::EROFS); + assert_eq!(error(mkdir(&fs, logs, "new")), lx::Error::EROFS); + assert_eq!( + error(fs.unlink(&request(logs), name("secret"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rmdir(&request(logs), name("payloads"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(logs), name("payloads"), root, name("moved"), 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr(&request(logs), &setattr(FATTR_MODE, |arg| arg.mode = 0o777))), + lx::Error::EROFS + ); + assert_eq!(error(open(&fs, logs, lx::O_RDWR)), lx::Error::EROFS); + // Its writable parent cannot remove, move, or replace it, so another + // directory cannot take its place and expose its hidden entries. + assert_eq!( + error(fs.rmdir(&request(root), name("logs"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(root), name("logs"), root, name("moved"), 0)), + lx::Error::EROFS + ); + mkdir(&fs, root, "other").unwrap(); + assert_eq!( + error(fs.rename( + &request(root), + name("other"), + root, + name("logs"), + RENAME_EXCHANGE + )), + lx::Error::EROFS + ); + assert_eq!( + error(fs.symlink(&request(root), name("logs"), name("out"))), + lx::Error::EROFS + ); + + assert_eq!( + share.entries("logs/payloads"), + ["payload", "private", "spilled"] + ); + assert_eq!( + share.entries(""), + ["build.log", "logs", "marker", "other", "out"] + ); + std::fs::remove_dir(share.path("other")).unwrap(); + std::fs::remove_file(share.path("logs/payloads/spilled")).unwrap(); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn allowed_paths_follow_the_write_policy() { + let share = Share::new(); + let fs = share.fs(&["logs"], &["logs/payloads"], &["out"]); + let payloads = lookup(&fs, "logs/payloads").unwrap(); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + assert_eq!(error(create(&fs, payloads, "spilled")), lx::Error::EROFS); + assert_eq!(error(open(&fs, payload, lx::O_RDWR)), lx::Error::EROFS); + + let fs = share.fs(&["logs"], &["logs/payloads"], &["logs/payloads"]); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + let fh = open(&fs, payload, lx::O_RDWR).unwrap(); + write(&fs, payload, fh, b"updated").unwrap(); + release(&fs, payload, fh); + assert_eq!(error(create(&fs, FUSE_ROOT_ID, "new")), lx::Error::EROFS); + assert_eq!(share.read("logs/payloads/payload"), "updated"); +} + +/// Filesystems may report an entry's type as unknown, so HostFs asks the host +/// whether a traverse-only entry is a directory before listing it. +#[test] +fn traverse_only_entries_of_unknown_type_are_resolved_on_the_host() { + let share = Share::new(); + let fs = share.fs(&["logs"], &["logs/payloads/private"], &[]); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let listable = |path: &str, file_type| { + root.volume + .entry_listable(&path.split('/').collect::(), file_type) + }; + for (path, expected) in [ + ("marker", true), + ("logs", true), + ("logs/secret", false), + ("logs/payloads", true), + ("logs/payloads/payload", false), + ("logs/payloads/private", true), + ] { + assert_eq!(listable(path, lx::DT_UNK), expected, "{path}"); + } + + // A traverse-only path that the host replaced with a file is not listed. + std::fs::rename(share.path("logs/payloads"), share.path("logs/moved")).unwrap(); + std::fs::write(share.path("logs/payloads"), "file").unwrap(); + assert!(!listable("logs/payloads", lx::DT_UNK)); + assert!(!listable("logs/payloads", lx::DT_REG)); +} + +/// A successful exchange swaps the names that the guest knows for the two +/// objects, so each node still reaches its own object. +#[cfg(unix)] +#[test] +fn exchanged_entries_keep_their_nodes() { + let share = Share::new(); + std::fs::create_dir(share.path("out/directory")).unwrap(); + std::fs::write(share.path("out/directory/file"), "nested").unwrap(); + let fs = share.fs(&[], &[], &["out"]); + let out = lookup(&fs, "out").unwrap(); + let existing = lookup(&fs, "out/existing").unwrap(); + let directory = lookup(&fs, "out/directory").unwrap(); + let nested = lookup(&fs, "out/directory/file").unwrap(); + + fs.rename( + &request(out), + name("existing"), + out, + name("directory"), + RENAME_EXCHANGE, + ) + .unwrap(); + assert_eq!(share.read("out/directory"), "existing"); + assert_eq!(share.read("out/existing/file"), "nested"); + + assert_eq!(read_node(&fs, existing), b"existing"); + assert_eq!(read_node(&fs, nested), b"nested"); + assert_eq!(list(&fs, directory), ["file"]); + let fh = open(&fs, nested, lx::O_RDWR | lx::O_TRUNC).unwrap(); + write(&fs, nested, fh, b"guest").unwrap(); + release(&fs, nested, fh); + assert_eq!(share.read("out/existing/file"), "guest"); + assert_eq!(lookup(&fs, "out/directory").unwrap(), existing); + assert_eq!(lookup(&fs, "out/existing").unwrap(), directory); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn hidden_objects_stay_hidden_at_other_paths() { + let share = Share::new(); + let fs = share.fs(&["logs", "marker"], &["logs/payloads"], &[]); + lookup(&fs, "logs/payloads/payload").unwrap(); + + // A hidden file stays hidden through a hard link. + std::fs::hard_link(share.path("marker"), share.path("alias")).unwrap(); + assert_eq!(error(lookup(&fs, "alias")), lx::Error::EACCES); + + // A traverse-only directory that the host moves is reachable only at its + // own path, so its hidden entries stay hidden. + std::fs::rename(share.path("logs"), share.path("moved")).unwrap(); + assert_eq!(error(lookup(&fs, "moved")), lx::Error::EACCES); + assert_eq!(error(lookup(&fs, "moved/secret")), lx::Error::EACCES); + + // Anything but a directory at a traverse-only path is hidden. + std::fs::write(share.path("logs"), "file").unwrap(); + assert_eq!(error(lookup(&fs, "logs")), lx::Error::EACCES); + assert!(!list(&fs, FUSE_ROOT_ID).contains(&"logs".to_owned())); + std::fs::remove_file(share.path("logs")).unwrap(); + std::fs::rename(share.path("moved"), share.path("logs")).unwrap(); + std::fs::remove_file(share.path("alias")).unwrap(); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn attaching_requires_directories_on_the_way_to_allowed_paths() { + let share = Share::new(); + for allowed in ["logs/secret/inner", "logs/missing/inner"] { + let profile = share.profile(&["logs"], &[allowed], &[]); + assert!( + VirtioFs::new_microvm(&share.root, profile).is_err(), + "{allowed}" + ); + } +} + +#[test] +fn access_policy_survives_save_and_restore() { + let share = Share::new(); + let profile = share.profile(&["logs"], &["logs/payloads"], &["out"]); + let source = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let existing = lookup(&source, "out/existing").unwrap(); + let fh = open(&source, existing, lx::O_RDWR).unwrap(); + let logs = lookup(&source, "logs").unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + assert_eq!(state.schema_version, SUBTREE_POLICY_SCHEMA_VERSION); + assert_eq!(state.denied_paths, ["logs"]); + assert_eq!(state.allowed_paths, ["logs/payloads"]); + assert_eq!(state.writable_paths, ["out"]); + + // The restore attachment needs exactly the same policy. + for other in [ + share.profile(&["logs"], &["logs/payloads"], &[]), + share.profile(&["logs"], &[], &["out"]), + share.profile(&[], &[], &[]), + ] { + assert!(validate_microvm_state(&state, &other).is_err()); + } + let destination = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + write(&destination, existing, fh, b"restored").unwrap(); + assert_eq!( + error(create(&destination, FUSE_ROOT_ID, "new")), + lx::Error::EROFS + ); + assert_eq!(list(&destination, logs), ["payloads"]); + assert_eq!(share.read("out/existing"), "restored"); +} + +#[test] +fn restore_rejects_a_writable_handle_outside_the_writable_paths() { + let share = Share::new(); + let profile = share.profile(&[], &[], &["out"]); + let source = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let marker = lookup(&source, "marker").unwrap(); + open(&source, marker, lx::O_RDONLY).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + validate_microvm_state(&state, &profile).unwrap(); + + let handle = state + .handles + .iter_mut() + .find(|handle| handle.node_id == marker) + .unwrap(); + handle.open_flags = lx::O_RDWR as u32 | lx::O_NOFOLLOW as u32; + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_state_records_only_policies_that_restrict_writes() { + let share = Share::new(); + // A deny-only attachment keeps the earlier schema, so that earlier + // releases, which enforce denied paths, can restore it. + let deny_only = share.profile(&["logs"], &[], &[]); + let source = VirtioFs::new_microvm(&share.root, deny_only.clone()).unwrap(); + let mut state = source + .save_microvm_state(&deny_only, SessionState::default()) + .unwrap(); + assert_eq!(state.schema_version, SCHEMA_VERSION); + assert!(state.denied_paths.is_empty() && state.allowed_paths.is_empty()); + validate_microvm_state(&state, &deny_only).unwrap(); + + // A state without a policy cannot restore an attachment with one, and an + // earlier schema cannot carry one. + let narrowed = share.profile(&[], &[], &["out"]); + assert!(validate_microvm_state(&state, &narrowed).is_err()); + state.writable_paths = owned(&["out"]); + assert!(validate_microvm_state(&state, &deny_only).is_err()); + assert!(validate_microvm_state(&state, &narrowed).is_err()); + + // A dormant slot carries no policy. + let mut dormant = + save_dormant_microvm_state(MICROVM_ATTACHMENT_ID, SessionState::default()).unwrap(); + validate_dormant_microvm_state(&dormant, MICROVM_ATTACHMENT_ID).unwrap(); + dormant.allowed_paths = owned(&["logs/payloads"]); + assert!(validate_dormant_microvm_state(&dormant, MICROVM_ATTACHMENT_ID).is_err()); +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs new file mode 100644 index 00000000000..c3313993c52 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -0,0 +1,643 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The fixed virtio-fs contract used by the microVM profile. + +use super::policy::SubtreePolicy; +#[cfg(windows)] +use anyhow::Context as _; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +/// Stable device-private attachment identifier of the first microVM share. +pub const MICROVM_ATTACHMENT_ID: &str = "fs:microvm0"; + +/// The mount tag of the first microVM share. +pub const MICROVM_MOUNT_TAG: &str = "microvm"; + +/// The attachment identifiers and mount tags of the fixed microVM virtio-fs +/// slots, in slot order. These are the only identities that the microVM ABI +/// accepts. +pub const MICROVM_SLOTS: [(&str, &str); 2] = [ + (MICROVM_ATTACHMENT_ID, MICROVM_MOUNT_TAG), + ("fs:microvm1", "microvm1"), +]; + +/// Returns the mount tag of the fixed microVM slot identified by +/// `attachment_id`, or `None` when no slot has that identifier. +pub fn microvm_mount_tag(attachment_id: &str) -> Option<&'static str> { + MICROVM_SLOTS + .iter() + .find(|(id, _)| *id == attachment_id) + .map(|(_, tag)| *tag) +} + +/// The number of FUSE request queues in the microVM ABI. +pub const MICROVM_REQUEST_QUEUES: u32 = 1; + +/// The minimum FUSE minor version with which the microVM protocol contract is +/// compatible. +pub const MICROVM_FUSE_MIN_MINOR: u32 = 31; + +/// The FUSE major version used by the microVM protocol contract. +pub const MICROVM_FUSE_MAJOR: u32 = 7; + +/// The largest FUSE request payload negotiated by the microVM profile. +pub const MICROVM_FUSE_MAX_WRITE: u32 = 1024 * 1024; + +const MAX_ROOT_IDENTITY_SIZE: usize = 4096; + +/// Fixed portions of the FUSE negotiation contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MicroVmFuseNegotiationPolicy; + +impl MicroVmFuseNegotiationPolicy { + /// Returns the required FUSE major protocol version. + pub const fn protocol_major(self) -> u32 { + MICROVM_FUSE_MAJOR + } + + /// Returns the oldest compatible FUSE minor protocol version. + pub const fn minimum_minor(self) -> u32 { + MICROVM_FUSE_MIN_MINOR + } + + /// Returns the exact maximum FUSE write size. + pub const fn maximum_write(self) -> u32 { + MICROVM_FUSE_MAX_WRITE + } + + /// Returns whether `READDIRPLUS` and `READDIRPLUS_AUTO` are requested + /// whenever the guest advertises them. + pub const fn requests_readdirplus(self) -> bool { + true + } + + /// Returns whether direct-I/O shared mappings are requested whenever the + /// guest advertises the extended flag. + pub const fn requests_direct_io_allow_mmap(self) -> bool { + true + } +} + +/// Access policy enforced by the host filesystem implementation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MicroVmAccessMode { + /// Reject mutations before they reach the host filesystem. + ReadOnly, + /// Permit the common, host-supported mutation set. + ReadWrite, +} + +/// Host identity that performs the guest's filesystem operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MicroVmOwnerMode { + /// Run every operation as the VMM process, which then owns the files that + /// the guest creates. + Vmm, + /// Run each operation as the UID and GID of its guest caller, without + /// supplementary groups or capabilities. Guest UID 0 and GID 0 are replaced + /// by the owner of the export root, which therefore must not be UID 0 or + /// GID 0. Requires a Linux host. + Caller, +} + +/// Errors in the fixed microVM virtio-fs attachment contract. +#[derive(Debug, thiserror::Error)] +pub enum MicroVmProfileError { + /// The resource did not identify a fixed microVM filesystem slot. + #[error("microVM virtio-fs stable ID is not a fixed slot")] + InvalidStableId, + /// The identity did not use the documented bounded attachment format. + #[error("microVM virtio-fs root identity is empty or exceeds {MAX_ROOT_IDENTITY_SIZE} bytes")] + InvalidRootIdentity, + /// The supplied host attachment does not match the resource identity. + #[error("microVM virtio-fs host root identity does not match the attachment")] + RootIdentityMismatch, + /// The denied-path list was not canonical. + #[error("microVM virtio-fs denied paths are invalid")] + InvalidDeniedPaths, + /// The allowed-path list was not canonical, or an allowed path was not + /// inside a denied path. + #[error("microVM virtio-fs allowed paths are invalid")] + InvalidAllowedPaths, + /// The writable-path list was not canonical, overlapped, was hidden, or + /// belonged to a read-only share. + #[error("microVM virtio-fs writable paths are invalid")] + InvalidWritablePaths, + /// Caller ownership needs per-thread filesystem credentials, which only + /// Linux provides. + #[error("microVM virtio-fs caller ownership requires a Linux host")] + UnsupportedOwnerMode, +} + +/// Immutable profile settings for the microVM virtio-fs device. +/// +/// The profile intentionally contains no host path. The host path is a +/// process-local attachment supplied when constructing or restoring the +/// filesystem and is never part of device-private saved state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MicroVmVirtioFsProfile { + stable_id: String, + mount_tag: &'static str, + root_identity: Vec, + access_mode: MicroVmAccessMode, + policy: SubtreePolicy, + owner_mode: MicroVmOwnerMode, +} + +impl MicroVmVirtioFsProfile { + /// Builds the constrained microVM profile from the resource attachment + /// fields. `stable_id`, `root_identity`, and `read_only` correspond + /// exactly to `VirtioFsProfile::Microvm`; `stable_id` selects the fixed + /// slot and therefore the mount tag. The guest's operations run as the + /// VMM until [`Self::with_owner_mode`] selects otherwise. + pub fn from_attachment( + stable_id: String, + root_identity: Vec, + read_only: bool, + denied_paths: Vec, + ) -> Result { + Self::from_attachment_with_policy( + stable_id, + root_identity, + read_only, + denied_paths, + Vec::new(), + Vec::new(), + ) + } + + /// Builds the profile like [`Self::from_attachment`], with the complete + /// access policy of the share: `allowed_paths` exposes subtrees of + /// `denied_paths` again, and `writable_paths`, when not empty, are the only + /// subtrees of a read-write share that the guest can modify. Each list + /// holds unique, canonical share-relative paths in lexical order. + pub fn from_attachment_with_policy( + stable_id: String, + root_identity: Vec, + read_only: bool, + denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, + ) -> Result { + let mount_tag = + microvm_mount_tag(&stable_id).ok_or(MicroVmProfileError::InvalidStableId)?; + if root_identity.is_empty() || root_identity.len() > MAX_ROOT_IDENTITY_SIZE { + return Err(MicroVmProfileError::InvalidRootIdentity); + } + let policy = SubtreePolicy::new( + Self::parse_policy_paths(denied_paths, MicroVmProfileError::InvalidDeniedPaths)?, + Self::parse_policy_paths(allowed_paths, MicroVmProfileError::InvalidAllowedPaths)?, + Self::parse_policy_paths(writable_paths, MicroVmProfileError::InvalidWritablePaths)?, + read_only, + )?; + Ok(Self { + stable_id, + mount_tag, + root_identity, + access_mode: if read_only { + MicroVmAccessMode::ReadOnly + } else { + MicroVmAccessMode::ReadWrite + }, + policy, + owner_mode: MicroVmOwnerMode::Vmm, + }) + } + + /// Selects the host identity that performs the guest's filesystem + /// operations. + pub fn with_owner_mode( + mut self, + owner_mode: MicroVmOwnerMode, + ) -> Result { + if owner_mode == MicroVmOwnerMode::Caller && !cfg!(target_os = "linux") { + return Err(MicroVmProfileError::UnsupportedOwnerMode); + } + self.owner_mode = owner_mode; + Ok(self) + } + + /// Returns the stable attachment ID used to match a restore attachment. + pub fn attachment_id(&self) -> &str { + &self.stable_id + } + + /// Returns the opaque, platform-specific root identity from the resource + /// attachment. + pub fn root_identity(&self) -> &[u8] { + &self.root_identity + } + + /// Validates that `root_path` is the host attachment identified by this + /// profile. + pub fn validate_root_path( + &self, + root_path: impl AsRef, + ) -> Result<(), MicroVmProfileError> { + match microvm_root_identity(root_path) { + Ok(identity) if identity == self.root_identity => Ok(()), + Ok(_) | Err(_) => Err(MicroVmProfileError::RootIdentityMismatch), + } + } + + /// Validates the root object reached through an already-opened volume and + /// confirms that the attachment path still names the configured root. + pub(crate) fn validate_opened_root( + &self, + root_path: impl AsRef, + stat: &lx::Stat, + ) -> Result<(), MicroVmProfileError> { + #[cfg(unix)] + { + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&stat.device_nr.to_le_bytes()); + identity.extend_from_slice(&stat.inode_nr.to_le_bytes()); + if identity != self.root_identity { + return Err(MicroVmProfileError::RootIdentityMismatch); + } + } + + #[cfg(windows)] + { + const PREFIX: &[u8] = b"openvmm-microvm-fs-windows-v1\0"; + let identity = self + .root_identity + .strip_prefix(PREFIX) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let length = identity + .get(..size_of::()) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let volume_bytes = usize::try_from(length) + .ok() + .and_then(|length| length.checked_mul(size_of::())) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let file_id = identity + .get(size_of::() + volume_bytes..) + .and_then(|bytes| bytes.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + if file_id != stat.inode_nr { + return Err(MicroVmProfileError::RootIdentityMismatch); + } + } + + self.validate_root_path(root_path) + } + + /// Returns the fixed FUSE mount tag of the profile's slot. + pub const fn mount_tag(&self) -> &'static str { + self.mount_tag + } + + /// Returns the fixed request queue count. + pub const fn request_queues(&self) -> u32 { + MICROVM_REQUEST_QUEUES + } + + /// Returns the host-enforced access policy. + pub const fn access_mode(&self) -> MicroVmAccessMode { + self.access_mode + } + + /// Returns whether host mutations must be rejected. + pub const fn is_readonly(&self) -> bool { + matches!(self.access_mode, MicroVmAccessMode::ReadOnly) + } + + /// Returns the host identity that performs the guest's operations. + pub const fn owner_mode(&self) -> MicroVmOwnerMode { + self.owner_mode + } + + /// Returns canonical host-relative paths hidden from the guest. + pub fn denied_paths(&self) -> &[PathBuf] { + self.policy.denied_paths() + } + + /// Returns canonical host-relative paths that are exposed again inside + /// denied paths. + pub fn allowed_paths(&self) -> &[PathBuf] { + self.policy.allowed_paths() + } + + /// Returns the canonical host-relative paths that are the only writable + /// parts of a read-write share, or nothing when the whole share is + /// writable. + pub fn writable_paths(&self) -> &[PathBuf] { + self.policy.writable_paths() + } + + /// Returns the share's access policy. + pub(crate) fn subtree_policy(&self) -> &SubtreePolicy { + &self.policy + } + + /// Returns the entry-cache lifetime required by the ABI. + pub const fn entry_cache_timeout(&self) -> Duration { + Duration::ZERO + } + + /// Parses unique, canonical share-relative paths in lexical order, which is + /// the order that the snapshot contract records. + fn parse_policy_paths( + paths: Vec, + error: MicroVmProfileError, + ) -> Result, MicroVmProfileError> { + if paths.len() > 128 || paths.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(error); + } + let mut parsed = Vec::with_capacity(paths.len()); + for path in paths { + if path.is_empty() + || path.len() > 4096 + || path.starts_with('/') + || path.ends_with('/') + || path.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | ':') + }) + { + return Err(error); + } + let mut relative = PathBuf::new(); + for component in path.split('/') { + if component.is_empty() || matches!(component, "." | "..") { + return Err(error); + } + relative.push(component); + } + parsed.push(relative); + } + Ok(parsed) + } + + /// Returns the attribute-cache lifetime required by the ABI. + pub const fn attribute_cache_timeout(&self) -> Duration { + Duration::ZERO + } + + /// Returns whether direct I/O is required for every opened file. + pub const fn direct_io(&self) -> bool { + true + } + + /// Returns whether the ABI exposes a DAX/shared-memory region. + pub const fn has_shared_memory(&self) -> bool { + false + } + + /// Returns whether the ABI permits packed virtqueues. + pub const fn packed_rings(&self) -> bool { + false + } + + /// Returns the fixed FUSE negotiation policy. + pub const fn fuse_negotiation(&self) -> MicroVmFuseNegotiationPolicy { + MicroVmFuseNegotiationPolicy + } +} + +/// Computes the portable root identity expected in +/// `VirtioFsProfile::Microvm::root_identity`. +pub fn microvm_root_identity(root_path: impl AsRef) -> anyhow::Result> { + #[expect( + clippy::disallowed_methods, + reason = "the attachment identity must resolve the host root's final target" + )] + let canonical = std::fs::canonicalize(root_path)?; + let metadata = std::fs::symlink_metadata(&canonical)?; + anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "microVM filesystem root is not a plain directory" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&metadata.dev().to_le_bytes()); + identity.extend_from_slice(&metadata.ino().to_le_bytes()); + Ok(identity) + } + + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use std::path::Component; + + let stat = pal::windows::fs::query_stat_lx_by_name(&canonical) + .context("failed to query the microVM filesystem root identity")?; + anyhow::ensure!( + stat.FileId != 0, + "microVM filesystem root has no stable file identity" + ); + let volume = canonical + .components() + .next() + .and_then(|component| match component { + Component::Prefix(prefix) => Some(prefix.as_os_str()), + _ => None, + }) + .context("microVM filesystem root has no volume prefix")?; + let volume = volume.encode_wide().collect::>(); + let volume_length = u32::try_from(volume.len()) + .context("microVM filesystem volume identity is too long")?; + let mut identity = b"openvmm-microvm-fs-windows-v1\0".to_vec(); + identity.extend_from_slice(&volume_length.to_le_bytes()); + identity.extend(volume.into_iter().flat_map(u16::to_le_bytes)); + identity.extend_from_slice(&stat.FileId.to_le_bytes()); + Ok(identity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_is_the_fixed_microvm_abi() { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + root_identity, + true, + vec!["secrets".to_owned()], + ) + .unwrap(); + assert_eq!(profile.attachment_id(), "fs:microvm0"); + assert_eq!(profile.mount_tag(), "microvm"); + assert_eq!(profile.request_queues(), 1); + assert!(profile.is_readonly()); + assert_eq!(profile.denied_paths(), &[PathBuf::from("secrets")]); + assert_eq!(profile.entry_cache_timeout(), Duration::ZERO); + assert_eq!(profile.attribute_cache_timeout(), Duration::ZERO); + assert!(profile.direct_io()); + assert!(!profile.has_shared_memory()); + assert!(!profile.packed_rings()); + assert_eq!(profile.fuse_negotiation().protocol_major(), 7); + assert_eq!(profile.fuse_negotiation().minimum_minor(), 31); + assert_eq!(profile.fuse_negotiation().maximum_write(), 1024 * 1024); + assert_eq!(profile.owner_mode(), MicroVmOwnerMode::Vmm); + profile + .validate_root_path(temporary_directory.path()) + .unwrap(); + } + + #[test] + fn profile_selects_the_mount_tag_of_its_slot() { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + for (stable_id, tag) in MICROVM_SLOTS { + let profile = MicroVmVirtioFsProfile::from_attachment( + stable_id.to_owned(), + root_identity.clone(), + false, + Vec::new(), + ) + .unwrap(); + assert_eq!(profile.attachment_id(), stable_id); + assert_eq!(profile.mount_tag(), tag); + assert_eq!(microvm_mount_tag(stable_id), Some(tag)); + } + assert_eq!(MICROVM_SLOTS[1], ("fs:microvm1", "microvm1")); + for stable_id in ["fs:microvm2", "microvm1", ""] { + assert_eq!(microvm_mount_tag(stable_id), None); + assert!(matches!( + MicroVmVirtioFsProfile::from_attachment( + stable_id.to_owned(), + root_identity.clone(), + false, + Vec::new(), + ), + Err(MicroVmProfileError::InvalidStableId) + )); + } + } + + #[test] + fn profile_selects_caller_ownership_only_on_linux() { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + false, + Vec::new(), + ) + .unwrap(); + let vmm = profile.clone().with_owner_mode(MicroVmOwnerMode::Vmm); + assert_eq!(vmm.unwrap(), profile); + + let caller = profile.clone().with_owner_mode(MicroVmOwnerMode::Caller); + if cfg!(target_os = "linux") { + let caller = caller.unwrap(); + assert_eq!(caller.owner_mode(), MicroVmOwnerMode::Caller); + assert_ne!(caller, profile); + } else { + assert!(matches!( + caller, + Err(MicroVmProfileError::UnsupportedOwnerMode) + )); + } + } + + #[test] + fn profile_rejects_an_attachment_identity_mismatch() { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + b"not-a-root-identity".to_vec(), + false, + Vec::new(), + ) + .unwrap(); + + assert!( + profile + .validate_root_path(temporary_directory.path()) + .is_err() + ); + } + + #[test] + fn profile_rejects_noncanonical_or_overlapping_denied_paths() { + let root = tempfile::tempdir().unwrap(); + let identity = microvm_root_identity(root.path()).unwrap(); + for denied_paths in [ + vec!["nested/path".to_owned(), "alpha".to_owned()], + vec!["secrets".to_owned(), "secrets/nested".to_owned()], + vec!["../outside".to_owned()], + vec!["alternate:name".to_owned()], + ] { + assert!( + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + identity.clone(), + true, + denied_paths, + ) + .is_err() + ); + } + } + + #[test] + fn profile_carries_a_canonical_subtree_policy() { + let root = tempfile::tempdir().unwrap(); + let identity = microvm_root_identity(root.path()).unwrap(); + fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() + } + let with_policy = + |read_only: bool, denied: &[&str], allowed: &[&str], writable: &[&str]| { + MicroVmVirtioFsProfile::from_attachment_with_policy( + MICROVM_ATTACHMENT_ID.to_owned(), + identity.clone(), + read_only, + owned(denied), + owned(allowed), + owned(writable), + ) + }; + + // Paths are in the lexical order of the snapshot contract, in which a + // sibling such as `logs-old` sorts before `logs/...`. + let profile = with_policy( + false, + &["logs", "logs-old"], + &["logs/payloads"], + &["out", "out-cache"], + ) + .unwrap(); + assert_eq!( + profile.denied_paths(), + [PathBuf::from("logs"), PathBuf::from("logs-old")] + ); + assert_eq!( + profile.allowed_paths(), + [["logs", "payloads"].iter().collect::()] + ); + assert_eq!( + profile.writable_paths(), + [PathBuf::from("out"), PathBuf::from("out-cache")] + ); + assert!(profile.subtree_policy().restricts_writes()); + + for (read_only, denied, allowed, writable) in [ + (false, vec!["logs"], vec!["payloads"], vec![]), + (false, vec!["logs"], vec!["logs/b", "logs/a"], vec![]), + (false, vec!["logs"], vec!["logs/../escape"], vec![]), + (true, vec![], vec![], vec!["out"]), + (false, vec![], vec![], vec!["out", "out"]), + (false, vec![], vec![], vec!["out/"]), + (false, vec!["logs"], vec![], vec!["logs/out"]), + ] { + assert!(with_policy(read_only, &denied, &allowed, &writable).is_err()); + } + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs new file mode 100644 index 00000000000..0ff9647fc2d --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM resource-profile resolution. + +use super::profile::MicroVmOwnerMode; +use super::profile::microvm_mount_tag; +use crate::virtio::VirtioFsDevice; +use virtio_resources::fs::VirtioFsBackend; +use virtio_resources::fs::VirtioFsHandle; +use virtio_resources::fs::microvm::VirtioFsProfile; +use vmcore::vm_task::VmTaskDriverSource; + +/// Rejects a resource whose tag is not the fixed tag of its slot. +fn validate_tag(resource: &VirtioFsHandle, stable_id: &str) -> anyhow::Result<()> { + let tag = microvm_mount_tag(stable_id).ok_or_else(|| { + anyhow::anyhow!("microVM virtio-fs attachment ID '{stable_id}' is not a fixed slot") + })?; + anyhow::ensure!( + resource.tag == tag, + "microVM virtio-fs tag for '{stable_id}' must be '{tag}'" + ); + Ok(()) +} + +pub(crate) fn resolve( + resource: &VirtioFsHandle, + driver_source: &VmTaskDriverSource, +) -> anyhow::Result> { + let device = match &resource.profile { + VirtioFsProfile::Standard => return Ok(None), + VirtioFsProfile::MicrovmDormant { stable_id } => { + validate_tag(resource, stable_id)?; + anyhow::ensure!( + matches!(resource.fs, VirtioFsBackend::Dormant), + "dormant microVM virtio-fs cannot have an active backend" + ); + VirtioFsDevice::new_microvm_dormant(driver_source, stable_id.clone(), None)? + } + VirtioFsProfile::Microvm { + stable_id, + root_identity, + read_only, + denied_paths, + allowed_paths, + writable_paths, + caller_identity, + } => { + validate_tag(resource, stable_id)?; + let VirtioFsBackend::HostFs { + root_path, + mount_options, + } = &resource.fs + else { + anyhow::bail!("microVM virtio-fs requires a HostFs backend"); + }; + anyhow::ensure!( + mount_options.is_empty(), + "microVM virtio-fs does not accept HostFs mount options" + ); + VirtioFsDevice::new_microvm_hostfs( + driver_source, + stable_id.clone(), + root_identity.clone(), + *read_only, + denied_paths.clone(), + allowed_paths.clone(), + writable_paths.clone(), + if *caller_identity { + MicroVmOwnerMode::Caller + } else { + MicroVmOwnerMode::Vmm + }, + root_path, + None, + )? + } + }; + Ok(Some(device)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::MICROVM_MOUNT_TAG; + use crate::profile::microvm_root_identity; + use crate::resolver::VirtioFsResolver; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virtio::resolve::ResolvedVirtioDevice; + use virtio::resolve::VirtioResolveInput; + use virtio_resources::fs::VirtioFsAggregateChild; + use vm_resource::ResolveResource; + use vmcore::vm_task::SingleDriverBackend; + + fn resolve( + driver: DefaultDriver, + tag: &str, + fs: VirtioFsBackend, + ) -> anyhow::Result { + resolve_slot(driver, MICROVM_ATTACHMENT_ID, tag, fs) + } + + fn resolve_slot( + driver: DefaultDriver, + stable_id: &str, + tag: &str, + fs: VirtioFsBackend, + ) -> anyhow::Result { + let root_path = match &fs { + VirtioFsBackend::HostFs { root_path, .. } => Some(root_path), + _ => None, + }; + let root_identity = root_path + .map(microvm_root_identity) + .transpose()? + .unwrap_or_else(|| vec![1]); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + VirtioFsResolver.resolve( + VirtioFsHandle { + tag: tag.to_owned(), + fs, + profile: VirtioFsProfile::Microvm { + stable_id: stable_id.to_owned(), + root_identity, + read_only: true, + denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), + caller_identity: false, + }, + }, + VirtioResolveInput { + driver_source: &driver_source, + }, + ) + } + + fn host_fs(root: &tempfile::TempDir) -> VirtioFsBackend { + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: String::new(), + } + } + + #[async_test] + async fn microvm_profile_requires_the_tag_of_its_slot(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + resolve_slot(driver.clone(), "fs:microvm1", "microvm1", host_fs(&root)).unwrap(); + for (stable_id, tag) in [ + ("fs:microvm1", MICROVM_MOUNT_TAG), + (MICROVM_ATTACHMENT_ID, "microvm1"), + ("fs:microvm2", "microvm2"), + ] { + assert!(resolve_slot(driver.clone(), stable_id, tag, host_fs(&root)).is_err()); + } + + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let dormant = |stable_id: &str, tag: &str| { + VirtioFsResolver.resolve( + VirtioFsHandle { + tag: tag.to_owned(), + fs: VirtioFsBackend::Dormant, + profile: VirtioFsProfile::MicrovmDormant { + stable_id: stable_id.to_owned(), + }, + }, + VirtioResolveInput { + driver_source: &driver_source, + }, + ) + }; + dormant("fs:microvm1", "microvm1").unwrap(); + assert!(dormant("fs:microvm1", MICROVM_MOUNT_TAG).is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_non_fixed_tag(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + let result = resolve( + driver, + "other", + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: String::new(), + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_mount_options(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: "ro".to_owned(), + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_non_host_backends(driver: DefaultDriver) { + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::Aggregate { + children: vec![VirtioFsAggregateChild { + name: "child".to_owned(), + root_path: ".".to_owned(), + mount_options: String::new(), + }], + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_section_backend(driver: DefaultDriver) { + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::SectionFs { + root_path: ".".to_owned(), + }, + ); + assert!(result.is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs new file mode 100644 index 00000000000..979b01c96b9 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Stable, device-private microVM virtio-fs state. +//! +//! These are deliberately separate from the runtime objects. In particular, +//! they contain relative names and object identities, never `LxFile`s, OS +//! handles, or host attachment paths. + +use mesh::payload::Protobuf; +use vmcore::save_restore::SavedStateRoot; + +pub(crate) const PREVIOUS_SCHEMA_VERSION: u32 = 4; +pub(crate) const SCHEMA_VERSION: u32 = 5; +/// The schema version of an attachment that performs requests as their guest +/// callers. Readers that predate caller ownership ignore `caller_identity` +/// and would restore the attachment as the VMM, so they must reject its state. +pub(crate) const CALLER_IDENTITY_SCHEMA_VERSION: u32 = 6; +/// The schema version of an attachment whose access policy has allowed or +/// writable paths, which it records along with its denied paths. Readers that +/// predate these paths would restore the attachment without them, and so +/// could let the guest write everywhere in the share, so they must reject its +/// state. The state records the ownership mode in `caller_identity`. +pub(crate) const SUBTREE_POLICY_SCHEMA_VERSION: u32 = 7; +pub(crate) const MAX_INODES: usize = 4096; +pub(crate) const MAX_HANDLES: usize = 4096; +pub(crate) const MAX_PATH_BYTES: usize = 4096; +pub(crate) const MAX_ALIASES_PER_INODE: usize = 256; +pub(crate) const MAX_ALIASES: usize = 16 * 1024; +pub(crate) const MAX_ALIAS_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_DIRECTORY_ENTRIES_PER_HANDLE: usize = 4096; +pub(crate) const MAX_DIRECTORY_ENTRY_NAME_BYTES: usize = 255; +pub(crate) const MAX_DIRECTORY_SNAPSHOT_BYTES: usize = 64 * 1024; +pub(crate) const MAX_DIRECTORY_ENTRIES: usize = 16 * 1024; +pub(crate) const MAX_DIRECTORY_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub attachment_id: String, + #[mesh(3)] + pub access_mode: u32, + #[mesh(4)] + pub request_queues: u32, + #[mesh(5)] + pub shared_memory_size: u64, + #[mesh(6)] + pub packed_rings: bool, + #[mesh(7)] + pub direct_io: bool, + #[mesh(8)] + pub entry_cache_timeout_ns: u64, + #[mesh(9)] + pub attribute_cache_timeout_ns: u64, + #[mesh(10)] + pub negotiation: SavedNegotiation, + #[mesh(11)] + pub next_node_id: u64, + #[mesh(12)] + pub next_handle_id: u64, + #[mesh(13)] + pub inodes: Vec, + #[mesh(14)] + pub handles: Vec, + #[mesh(15)] + pub attachment_root_identity: Vec, + #[mesh(16)] + pub maximum_request_size: u32, + #[mesh(17)] + pub dormant: bool, + /// Whether requests run as their guest callers, which version + /// [`CALLER_IDENTITY_SCHEMA_VERSION`] states require. States that predate + /// this field ran every request as the VMM. + #[mesh(18)] + pub caller_identity: bool, + /// The canonical share-relative paths that the attachment hides, recorded + /// only by version [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(19)] + pub denied_paths: Vec, + /// The canonical share-relative paths that the attachment exposes again + /// inside denied paths, recorded only by version + /// [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(20)] + pub allowed_paths: Vec, + /// The canonical share-relative paths that are the only writable parts of + /// the attachment, recorded only by version + /// [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(21)] + pub writable_paths: Vec, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedNegotiation { + #[mesh(1)] + pub initialized: bool, + #[mesh(2)] + pub major: u32, + #[mesh(3)] + pub minor: u32, + #[mesh(4)] + pub capable: u32, + #[mesh(5)] + pub capable2: u32, + #[mesh(6)] + pub want: u32, + #[mesh(7)] + pub want2: u32, + #[mesh(8)] + pub max_readahead: u32, + #[mesh(9)] + pub max_write: u32, + #[mesh(10)] + pub max_background: u32, + #[mesh(11)] + pub congestion_threshold: u32, + #[mesh(12)] + pub time_gran: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedObjectIdentity { + #[mesh(1)] + pub device_id: u64, + #[mesh(2)] + pub inode_id: u64, + #[mesh(3)] + pub kind: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedInode { + #[mesh(1)] + pub node_id: u64, + #[mesh(2)] + pub volume_id: u32, + #[mesh(3)] + pub relative_aliases: Vec>, + #[mesh(4)] + pub lookup_count: u64, + #[mesh(5)] + pub guest_inode_id: u64, + #[mesh(6)] + pub object_identity: SavedObjectIdentity, +} + +#[derive(Clone, Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedDirectoryEntry { + #[mesh(1)] + pub name: Vec, + #[mesh(2)] + pub next_cookie: u64, + #[mesh(3)] + pub guest_inode_id: u64, + #[mesh(4)] + pub kind: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedHandle { + #[mesh(1)] + pub handle_id: u64, + #[mesh(2)] + pub node_id: u64, + #[mesh(3)] + pub open_flags: u32, + #[mesh(4)] + pub kind: u32, + #[mesh(5)] + pub object_identity: SavedObjectIdentity, + #[mesh(6)] + pub directory_entries: Vec, + #[mesh(7)] + pub directory_snapshot_built: bool, +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs new file mode 100644 index 00000000000..19187f2fb97 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -0,0 +1,696 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM virtio-fs state encoding and validation. + +use super::MAX_FUSE_REQUEST_BYTES; +use super::profile::MICROVM_FUSE_MAJOR; +use super::profile::MICROVM_FUSE_MAX_WRITE; +use super::profile::MICROVM_FUSE_MIN_MINOR; +use super::profile::MICROVM_REQUEST_QUEUES; +use super::profile::MicroVmAccessMode; +use super::profile::MicroVmOwnerMode; +use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_DIRECTORY_BYTES; +use super::saved_state::MAX_DIRECTORY_ENTRIES; +use super::saved_state::MAX_DIRECTORY_ENTRIES_PER_HANDLE; +use super::saved_state::MAX_HANDLES; +use super::saved_state::MAX_INODES; +use super::saved_state::MAX_PATH_BYTES; +use super::saved_state::PREVIOUS_SCHEMA_VERSION; +use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; +use super::saved_state::SavedNegotiation; +use super::saved_state::SavedObjectIdentity; +use super::saved_state::SavedState; +use crate::FuseNegotiation; +use crate::file::VirtioFsFile; +use crate::inode::VirtioFsVolume; +use anyhow::Context; +use fuse::SessionInfoState; +use fuse::SessionState; +use fuse::protocol::*; +use std::collections::HashSet; +use std::ffi::OsString; +use std::path::Path; +use std::path::PathBuf; + +const MICROVM_SESSION_DEFAULT_WANT: u32 = FUSE_ASYNC_READ + | FUSE_PARALLEL_DIROPS + | FUSE_AUTO_INVAL_DATA + | FUSE_HANDLE_KILLPRIV + | FUSE_ASYNC_DIO + | FUSE_ATOMIC_O_TRUNC + | FUSE_BIG_WRITES + | FUSE_MAX_PAGES + | FUSE_INIT_EXT; + +fn saved_access_mode(access_mode: MicroVmAccessMode) -> u32 { + match access_mode { + MicroVmAccessMode::ReadOnly => 1, + MicroVmAccessMode::ReadWrite => 2, + } +} + +pub(crate) fn fuse_negotiation_from_session(state: SessionState) -> FuseNegotiation { + FuseNegotiation { + initialized: state.initialized, + major: state.info.major, + minor: state.info.minor, + capable: state.info.capable, + capable2: state.info.capable2, + want: state.info.want, + want2: state.info.want2, + max_readahead: state.info.max_readahead, + max_write: state.info.max_write, + max_background: state.info.max_background, + congestion_threshold: state.info.congestion_threshold, + time_gran: state.info.time_gran, + } +} + +pub(crate) fn saved_negotiation(state: SessionState) -> SavedNegotiation { + SavedNegotiation { + initialized: state.initialized, + major: state.info.major, + minor: state.info.minor, + capable: state.info.capable, + capable2: state.info.capable2, + want: state.info.want, + want2: state.info.want2, + max_readahead: state.info.max_readahead, + max_write: state.info.max_write, + max_background: state.info.max_background.into(), + congestion_threshold: state.info.congestion_threshold.into(), + time_gran: state.info.time_gran, + } +} + +pub(crate) fn session_state_from_saved( + negotiation: &SavedNegotiation, +) -> anyhow::Result { + let state = SessionState { + initialized: negotiation.initialized, + info: SessionInfoState { + major: negotiation.major, + minor: negotiation.minor, + max_readahead: negotiation.max_readahead, + capable: negotiation.capable, + capable2: negotiation.capable2, + want: negotiation.want, + want2: negotiation.want2, + max_background: negotiation + .max_background + .try_into() + .context("saved FUSE max_background is out of range")?, + congestion_threshold: negotiation + .congestion_threshold + .try_into() + .context("saved FUSE congestion_threshold is out of range")?, + max_write: negotiation.max_write, + time_gran: negotiation.time_gran, + }, + }; + state + .validate() + .map_err(anyhow::Error::from) + .context("saved FUSE session state is invalid")?; + Ok(state) +} + +pub(crate) fn saved_identity(stat: &lx::Stat) -> SavedObjectIdentity { + SavedObjectIdentity { + device_id: stat.device_nr, + inode_id: stat.inode_nr, + kind: stat.mode & lx::S_IFMT, + } +} + +/// Returns the canonical `/`-separated spelling of share-relative policy +/// paths, as the attachment's resource and the snapshot contract spell them. +pub(crate) fn saved_policy_paths(paths: &[PathBuf]) -> Vec { + paths + .iter() + .map(|path| { + path.iter() + .map(|component| component.to_string_lossy()) + .collect::>() + .join("/") + }) + .collect() +} + +/// Returns the oldest schema version that can record the state of `profile`'s +/// attachment, so that releases that cannot enforce its policy reject it. +pub(crate) fn schema_version(profile: &MicroVmVirtioFsProfile) -> u32 { + if profile.subtree_policy().restricts_writes() { + SUBTREE_POLICY_SCHEMA_VERSION + } else if profile.owner_mode() == MicroVmOwnerMode::Caller { + CALLER_IDENTITY_SCHEMA_VERSION + } else { + SCHEMA_VERSION + } +} + +pub(crate) fn validate_identity( + stat: &lx::Stat, + saved: &SavedObjectIdentity, +) -> anyhow::Result<()> { + anyhow::ensure!( + saved.kind == stat.mode & lx::S_IFMT, + "object kind changed from {:#x} to {:#x}", + saved.kind, + stat.mode & lx::S_IFMT + ); + anyhow::ensure!( + saved.device_id == stat.device_nr && saved.inode_id == stat.inode_nr, + "object identity changed" + ); + Ok(()) +} + +pub(crate) fn validate_saved_identity( + actual: &SavedObjectIdentity, + expected: &SavedObjectIdentity, +) -> anyhow::Result<()> { + anyhow::ensure!( + actual.kind == expected.kind + && actual.device_id == expected.device_id + && actual.inode_id == expected.inode_id, + "object identity changed between aliases" + ); + Ok(()) +} + +pub(crate) fn validate_reopenable_alias( + volume: &VirtioFsVolume, + path: &Path, +) -> anyhow::Result { + validate_relative_path(path, true).map_err(anyhow::Error::from)?; + volume + .ensure_path_allowed(path) + .map_err(anyhow::Error::from)?; + // The object itself may be a symbolic link, which is reopened without + // being followed, but no ancestor may be one. + let component_count = path.components().count(); + let mut prefix = PathBuf::new(); + for (index, component) in path.components().enumerate() { + let std::path::Component::Normal(component) = component else { + anyhow::bail!("alias contains a non-normal path component"); + }; + prefix.push(component); + let stat = volume + .lstat(&prefix) + .map_err(anyhow::Error::from) + .context("alias component cannot be inspected")?; + anyhow::ensure!( + index + 1 == component_count || stat.mode & lx::S_IFMT != lx::S_IFLNK, + "alias crosses a symbolic-link component" + ); + } + volume + .lstat(path) + .map_err(anyhow::Error::from) + .context("alias cannot be inspected") +} + +pub(crate) fn save_dormant_microvm_state( + attachment_id: &str, + session_state: SessionState, +) -> anyhow::Result { + anyhow::ensure!( + session_state == SessionState::default(), + "dormant microVM virtio-fs has initialized FUSE state" + ); + Ok(SavedState { + schema_version: SCHEMA_VERSION, + attachment_id: attachment_id.to_owned(), + access_mode: 0, + request_queues: MICROVM_REQUEST_QUEUES, + shared_memory_size: 0, + packed_rings: false, + direct_io: true, + entry_cache_timeout_ns: 0, + attribute_cache_timeout_ns: 0, + negotiation: saved_negotiation(session_state), + next_node_id: 0, + next_handle_id: 0, + inodes: Vec::new(), + handles: Vec::new(), + attachment_root_identity: Vec::new(), + maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, + dormant: true, + caller_identity: false, + denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), + }) +} + +pub(crate) fn validate_dormant_microvm_state( + state: &SavedState, + attachment_id: &str, +) -> anyhow::Result { + anyhow::ensure!( + state.schema_version == SCHEMA_VERSION && state.dormant, + "saved virtio-fs state is not a supported dormant slot" + ); + anyhow::ensure!( + state.attachment_id == attachment_id, + "saved attachment ID does not match the microVM ABI" + ); + anyhow::ensure!( + state.access_mode == 0 + && state.request_queues == MICROVM_REQUEST_QUEUES + && state.shared_memory_size == 0 + && !state.packed_rings + && state.direct_io + && state.entry_cache_timeout_ns == 0 + && state.attribute_cache_timeout_ns == 0 + && state.maximum_request_size == MAX_FUSE_REQUEST_BYTES as u32 + && state.next_node_id == 0 + && state.next_handle_id == 0 + && state.inodes.is_empty() + && state.handles.is_empty() + && state.attachment_root_identity.is_empty() + && !state.caller_identity + && state.denied_paths.is_empty() + && state.allowed_paths.is_empty() + && state.writable_paths.is_empty(), + "dormant virtio-fs state contains active filesystem policy or objects" + ); + let session_state = session_state_from_saved(&state.negotiation)?; + anyhow::ensure!( + session_state == SessionState::default(), + "dormant microVM virtio-fs contains initialized FUSE state" + ); + Ok(session_state) +} + +pub(crate) fn validate_microvm_state( + state: &SavedState, + profile: &MicroVmVirtioFsProfile, +) -> anyhow::Result<()> { + anyhow::ensure!( + matches!( + state.schema_version, + PREVIOUS_SCHEMA_VERSION + | SCHEMA_VERSION + | CALLER_IDENTITY_SCHEMA_VERSION + | SUBTREE_POLICY_SCHEMA_VERSION + ) && !state.dormant, + "unsupported virtio-fs state schema version {}", + state.schema_version + ); + anyhow::ensure!( + match state.schema_version { + CALLER_IDENTITY_SCHEMA_VERSION => state.caller_identity, + SUBTREE_POLICY_SCHEMA_VERSION => true, + _ => !state.caller_identity, + }, + "saved ownership mode does not match the virtio-fs state schema version" + ); + anyhow::ensure!( + state.attachment_id == profile.attachment_id(), + "saved attachment ID does not match the microVM ABI" + ); + anyhow::ensure!( + state.attachment_root_identity == profile.root_identity(), + "saved root identity does not match the restore attachment" + ); + anyhow::ensure!( + state.access_mode == saved_access_mode(profile.access_mode()), + "saved access mode does not match the restore profile" + ); + anyhow::ensure!( + state.caller_identity == (profile.owner_mode() == MicroVmOwnerMode::Caller), + "saved ownership mode does not match the restore profile" + ); + let saved_policy = ( + &state.denied_paths, + &state.allowed_paths, + &state.writable_paths, + ); + if state.schema_version == SUBTREE_POLICY_SCHEMA_VERSION { + let policy = profile.subtree_policy(); + anyhow::ensure!( + policy.restricts_writes() + && saved_policy + == ( + &saved_policy_paths(policy.denied_paths()), + &saved_policy_paths(policy.allowed_paths()), + &saved_policy_paths(policy.writable_paths()), + ), + "saved access policy does not match the restore profile" + ); + } else { + // Earlier states record no access policy, and their attachments had + // neither allowed nor writable paths. + anyhow::ensure!( + !profile.subtree_policy().restricts_writes() + && state.denied_paths.is_empty() + && state.allowed_paths.is_empty() + && state.writable_paths.is_empty(), + "saved access policy does not match the restore profile" + ); + } + anyhow::ensure!( + state.request_queues == profile.request_queues() + && state.shared_memory_size == 0 + && !state.packed_rings + && state.direct_io + && state.entry_cache_timeout_ns == 0 + && state.attribute_cache_timeout_ns == 0 + && state.maximum_request_size == MAX_FUSE_REQUEST_BYTES as u32, + "saved device policy does not match the fixed microVM ABI" + ); + anyhow::ensure!( + state.inodes.len() <= MAX_INODES, + "saved inode table is too large" + ); + anyhow::ensure!( + state.handles.len() <= MAX_HANDLES, + "saved handle table is too large" + ); + anyhow::ensure!( + state.next_node_id != 0 && state.next_handle_id != 0, + "saved allocation state is invalid" + ); + + let session_state = session_state_from_saved(&state.negotiation)?; + if session_state.initialized { + let expected_want = (MICROVM_SESSION_DEFAULT_WANT & state.negotiation.capable) + | (state.negotiation.capable & (FUSE_DO_READDIRPLUS | FUSE_READDIRPLUS_AUTO)); + let expected_want2 = if expected_want & FUSE_INIT_EXT != 0 { + state.negotiation.capable2 & FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2 + } else { + 0 + }; + anyhow::ensure!( + state.negotiation.major == MICROVM_FUSE_MAJOR + && state.negotiation.minor >= MICROVM_FUSE_MIN_MINOR + && state.negotiation.minor <= FUSE_KERNEL_MINOR_VERSION + && state.negotiation.want == expected_want + && state.negotiation.want2 == expected_want2 + && state.negotiation.max_background == 0 + && state.negotiation.congestion_threshold == 0 + && state.negotiation.time_gran == 1 + && state.negotiation.max_write == MICROVM_FUSE_MAX_WRITE, + "saved FUSE negotiation is not reproducible by the microVM profile" + ); + } + + let mut root_count = 0; + let mut largest_node_id = 0; + let mut inode_ids = HashSet::with_capacity(state.inodes.len()); + let mut alias_paths = HashSet::new(); + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + // Every alias of each inode is writable, when the access policy restricts + // writes. + let mut writable_inodes = HashSet::new(); + let policy = profile.subtree_policy(); + for inode in &state.inodes { + anyhow::ensure!( + inode.node_id != 0 && inode_ids.insert(inode.node_id), + "saved inode IDs are invalid or duplicated" + ); + largest_node_id = largest_node_id.max(inode.node_id); + if inode.node_id == FUSE_ROOT_ID { + root_count += 1; + } + anyhow::ensure!( + inode.volume_id == 0, + "saved aggregate volumes are unsupported" + ); + anyhow::ensure!( + inode.lookup_count != 0, + "saved inode lookup count is invalid" + ); + anyhow::ensure!( + !inode.relative_aliases.is_empty() + && inode.relative_aliases.len() <= MAX_ALIASES_PER_INODE, + "saved inode has no bounded aliases" + ); + alias_count = alias_count + .checked_add(inode.relative_aliases.len()) + .context("saved alias count overflow")?; + anyhow::ensure!(alias_count <= MAX_ALIASES, "saved alias table is too large"); + let mut writable = true; + for alias in &inode.relative_aliases { + anyhow::ensure!( + alias.len() <= MAX_PATH_BYTES, + "saved relative alias is too long" + ); + alias_bytes = alias_bytes + .checked_add(alias.len()) + .context("saved alias bytes overflow")?; + anyhow::ensure!( + alias_bytes <= MAX_ALIAS_BYTES, + "saved aliases exceed the aggregate byte bound" + ); + let path = decode_relative_path(alias)?; + validate_relative_path(&path, true).map_err(anyhow::Error::from)?; + if inode.node_id == FUSE_ROOT_ID { + anyhow::ensure!( + path.as_os_str().is_empty(), + "root inode has a non-root alias" + ); + } else { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "non-root inode has a root alias" + ); + } + writable &= policy.is_writable(&path); + anyhow::ensure!( + alias_paths.insert(path), + "saved aliases are duplicated or ambiguous" + ); + } + if writable { + writable_inodes.insert(inode.node_id); + } + } + anyhow::ensure!(root_count == 1, "saved inode table must contain one root"); + anyhow::ensure!( + state.next_node_id > largest_node_id, + "saved next inode ID can reuse an active ID" + ); + + let mut largest_handle_id = 0; + let mut handle_ids = HashSet::with_capacity(state.handles.len()); + let mut directory_entry_count = 0usize; + let mut directory_bytes = 0usize; + for handle in &state.handles { + anyhow::ensure!( + handle.handle_id != 0 && handle_ids.insert(handle.handle_id), + "saved handle IDs are invalid or duplicated" + ); + largest_handle_id = largest_handle_id.max(handle.handle_id); + anyhow::ensure!( + inode_ids.contains(&handle.node_id), + "saved handle refers to an unknown inode" + ); + anyhow::ensure!( + handle.object_identity.kind == handle.kind && handle.kind != lx::S_IFLNK, + "saved handle has an unsupported object kind" + ); + let reopen_flags = reopen_flags(handle.open_flags)?; + let writable_handle = reopen_flags & lx::O_ACCESS_MASK as u32 != lx::O_RDONLY as u32; + if profile.is_readonly() { + anyhow::ensure!( + !writable_handle, + "read-only microVM profile cannot restore a writable handle" + ); + } + if policy.restricts_writes() { + anyhow::ensure!( + !writable_handle || writable_inodes.contains(&handle.node_id), + "microVM access policy cannot restore a writable handle outside its writable paths" + ); + } + anyhow::ensure!( + handle.directory_entries.len() <= MAX_DIRECTORY_ENTRIES_PER_HANDLE, + "saved directory enumeration is too large" + ); + anyhow::ensure!( + handle.directory_snapshot_built || handle.directory_entries.is_empty(), + "uninitialized directory snapshot retained entries" + ); + anyhow::ensure!( + !handle.directory_snapshot_built || handle.kind == lx::S_IFDIR, + "saved directory snapshot belongs to a non-directory handle" + ); + VirtioFsFile::validate_directory_entries(&handle.directory_entries) + .map_err(anyhow::Error::from)?; + directory_entry_count = directory_entry_count + .checked_add(handle.directory_entries.len()) + .context("saved directory entry count overflow")?; + anyhow::ensure!( + directory_entry_count <= MAX_DIRECTORY_ENTRIES, + "saved directory entries exceed the aggregate bound" + ); + for entry in &handle.directory_entries { + directory_bytes = directory_bytes + .checked_add(entry.name.len()) + .context("saved directory bytes overflow")?; + } + anyhow::ensure!( + directory_bytes <= MAX_DIRECTORY_BYTES, + "saved directory entries exceed the aggregate byte bound" + ); + } + anyhow::ensure!( + state.next_handle_id > largest_handle_id, + "saved next handle ID can reuse an active ID" + ); + + Ok(()) +} + +pub(crate) fn reopen_flags(saved: u32) -> anyhow::Result { + const O_NOCTTY: u32 = 0x100; + const O_NONBLOCK: u32 = 0x800; + const O_DSYNC: u32 = 0x1000; + const O_SYNC: u32 = 0x101000; + const O_LARGEFILE: u32 = 0x8000; + const O_CLOEXEC: u32 = 0x80000; + const O_TMPFILE: u32 = 0x410000; + + anyhow::ensure!( + saved & O_TMPFILE != O_TMPFILE, + "saved handle uses unsupported O_TMPFILE semantics" + ); + let allowed = lx::O_ACCESS_MASK as u32 + | lx::O_CREAT as u32 + | lx::O_EXCL as u32 + | lx::O_TRUNC as u32 + | lx::O_APPEND as u32 + | lx::O_DIRECTORY as u32 + | lx::O_NOFOLLOW as u32 + | lx::O_NOATIME as u32 + | O_NOCTTY + | O_NONBLOCK + | O_DSYNC + | O_SYNC + | O_LARGEFILE + | O_CLOEXEC; + anyhow::ensure!( + saved & !allowed == 0, + "saved handle has unsupported open flags {:#x}", + saved + ); + anyhow::ensure!( + saved & lx::O_ACCESS_MASK as u32 != lx::O_NOACCESS as u32, + "saved handle has invalid access mode" + ); + Ok(saved & !(lx::O_CREAT as u32 | lx::O_EXCL as u32 | lx::O_TRUNC as u32)) +} + +pub(crate) fn encode_relative_path(path: &Path) -> anyhow::Result> { + validate_relative_path(path, true).map_err(anyhow::Error::from)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + Ok(path.as_os_str().as_bytes().to_vec()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + Ok(bytes) + } +} + +pub(crate) fn relative_path_encoded_len(path: &Path) -> lx::Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + Ok(path.as_os_str().as_bytes().len()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str() + .encode_wide() + .count() + .checked_mul(size_of::()) + .ok_or(lx::Error::E2BIG) + } +} + +pub(crate) fn decode_relative_path(bytes: &[u8]) -> anyhow::Result { + anyhow::ensure!( + bytes.len() <= MAX_PATH_BYTES, + "saved relative alias is too long" + ); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + + anyhow::ensure!( + bytes.len().is_multiple_of(2), + "saved UTF-16 path is malformed" + ); + let units = bytes + .as_chunks::<2>() + .0 + .iter() + .copied() + .map(u16::from_le_bytes) + .collect::>(); + Ok(PathBuf::from(OsString::from_wide(&units))) + } +} + +pub(crate) fn validate_relative_path(path: &Path, strict: bool) -> lx::Result<()> { + for component in path.components() { + let std::path::Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + if component.is_empty() { + return Err(lx::Error::EINVAL); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + let component = component.as_bytes(); + if component.contains(&b'\0') + || (strict && (component.contains(&b'\\') || component.contains(&b':'))) + { + return Err(lx::Error::EINVAL); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + let units = component.encode_wide().collect::>(); + if units.contains(&0) + || (strict && (units.contains(&(b'\\' as u16)) || units.contains(&(b':' as u16)))) + { + return Err(lx::Error::EINVAL); + } + } + } + Ok(()) +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs new file mode 100644 index 00000000000..2a6709f42a9 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for symbolic links on microVM shares. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::SavedHandle; +use super::saved_state::SavedObjectIdentity; +use super::state::validate_microvm_state; +use crate::VirtioFs; +use fuse::Fuse; +use fuse::Request; +use fuse::Session; +use fuse::SessionState; +use fuse::protocol::FATTR_MODE; +use fuse::protocol::FATTR_SIZE; +use fuse::protocol::FUSE_READLINK; +use fuse::protocol::FUSE_ROOT_ID; +use fuse::protocol::fuse_in_header; +use fuse::protocol::fuse_setattr_in; +use lxutil::LxCreateOptions; +use lxutil::LxVolume; +use lxutil::LxVolumeOptions; +use std::path::Path; +use std::sync::Arc; +use tempfile::tempdir; +use test_with_tracing::test; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +fn profile(root_path: &Path, read_only: bool, denied_paths: &[&str]) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + read_only, + denied_paths.iter().map(|path| (*path).to_owned()).collect(), + ) + .unwrap() +} + +/// Builds a request header for `node_id`, as the guest kernel would send it. +fn request(node_id: u64) -> Request { + let header = fuse_in_header { + len: size_of::() as u32, + opcode: FUSE_READLINK, + unique: 1, + nodeid: node_id, + uid: 0, + gid: 0, + pid: 0, + padding: 0, + }; + Request::new(header.as_bytes()).unwrap() +} + +fn name(value: &[u8]) -> &lx::LxStr { + lx::LxStr::from_bytes(value) +} + +fn error(result: lx::Result) -> lx::Error { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error, + } +} + +/// Creates a link on the host the way the microVM volume does on every platform. +fn host_symlink(root: &Path, link: &str, target: &str) { + LxVolumeOptions::new() + .confine_paths(true) + .new_volume(root) + .unwrap() + .symlink(link, target, LxCreateOptions::new(0, 0, 0)) + .unwrap(); +} + +fn host_read_link(root: &Path, link: &str) -> Vec { + LxVolume::new(root) + .unwrap() + .read_link(link) + .unwrap() + .as_bytes() + .to_vec() +} + +fn host_exists(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() +} + +fn setattr(valid: u32, update: impl FnOnce(&mut fuse_setattr_in)) -> fuse_setattr_in { + let mut arg = fuse_setattr_in::new_zeroed(); + arg.valid = valid; + update(&mut arg); + arg +} + +/// A share and a sibling host directory that the guest must never reach. +struct Fixture { + _directory: tempfile::TempDir, + root: std::path::PathBuf, + outside: std::path::PathBuf, +} + +impl Fixture { + fn new() -> Self { + let directory = tempdir().unwrap(); + let root = directory.path().join("share"); + let outside = directory.path().join("outside"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("secret"), b"host secret").unwrap(); + Self { + _directory: directory, + root, + outside, + } + } + + fn outside_target(&self) -> Vec { + self.outside + .join("secret") + .to_str() + .unwrap() + .replace('\\', "/") + .into_bytes() + } + + fn assert_outside_unchanged(&self) { + let names = std::fs::read_dir(&self.outside) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, ["secret"]); + assert_eq!( + std::fs::read(self.outside.join("secret")).unwrap(), + b"host secret" + ); + } +} + +#[test] +fn rw_symlink_preserves_target_verbatim() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let targets: [&[u8]; 6] = [ + b"/absolute/host/path", + b"../../outside/secret", + b"dangling/relative", + b"with space", + b"\xff\xfe-not-utf8", + b"node_modules/.bin/../tool/bin/cli.js", + ]; + + for (index, target) in targets.iter().enumerate() { + let link = format!("link-{index}"); + let entry = fs + .symlink(&request(FUSE_ROOT_ID), name(link.as_bytes()), name(target)) + .unwrap(); + assert_eq!(entry.attr.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(entry.attr.size, target.len() as u64); + + let node = request(entry.nodeid); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), *target); + assert_eq!( + fs.get_attr(&node, 0, 0).unwrap().attr.mode & lx::S_IFMT, + lx::S_IFLNK + ); + assert_eq!(host_read_link(&fixture.root, &link), *target); + + let lookup = fs + .lookup(&request(FUSE_ROOT_ID), name(link.as_bytes())) + .unwrap(); + assert_eq!(lookup.nodeid, entry.nodeid); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn ro_symlink_is_rejected_with_erofs() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, true, &[])).unwrap(); + + assert_eq!( + error(fs.symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target"))), + lx::Error::EROFS + ); + assert!(!host_exists(&fixture.root.join("link"))); +} + +#[test] +fn symlink_target_bounds_match_linux() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + + assert_eq!( + error(fs.symlink(&root, name(b"empty"), name(b""))), + lx::Error::ENOENT + ); + assert_eq!( + error(fs.symlink(&root, name(b"long"), name(&[b'x'; 4096]))), + lx::Error::ENAMETOOLONG + ); + assert!(!host_exists(&fixture.root.join("empty"))); + assert!(!host_exists(&fixture.root.join("long"))); +} + +#[test] +fn symlink_respects_inode_limits_before_creating_the_link() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + fs.inner.inodes.write().inodes_by_node_id.next_handle = 0; + + assert_eq!( + error(fs.symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target"))), + lx::Error::ENOSPC + ); + assert!(!host_exists(&fixture.root.join("link"))); +} + +#[test] +fn guest_links_cannot_reach_a_denied_path() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("allowed")).unwrap(); + std::fs::create_dir(fixture.root.join("secrets")).unwrap(); + std::fs::write(fixture.root.join("secrets").join("token"), b"token").unwrap(); + let fs = + VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &["secrets"])).unwrap(); + let root = request(FUSE_ROOT_ID); + + // A link cannot take the place of the denied path. + std::fs::rename( + fixture.root.join("secrets"), + fixture.root.join("moved-secrets"), + ) + .unwrap(); + assert_eq!( + error(fs.symlink(&root, name(b"secrets"), name(b"allowed"))), + lx::Error::EACCES + ); + assert!(!host_exists(&fixture.root.join("secrets"))); + std::fs::rename( + fixture.root.join("moved-secrets"), + fixture.root.join("secrets"), + ) + .unwrap(); + + // A link may name the denied path, but the guest resolves it, and every + // lookup of the denied path is still refused. + let allowed = fs.lookup(&root, name(b"allowed")).unwrap(); + for (link, target) in [ + (b"alias".as_slice(), b"../secrets".as_slice()), + (b"token".as_slice(), b"../secrets/token".as_slice()), + ] { + let entry = fs + .symlink(&request(allowed.nodeid), name(link), name(target)) + .unwrap(); + assert_eq!( + fs.read_link(&request(entry.nodeid)).unwrap().as_bytes(), + target + ); + // Using the link as a directory never traverses it on the host. + assert_eq!( + error(fs.lookup(&request(entry.nodeid), name(b"token"))), + lx::Error::ELOOP + ); + } + assert_eq!(error(fs.lookup(&root, name(b"secrets"))), lx::Error::EACCES); + assert_eq!( + std::fs::read(fixture.root.join("secrets").join("token")).unwrap(), + b"token" + ); +} + +#[test] +fn symlink_inode_operations_do_not_follow() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let target = fixture.outside_target(); + let entry = fs.symlink(&root, name(b"escape"), name(&target)).unwrap(); + let node = request(entry.nodeid); + + assert_eq!(error(fs.open(&node, lx::O_RDWR as u32)), lx::Error::ELOOP); + assert_eq!(error(fs.lookup(&node, name(b"secret"))), lx::Error::ELOOP); + // Neither truncation nor a mode change may reach the target. Linux + // rejects both; Windows applies them to the inert link file itself. + let truncate = fs.set_attr(&node, &setattr(FATTR_SIZE, |arg| arg.size = 0)); + let chmod = fs.set_attr(&node, &setattr(FATTR_MODE, |arg| arg.mode = 0o600)); + if cfg!(unix) { + assert_eq!(error(truncate), lx::Error::ELOOP); + assert_eq!(error(chmod), lx::Error::ENOTSUP); + } + fs.statfs(&node).unwrap(); + assert_eq!( + fs.get_attr(&node, 0, 0).unwrap().attr.mode & lx::S_IFMT, + lx::S_IFLNK + ); + + // Renaming, hard-linking, and removing the link act on the link itself. + fs.rename(&root, name(b"escape"), FUSE_ROOT_ID, name(b"renamed"), 0) + .unwrap(); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), target); + let hard = fs.link(&root, name(b"hard"), entry.nodeid).unwrap(); + assert_eq!(hard.nodeid, entry.nodeid); + fs.unlink(&root, name(b"renamed")).unwrap(); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), target); + fs.unlink(&root, name(b"hard")).unwrap(); + assert!(!host_exists(&fixture.root.join("hard"))); + fixture.assert_outside_unchanged(); +} + +#[test] +fn renamed_nodes_keep_an_exact_path() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let file = fs.lookup(&root, name(b"file")).unwrap(); + let link = fs.symlink(&root, name(b"link"), name(b"file")).unwrap(); + + // The guest may use a node again before it looks the new name up. + fs.rename(&root, name(b"file"), FUSE_ROOT_ID, name(b"moved-file"), 0) + .unwrap(); + fs.rename(&root, name(b"link"), FUSE_ROOT_ID, name(b"moved-link"), 0) + .unwrap(); + for (node, path, kind) in [ + (file.nodeid, "moved-file", lx::S_IFREG), + (link.nodeid, "moved-link", lx::S_IFLNK), + ] { + // A trailing separator would make the host resolve the name as a + // directory and follow a link. + let inode = fs.get_inode(node).unwrap(); + assert_eq!(inode.clone_path().as_os_str(), path); + assert!( + inode + .aliases() + .iter() + .all(|alias| alias.as_os_str() == path) + ); + assert_eq!( + fs.get_attr(&request(node), 0, 0).unwrap().attr.mode & lx::S_IFMT, + kind + ); + } + assert_eq!( + fs.read_link(&request(link.nodeid)).unwrap().as_bytes(), + b"file" + ); +} + +#[test] +fn stale_node_replaced_by_a_link_cannot_reach_the_target() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("victim"), b"victim").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let victim = fs.lookup(&root, name(b"victim")).unwrap(); + + // The guest keeps the node of an unlinked file and puts a link at its + // former name. + fs.unlink(&root, name(b"victim")).unwrap(); + fs.symlink(&root, name(b"victim"), name(&fixture.outside_target())) + .unwrap(); + + let node = request(victim.nodeid); + let _ = fs.set_attr(&node, &setattr(FATTR_SIZE, |arg| arg.size = 0)); + let _ = fs.set_attr(&node, &setattr(FATTR_MODE, |arg| arg.mode = 0o777)); + let _ = fs.open(&node, lx::O_RDWR as u32); + fixture.assert_outside_unchanged(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(fixture.outside.join("secret")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o777, 0o777); + } +} + +#[cfg(unix)] +#[test] +fn replaced_directory_cannot_redirect_a_node_outside_the_share() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("secret"), b"inside").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let directory = fs.lookup(&request(FUSE_ROOT_ID), name(b"dir")).unwrap(); + let secret = fs + .lookup(&request(directory.nodeid), name(b"secret")) + .unwrap(); + + // A concurrent rename can swap the directory for a link to the host + // between any check and the host operation. + std::fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + std::os::unix::fs::symlink(&fixture.outside, fixture.root.join("dir")).unwrap(); + + assert_eq!( + error(fs.open(&request(secret.nodeid), lx::O_RDWR as u32)), + lx::Error::ELOOP + ); + // The volume itself refuses to traverse the link, so the outcome does not + // depend on the component check winning the race. + let volume = fs.get_inode(FUSE_ROOT_ID).unwrap().volume(); + let path = Path::new("dir/secret"); + assert_eq!(error(volume.open(path, lx::O_RDWR, None)), lx::Error::ELOOP); + let attr = lxutil::SetAttributes { + mode: Some(0o777), + ..Default::default() + }; + assert_eq!(error(volume.set_attr(path, attr)), lx::Error::ELOOP); + assert_eq!(error(volume.unlink(path, 0)), lx::Error::ELOOP); + fixture.assert_outside_unchanged(); +} + +#[test] +fn readdirplus_returns_usable_symlink_nodes() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + host_symlink(&fixture.root, "link", "file"); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, true, &[])).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let handle = fs + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + + let entries = fs + .get_file(handle) + .unwrap() + .read_dir(&fs, 0, 4096, true) + .unwrap(); + assert!(!entries.is_empty()); + let link = fs.lookup(&request(FUSE_ROOT_ID), name(b"link")).unwrap(); + assert_eq!(link.attr.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!( + fs.read_link(&request(link.nodeid)).unwrap().as_bytes(), + b"file" + ); +} + +#[test] +fn symlink_inode_survives_save_and_restore() { + let fixture = Fixture::new(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let entry = source + .symlink( + &request(FUSE_ROOT_ID), + name(b"link"), + name(b"../verbatim/target"), + ) + .unwrap(); + + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let saved = state + .inodes + .iter() + .find(|inode| inode.node_id == entry.nodeid) + .unwrap(); + assert_eq!(saved.object_identity.kind, lx::S_IFLNK); + validate_microvm_state(&state, &profile).unwrap(); + + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert_eq!( + destination + .read_link(&request(entry.nodeid)) + .unwrap() + .as_bytes(), + b"../verbatim/target" + ); +} + +#[test] +fn restore_rejects_a_replaced_symlink() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + source + .symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"file")) + .unwrap(); + let link_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let file_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + // Keep the original link alive so that its identity cannot be reused. + std::fs::rename(fixture.root.join("link"), fixture.root.join("saved")).unwrap(); + host_symlink(&fixture.root, "link", "file"); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, link_state, &session) + .is_err() + ); + + std::fs::remove_file(fixture.root.join("link")).unwrap(); + std::fs::write(fixture.root.join("link"), b"file").unwrap(); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, file_state, &session) + .is_err() + ); +} + +#[test] +fn restore_rejects_an_alias_that_crosses_a_symlink() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("file"), b"file").unwrap(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let directory = source.lookup(&request(FUSE_ROOT_ID), name(b"dir")).unwrap(); + source + .lookup(&request(directory.nodeid), name(b"file")) + .unwrap(); + // Keep only the file's node, whose saved alias names the directory. + source.forget(directory.nodeid, 1); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + assert!( + state + .inodes + .iter() + .all(|inode| inode.node_id != directory.nodeid) + ); + + // The same file remains reachable, but only through a link. + std::fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + host_symlink(&fixture.root, "dir", "saved"); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + let error = destination + .restore_microvm_state(&profile, state, &session) + .unwrap_err(); + assert!( + format!("{error:#}").contains("alias crosses a symbolic-link component"), + "{error:#}" + ); +} + +#[test] +fn saved_symlink_handle_is_still_rejected() { + let fixture = Fixture::new(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let entry = source + .symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target")) + .unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let identity = &state + .inodes + .iter() + .find(|inode| inode.node_id == entry.nodeid) + .unwrap() + .object_identity; + let handle = SavedHandle { + handle_id: state.next_handle_id, + node_id: entry.nodeid, + open_flags: (lx::O_RDONLY | lx::O_NOFOLLOW) as u32, + kind: lx::S_IFLNK, + object_identity: SavedObjectIdentity { + device_id: identity.device_id, + inode_id: identity.inode_id, + kind: identity.kind, + }, + directory_entries: Vec::new(), + directory_snapshot_built: false, + }; + state.next_handle_id += 1; + state.handles.push(handle); + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[cfg(windows)] +#[test] +fn windows_microvm_symlinks_are_inert_wsl_links() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("file"), b"file").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + + for (link, target) in [ + ("file-link", "dir/file"), + ("dir-link", "dir"), + ("escape", "../outside/secret"), + ] { + fs.symlink( + &request(FUSE_ROOT_ID), + name(link.as_bytes()), + name(target.as_bytes()), + ) + .unwrap(); + let host = fixture.root.join(link); + let metadata = std::fs::symlink_metadata(&host).unwrap(); + assert_ne!(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT, 0); + assert!(std::fs::read_link(&host).is_err()); + assert!(std::fs::metadata(&host).is_err()); + assert!(std::fs::read(&host).is_err()); + } + fixture.assert_outside_unchanged(); +} diff --git a/vm/devices/virtio/virtiofs/src/resolver.rs b/vm/devices/virtio/virtiofs/src/resolver.rs index 4084646a6c0..c66cfe9977c 100644 --- a/vm/devices/virtio/virtiofs/src/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/resolver.rs @@ -31,7 +31,13 @@ impl ResolveResource for VirtioFsResolver { resource: VirtioFsHandle, input: VirtioResolveInput<'_>, ) -> Result { + if let Some(device) = crate::microvm::resolver::resolve(&resource, input.driver_source)? { + return Ok(device.into()); + } let device = match &resource.fs { + VirtioFsBackend::Dormant => { + anyhow::bail!("standard virtio-fs requires an active backend") + } VirtioFsBackend::HostFs { root_path, mount_options, @@ -46,15 +52,13 @@ impl ResolveResource for VirtioFsResolver { None, ), #[cfg(windows)] - VirtioFsBackend::SectionFs { root_path } => { - VirtioFsDevice::new( - input.driver_source, - &resource.tag, - crate::SectionFs::new(root_path)?, - 8 * 1024 * 1024 * 1024, // 8GB of shared memory, - None, - ) - } + VirtioFsBackend::SectionFs { root_path } => VirtioFsDevice::new( + input.driver_source, + &resource.tag, + crate::SectionFs::new(root_path)?, + 8 * 1024 * 1024 * 1024, // 8GB of shared memory, + None, + ), #[cfg(not(windows))] VirtioFsBackend::SectionFs { .. } => { anyhow::bail!("section fs not supported on this platform") diff --git a/vm/devices/virtio/virtiofs/src/section.rs b/vm/devices/virtio/virtiofs/src/section.rs index 8999689b447..5d8140ff010 100644 --- a/vm/devices/virtio/virtiofs/src/section.rs +++ b/vm/devices/virtio/virtiofs/src/section.rs @@ -253,8 +253,8 @@ impl fuse::Fuse for SectionFs { state: InodeState::Open(section), }; let attr = inode.attr(0, &self.attr); - let node_id = self.inodes.lock().insert(inode); - tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); + let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; + tracing::trace!(node_id, name = ?name, "node_id"); Ok(fuse::protocol::fuse_entry_out::new( node_id, Duration::from_secs(0), @@ -359,9 +359,9 @@ impl fuse::Fuse for SectionFs { }; let inode = Inode { size, state }; let mut attr = inode.attr(0, &self.attr); - let node_id = self.inodes.lock().insert(inode); + let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; attr.ino = node_id; - tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); + tracing::trace!(node_id, name = ?name, "node_id"); Ok(fuse::CreateOut { entry: fuse::protocol::fuse_entry_out::new( node_id, diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index a66fbe4efff..31960ac9989 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::VirtioFs; +use crate::microvm::MAX_FUSE_REQUEST_BYTES; +use crate::microvm::owner::CallerIdentity; +use crate::profile::MicroVmVirtioFsProfile; use crate::virtio_util::VirtioPayloadReader; use crate::virtio_util::VirtioPayloadWriter; use anyhow::Context as _; @@ -9,6 +13,7 @@ use guestmem::GuestMemory; use guestmem::MappedMemoryRegion; use inspect::InspectMut; use pal_async::wait::PolledWait; +use parking_lot::Mutex; use std::io; use std::io::Write; use std::sync::Arc; @@ -22,8 +27,12 @@ use virtio::QueueResources; use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::VirtioQueueCallbackWork; +use virtio::device::saved_state::DeviceStateValidator; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; use zerocopy::Immutable; @@ -50,9 +59,9 @@ const MAX_REQUEST_QUEUES: u32 = 8; /// PCI configuration space values for virtio-fs devices. #[repr(C)] #[derive(IntoBytes, Immutable, KnownLayout)] -struct VirtioFsDeviceConfig { - tag: [u8; 36], - num_request_queues: u32, +pub(crate) struct VirtioFsDeviceConfig { + pub(crate) tag: [u8; 36], + pub(crate) num_request_queues: u32, } /// A virtio-fs PCI device. @@ -61,17 +70,96 @@ pub struct VirtioFsDevice { task_name: Box, driver: VmTaskDriver, #[inspect(skip)] - config: VirtioFsDeviceConfig, + pub(crate) config: VirtioFsDeviceConfig, #[inspect(skip)] - fs: Arc, + pub(crate) fs: Arc, #[inspect(skip)] - workers: Vec>, + pub(crate) workers: Vec>, shmem_size: u64, #[inspect(skip)] shared_memory_region: Option>, #[inspect(skip)] notify_corruption: Arc, num_request_queues: u32, + #[inspect(skip)] + pub(crate) microvm_attachment_id: Option, + #[inspect(skip)] + pub(crate) microvm_profile: Option, + #[inspect(skip)] + pub(crate) stateful_fs: Option, + /// Set when requests run as their guest callers. + #[inspect(skip)] + pub(crate) caller_identity: Option, + #[inspect(skip)] + pub(crate) admission: Arc, + #[inspect(skip)] + pub(crate) save_error: Option, +} + +struct AdmissionState { + accepting: bool, + in_flight: u32, +} + +pub(crate) struct RequestAdmission { + state: Mutex, +} + +struct RequestGuard { + admission: Arc, +} + +impl RequestAdmission { + fn new() -> Self { + Self { + state: Mutex::new(AdmissionState { + accepting: true, + in_flight: 0, + }), + } + } + + fn accept(self: &Arc) -> Option { + let mut state = self.state.lock(); + if !state.accepting { + return None; + } + state.in_flight = state.in_flight.checked_add(1)?; + Some(RequestGuard { + admission: Arc::clone(self), + }) + } + + pub(crate) fn quiesce(&self) { + let mut state = self.state.lock(); + state.accepting = false; + } + + pub(crate) fn resume(&self) { + let mut state = self.state.lock(); + state.accepting = true; + } + + pub(crate) fn verify_drained(&self) -> anyhow::Result<()> { + let state = self.state.lock(); + anyhow::ensure!( + !state.accepting, + "virtio-fs save was not preceded by input quiesce" + ); + anyhow::ensure!( + state.in_flight == 0, + "virtio-fs has {} unrepresented in-flight request(s)", + state.in_flight + ); + Ok(()) + } +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + let mut state = self.admission.state.lock(); + state.in_flight = state.in_flight.saturating_sub(1); + } } impl VirtioFsDevice { @@ -142,6 +230,12 @@ impl VirtioFsDevice { shared_memory_region: None, notify_corruption, num_request_queues, + microvm_attachment_id: None, + microvm_profile: None, + stateful_fs: None, + caller_identity: None, + admission: Arc::new(RequestAdmission::new()), + save_error: None, } } } @@ -150,10 +244,7 @@ impl VirtioDevice for VirtioFsDevice { fn traits(&self) -> DeviceTraits { DeviceTraits { device_id: virtio::spec::VirtioDeviceType::FS, - device_features: VirtioDeviceFeatures::new() - .with_ring_event_idx(true) - .with_ring_indirect_desc(true) - .with_ring_packed(true), + device_features: crate::microvm::device::device_features(self), max_queues: 1 + self.num_request_queues as u16, device_register_length: self.config.as_bytes().len() as u32, shared_memory: DeviceTraitsSharedMemory { @@ -163,28 +254,29 @@ impl VirtioDevice for VirtioFsDevice { } } + fn supports_accelerated_doorbells(&self) -> bool { + crate::microvm::device::supports_accelerated_doorbells(self) + } + async fn read_registers_u32(&mut self, offset: u16) -> u32 { let offset = offset as usize; let config = self.config.as_bytes(); - if offset < config.len() { - u32::from_le_bytes( - config[offset..offset + 4] - .try_into() - .expect("Incorrect length"), - ) - } else { - 0 - } + config + .get(offset..offset.saturating_add(4)) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .unwrap_or(0) } async fn write_registers_u32(&mut self, offset: u16, val: u32) { - tracing::warn!(offset, val, "[virtiofs] Unknown write",); + tracelimit::warn_ratelimited!(offset, val, "[virtiofs] Unknown write",); } fn set_shared_memory_region( &mut self, region: &Arc, ) -> anyhow::Result<()> { + crate::microvm::device::validate_shared_memory(self)?; self.shared_memory_region = Some(region.clone()); Ok(()) } @@ -196,11 +288,14 @@ impl VirtioDevice for VirtioFsDevice { features: &VirtioDeviceFeatures, initial_state: Option, ) -> anyhow::Result<()> { + crate::microvm::device::validate_queue_features(self, features)?; let mut tc = TaskControl::new(VirtioFsWorker { fs: self.fs.clone(), shared_memory_region: self.shared_memory_region.clone(), shared_memory_size: self.shmem_size, notify_corruption: self.notify_corruption.clone(), + admission: Arc::clone(&self.admission), + caller_identity: self.caller_identity, }); let queue_event = PolledWait::new(&self.driver, resources.event) @@ -233,6 +328,8 @@ impl VirtioDevice for VirtioFsDevice { shared_memory_region: None, shared_memory_size: 0, notify_corruption: self.notify_corruption.clone(), + admission: Arc::clone(&self.admission), + caller_identity: self.caller_identity, }) }); } @@ -252,6 +349,7 @@ impl VirtioDevice for VirtioFsDevice { async fn reset(&mut self) { self.workers.clear(); + crate::microvm::device::reset(self); if let Some(region) = &self.shared_memory_region { if let Err(e) = region.unmap(0, self.shmem_size as usize) { tracing::error!( @@ -263,16 +361,44 @@ impl VirtioDevice for VirtioFsDevice { self.shared_memory_region = None; self.fs.destroy(); } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + crate::microvm::device::quiesce_input(self); + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + crate::microvm::device::resume_input(self); + Ok(()) + } + + fn supports_save_restore(&self) -> bool { + crate::microvm::device::supports_save_restore(self) + } + + fn save_device(&mut self) -> Result, SaveError> { + crate::microvm::device::save_device(self) + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + crate::microvm::device::restore_device(self, state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + crate::microvm::device::device_state_validator(self) + } } -struct VirtioFsWorker { +pub(crate) struct VirtioFsWorker { fs: Arc, shared_memory_region: Option>, shared_memory_size: u64, notify_corruption: Arc, + admission: Arc, + caller_identity: Option, } -struct VirtioFsQueue { +pub(crate) struct VirtioFsQueue { queue: VirtioQueue, mem: GuestMemory, } @@ -284,15 +410,21 @@ impl AsyncRun for VirtioFsWorker { state: &mut VirtioFsQueue, ) -> Result<(), Cancelled> { loop { + // Admission must precede queue.next(): after save closes the + // gate, an unowned descriptor must remain on the virtqueue rather + // than being dequeued and falsely completed with an empty reply. + let Some(request_guard) = self.admission.accept() else { + break; + }; let work = stop.until_stopped(state.queue.next()).await?; let Some(work) = work else { break }; match work { Ok(work) => { - let bytes = process_virtiofs_request(self, &state.mem, &work); + let bytes = process_virtiofs_request(self, &state.mem, &work, request_guard); state.queue.complete(work, bytes); } Err(err) => { - tracing::error!( + tracelimit::error_ratelimited!( error = &err as &dyn std::error::Error, "Failed processing queue" ); @@ -308,13 +440,25 @@ fn process_virtiofs_request( worker: &VirtioFsWorker, mem: &GuestMemory, work: &VirtioQueueCallbackWork, + _request_guard: RequestGuard, ) -> u32 { + let readable_len = work.get_payload_length(false) as usize; + if readable_len > MAX_FUSE_REQUEST_BYTES { + tracelimit::error_ratelimited!( + readable_len, + max_request_bytes = MAX_FUSE_REQUEST_BYTES, + "virtio-fs request exceeds the fixed maximum size" + ); + (worker.notify_corruption)(); + return 0; + } + // Parse the request. let reader = VirtioPayloadReader::new(mem, work); let request = match fuse::Request::new(reader) { Ok(request) => request, Err(e) => { - tracing::error!( + tracelimit::error_ratelimited!( error = &e as &dyn std::error::Error, "[virtiofs] Invalid FUSE message, error" ); @@ -343,7 +487,9 @@ fn process_virtiofs_request( region: shared_memory_region.as_ref(), size: worker.shared_memory_size, }); - worker.fs.dispatch( + crate::microvm::owner::dispatch( + worker.caller_identity.as_ref(), + &worker.fs, request, &mut sender, mapper.as_ref().map(|x| x as &dyn fuse::Mapper), @@ -363,16 +509,20 @@ struct VirtioReplySender<'a> { impl fuse::ReplySender for VirtioReplySender<'_> { fn send(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<()> { let mut writer = VirtioPayloadWriter::new(self.mem, self.work); - let mut size = 0; + let mut size: usize = 0; // Write all the slices to the payload buffers. // N.B. write_vectored isn't used because it isn't guaranteed to write all the data. for buf in bufs { writer.write_all(buf)?; - size += buf.len(); + size = size + .checked_add(buf.len()) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; } - self.bytes_written = size as u32; + self.bytes_written = size + .try_into() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; Ok(()) } } @@ -421,6 +571,7 @@ mod tests { use crate::VirtioFs; use pal_async::DefaultDriver; use pal_async::async_test; + use test_with_tracing::test; use vmcore::vm_task::SingleDriverBackend; fn make_device( @@ -448,6 +599,7 @@ mod tests { device.traits().max_queues, 1 + DEFAULT_NUM_REQUEST_QUEUES as u16 ); + assert!(device.supports_accelerated_doorbells()); } #[async_test] @@ -476,4 +628,16 @@ mod tests { assert_eq!(device.config.num_request_queues, 3); assert_eq!(device.traits().max_queues, 4); } + + #[test] + fn closed_admission_rejects_new_work_until_all_requests_drain() { + let admission = Arc::new(RequestAdmission::new()); + let request = admission.accept().unwrap(); + admission.quiesce(); + assert!(admission.accept().is_none()); + assert!(admission.verify_drained().is_err()); + + drop(request); + admission.verify_drained().unwrap(); + } } diff --git a/vm/devices/virtio/virtiofs/src/virtio_util.rs b/vm/devices/virtio/virtiofs/src/virtio_util.rs index e175bff9582..c52969964db 100644 --- a/vm/devices/virtio/virtiofs/src/virtio_util.rs +++ b/vm/devices/virtio/virtiofs/src/virtio_util.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::microvm::MAX_FUSE_REQUEST_BYTES; use guestmem::GuestMemory; use std::cmp; use std::io; @@ -61,7 +62,7 @@ impl<'payload, 'mem> VirtioPayloadReader<'payload, 'mem> { /// Gets the remaining length of the current payload buffer only. fn get_current_remaining_len(&mut self) -> usize { if let Some(payload) = self.get_payload() { - payload.length as usize - self.offset + (payload.length as usize).saturating_sub(self.offset) } else { 0 } @@ -74,10 +75,16 @@ impl Read for VirtioPayloadReader<'_, '_> { // Determine how much space is left in the current buffer, and read at most that much. // A single call to read won't cross payload buffers, so to read more you must call // it repeatedly or e.g. use read_exact. - let remaining = payload.length as usize - self.offset; + let remaining = (payload.length as usize) + .checked_sub(self.offset) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; let size = cmp::min(remaining, buf.len()); + let address = payload + .address + .checked_add(self.offset as u64) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; self.guest_memory - .read_at(payload.address + self.offset as u64, &mut buf[..size]) + .read_at(address, &mut buf[..size]) .map_err(io::Error::other)?; self.offset += size; self.position += size; @@ -103,9 +110,19 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { return Err(lx::Error::EINVAL); } - buffer.resize_with(buffer.len() + len, Default::default); + let new_len = buffer.len().checked_add(len).ok_or(lx::Error::E2BIG)?; + if new_len > MAX_FUSE_REQUEST_BYTES { + return Err(lx::Error::E2BIG); + } + buffer + .try_reserve_exact(len) + .map_err(|_| lx::Error::ENOMEM)?; + buffer.resize(new_len, 0); let start_offset = self.offset; - assert!(self.read(&mut buffer[buffer_offset..])? == len); + let start_position = self.position; + if self.read(&mut buffer[buffer_offset..])? != len { + return Err(lx::Error::EIO); + } // Search for a matching byte in the portion of the buffer we just read. if let Some(length) = buffer[buffer_offset..].iter().position(|&c| c == byte) { @@ -114,6 +131,7 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { // Rewind the offset to be just after the matching byte. self.offset = start_offset + length + 1; + self.position = start_position + length + 1; return Ok(buffer); } else { buffer_offset += len; @@ -122,7 +140,7 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { } fn remaining_len(&self) -> usize { - self.len - self.position + self.len.saturating_sub(self.position) } } @@ -175,10 +193,16 @@ impl Write for VirtioPayloadWriter<'_, '_> { // Find the remaining size of the current payload buffer, and write at most that much. // This method never writes data spanning multiple buffers, so to write more you must // call it repeatedly or use write_all. - let remaining = payload.length as usize - self.offset; + let remaining = (payload.length as usize) + .checked_sub(self.offset) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; let size = cmp::min(remaining, buf.len()); + let address = payload + .address + .checked_add(self.offset as u64) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; self.guest_memory - .write_at(payload.address + self.offset as u64, &buf[..size]) + .write_at(address, &buf[..size]) .map_err(io::Error::other)?; self.offset += size; Ok(size) diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index af2d0dfeab5..461c8358f0c 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -39,12 +39,17 @@ mod ioctl { const KVMIO: u8 = 0xae; + ioctl_write_int_bad!(kvm_get_api_version, request_code_none!(KVMIO, 0x0)); ioctl_write_int_bad!(kvm_create_vm, request_code_none!(KVMIO, 0x1)); + #[cfg(target_arch = "x86_64")] + ioctl_readwrite!(kvm_get_msr_index_list, KVMIO, 0x02, kvm_msr_list); ioctl_write_int_bad!(kvm_check_extension, request_code_none!(KVMIO, 0x03)); ioctl_write_int_bad!(kvm_get_vcpu_mmap_size, request_code_none!(KVMIO, 0x04)); #[cfg(target_arch = "x86_64")] ioctl_readwrite!(kvm_get_supported_cpuid, KVMIO, 0x05, kvm_cpuid2); #[cfg(target_arch = "x86_64")] + ioctl_readwrite!(kvm_get_msr_feature_index_list, KVMIO, 0x0a, kvm_msr_list); + #[cfg(target_arch = "x86_64")] ioctl_readwrite!(kvm_get_supported_hv_cpuid, KVMIO, 0xc1, kvm_cpuid2); ioctl_write_int_bad!(kvm_create_vcpu, request_code_none!(KVMIO, 0x41)); ioctl_write_ptr!( @@ -97,6 +102,8 @@ mod ioctl { ioctl_read!(kvm_get_debugregs, KVMIO, 0xa1, kvm_debugregs); #[cfg(target_arch = "x86_64")] ioctl_write_ptr!(kvm_set_debugregs, KVMIO, 0xa2, kvm_debugregs); + #[cfg(target_arch = "x86_64")] + ioctl_write_int_bad!(kvm_get_tsc_khz, request_code_none!(KVMIO, 0xa3)); ioctl_write_ptr!(kvm_enable_cap, KVMIO, 0xa3, kvm_enable_cap); #[cfg(target_arch = "x86_64")] ioctl_read!(kvm_get_xsave, KVMIO, 0xa4, kvm_xsave); @@ -129,6 +136,10 @@ mod ioctl { ioctl_write_ptr!(kvm_set_device_attr, KVMIO, 0xe1, kvm_device_attr); #[cfg(target_arch = "x86_64")] ioctl_write_ptr!(kvm_get_device_attr, KVMIO, 0xe2, kvm_device_attr); + #[cfg(target_arch = "x86_64")] + ioctl_write_ptr!(kvm_has_device_attr, KVMIO, 0xe3, kvm_device_attr); + #[cfg(target_arch = "x86_64")] + ioctl_write_ptr!(kvm_x86_set_msr_filter, KVMIO, 0xc6, kvm_msr_filter); ioctl_readwrite!(kvm_create_guest_memfd, KVMIO, 0xd4, kvm_create_guest_memfd); #[cfg(target_arch = "aarch64")] ioctl_readwrite_bad!( @@ -283,6 +294,15 @@ pub enum Error { SetRegs(#[source] nix::Error), #[error("SetSRegs")] SetSRegs(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetTscFrequency")] + GetTscFrequency(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetTscOffset")] + GetTscOffset(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("SetTscOffset")] + SetTscOffset(#[source] nix::Error), #[error("Run")] Run(#[source] nix::Error), #[error("RunMemoryFault(flags={flags:#x}, gpa={gpa:#x}, size={size:#x})")] @@ -340,6 +360,12 @@ pub enum Error { requested: usize, failed_msr: u32, }, + #[cfg(target_arch = "x86_64")] + #[error("SetMsrFilter")] + SetMsrFilter(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("invalid MSR filter: {0}")] + InvalidMsrFilter(&'static str), #[error("SetupMce")] SetupMce(#[source] nix::Error), #[error("GetMceCapSupported")] @@ -376,6 +402,20 @@ pub enum Error { GetClock(#[source] nix::Error), #[error("SetClock")] SetClock(#[source] nix::Error), + #[error("GetApiVersion")] + GetApiVersion(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetMsrIndexList")] + GetMsrIndexList(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetMsrFeatureIndexList")] + GetMsrFeatureIndexList(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetFeatureMsr({0:#x})")] + GetFeatureMsr(u32, #[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("KVM did not report the MSR-based feature {0:#x}")] + FeatureMsrNotReported(u32), } type Result = std::result::Result; @@ -500,6 +540,111 @@ impl Kvm { unsafe { ioctl::kvm_check_extension(self.as_fd().as_raw_fd(), extension as i32) } } + /// Returns the KVM API version (`KVM_GET_API_VERSION`), which is 12 for + /// every stable KVM. + pub fn api_version(&self) -> Result { + // SAFETY: Calling IOCTL as documented, with no special requirements. + unsafe { ioctl::kvm_get_api_version(self.as_fd().as_raw_fd(), 0) } + .map_err(Error::GetApiVersion) + } + + /// Returns the MSRs that KVM can save and restore for a vCPU + /// (`KVM_GET_MSR_INDEX_LIST`). + #[cfg(target_arch = "x86_64")] + pub fn msr_index_list(&self) -> Result> { + self.msr_list(ioctl::kvm_get_msr_index_list) + .map_err(Error::GetMsrIndexList) + } + + /// Returns the MSR-based features that KVM reports + /// (`KVM_GET_MSR_FEATURE_INDEX_LIST`). + #[cfg(target_arch = "x86_64")] + pub fn msr_feature_index_list(&self) -> Result> { + self.msr_list(ioctl::kvm_get_msr_feature_index_list) + .map_err(Error::GetMsrFeatureIndexList) + } + + #[cfg(target_arch = "x86_64")] + fn msr_list( + &self, + get: unsafe fn(libc::c_int, *mut kvm_msr_list) -> nix::Result, + ) -> nix::Result> { + const MAX_MSR_LIST_ENTRIES: usize = 1024; + + #[repr(C)] + struct MsrList { + header: kvm_msr_list, + indices: [u32; MAX_MSR_LIST_ENTRIES], + } + + let mut list = MsrList { + header: kvm_msr_list { + nmsrs: MAX_MSR_LIST_ENTRIES as u32, + ..Default::default() + }, + indices: [0; MAX_MSR_LIST_ENTRIES], + }; + // SAFETY: The header is immediately followed by space for `nmsrs` + // indices, as the ioctl requires. + unsafe { + get(self.as_fd().as_raw_fd(), &mut list.header)?; + } + let count = (list.header.nmsrs as usize).min(MAX_MSR_LIST_ENTRIES); + Ok(list.indices[..count].to_vec()) + } + + /// Returns the value that KVM reports for the MSR-based feature `index` + /// (`KVM_GET_MSRS` on the system file descriptor). + #[cfg(target_arch = "x86_64")] + pub fn feature_msr(&self, index: u32) -> Result { + #[repr(C)] + struct Msrs { + header: kvm_msrs, + entries: [kvm_msr_entry; 1], + } + + let mut msrs = Msrs { + header: kvm_msrs { + nmsrs: 1, + ..Default::default() + }, + entries: [kvm_msr_entry { + index, + reserved: 0, + data: 0, + }], + }; + // SAFETY: The header is immediately followed by `nmsrs` entries, as + // the ioctl requires. + let read = unsafe { ioctl::kvm_get_msrs(self.as_fd().as_raw_fd(), &mut msrs.header) } + .map_err(|err| Error::GetFeatureMsr(index, err))?; + if read != 1 { + return Err(Error::FeatureMsrNotReported(index)); + } + Ok(msrs.entries[0].data) + } + + /// Returns the XSAVE features that KVM supports for guests + /// (`KVM_X86_XCOMP_GUEST_SUPP`), including dynamically enabled features + /// that a VMM must request permission for before KVM reports them in + /// [`Self::supported_cpuid`]. + #[cfg(target_arch = "x86_64")] + pub fn xsave_guest_supported(&self) -> Result { + let mut value = 0u64; + let attr = kvm_device_attr { + group: KVM_X86_GRP_SYSTEM, + attr: u64::from(KVM_X86_XCOMP_GUEST_SUPP), + addr: std::ptr::from_mut(&mut value) as u64, + flags: 0, + }; + // SAFETY: `attr.addr` points to `value` for the duration of the ioctl. + unsafe { + ioctl::kvm_get_device_attr(self.as_fd().as_raw_fd(), &attr) + .map_err(Error::GetDeviceAttr)?; + } + Ok(value) + } + pub fn new_vm(&self, vm_type: VmType) -> Result { let raw_vm_type = self.raw_vm_type(vm_type)?; self.new_vm_with_type(raw_vm_type) @@ -609,6 +754,76 @@ struct Cpuid { entries: [kvm_cpuid_entry2; 256], } +/// One range of an MSR filter installed with [`Partition::set_msr_filter`]. +#[cfg(target_arch = "x86_64")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MsrFilterRange { + /// The first MSR of the range. + pub base: u32, + /// The number of MSRs in the range. + pub nmsrs: u32, + /// Whether the range applies to reads. + pub read: bool, + /// Whether the range applies to writes. + pub write: bool, + /// One bit per MSR, LSB first: a set bit lets KVM handle the access, a + /// clear bit denies it. A denied access raises #GP in the guest or, with + /// `KVM_MSR_EXIT_REASON_FILTER` enabled, exits to user space. Holds + /// `nmsrs.div_ceil(8)` bytes. + pub bitmap: Vec, +} + +#[cfg(target_arch = "x86_64")] +impl MsrFilterRange { + /// Returns a range that denies reads and writes of every MSR in `msrs`. + pub fn deny(msrs: std::ops::RangeInclusive) -> Self { + let nmsrs = msrs.end() - msrs.start() + 1; + Self { + base: *msrs.start(), + nmsrs, + read: true, + write: true, + bitmap: vec![0; nmsrs.div_ceil(8) as usize], + } + } + + fn validate(&self) -> Result<()> { + if self.nmsrs == 0 { + return Err(Error::InvalidMsrFilter("empty range")); + } + if !self.read && !self.write { + return Err(Error::InvalidMsrFilter("range applies to no access")); + } + if self.bitmap.len() != self.nmsrs.div_ceil(8) as usize + || self.kvm_bitmap_words() * 8 > KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize + { + return Err(Error::InvalidMsrFilter( + "bitmap size does not match the range", + )); + } + Ok(()) + } + + /// The number of 64-bit words of the bitmap that KVM copies, + /// `BITS_TO_LONGS(nmsrs)`. + fn kvm_bitmap_words(&self) -> usize { + self.nmsrs.div_ceil(64) as usize + } + + /// Returns the bitmap as KVM reads it: [`Self::kvm_bitmap_words`] words + /// that hold the bytes of `bitmap` in order, little-endian, followed by + /// zero padding. Bit `i` stays the bit for MSR `base + i`. + fn kvm_bitmap(&self) -> Vec { + let mut words = vec![0; self.kvm_bitmap_words()]; + for (word, bytes) in words.iter_mut().zip(self.bitmap.chunks(8)) { + let mut le = [0; 8]; + le[..bytes.len()].copy_from_slice(bytes); + *word = u64::from_le_bytes(le); + } + words + } +} + #[derive(Debug)] pub struct Partition { vm: File, @@ -652,6 +867,19 @@ impl Partition { unsafe { ioctl::kvm_check_extension(self.vm.as_raw_fd(), extension as i32) } } + /// Returns the TSC frequency, in Hz, that new vCPUs of this VM get + /// (`KVM_GET_TSC_KHZ` on the VM, which requires `KVM_CAP_VM_TSC_CONTROL`). + #[cfg(target_arch = "x86_64")] + pub fn tsc_frequency_hz(&self) -> Result { + // SAFETY: Calling the documented VM ioctl with no pointer argument. + let khz = unsafe { ioctl::kvm_get_tsc_khz(self.vm.as_raw_fd(), 0) } + .map_err(Error::GetTscFrequency)?; + if khz <= 0 { + return Err(Error::GetTscFrequency(nix::errno::Errno::EINVAL)); + } + Ok(khz as u64 * 1000) + } + #[cfg(target_arch = "aarch64")] pub fn arm_rmi_populate(&self, populate: &mut KvmArmRmiPopulate) -> Result<()> { // SAFETY: `populate` points to a valid KVM_ARM_RMI_POPULATE argument for @@ -815,6 +1043,12 @@ impl Partition { } pub fn enable_unknown_msr_exits(&self) -> Result<()> { + self.enable_msr_exits(KVM_MSR_EXIT_REASON_UNKNOWN) + } + + /// Enables user-space MSR exits (`KVM_CAP_X86_USER_SPACE_MSR`) for the + /// `KVM_MSR_EXIT_REASON_*` bits in `reasons`, replacing the previous set. + pub fn enable_msr_exits(&self, reasons: u32) -> Result<()> { // SAFETY: Calling IOCTL as documented, with no special requirements. // TODO: We are not checking KVM_CAP_ENABLE_CAP_VM first. unsafe { @@ -822,7 +1056,7 @@ impl Partition { self.vm.as_raw_fd(), &kvm_enable_cap { cap: KVM_CAP_X86_USER_SPACE_MSR, - args: [KVM_MSR_EXIT_REASON_UNKNOWN.into(), 0, 0, 0], + args: [reasons.into(), 0, 0, 0], ..Default::default() }, ) @@ -831,6 +1065,55 @@ impl Partition { Ok(()) } + /// Installs an MSR filter (`KVM_X86_SET_MSR_FILTER`), replacing the + /// previous one. KVM checks `ranges` in order and uses the first range + /// that covers the MSR and the access type; accesses no range covers are + /// allowed if `default_allow`, and denied otherwise. + /// + /// The filter applies to guest accesses only: host-initiated + /// `KVM_GET_MSRS` and `KVM_SET_MSRS` bypass it. KVM never filters the + /// x2APIC MSRs. + #[cfg(target_arch = "x86_64")] + pub fn set_msr_filter(&self, default_allow: bool, ranges: &[MsrFilterRange]) -> Result<()> { + if ranges.len() > KVM_MSR_FILTER_MAX_RANGES as usize { + return Err(Error::InvalidMsrFilter("too many ranges")); + } + let mut filter = kvm_msr_filter { + flags: if default_allow { + KVM_MSR_FILTER_DEFAULT_ALLOW + } else { + KVM_MSR_FILTER_DEFAULT_DENY + }, + ..Default::default() + }; + // KVM copies each bitmap in whole `long`s, so it gets padded copies. + let bitmaps = ranges + .iter() + .map(|range| { + range.validate()?; + Ok(range.kvm_bitmap()) + }) + .collect::>>()?; + for ((slot, range), bitmap) in filter.ranges.iter_mut().zip(ranges).zip(&bitmaps) { + *slot = kvm_msr_filter_range { + flags: if range.read { KVM_MSR_FILTER_READ } else { 0 } + | if range.write { KVM_MSR_FILTER_WRITE } else { 0 }, + nmsrs: range.nmsrs, + base: range.base, + // KVM only reads the bitmap, and copies it during the ioctl. + bitmap: bitmap.as_ptr().cast::().cast_mut(), + }; + } + // SAFETY: `filter` and `bitmaps` are valid for the duration of the + // ioctl. KVM copies `BITS_TO_LONGS(nmsrs) * sizeof(long)` bytes of + // each range's bitmap, which is the size of its padded copy. + unsafe { + ioctl::kvm_x86_set_msr_filter(self.vm.as_raw_fd(), &filter) + .map_err(Error::SetMsrFilter)?; + } + Ok(()) + } + /// Set the VCPU index of the BSP. This must be called before any VCPUs are /// created. #[cfg(target_arch = "x86_64")] @@ -1276,6 +1559,82 @@ pub enum RoutingEntry { pub struct Processor<'a>(&'a Partition, u32); impl<'a> Processor<'a> { + #[cfg(target_arch = "x86_64")] + pub fn tsc_frequency_hz(&self) -> Result { + // SAFETY: Calling the documented vCPU ioctl with no pointer argument. + let khz = unsafe { ioctl::kvm_get_tsc_khz(self.get().vcpu.as_raw_fd(), 0) } + .map_err(Error::GetTscFrequency)?; + if khz <= 0 { + return Err(Error::GetTscFrequency(nix::errno::Errno::EINVAL)); + } + Ok(khz as u64 * 1000) + } + + /// Gets the guest TSC offset, in guest TSC cycles. + #[cfg(target_arch = "x86_64")] + pub fn tsc_offset(&self) -> Result { + let mut offset = 0u64; + let attr = kvm_device_attr { + group: KVM_VCPU_TSC_CTRL, + attr: u64::from(KVM_VCPU_TSC_OFFSET), + addr: std::ptr::from_mut(&mut offset) as u64, + flags: 0, + }; + // SAFETY: `attr.addr` points to `offset` for the duration of the ioctl. + unsafe { + ioctl::kvm_get_device_attr(self.get().vcpu.as_raw_fd(), &attr) + .map_err(Error::GetTscOffset)?; + } + Ok(offset) + } + + /// Sets the guest TSC offset without KVM's TSC-write synchronization heuristic. + #[cfg(target_arch = "x86_64")] + pub fn set_tsc_offset(&self, offset: u64) -> Result<()> { + // SAFETY: `offset` is the u64 input required by KVM_VCPU_TSC_OFFSET. + unsafe { + self.set_device_attr(KVM_VCPU_TSC_CTRL, KVM_VCPU_TSC_OFFSET, &offset, 0) + .map_err(Error::SetTscOffset)?; + } + Ok(()) + } + + /// Returns whether the vCPU supports the `KVM_VCPU_TSC_OFFSET` attribute + /// (`KVM_HAS_DEVICE_ATTR`, Linux 5.16 and later). + #[cfg(target_arch = "x86_64")] + pub fn has_tsc_offset_attr(&self) -> bool { + let attr = kvm_device_attr { + group: KVM_VCPU_TSC_CTRL, + attr: u64::from(KVM_VCPU_TSC_OFFSET), + addr: 0, + flags: 0, + }; + // SAFETY: KVM_HAS_DEVICE_ATTR does not dereference `attr.addr`. + unsafe { ioctl::kvm_has_device_attr(self.get().vcpu.as_raw_fd(), &attr) }.is_ok() + } + + /// Makes KVM honor the guest's KVM paravirtual feature leaf (`0x40000001`), + /// so that the paravirtual features it doesn't advertise, such as KVM's + /// paravirtual MSRs, are unavailable to the guest + /// (`KVM_CAP_ENFORCE_PV_FEATURE_CPUID` on the vCPU). + #[cfg(target_arch = "x86_64")] + pub fn enable_enforce_pv_feature_cpuid(&self) -> Result<()> { + // SAFETY: Calling IOCTL as documented. The capability's only argument + // is a flag, which KVM does not dereference. + unsafe { + ioctl::kvm_enable_cap( + self.get().vcpu.as_raw_fd(), + &kvm_enable_cap { + cap: KVM_CAP_ENFORCE_PV_FEATURE_CPUID, + args: [1, 0, 0, 0], + ..Default::default() + }, + ) + .map_err(|err| Error::EnableCap("enforce_pv_feature_cpuid", err))?; + } + Ok(()) + } + pub fn enable_synic(&self) -> Result<()> { // TODO: We are not checking KVM_CAP_ENABLE_CAP_VM first. // TODO: We are not calling KVM_CHECK_EXTENSION first. @@ -1765,6 +2124,7 @@ impl<'a> Processor<'a> { VpRunner { partition: self.0, idx: self.1, + pending_extint: None, _not_send_sync: PhantomData, } } @@ -1773,6 +2133,7 @@ impl<'a> Processor<'a> { pub struct VpRunner<'a> { partition: &'a Partition, idx: u32, + pending_extint: Option, // This type stores the current thread in `partition` and removes it in // `drop`, so don't allow sending or sharing this. _not_send_sync: PhantomData<*const u8>, @@ -1878,6 +2239,9 @@ impl<'a> VpRunner<'a> { if !self.run_vp_once()? { return Ok(Exit::Interrupted); } + // KVM accepted the run, so an extint queued while the interrupt window + // was open has now been injected before guest execution. + self.pending_extint = None; let exit = match self.run_data().exit_reason { #[cfg(target_arch = "x86_64")] @@ -2065,11 +2429,24 @@ impl<'a> VpRunner<'a> { /// [`Self::check_or_request_interrupt_window`] has returned `true`. pub fn inject_extint_interrupt(&mut self, vector: u8) -> Result<()> { self.partition.vp(self.idx).interrupt(vector.into())?; + self.pending_extint = Some(vector); // Remember that there is a pending extint interrupt. KVM will update // this field again after the VP runs. self.run_data().ready_for_interrupt_injection = 0; Ok(()) } + + /// Returns an extint queued in KVM but not yet consumed by a successful run. + pub fn pending_extint(&self) -> Option { + self.pending_extint + } + + /// Restores an extint that was queued but not injected at capture time. + pub fn restore_pending_extint(&mut self, vector: u8) -> Result<()> { + self.partition.vp(self.idx).interrupt(vector.into())?; + self.pending_extint = Some(vector); + Ok(()) + } } #[derive(Debug)] @@ -2250,4 +2627,91 @@ mod tests { assert_eq!(update.type_, KVM_SEV_SNP_PAGE_TYPE_ZERO_UAPI); assert_eq!(update.flags, 0); } + + #[test] + fn msr_filter_deny_range_covers_every_msr() { + let range = MsrFilterRange::deny(0x4000_0000..=0x4000_01ff); + assert_eq!(range.base, 0x4000_0000); + assert_eq!(range.nmsrs, 0x200); + assert!(range.read && range.write); + assert_eq!(range.bitmap, vec![0; 0x40]); + range.validate().unwrap(); + + let single = MsrFilterRange::deny(0x3b..=0x3b); + assert_eq!((single.base, single.nmsrs), (0x3b, 1)); + assert_eq!(single.bitmap, vec![0]); + single.validate().unwrap(); + } + + #[test] + fn msr_filter_bitmap_is_padded_to_whole_words() { + let max = KVM_MSR_FILTER_MAX_BITMAP_SIZE * 8; + for (nmsrs, words) in [ + (1, 1), + (8, 1), + (63, 1), + (64, 1), + (65, 2), + (256, 4), + (max, max as usize / 64), + ] { + let mut range = MsrFilterRange::deny(0x100..=0x100 + nmsrs - 1); + for (i, byte) in range.bitmap.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37) ^ 0xa5; + } + range.validate().unwrap(); + let padded = range.kvm_bitmap(); + // KVM copies BITS_TO_LONGS(nmsrs) * sizeof(long) bytes. + assert_eq!(padded.len(), words, "{nmsrs} MSRs"); + let bytes: Vec = padded.iter().flat_map(|word| word.to_le_bytes()).collect(); + assert_eq!( + bytes[..range.bitmap.len()], + range.bitmap[..], + "{nmsrs} MSRs" + ); + assert!( + bytes[range.bitmap.len()..].iter().all(|&byte| byte == 0), + "{nmsrs} MSRs" + ); + // KVM tests bit `i` of the `long` array for MSR `base + i`. + for i in 0..nmsrs as usize { + assert_eq!( + u64::from((range.bitmap[i / 8] >> (i % 8)) & 1), + (padded[i / 64] >> (i % 64)) & 1, + "{nmsrs} MSRs, bit {i}" + ); + } + } + } + + #[test] + fn msr_filter_rejects_malformed_ranges() { + let valid = MsrFilterRange::deny(0x10..=0x1f); + for range in [ + MsrFilterRange { + nmsrs: 0, + bitmap: Vec::new(), + ..valid.clone() + }, + MsrFilterRange { + read: false, + write: false, + ..valid.clone() + }, + MsrFilterRange { + bitmap: vec![0; 3], + ..valid.clone() + }, + MsrFilterRange { + nmsrs: (KVM_MSR_FILTER_MAX_BITMAP_SIZE + 1) * 8, + bitmap: vec![0; KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize + 1], + ..valid.clone() + }, + ] { + assert!( + matches!(range.validate(), Err(Error::InvalidMsrFilter(_))), + "{range:?}" + ); + } + } } diff --git a/vm/loader/igvmfilegen/src/snp_linux_direct.rs b/vm/loader/igvmfilegen/src/snp_linux_direct.rs index f34a01684a0..a9fe8844649 100644 --- a/vm/loader/igvmfilegen/src/snp_linux_direct.rs +++ b/vm/loader/igvmfilegen/src/snp_linux_direct.rs @@ -114,6 +114,7 @@ impl FixedGuestLayout { with_psp: false, pm_base: DEFAULT_PM_PIO_BASE, acpi_irq: DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, } @@ -581,6 +582,15 @@ mod tests { entries = rest; } assert!(entries.is_empty()); + + let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = layout.acpi_builder().arch + else { + panic!("fixed layout must use x86 ACPI"); + }; + assert!(level_triggered_irqs.is_empty()); } } diff --git a/vm/loader/loader_defs/src/linux.rs b/vm/loader/loader_defs/src/linux.rs index 4916db0ed85..7d615965f05 100644 --- a/vm/loader/loader_defs/src/linux.rs +++ b/vm/loader/loader_defs/src/linux.rs @@ -222,7 +222,8 @@ pub struct boot_params { pub _pad2: [u8; 4], pub tboot_addr: u64, pub ist_info: ist_info, - pub _pad3: [u8; 16], + pub acpi_rsdp_addr: u64, + pub _pad3: [u8; 8], pub hd0_info: [u8; 16], pub hd1_info: [u8; 16], pub sys_desc_table: sys_desc_table, diff --git a/vm/loader/src/elf.rs b/vm/loader/src/elf.rs index c0a631f0b4c..a2885f42b4c 100644 --- a/vm/loader/src/elf.rs +++ b/vm/loader/src/elf.rs @@ -104,6 +104,33 @@ pub fn load_static_elf( acceptance: crate::importer::BootPageAcceptance, tag: &'static str, ) -> Result +where + F: Read + Seek, +{ + let mut buffer = ChunkBuf::new(); + load_static_elf_with_buffer( + importer, + kernel_image, + start_address, + load_offset, + assume_pic, + acceptance, + tag, + &mut buffer, + ) +} + +/// Loads a static ELF while reusing the caller's page-aligned import buffer. +pub(crate) fn load_static_elf_with_buffer( + importer: &mut dyn ImageLoad, + kernel_image: &mut F, + start_address: u64, + load_offset: u64, + assume_pic: bool, + acceptance: crate::importer::BootPageAcceptance, + tag: &'static str, + buffer: &mut ChunkBuf, +) -> Result where F: Read + Seek, { @@ -244,7 +271,6 @@ where drop(reader); // During the second pass, import each segment. - let mut buf = ChunkBuf::new(); for seg in &segments { let mem_offset = seg .p_paddr @@ -268,19 +294,20 @@ where } if seg.p_memsz > 0 { - buf.import_file_region( - importer, - ImportFileRegion { - file: kernel_image, - file_offset: seg.p_offset, - file_length: seg.p_filesz, - gpa: mem_offset, - memory_length: seg.p_memsz, - acceptance, - tag, - }, - ) - .map_err(Error::ImportFileRegion)?; + buffer + .import_file_region( + importer, + ImportFileRegion { + file: kernel_image, + file_offset: seg.p_offset, + file_length: seg.p_filesz, + gpa: mem_offset, + memory_length: seg.p_memsz, + acceptance, + tag, + }, + ) + .map_err(Error::ImportFileRegion)?; } } diff --git a/vm/loader/src/lib.rs b/vm/loader/src/lib.rs index 30353fc0e8c..35c236cc044 100644 --- a/vm/loader/src/lib.rs +++ b/vm/loader/src/lib.rs @@ -11,6 +11,7 @@ pub mod cpuid; pub mod elf; pub mod importer; pub mod linux; +pub mod mptable; pub mod paravisor; pub mod pcat; pub mod smbios; diff --git a/vm/loader/src/linux.rs b/vm/loader/src/linux.rs index 09f5710317b..cebf08da055 100644 --- a/vm/loader/src/linux.rs +++ b/vm/loader/src/linux.rs @@ -3,6 +3,8 @@ //! Linux specific loader definitions and implementation. +pub mod microvm; + use crate::common::ChunkBuf; use crate::common::ImportFileRegion; use crate::common::ImportFileRegionError; @@ -14,6 +16,7 @@ use crate::importer::BootPageAcceptance; use crate::importer::GuestArch; use crate::importer::ImageLoad; use crate::importer::X86Register; +use crate::mptable; use aarch64defs::Cpsr64; use aarch64defs::IntermPhysAddrSize; use aarch64defs::SctlrEl1; @@ -221,6 +224,16 @@ pub enum Error { TooManyMemoryRanges(usize), #[error("acpi tables are empty")] EmptyAcpiTables, + #[error("MP-table Linux direct boot requires an uncompressed ELF kernel")] + MpTableRequiresElf, + #[error("failed to construct MP tables")] + MpTable(#[from] mptable::Error), + #[error("MP table ending at {table_end:#x} overlaps the GDT at {gdt_addr:#x}")] + MpTableOverlap { table_end: usize, gdt_addr: u64 }, + #[error("invalid MP-table reserved memory range {start:#x}..{end:#x}")] + InvalidReservedMemoryRange { start: u64, end: u64 }, + #[error("the memory layout cannot represent the fixed MP-table platform ranges")] + InvalidMpTableMemoryLayout, } /// ACPI tables to place in guest memory: a one-page RSDP plus the tables it diff --git a/vm/loader/src/linux/microvm.rs b/vm/loader/src/linux/microvm.rs new file mode 100644 index 00000000000..e468aba4840 --- /dev/null +++ b/vm/loader/src/linux/microvm.rs @@ -0,0 +1,859 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Linux direct boot for the microVM MP-table machine profile. + +use super::CR3_BASE; +use super::Error; +use super::GDT_BASE; +use super::InitrdAddressType; +use super::InitrdConfig; +use super::InitrdInfo; +use super::KERNEL_BASE; +use super::KernelInfo; +use super::LoadInfo; +use super::ZERO_PAGE_BASE; +use crate::common::ChunkBuf; +use crate::common::ImportFileRegion; +use crate::common::import_default_gdt; +use crate::elf::load_static_elf_with_buffer; +use crate::importer::BootPageAcceptance; +use crate::importer::ImageLoad; +use crate::importer::X86Register; +use crate::mptable; +use hvdef::HV_PAGE_SIZE; +use loader_defs::linux as defs; +use memory_range::MemoryRange; +use page_table::IdentityMapSize; +use page_table::x64::IdentityMapBuilder; +use page_table::x64::PageTable; +use page_table::x64::align_up_to_large_page_size; +use page_table::x64::align_up_to_page_size; +use std::ffi::CString; +use std::io::Read; +use std::io::Seek; +use vm_topology::memory::MemoryLayout; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +const MPTABLE_CMDLINE_BASE: u64 = 0x2_0000; +const MPTABLE_CMDLINE_END: u64 = 0x3_0000; +const MPTABLE_ISA_HOLE_BASE: u64 = 0xa_0000; +const MPTABLE_ISA_HOLE_END: u64 = 0x10_0000; +const MPTABLE_MMIO_GAP_BASE: u64 = 0xc000_0000; +const MPTABLE_MMIO_GAP_END: u64 = 0x1_0000_0000; +const MPTABLE_LOAD_CHUNK_SIZE: usize = 1024 * 1024; +const MPTABLE_PAGE_TABLE_COUNT: usize = 6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct E820Range { + start: u64, + end: u64, + entry_type: u32, +} + +fn build_setup_header(cmdline: &CString, initrd_base: u32, initrd_size: u32) -> defs::setup_header { + const LOADER_TYPE_UNREGISTERED: u8 = 0xff; + + let mut hdr = defs::setup_header { + boot_flag: 0xaa55.into(), + header: 0x53726448.into(), + kernel_alignment: 0x100000.into(), + ..FromZeros::new_zeroed() + }; + hdr.type_of_loader = LOADER_TYPE_UNREGISTERED; + hdr.cmd_line_ptr = MPTABLE_CMDLINE_BASE.try_into().expect("must fit in u32"); + hdr.cmdline_size = (cmdline.as_bytes().len() as u64) + .try_into() + .expect("must fit in u32"); + hdr.ramdisk_image = initrd_base.into(); + hdr.ramdisk_size = initrd_size.into(); + hdr +} + +fn range_is_in_ram(mem_layout: &MemoryLayout, range: MemoryRange) -> bool { + mem_layout + .ram() + .iter() + .any(|ram| range.start() >= ram.range.start() && range.end() <= ram.range.end()) +} + +fn build_mptable_e820( + mem_layout: &MemoryLayout, + reserved_memory_ranges: &[MemoryRange], +) -> Result, Error> { + let mut previous_end = 0; + for range in reserved_memory_ranges { + let valid = !range.is_empty() + && range.start().is_multiple_of(HV_PAGE_SIZE) + && range.end().is_multiple_of(HV_PAGE_SIZE) + && range.start() >= previous_end + && range_is_in_ram(mem_layout, *range); + if !valid { + return Err(Error::InvalidReservedMemoryRange { + start: range.start(), + end: range.end(), + }); + } + previous_end = range.end(); + } + + let isa_hole = MemoryRange::new(MPTABLE_ISA_HOLE_BASE..MPTABLE_ISA_HOLE_END); + let mmio_gap = MemoryRange::new(MPTABLE_MMIO_GAP_BASE..MPTABLE_MMIO_GAP_END); + if !range_is_in_ram(mem_layout, isa_hole) + || mem_layout + .ram() + .iter() + .any(|ram| ram.range.overlaps(&mmio_gap)) + || reserved_memory_ranges + .iter() + .any(|range| range.overlaps(&isa_hole)) + { + return Err(Error::InvalidMpTableMemoryLayout); + } + + let mut reservations = reserved_memory_ranges.to_vec(); + reservations.push(isa_hole); + reservations.sort(); + + let mut entries = Vec::with_capacity(mem_layout.ram().len() + reservations.len() * 2 + 1); + for ram in mem_layout.ram() { + let mut next = ram.range.start(); + for reserved in reservations.iter().filter(|reserved| { + reserved.start() >= ram.range.start() && reserved.end() <= ram.range.end() + }) { + if next < reserved.start() { + entries.push(E820Range { + start: next, + end: reserved.start(), + entry_type: defs::E820_RAM, + }); + } + entries.push(E820Range { + start: reserved.start(), + end: reserved.end(), + entry_type: defs::E820_RESERVED, + }); + next = reserved.end(); + } + if next < ram.range.end() { + entries.push(E820Range { + start: next, + end: ram.range.end(), + entry_type: defs::E820_RAM, + }); + } + } + entries.sort_by_key(|entry| (entry.start, entry.end)); + + let mut merged: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + if entry.start >= entry.end { + return Err(Error::InvalidMpTableMemoryLayout); + } + if let Some(previous) = merged.last_mut() { + if entry.start < previous.end { + return Err(Error::InvalidMpTableMemoryLayout); + } + if entry.start == previous.end && entry.entry_type == previous.entry_type { + previous.end = entry.end; + continue; + } + } + merged.push(entry); + } + Ok(merged) +} + +fn build_mptable_zero_page( + mem_layout: &MemoryLayout, + reserved_memory_ranges: &[MemoryRange], + cmdline: &CString, + initrd_base: u32, + initrd_size: u32, +) -> Result { + let hdr = build_setup_header(cmdline, initrd_base, initrd_size); + let mut boot_params = defs::boot_params { + hdr, + ..FromZeros::new_zeroed() + }; + let entries = build_mptable_e820(mem_layout, reserved_memory_ranges)?; + let capacity = boot_params.e820_map.len(); + if entries.len() > capacity { + return Err(Error::TooManyMemoryRanges(capacity)); + } + for (output, entry) in boot_params.e820_map.iter_mut().zip(&entries) { + *output = defs::e820entry { + addr: entry.start.into(), + size: (entry.end - entry.start).into(), + typ: entry.entry_type.into(), + }; + } + boot_params.e820_entries = + u8::try_from(entries.len()).map_err(|_| Error::TooManyMemoryRanges(capacity))?; + Ok(boot_params) +} + +fn import_initrd_with_buffer( + initrd: Option>, + next_addr: u64, + importer: &mut dyn ImageLoad, + buffer: &mut ChunkBuf, +) -> Result, Error> { + let initrd_info = match initrd { + Some(cfg) => { + let initrd_address = match cfg.initrd_address { + InitrdAddressType::AfterKernel => align_up_to_large_page_size(next_addr), + InitrdAddressType::Address(addr) => addr, + }; + + tracing::trace!(initrd_address, "loading initrd"); + super::check_address_alignment(initrd_address)?; + + buffer + .import_file_region( + importer, + ImportFileRegion { + file: cfg.initrd, + file_offset: 0, + file_length: cfg.size, + gpa: initrd_address, + memory_length: cfg.size, + acceptance: BootPageAcceptance::Exclusive, + tag: "linux-initrd", + }, + ) + .map_err(Error::ImportInitrd)?; + + Some(InitrdInfo { + gpa: initrd_address, + size: cfg.size, + }) + } + None => None, + }; + Ok(initrd_info) +} + +fn load_uncompressed_kernel_and_initrd_x64_with_buffer( + importer: &mut dyn ImageLoad, + kernel_image: &mut F, + kernel_minimum_start_address: u64, + initrd: Option>, + buffer: &mut ChunkBuf, +) -> Result +where + F: Read + Seek, +{ + let elf_load_info = load_static_elf_with_buffer( + importer, + kernel_image, + kernel_minimum_start_address, + 0, + false, + BootPageAcceptance::Exclusive, + "linux-kernel", + buffer, + ) + .map_err(Error::ElfLoader)?; + + let crate::elf::LoadInfo { + minimum_address_used: min_addr, + next_available_address: next_addr, + entrypoint, + } = elf_load_info; + tracing::trace!(min_addr, next_addr, entrypoint, "loaded kernel"); + + let initrd_info = import_initrd_with_buffer(initrd, next_addr, importer, buffer)?; + + Ok(LoadInfo { + kernel: KernelInfo { + gpa: min_addr, + size: next_addr - min_addr, + entrypoint, + }, + initrd: initrd_info, + dtb: None, + bzimage_setup_header: None, + }) +} + +fn import_command_line( + importer: &mut impl ImageLoad, + cmdline: &CString, +) -> Result<(), Error> { + let raw_cmdline = cmdline.as_bytes_with_nul(); + let slot_size = MPTABLE_CMDLINE_END - MPTABLE_CMDLINE_BASE; + if raw_cmdline.len() as u64 > slot_size { + return Err(Error::CommandLineTooLong(raw_cmdline.len(), slot_size)); + } + if raw_cmdline.len() > 1 { + let cmdline_size_pages = align_up_to_page_size(raw_cmdline.len() as u64) / HV_PAGE_SIZE; + importer + .import_pages( + MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE, + cmdline_size_pages, + "linux-commandline", + BootPageAcceptance::Exclusive, + raw_cmdline, + ) + .map_err(Error::Importer)?; + } + Ok(()) +} + +fn import_x86_boot_pages(importer: &mut impl ImageLoad) -> Result<(), Error> { + import_default_gdt(importer, GDT_BASE / HV_PAGE_SIZE).map_err(Error::Importer)?; + let mut page_table_work_buffer: Vec = + vec![PageTable::new_zeroed(); MPTABLE_PAGE_TABLE_COUNT]; + let mut page_table: Vec = vec![0; MPTABLE_PAGE_TABLE_COUNT * HV_PAGE_SIZE as usize]; + let page_table = IdentityMapBuilder::new( + CR3_BASE, + IdentityMapSize::Size4Gb, + page_table_work_buffer.as_mut_slice(), + page_table.as_mut_slice(), + )? + .build(); + assert!((page_table.len() as u64).is_multiple_of(HV_PAGE_SIZE)); + importer + .import_pages( + CR3_BASE / HV_PAGE_SIZE, + page_table.len() as u64 / HV_PAGE_SIZE, + "linux-pagetables", + BootPageAcceptance::Exclusive, + page_table, + ) + .map_err(Error::Importer)?; + Ok(()) +} + +fn import_x86_registers( + importer: &mut impl ImageLoad, + load_info: &LoadInfo, +) -> Result<(), Error> { + let mut import_reg = |register| { + importer + .import_vp_register(register) + .map_err(Error::Importer) + }; + + import_reg(X86Register::Cr0(x86defs::X64_CR0_PG | x86defs::X64_CR0_PE))?; + import_reg(X86Register::Cr3(CR3_BASE))?; + import_reg(X86Register::Cr4(x86defs::X64_CR4_PAE))?; + import_reg(X86Register::Efer( + x86defs::X64_EFER_SCE + | x86defs::X64_EFER_LME + | x86defs::X64_EFER_LMA + | x86defs::X64_EFER_NXE, + ))?; + import_reg(X86Register::Rip(load_info.kernel.entrypoint))?; + import_reg(X86Register::Rsi(ZERO_PAGE_BASE))?; + Ok(()) +} + +fn import_mptable_config( + importer: &mut impl ImageLoad, + load_info: &LoadInfo, + cmdline: &CString, + mem_layout: &MemoryLayout, + config: &mptable::MpTableConfig<'_>, + reserved_memory_ranges: &[MemoryRange], +) -> Result<(), Error> { + let raw_cmdline = cmdline.as_bytes_with_nul(); + let slot_size = MPTABLE_CMDLINE_END - MPTABLE_CMDLINE_BASE; + if raw_cmdline.len() as u64 > slot_size { + return Err(Error::CommandLineTooLong(raw_cmdline.len(), slot_size)); + } + let tables = mptable::build(config)?; + let table_end = mptable::MP_CONFIG_TABLE_ADDR + .checked_add(tables.configuration_table.len()) + .ok_or(Error::InvalidMpTableMemoryLayout)?; + if table_end > GDT_BASE as usize { + return Err(Error::MpTableOverlap { + table_end, + gdt_addr: GDT_BASE, + }); + } + let boot_params = build_mptable_zero_page( + mem_layout, + reserved_memory_ranges, + cmdline, + load_info.initrd.as_ref().map(|info| info.gpa).unwrap_or(0) as u32, + load_info.initrd.as_ref().map(|info| info.size).unwrap_or(0) as u32, + )?; + + let mut mp_page = [0u8; HV_PAGE_SIZE as usize]; + mp_page[mptable::MP_FLOATING_POINTER_ADDR + ..mptable::MP_FLOATING_POINTER_ADDR + tables.floating_pointer.len()] + .copy_from_slice(&tables.floating_pointer); + mp_page[mptable::MP_CONFIG_TABLE_ADDR..table_end].copy_from_slice(&tables.configuration_table); + importer + .import_pages( + 0, + 1, + "linux-mptable", + BootPageAcceptance::Exclusive, + &mp_page, + ) + .map_err(Error::Importer)?; + + import_command_line(importer, cmdline)?; + import_x86_boot_pages(importer)?; + importer + .import_pages( + ZERO_PAGE_BASE / HV_PAGE_SIZE, + 1, + "linux-zeropage", + BootPageAcceptance::Exclusive, + boot_params.as_bytes(), + ) + .map_err(Error::Importer)?; + import_x86_registers(importer, load_info)?; + Ok(()) +} + +/// Loads an uncompressed x86-64 ELF kernel through Linux direct boot with +/// Intel MP tables and no ACPI, SMBIOS, or firmware tables. +pub fn load_x86_mptable( + importer: &mut impl ImageLoad, + kernel_image: &mut F, + initrd: Option>, + cmdline: &CString, + mem_layout: &MemoryLayout, + config: &mptable::MpTableConfig<'_>, + reserved_memory_ranges: &[MemoryRange], +) -> Result +where + F: Read + Seek, +{ + if crate::bzimage::is_bzimage(kernel_image).map_err(Error::BzImage)? { + return Err(Error::MpTableRequiresElf); + } + let mut buffer = ChunkBuf::with_size(MPTABLE_LOAD_CHUNK_SIZE); + let load_info = load_uncompressed_kernel_and_initrd_x64_with_buffer( + importer, + kernel_image, + KERNEL_BASE, + initrd, + &mut buffer, + )?; + import_mptable_config( + importer, + &load_info, + cmdline, + mem_layout, + config, + reserved_memory_ranges, + )?; + Ok(load_info) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::importer::IgvmParameterType; + use crate::importer::IsolationConfig; + use crate::importer::ParameterAreaIndex; + use crate::importer::StartupMemoryType; + use test_with_tracing::test; + use zerocopy::FromBytes; + + const MB: u64 = 0x100000; + const GB: u64 = 0x4000_0000; + + fn make_layout(ram_size: u64) -> MemoryLayout { + MemoryLayout::new( + ram_size, + &[MemoryRange::new(4 * GB - 128 * MB..4 * GB)], + &[], + &[], + None, + ) + .unwrap() + } + + fn make_mptable_layout(ram_size: u64) -> MemoryLayout { + MemoryLayout::new( + ram_size, + &[MemoryRange::new(3 * GB..4 * GB)], + &[], + &[], + None, + ) + .unwrap() + } + + #[derive(Default)] + struct RecordingImporter { + pages: Vec<(String, u64, u64)>, + imports: Vec, + registers: Vec, + vp_context_page: Option, + } + + #[derive(Debug)] + struct ImportRecord { + page_base: u64, + page_count: u64, + tag: String, + data: Vec, + } + + impl RecordingImporter { + fn page_base(&self, tag: &str) -> Option { + self.pages + .iter() + .find(|(t, ..)| t == tag) + .map(|(_, base, _)| *base) + } + } + + impl ImageLoad for RecordingImporter { + fn isolation_config(&self) -> IsolationConfig { + IsolationConfig { + paravisor_present: false, + isolation_type: crate::importer::IsolationType::None, + shared_gpa_boundary_bits: None, + } + } + + fn create_parameter_area( + &mut self, + _page_base: u64, + _page_count: u32, + _debug_tag: &str, + ) -> anyhow::Result { + unimplemented!() + } + + fn create_parameter_area_with_data( + &mut self, + _page_base: u64, + _page_count: u32, + _debug_tag: &str, + _initial_data: &[u8], + ) -> anyhow::Result { + unimplemented!() + } + + fn import_parameter( + &mut self, + _parameter_area: ParameterAreaIndex, + _byte_offset: u32, + _parameter_type: IgvmParameterType, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn import_pages( + &mut self, + page_base: u64, + page_count: u64, + debug_tag: &'static str, + _acceptance: BootPageAcceptance, + data: &[u8], + ) -> anyhow::Result<()> { + self.pages + .push((debug_tag.to_string(), page_base, page_count)); + self.imports.push(ImportRecord { + page_base, + page_count, + tag: debug_tag.to_string(), + data: data.to_vec(), + }); + Ok(()) + } + + fn import_vp_register(&mut self, register: X86Register) -> anyhow::Result<()> { + self.registers.push(register); + Ok(()) + } + + fn verify_startup_memory_available( + &mut self, + _page_base: u64, + _page_count: u64, + _memory_type: StartupMemoryType, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn set_vp_context_page(&mut self, page_base: u64) -> anyhow::Result<()> { + self.vp_context_page = Some(page_base); + Ok(()) + } + + fn relocation_region( + &mut self, + _gpa: u64, + _size_bytes: u64, + _relocation_alignment: u64, + _minimum_relocation_gpa: u64, + _maximum_relocation_gpa: u64, + _apply_rip_offset: bool, + _apply_gdtr_offset: bool, + _vp_index: u16, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn page_table_relocation( + &mut self, + _page_table_gpa: u64, + _size_pages: u64, + _used_pages: u64, + _vp_index: u16, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn set_imported_regions_config_page(&mut self, _page_base: u64) { + unimplemented!() + } + } + + fn test_load_info() -> LoadInfo { + LoadInfo { + kernel: KernelInfo { + gpa: KERNEL_BASE, + size: 0x1000, + entrypoint: KERNEL_BASE, + }, + initrd: None, + dtb: None, + bzimage_setup_header: None, + } + } + + fn mptable_config(apic_ids: &[u32]) -> mptable::MpTableConfig<'_> { + mptable::MpTableConfig { + apic_ids, + level_triggered_irqs: &[4, 5, 6, 7], + } + } + + fn imported_zero_page(importer: &RecordingImporter) -> defs::boot_params { + let data = &importer + .imports + .iter() + .find(|import| import.tag == "linux-zeropage") + .unwrap() + .data; + defs::boot_params::read_from_bytes(data).unwrap() + } + + #[test] + fn mptable_config_places_boot_data_without_firmware_tables() { + let apic_ids = [0, 1, 2, 3]; + let mut importer = RecordingImporter::default(); + import_mptable_config( + &mut importer, + &test_load_info(), + &CString::new("console=hvc0").unwrap(), + &make_layout(256 * MB), + &mptable_config(&apic_ids), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + + let mp = importer + .imports + .iter() + .find(|import| import.tag == "linux-mptable") + .unwrap(); + assert_eq!(mp.page_base, 0); + assert_eq!(mp.page_count, 1); + assert_eq!( + &mp.data[mptable::MP_FLOATING_POINTER_ADDR..mptable::MP_FLOATING_POINTER_ADDR + 4], + b"_MP_" + ); + let table_length = u16::from_le_bytes( + mp.data[mptable::MP_CONFIG_TABLE_ADDR + 4..mptable::MP_CONFIG_TABLE_ADDR + 6] + .try_into() + .unwrap(), + ) as usize; + assert!(mptable::MP_CONFIG_TABLE_ADDR + table_length <= GDT_BASE as usize); + assert_eq!( + mp.data[mptable::MP_CONFIG_TABLE_ADDR..mptable::MP_CONFIG_TABLE_ADDR + table_length] + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + importer.page_base("linux-commandline"), + Some(MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE) + ); + assert!(importer.imports.iter().all(|import| { + !import.tag.contains("acpi") + && !import.tag.contains("rsdp") + && !import.tag.contains("smbios") + })); + + let boot_params = imported_zero_page(&importer); + assert_eq!(boot_params.acpi_rsdp_addr, 0); + assert_eq!( + u32::from(boot_params.hdr.cmd_line_ptr), + MPTABLE_CMDLINE_BASE as u32 + ); + assert_eq!(u32::from(boot_params.hdr.cmdline_size), 12); + assert!( + importer + .registers + .contains(&X86Register::Rsi(ZERO_PAGE_BASE)) + ); + assert!(importer.registers.contains(&X86Register::Cr3(CR3_BASE))); + assert!( + importer + .registers + .contains(&X86Register::Cr0(x86defs::X64_CR0_PG | x86defs::X64_CR0_PE)) + ); + assert!( + importer + .registers + .contains(&X86Register::Cr4(x86defs::X64_CR4_PAE)) + ); + assert!(importer.registers.contains(&X86Register::Rip(KERNEL_BASE))); + assert!( + !importer + .registers + .contains(&X86Register::Pat(x86defs::X86X_MSR_DEFAULT_PAT)) + ); + assert!( + !importer + .registers + .contains(&X86Register::MtrrDefType(0xc00)) + ); + } + + #[test] + fn mptable_config_supports_full_command_line_region() { + let cmdline = CString::new(vec![b'a'; 65_535]).unwrap(); + let mut importer = RecordingImporter::default(); + import_mptable_config( + &mut importer, + &test_load_info(), + &cmdline, + &make_layout(256 * MB), + &mptable_config(&[0]), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + let imported = importer + .imports + .iter() + .find(|import| import.tag == "linux-commandline") + .unwrap(); + assert_eq!(imported.page_base, MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE); + assert_eq!(imported.page_count, 16); + assert_eq!(imported.data.len(), 65_536); + assert_eq!(imported.data[65_535], 0); + + let mut importer = RecordingImporter::default(); + let cmdline = CString::new(vec![b'a'; 65_536]).unwrap(); + let error = import_mptable_config( + &mut importer, + &test_load_info(), + &cmdline, + &make_layout(256 * MB), + &mptable_config(&[0]), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap_err(); + assert!(matches!(error, Error::CommandLineTooLong(65_537, 65_536))); + assert!(importer.imports.is_empty()); + } + + #[test] + fn mptable_e820_reserves_status_and_isa_hole_and_leaves_mmio_gap() { + let entries = build_mptable_e820( + &make_mptable_layout(8 * GB), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + assert_eq!( + entries.as_slice(), + &[ + E820Range { + start: 0, + end: 0x3_0000, + entry_type: defs::E820_RAM, + }, + E820Range { + start: 0x3_0000, + end: 0x3_1000, + entry_type: defs::E820_RESERVED, + }, + E820Range { + start: 0x3_1000, + end: MPTABLE_ISA_HOLE_BASE, + entry_type: defs::E820_RAM, + }, + E820Range { + start: MPTABLE_ISA_HOLE_BASE, + end: MPTABLE_ISA_HOLE_END, + entry_type: defs::E820_RESERVED, + }, + E820Range { + start: MPTABLE_ISA_HOLE_END, + end: MPTABLE_MMIO_GAP_BASE, + entry_type: defs::E820_RAM, + }, + E820Range { + start: MPTABLE_MMIO_GAP_END, + end: 9 * GB, + entry_type: defs::E820_RAM, + }, + ] + ); + } + + #[test] + fn mptable_e820_rejects_invalid_reservations_and_capacity() { + let layout = make_mptable_layout(256 * MB); + for ranges in [ + vec![MemoryRange::new(256 * MB..256 * MB + HV_PAGE_SIZE)], + vec![ + MemoryRange::new(0x4_0000..0x4_2000), + MemoryRange::new(0x4_1000..0x4_3000), + ], + vec![ + MemoryRange::new(0x5_0000..0x5_1000), + MemoryRange::new(0x4_0000..0x4_1000), + ], + ] { + assert!(matches!( + build_mptable_e820(&layout, &ranges), + Err(Error::InvalidReservedMemoryRange { .. }) + )); + } + + let reservations = (0..70) + .map(|index| { + let start = 0x1_0000 + index * 0x2000; + MemoryRange::new(start..start + HV_PAGE_SIZE) + }) + .collect::>(); + assert!(matches!( + build_mptable_zero_page(&layout, &reservations, &CString::new("").unwrap(), 0, 0,), + Err(Error::TooManyMemoryRanges(128)) + )); + } + + #[test] + fn mptable_config_rejects_table_overlap_before_import() { + let apic_ids = (0..200).collect::>(); + let mut importer = RecordingImporter::default(); + let error = import_mptable_config( + &mut importer, + &test_load_info(), + &CString::new("").unwrap(), + &make_mptable_layout(256 * MB), + &mptable_config(&apic_ids), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap_err(); + assert!(matches!(error, Error::MpTableOverlap { .. })); + assert!(importer.imports.is_empty()); + } +} diff --git a/vm/loader/src/mptable.rs b/vm/loader/src/mptable.rs new file mode 100644 index 00000000000..206b23a5c28 --- /dev/null +++ b/vm/loader/src/mptable.rs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Intel MP 1.4 table construction for x86 guests. + +use thiserror::Error; + +/// Guest-physical address of the MP floating pointer. +pub const MP_FLOATING_POINTER_ADDR: usize = 0; +/// Guest-physical address of the MP configuration table. +pub const MP_CONFIG_TABLE_ADDR: usize = 0x400; + +const MP_IRQ_FLAGS_LEVEL_HIGH: u16 = 0x000d; +const MP_CONFIG_HEADER_SIZE: usize = 44; +const MP_PROCESSOR_SIZE: usize = 20; +const MP_NON_PROCESSOR_ENTRY_COUNT: usize = 17; + +/// Guest-visible processor and interrupt data represented by MP tables. +#[derive(Debug, Clone, Copy)] +pub struct MpTableConfig<'a> { + /// APIC IDs in virtual-processor order. The first entry is the BSP. + pub apic_ids: &'a [u32], + /// ISA IRQs described as active-high, level-triggered. + pub level_triggered_irqs: &'a [u32], +} + +/// Fully constructed MP 1.4 data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MpTables { + /// The 16-byte MP floating pointer. + pub floating_pointer: [u8; 16], + /// The MP configuration table referenced by the floating pointer. + pub configuration_table: Vec, +} + +/// MP table construction error. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// At least one processor must be described. + #[error("MP processor topology must contain at least one processor")] + NoProcessors, + /// MicroVM APIC IDs must be contiguous and start at zero. + #[error("MP processor {index} has APIC ID {apic_id}; expected {expected}")] + InvalidApicId { + /// Processor index. + index: usize, + /// Supplied APIC ID. + apic_id: u32, + /// Required APIC ID. + expected: u32, + }, + /// MP 1.4 processor entries contain only an 8-bit APIC ID. + #[error("MP processor {index} APIC ID {apic_id} does not fit in an MP 1.4 entry")] + ApicIdTooLarge { + /// Processor index. + index: usize, + /// Supplied APIC ID. + apic_id: u32, + }, + /// The entry count or table length cannot be represented. + #[error("MP table has too many processor or interrupt entries")] + TooManyEntries, + /// ISA IRQs are limited to 0 through 15. + #[error("MP table IRQ {0} is outside the ISA range")] + InvalidIrq(u32), +} + +/// Builds the MP floating pointer and configuration table. +pub fn build(config: &MpTableConfig<'_>) -> Result { + let configuration_table = build_config_table(config)?; + let mut floating_pointer = [0u8; 16]; + floating_pointer[..4].copy_from_slice(b"_MP_"); + floating_pointer[4..8].copy_from_slice(&(MP_CONFIG_TABLE_ADDR as u32).to_le_bytes()); + floating_pointer[8] = 1; + floating_pointer[9] = 4; + floating_pointer[10] = checksum(&floating_pointer); + Ok(MpTables { + floating_pointer, + configuration_table, + }) +} + +/// Builds an Intel MP 1.4 configuration table. +pub fn build_config_table(config: &MpTableConfig<'_>) -> Result, Error> { + if config.apic_ids.is_empty() { + return Err(Error::NoProcessors); + } + if let Some(&irq) = config.level_triggered_irqs.iter().find(|irq| **irq >= 16) { + return Err(Error::InvalidIrq(irq)); + } + let entry_count = u16::try_from( + config + .apic_ids + .len() + .checked_add(MP_NON_PROCESSOR_ENTRY_COUNT) + .ok_or(Error::TooManyEntries)?, + ) + .map_err(|_| Error::TooManyEntries)?; + + let capacity = MP_CONFIG_HEADER_SIZE + .checked_add( + MP_PROCESSOR_SIZE + .checked_mul(config.apic_ids.len()) + .ok_or(Error::TooManyEntries)?, + ) + .and_then(|size| size.checked_add(136)) + .ok_or(Error::TooManyEntries)?; + let mut table = Vec::with_capacity(capacity); + table.extend_from_slice(b"PCMP"); + table.extend_from_slice(&0u16.to_le_bytes()); + table.push(4); + table.push(0); + table.extend_from_slice(b"OPENVMM "); + table.extend_from_slice(b"MICROVM "); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&0u16.to_le_bytes()); + table.extend_from_slice(&entry_count.to_le_bytes()); + table.extend_from_slice(&0xfee0_0000u32.to_le_bytes()); + table.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(table.len(), MP_CONFIG_HEADER_SIZE); + + for (index, &apic_id) in config.apic_ids.iter().enumerate() { + let expected = u32::try_from(index).map_err(|_| Error::TooManyEntries)?; + if apic_id != expected { + return Err(Error::InvalidApicId { + index, + apic_id, + expected, + }); + } + let apic_id = + u8::try_from(apic_id).map_err(|_| Error::ApicIdTooLarge { index, apic_id })?; + table.extend_from_slice(&[0, apic_id, 0x14, if index == 0 { 3 } else { 1 }]); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&[0; 8]); + } + assert_eq!( + table.len(), + MP_CONFIG_HEADER_SIZE + MP_PROCESSOR_SIZE * config.apic_ids.len() + ); + + table.extend_from_slice(&[1, 0]); + table.extend_from_slice(b"ISA "); + table.extend_from_slice(&[2, 0, 0x11, 1]); + table.extend_from_slice(&0xfec0_0000u32.to_le_bytes()); + + for irq in (0u8..16).filter(|irq| *irq != 2) { + let pin = if irq == 0 { 2 } else { irq }; + let flags = if config.level_triggered_irqs.contains(&u32::from(irq)) { + MP_IRQ_FLAGS_LEVEL_HIGH + } else { + 0 + }; + table.extend_from_slice(&[3, 0]); + table.extend_from_slice(&flags.to_le_bytes()); + table.extend_from_slice(&[0, irq, 0, pin]); + } + + let table_len = u16::try_from(table.len()).map_err(|_| Error::TooManyEntries)?; + table[4..6].copy_from_slice(&table_len.to_le_bytes()); + table[7] = checksum(&table); + Ok(table) +} + +fn checksum(bytes: &[u8]) -> u8 { + 0u8.wrapping_sub( + bytes + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn builds_valid_checksums_and_processor_entries() { + for processor_count in [1usize, 2, 4, 8] { + let apic_ids = (0..processor_count as u32).collect::>(); + let tables = build(&MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[4, 5, 6, 7], + }) + .unwrap(); + + assert_eq!(&tables.floating_pointer[..4], b"_MP_"); + assert_eq!( + u32::from_le_bytes(tables.floating_pointer[4..8].try_into().unwrap()), + MP_CONFIG_TABLE_ADDR as u32 + ); + assert_eq!( + tables + .floating_pointer + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + tables + .configuration_table + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + tables.configuration_table.len(), + 180 + MP_PROCESSOR_SIZE * processor_count + ); + for (index, entry) in tables.configuration_table + [MP_CONFIG_HEADER_SIZE..MP_CONFIG_HEADER_SIZE + MP_PROCESSOR_SIZE * processor_count] + .chunks_exact(MP_PROCESSOR_SIZE) + .enumerate() + { + assert_eq!(entry[0], 0); + assert_eq!(entry[1], index as u8); + assert_eq!(entry[3], if index == 0 { 3 } else { 1 }); + } + } + } + + #[test] + fn rejects_invalid_inputs() { + assert_eq!( + build(&MpTableConfig { + apic_ids: &[], + level_triggered_irqs: &[], + }), + Err(Error::NoProcessors) + ); + assert!(matches!( + build(&MpTableConfig { + apic_ids: &[0, 2], + level_triggered_irqs: &[], + }), + Err(Error::InvalidApicId { .. }) + )); + assert_eq!( + build(&MpTableConfig { + apic_ids: &[0], + level_triggered_irqs: &[16], + }), + Err(Error::InvalidIrq(16)) + ); + let apic_ids = (0..=256).collect::>(); + assert!(matches!( + build(&MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[], + }), + Err(Error::ApicIdTooLarge { + index: 256, + apic_id: 256 + }) + )); + } +} diff --git a/vm/power_resources/src/lib.rs b/vm/power_resources/src/lib.rs index d3fbea82f30..1e83c97069a 100644 --- a/vm/power_resources/src/lib.rs +++ b/vm/power_resources/src/lib.rs @@ -52,4 +52,9 @@ pub enum PowerRequest { /// The VP that caused the triple fault. vp: u32, }, + /// Power off the VM and return the guest-provided process status. + PowerOffWithStatus { + /// Guest-provided process status. + code: u8, + }, } diff --git a/vm/vmcore/src/device_state.rs b/vm/vmcore/src/device_state.rs index bffa5515366..094144a76a9 100644 --- a/vm/vmcore/src/device_state.rs +++ b/vm/vmcore/src/device_state.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] use std::future::Future; +use std::time::Duration; /// Trait for transitioning device state. pub trait ChangeDeviceState { @@ -27,6 +28,26 @@ pub trait ChangeDeviceState { // to finish, which may require a bunch of other changes. fn start(&mut self); + /// Starts a device and waits until startup has either completed or failed. + /// + /// The default preserves the notification-only behavior of [`Self::start`]. + /// Devices that restore process-local workers should override this so a + /// restore failure is reported before dependent units start. + fn start_fallible(&mut self) -> impl Send + Future> { + self.start(); + async { Ok(()) } + } + + /// Stops accepting new host input before a snapshot vCPU boundary. + fn quiesce_input(&mut self) -> impl Send + Future> { + async { Ok(()) } + } + + /// Resumes host input after a failed snapshot transaction. + fn resume_input(&mut self) -> impl Send + Future> { + async { Ok(()) } + } + /// Stops a device's asynchronous work. /// /// After this returns, the device must not process any additional work. It @@ -42,4 +63,21 @@ pub trait ChangeDeviceState { /// Callers must ensure that the device is in a stopped state before calling /// this method. fn reset(&mut self) -> impl Send + Future; + + /// Advances guest-visible device time after snapshot restore. + fn advance_time( + &mut self, + _duration: Duration, + ) -> impl Send + Future> { + async { Ok(()) } + } + + /// Returns whether [`Self::advance_time`] does any work for this device, + /// because it keeps guest-visible time of its own instead of deriving its + /// deadlines from VM time. Only such devices receive the advance after a + /// restore, so a device that implements [`Self::advance_time`] must + /// return `true`. + fn advances_time(&self) -> bool { + false + } } diff --git a/vm/vmcore/src/save_restore.rs b/vm/vmcore/src/save_restore.rs index 895215fe015..e3644fd6ee7 100644 --- a/vm/vmcore/src/save_restore.rs +++ b/vm/vmcore/src/save_restore.rs @@ -115,7 +115,7 @@ pub trait ProtobufSaveRestore { } /// An opaque saved state blob, encoded as a protobuf message. -#[derive(Debug, Protobuf)] +#[derive(Clone, Debug, Protobuf)] #[mesh(transparent)] pub struct SavedStateBlob(ProtobufAny); @@ -141,6 +141,11 @@ impl SavedStateBlob { pub fn parse(&self) -> Result { self.0.parse() } + + /// Returns the encoded state payload length without decoding it. + pub fn encoded_len(&self) -> usize { + self.0.value_len() + } } impl ProtobufSaveRestore for T @@ -244,3 +249,16 @@ pub mod private { }; } } + +#[cfg(test)] +mod tests { + use super::NoSavedState; + use super::SavedStateBlob; + + #[test] + fn cloned_saved_state_blob_parses() { + let state = SavedStateBlob::new(NoSavedState); + state.clone().parse::().unwrap(); + state.parse::().unwrap(); + } +} diff --git a/vm/vmcore/src/vmtime.rs b/vm/vmcore/src/vmtime.rs index 0991be23693..e3f30659663 100644 --- a/vm/vmcore/src/vmtime.rs +++ b/vm/vmcore/src/vmtime.rs @@ -481,6 +481,16 @@ impl VmTimeKeeper { self.reset_to(vmtime).await } + /// Advances stopped VM time by `duration`. + pub async fn advance(&mut self, duration: Duration) { + let vmtime = self + .time + .stop_time() + .expect("VM time must be stopped before advancing") + .wrapping_add(duration); + self.reset_to(vmtime).await; + } + async fn reset_to(&mut self, vmtime: VmTime) { assert!(!self.time.is_started(), "should be stopped"); self.time = TimeState::Stopped(vmtime); diff --git a/vm/whp/src/abi/x64.rs b/vm/whp/src/abi/x64.rs index d50797d3fcd..1ce7c64bac6 100644 --- a/vm/whp/src/abi/x64.rs +++ b/vm/whp/src/abi/x64.rs @@ -342,7 +342,7 @@ impl WHV_PROCESSOR_FEATURES { impl WHV_PROCESSOR_FEATURES1 { pub const ACountMCountSupport: Self = Self(1 << 0); - pub const Reserved1: Self = Self(1 << 1); + pub const TscInvariantSupport: Self = Self(1 << 1); pub const ClZeroSupport: Self = Self(1 << 2); pub const RdpruSupport: Self = Self(1 << 3); @@ -747,12 +747,12 @@ pub const WHvX64CpuidResult2FlagVpSpecific: WHV_X64_CPUID_RESULT2_FLAGS = #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct WHV_X64_CPUID_RESULT2 { - Function: u32, - Index: u32, - VpIndex: u32, - Flags: WHV_X64_CPUID_RESULT2_FLAGS, - Output: WHV_CPUID_OUTPUT, - Mask: WHV_CPUID_OUTPUT, + pub Function: u32, + pub Index: u32, + pub VpIndex: u32, + pub Flags: WHV_X64_CPUID_RESULT2_FLAGS, + pub Output: WHV_CPUID_OUTPUT, + pub Mask: WHV_CPUID_OUTPUT, } #[repr(C)] diff --git a/vm/whp/src/lib.rs b/vm/whp/src/lib.rs index 2662a514bc0..1052d22c827 100644 --- a/vm/whp/src/lib.rs +++ b/vm/whp/src/lib.rs @@ -117,6 +117,21 @@ pub mod capabilities { Err(err) => Err(err), } } + /// The available processor features of the first bank, from the query + /// that predates feature banks. + pub fn processor_features_bank0() -> Result { + get(abi::WHvCapabilityCodeProcessorFeatures) + } + /// The available processor feature banks, without a fallback to the + /// query that predates them. + pub fn processor_features_banks() -> Result { + get(abi::WHvCapabilityCodeProcessorFeaturesBanks) + } + /// The available synthetic processor feature banks, whatever their count. + pub fn synthetic_processor_features_banks() + -> Result { + get(abi::WHvCapabilityCodeSyntheticProcessorFeaturesBanks) + } /// Processor frequency capping capabilities. pub fn processor_frequency_cap() -> Result { get(abi::WHvCapabilityCodeProcessorFrequencyCap) @@ -166,6 +181,8 @@ pub struct SyntheticProcessorFeatures { pub struct WHvError(NonZeroI32); impl WHvError { + pub const ERROR_NOT_SUPPORTED: Self = Self(NonZeroI32::new(0x8007_0032_u32 as i32).unwrap()); + pub const WHV_E_UNKNOWN_CAPABILITY: Self = Self(NonZeroI32::new(api::WHV_E_UNKNOWN_CAPABILITY).unwrap()); @@ -234,6 +251,10 @@ pub enum PartitionProperty<'a> { AllowDeviceAssignment(bool), DisableSmt(bool), ProcessorFeatures(ProcessorFeatures), + /// Processor features banks 0 and 1, always set through the banks + /// property. Unlike [`Self::ProcessorFeatures`], a zero bank 1 is set + /// explicitly instead of keeping WHP's default. + ProcessorFeaturesBanks(ProcessorFeatures), ProcessorClFlushSize(u8), #[cfg(target_arch = "x86_64")] CpuidExitList(&'a [u32]), @@ -427,6 +448,14 @@ impl Partition { set(partition_prop::ProcessorFeaturesBanks, &banks) } } + PartitionProperty::ProcessorFeaturesBanks(val) => { + banks = abi::WHV_PROCESSOR_FEATURES_BANKS { + BanksCount: 2, + Reserved0: 0, + Banks: [val.bank0.0, val.bank1.0], + }; + set(partition_prop::ProcessorFeaturesBanks, &banks) + } PartitionProperty::ProcessorClFlushSize(val) => { set(partition_prop::ProcessorClFlushSize, val) } @@ -737,6 +766,15 @@ impl Partition { self.get_property(partition_prop::ReferenceTime) } + pub fn extended_vm_exits(&self) -> Result { + self.get_property(partition_prop::ExtendedVmExits) + } + + #[cfg(target_arch = "x86_64")] + pub fn x64_msr_exit_bitmap(&self) -> Result { + self.get_property(partition_prop::X64MsrExitBitmap) + } + pub fn physical_address_width(&self) -> Result { self.get_property(partition_prop::PhysicalAddressWidth) } diff --git a/vm/whp/src/x64.rs b/vm/whp/src/x64.rs index 53b89e2b3ce..10e328d1434 100644 --- a/vm/whp/src/x64.rs +++ b/vm/whp/src/x64.rs @@ -43,6 +43,8 @@ pub enum Register64 { XCr0 = abi::WHvX64RegisterXCr0.0, Tsc = abi::WHvX64RegisterTsc.0, + TscAdjust = abi::WHvX64RegisterTscAdjust.0, + TscDeadline = abi::WHvX64RegisterTscDeadline.0, Efer = abi::WHvX64RegisterEfer.0, KernelGsBase = abi::WHvX64RegisterKernelGsBase.0, ApicBase = abi::WHvX64RegisterApicBase.0, diff --git a/vm/x86/x86defs/src/lib.rs b/vm/x86/x86defs/src/lib.rs index 5f8b79383c3..0edd5d61cc0 100644 --- a/vm/x86/x86defs/src/lib.rs +++ b/vm/x86/x86defs/src/lib.rs @@ -209,6 +209,7 @@ pub struct MiscEnable { } pub const X86X_MSR_TSC: u32 = 0x10; +pub const X86X_MSR_TSC_DEADLINE: u32 = 0x6e0; pub const X86X_IA32_MSR_PLATFORM_ID: u32 = 0x17; pub const X86X_MSR_APIC_BASE: u32 = 0x1b; pub const X86X_MSR_EBL_CR_POWERON: u32 = 0x2a; diff --git a/vmm_core/Cargo.toml b/vmm_core/Cargo.toml index fff9330f44f..046fc3c9d37 100644 --- a/vmm_core/Cargo.toml +++ b/vmm_core/Cargo.toml @@ -65,5 +65,9 @@ iced-x86 = { optional = true, workspace = true, features = ["std", "fast_fmt"] } [build-dependencies] build_rs_guest_arch.workspace = true +[dev-dependencies] +loader.workspace = true +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vmm_core/cpu_profile/Cargo.toml b/vmm_core/cpu_profile/Cargo.toml new file mode 100644 index 00000000000..f3db0c69e18 --- /dev/null +++ b/vmm_core/cpu_profile/Cargo.toml @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "cpu_profile" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +hvdef.workspace = true +safe_intrinsics.workspace = true + +serde = { workspace = true, features = ["derive", "std"] } +serde_json = { workspace = true, features = ["std"] } +sha2.workspace = true +thiserror.workspace = true + +[target.'cfg(windows)'.dependencies.windows-sys] +workspace = true +features = ["Win32_Foundation", "Win32_System_Registry"] + +[dev-dependencies] +test_with_tracing.workspace = true + +[build-dependencies] +build_rs_guest_arch.workspace = true + +[lints] +workspace = true diff --git a/vmm_core/cpu_profile/build.rs b/vmm_core/cpu_profile/build.rs new file mode 100644 index 00000000000..9a6ae219443 --- /dev/null +++ b/vmm_core/cpu_profile/build.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![expect(missing_docs)] + +fn main() { + build_rs_guest_arch::emit_guest_arch() +} diff --git a/vmm_core/cpu_profile/examples/check_fingerprint.rs b/vmm_core/cpu_profile/examples/check_fingerprint.rs new file mode 100644 index 00000000000..0f85af1bb6a --- /dev/null +++ b/vmm_core/cpu_profile/examples/check_fingerprint.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Checks host fingerprints against the pinned profile of their generation. +//! +//! ```text +//! cargo run -p cpu_profile --example check_fingerprint -- ... +//! ``` +//! +//! Prints one `NVX-CPU-PROFILE:` line per fingerprint, and fails if any +//! check fails. + +use cpu_profile::fingerprint::CpuFingerprint; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut status = ExitCode::SUCCESS; + for path in std::env::args().skip(1) { + let fingerprint = std::fs::read_to_string(&path) + .map_err(|error| error.to_string()) + .and_then(|json| CpuFingerprint::from_json(&json).map_err(|error| error.to_string())); + match fingerprint { + Ok(fingerprint) => { + let check = cpu_profile::check_fingerprint(&fingerprint); + println!("{path}: {}", check.summary_line(&fingerprint)); + if check.result.is_err() { + status = ExitCode::FAILURE; + } + } + Err(error) => { + println!("{path}: error: {error}"); + status = ExitCode::FAILURE; + } + } + } + status +} diff --git a/vmm_core/cpu_profile/examples/derive_profile.rs b/vmm_core/cpu_profile/examples/derive_profile.rs new file mode 100644 index 00000000000..9129ca4e972 --- /dev/null +++ b/vmm_core/cpu_profile/examples/derive_profile.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Derives a CPU profile from host fingerprints. +//! +//! ```text +//! cargo run -p cpu_profile --example derive_profile -- ... +//! ``` +//! +//! Writes the profile, as pretty canonical JSON, to standard output, and its +//! ID and digest to standard error. Pin it under +//! `vmm_core/cpu_profile/profiles/.json`, regenerate the static data in +//! `src/pinned_data.rs` with the `generate_pinned` example, and add the file +//! and its golden digest to the catalog test's `FILES` table. + +use cpu_profile::derive; +use cpu_profile::fingerprint::CpuFingerprint; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let usage = "usage: derive_profile ..."; + let name = args.next().ok_or(usage)?; + let revision = args.next().ok_or(usage)?.parse::()?; + let generation = derive::known_generation(&name).ok_or_else(|| { + format!( + "unknown generation {name:?}; known: {}", + derive::KNOWN_GENERATIONS + .iter() + .map(|generation| generation.name) + .collect::>() + .join(", ") + ) + })?; + let fingerprints = args + .map(|path| { + let json = + std::fs::read_to_string(&path).map_err(|error| format!("{path}: {error}"))?; + CpuFingerprint::from_json(&json).map_err(|error| format!("{path}: {error}")) + }) + .collect::, _>>()?; + let profile = derive::derive_profile(generation, revision, &fingerprints)?; + print!("{}", profile.to_pretty_json()); + eprintln!("{} {}", profile.id(), profile.digest_string()); + Ok(()) +} diff --git a/vmm_core/cpu_profile/examples/generate_pinned.rs b/vmm_core/cpu_profile/examples/generate_pinned.rs new file mode 100644 index 00000000000..5491dfe2872 --- /dev/null +++ b/vmm_core/cpu_profile/examples/generate_pinned.rs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Generates `src/pinned_data.rs`, the pinned profiles as static data, from +//! their pinned JSON files. +//! +//! ```text +//! cargo run -p cpu_profile --example generate_pinned -- vmm_core/cpu_profile/profiles/.json... > vmm_core/cpu_profile/src/pinned_data.rs +//! cargo xtask fmt --pass rustfmt --fix +//! ``` +//! +//! List the files in catalog order. The crate's tests check every generated +//! constant against the files and the golden digests. + +use cpu_profile::CpuProfile; +use cpu_profile::derive::KNOWN_GENERATIONS; +use std::fmt::Write; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(source) => { + print!("{source}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let mut profiles = Vec::new(); + for path in std::env::args().skip(1) { + let json = std::fs::read_to_string(&path).map_err(|error| format!("{path}: {error}"))?; + let profile = + CpuProfile::from_pretty_json(&json).map_err(|error| format!("{path}: {error}"))?; + profiles.push(profile); + } + if profiles.is_empty() { + return Err("no profile files given".to_owned()); + } + let mut out = String::new(); + writeln!( + out, + "// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as static data, generated from `profiles/.json` by +//! the `generate_pinned` example. Do not edit: the tests check every constant +//! against the JSON files and the golden digests. + +use crate::derive::KNOWN_GENERATIONS; +use crate::pinned::PinnedProfile; +use crate::pinned::digest; +use crate::pinned::leaf; +use crate::pinned::msr; +use crate::pinned::source; +use crate::pinned::xsave; + +/// The pinned profiles, in catalog order. Released profiles are immutable: a +/// change is a new revision with a new ID. +pub(crate) const PINNED: [PinnedProfile; {}] = [", + profiles.len() + ) + .unwrap(); + for profile in &profiles { + render(&mut out, profile)?; + } + out.push_str("];\n"); + Ok(out) +} + +fn render(out: &mut String, profile: &CpuProfile) -> Result<(), String> { + let generation = KNOWN_GENERATIONS + .iter() + .position(|known| known.name == profile.generation().name) + .ok_or_else(|| format!("{}: unknown generation", profile.id()))?; + let encoding = String::from_utf8(profile.encode()).expect("canonical JSON is UTF-8"); + let hashes = (0..) + .find(|&count| !encoding.contains(&format!("\"{}", "#".repeat(count + 1)))) + .unwrap() + + 1; + let fence = "#".repeat(hashes); + let digest = profile + .digest() + .iter() + .fold(String::new(), |mut hex, byte| { + write!(hex, "{byte:02x}").unwrap(); + hex + }); + writeln!(out, " PinnedProfile {{").unwrap(); + writeln!(out, " id: {:?},", profile.id()).unwrap(); + writeln!(out, " digest: digest({digest:?}),").unwrap(); + writeln!(out, " encoding: r{fence}\"{encoding}\"{fence},").unwrap(); + writeln!(out, " description: {:?},", profile.description()).unwrap(); + writeln!(out, " generation: &KNOWN_GENERATIONS[{generation}],").unwrap(); + writeln!(out, " cpuid: &[").unwrap(); + for entry in profile.cpuid() { + let subleaf = match entry.subleaf { + Some(subleaf) => format!("Some({})", hex(subleaf.0.into())), + None => "None".to_owned(), + }; + writeln!( + out, + " leaf({}, {subleaf}, {}, {}),", + hex(entry.leaf.0.into()), + hex_array(entry.values()), + hex_array(entry.masks()) + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!(out, " xcr0: {},", hex(profile.xcr0())).unwrap(); + writeln!(out, " xss: {},", hex(profile.xss())).unwrap(); + writeln!(out, " xsave_components: &[").unwrap(); + for component in profile.xsave_components() { + writeln!( + out, + " xsave({}, {}, {}, {}, {}, {}),", + component.index, + component.size, + component.offset, + component.supervisor, + component.align64, + component.xfd + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!( + out, + " physical_address_width: {},", + profile.physical_address_width() + ) + .unwrap(); + writeln!(out, " msrs: &[").unwrap(); + for msr in profile.msrs() { + writeln!( + out, + " msr({}, {}, {}),", + hex(msr.index.0.into()), + hex(msr.value.0), + hex(msr.mask.0) + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!( + out, + " provenance_method: {:?},", + profile.provenance().method + ) + .unwrap(); + writeln!(out, " provenance_sources: &[").unwrap(); + for source in &profile.provenance().sources { + writeln!( + out, + " source({:?}, {}, &{:?}),", + source.backend, source.hosts, source.surface_digests + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!(out, " }},").unwrap(); + Ok(()) +} + +/// Returns `value` as a Rust hex literal, with digit groups of four. +fn hex(value: u64) -> String { + let digits = format!("{value:x}"); + if digits.len() <= 4 { + return format!("0x{digits}"); + } + let mut grouped = String::new(); + for (i, digit) in digits.chars().enumerate() { + if i != 0 && (digits.len() - i) % 4 == 0 { + grouped.push('_'); + } + grouped.push(digit); + } + format!("0x{grouped}") +} + +fn hex_array(values: [u32; 4]) -> String { + format!("[{}]", values.map(|value| hex(value.into())).join(", ")) +} diff --git a/vmm_core/cpu_profile/profiles/amd.genoa.v1.json b/vmm_core/cpu_profile/profiles/amd.genoa.v1.json new file mode 100644 index 00000000000..d0a7daf7ffe --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.genoa.v1.json @@ -0,0 +1,1135 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a10f11", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x219c07a9", + "0x00400604", + "0x00000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a10f11", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x6f6e6547", + "0x00002961", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff48ff40", + "0xff48ff40", + "0x20080140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x5c002200", + "0x6c004200", + "0x04006140", + "0x0a009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x00000005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x01c0003f", + "0x000007ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000204", + "0x00000000", + "0x00000006", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 25, + "model": 17, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "genoa" + }, + "id": "amd.genoa.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/amd.milan.v1.json b/vmm_core/cpu_profile/profiles/amd.milan.v1.json new file mode 100644 index 00000000000..d91b785a91c --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.milan.v1.json @@ -0,0 +1,895 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000000d", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a00f11", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x219c07a9", + "0x00400604", + "0x00000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a00f11", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x616c694d", + "0x0000296e", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff40ff40", + "0xff40ff40", + "0x20080140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x48002200", + "0x68004200", + "0x02006140", + "0x08009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x00000005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x01c0003f", + "0x000003ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 25, + "model": 1, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "milan" + }, + "id": "amd.milan.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/amd.turin.v1.json b/vmm_core/cpu_profile/profiles/amd.turin.v1.json new file mode 100644 index 00000000000..b855b347d1e --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.turin.v1.json @@ -0,0 +1,1223 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00b00f21", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000001", + "0xf1bf07a9", + "0x00405f46", + "0x00000110" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000030", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000980", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000340", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000380", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000580", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00b00f21", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x69727554", + "0x0000296e", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff60ff40", + "0xff60ff40", + "0x300c0140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x40802040", + "0x60804040", + "0x04008140", + "0x0c009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x40020005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000003ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000204", + "0x00000000", + "0x00000006", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 26, + "model": 2, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "turin" + }, + "id": "amd.turin.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 832, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 896, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1408, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json b/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json new file mode 100644 index 00000000000..58258c60f90 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json @@ -0,0 +1,781 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000906a3", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x0240003f", + "0x000007ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x02c0003f", + "0x00007fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000002", + "0x219c27a9", + "0x18400724", + "0xac004410" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000810", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000001" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x726f4320", + "0x4d542865" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x72502029", + "0x7365636f", + "0x20726f73", + "0x646c4128" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x4c207265", + "0x29656b61", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x05007040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000302e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 151, + "steppings": [ + 0, + 15 + ] + }, + { + "family": 6, + "model": 154, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "alderlake" + }, + "id": "intel.alderlake.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x000000000002e123" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json new file mode 100644 index 00000000000..7a58a9a81e2 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json @@ -0,0 +1,820 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000c06f2", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000007ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x04c0003f", + "0x00033fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0xf1bf2fb9", + "0x00415f46", + "0x20000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x72656d45" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20646c61", + "0x69706152", + "0x00297364", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x08007040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003930", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 207, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "emeraldrapids" + }, + "id": "intel.emeraldrapids.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000000000021" + } + ], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "mshv", + "hosts": 4, + "surface_digests": [ + "sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1" + ] + }, + { + "backend": "whp", + "hosts": 2, + "surface_digests": [ + "sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json new file mode 100644 index 00000000000..495ffba7da6 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json @@ -0,0 +1,843 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001b", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000606a6", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x04c0003f", + "0x000003ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x02c0003f", + "0x0000ffff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000002", + "0xf1bf2fb9", + "0x00415f46", + "0x20000010" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000001" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x20656349" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x656b614c", + "0x2950532d", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x01006040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000392e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 106, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "icelake-sp" + }, + "id": "intel.icelake-sp.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000008000121" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 5, + "surface_digests": [ + "sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f" + ] + }, + { + "backend": "mshv", + "hosts": 1, + "surface_digests": [ + "sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379" + ] + }, + { + "backend": "whp", + "hosts": 3, + "surface_digests": [ + "sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json new file mode 100644 index 00000000000..353fa43cf72 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json @@ -0,0 +1,779 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000016", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00050654", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x76036301", + "0x00f0b5ff", + "0x00000000", + "0x00c30000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000003ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x0280003f", + "0x00004fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0xd19f6fb9", + "0x00000000", + "0xac000400" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000ff", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000040", + "0x000003c0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000040", + "0x00000400", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x6c796b53" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2d656b61", + "0x00295053", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x01006040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000302e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 85, + "steppings": [ + 0, + 4 + ] + } + ], + "name": "skylake-sp" + }, + "id": "intel.skylake-sp.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000000000000" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c" + ] + }, + { + "backend": "mshv", + "hosts": 1, + "surface_digests": [ + "sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82" + ] + }, + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000ff", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 3, + "offset": 960, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 4, + "offset": 1024, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/src/canonical.rs b/vmm_core/cpu_profile/src/canonical.rs new file mode 100644 index 00000000000..f4cead58bf3 --- /dev/null +++ b/vmm_core/cpu_profile/src/canonical.rs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical JSON and its SHA-256 digests. +//! +//! The canonical form sorts object keys by their UTF-8 bytes and keeps array +//! order, so the producer is responsible for ordering arrays. The compact form +//! has no insignificant whitespace and is the input of every digest. The +//! pretty form indents by two spaces and is what fingerprints are written as. +//! Both are independent of how `serde_json` orders map keys. + +use serde::Serialize; +use serde_json::Value; +use sha2::Digest; + +/// The prefix of every digest string. +pub(crate) const DIGEST_PREFIX: &str = "sha256:"; + +/// Returns the compact canonical encoding of `value`. +pub(crate) fn to_compact(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value, None, 0); + out +} + +/// Returns the compact canonical encoding of a value whose serialization is +/// already canonical: every object's fields are serialized in the byte order +/// of their keys, and no map is unordered. +/// +/// This serializes directly, without the [`Value`] tree that [`to_compact`] +/// sorts, which costs a few hundred microseconds per profile on every +/// OpenVMM start. Tests check that both give the same bytes for every type +/// that uses it. +pub(crate) fn to_compact_ordered(value: &T) -> Vec { + serde_json::to_vec(value).expect("serialization with string keys is infallible") +} + +/// Returns the pretty canonical encoding of a value whose serialization is +/// already canonical, as for [`to_compact_ordered`], with a final newline. +pub(crate) fn to_pretty_ordered(value: &T) -> String { + let mut out = + serde_json::to_string_pretty(value).expect("serialization with string keys is infallible"); + out.push('\n'); + out +} + +/// Returns the pretty canonical encoding of `value`, with a final newline. +pub(crate) fn to_pretty(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value, Some(2), 0); + out.push('\n'); + out +} + +/// Returns `sha256:` and the hex SHA-256 of the compact canonical encoding of +/// `value`. +pub(crate) fn digest(value: &Value) -> String { + format_digest(&sha256(to_compact(value).as_bytes())) +} + +/// Returns the SHA-256 of `bytes`. +pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] { + sha2::Sha256::digest(bytes).into() +} + +/// Returns `sha256:` and the lowercase hex of `hash`. +pub(crate) fn format_digest(hash: &[u8; 32]) -> String { + let mut out = String::with_capacity(DIGEST_PREFIX.len() + 64); + out.push_str(DIGEST_PREFIX); + for byte in hash { + out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap()); + out.push(char::from_digit(u32::from(byte & 0xf), 16).unwrap()); + } + out +} + +fn write_value(out: &mut String, value: &Value, indent: Option, depth: usize) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => out.push_str(&value.to_string()), + Value::String(value) => write_string(out, value), + Value::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.iter().enumerate() { + if i != 0 { + out.push(','); + } + write_newline(out, indent, depth + 1); + write_value(out, item, indent, depth + 1); + } + write_newline(out, indent, depth); + out.push(']'); + } + Value::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + let mut entries = map.iter().collect::>(); + entries.sort_unstable_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes())); + out.push('{'); + for (i, (key, item)) in entries.into_iter().enumerate() { + if i != 0 { + out.push(','); + } + write_newline(out, indent, depth + 1); + write_string(out, key); + out.push(':'); + if indent.is_some() { + out.push(' '); + } + write_value(out, item, indent, depth + 1); + } + write_newline(out, indent, depth); + out.push('}'); + } + } +} + +fn write_newline(out: &mut String, indent: Option, depth: usize) { + if let Some(indent) = indent { + out.push('\n'); + out.extend(std::iter::repeat_n(' ', indent * depth)); + } +} + +fn write_string(out: &mut String, value: &str) { + // serde_json escapes deterministically, and serializing a string cannot + // fail. + out.push_str(&serde_json::to_string(value).expect("string serialization is infallible")); +} + +#[cfg(test)] +mod tests { + use super::digest; + use super::to_compact; + use super::to_pretty; + use serde_json::json; + use test_with_tracing::test; + + #[test] + fn sorts_object_keys_and_keeps_array_order() { + let value = json!({"b": [3, 1, {"z": null, "a": true}], "a": "x", "B": {}}); + assert_eq!( + to_compact(&value), + r#"{"B":{},"a":"x","b":[3,1,{"a":true,"z":null}]}"# + ); + } + + #[test] + fn pretty_form_is_indented_and_terminated() { + let value = json!({"b": [1, 2], "a": {"c": "d"}, "e": [], "f": {}}); + assert_eq!( + to_pretty(&value), + "{\n \"a\": {\n \"c\": \"d\"\n },\n \"b\": [\n 1,\n 2\n ],\n \"e\": [],\n \"f\": {}\n}\n" + ); + } + + #[test] + fn escapes_strings() { + let value = json!({"k\"ey": "line\nbreak\u{1}"}); + assert_eq!(to_compact(&value), r#"{"k\"ey":"line\nbreak\u0001"}"#); + } + + #[test] + fn digest_is_sha256_of_compact_form() { + // SHA-256 of `{}`. + assert_eq!( + digest(&json!({})), + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ); + // Key order does not change the digest. + assert_eq!( + digest(&json!({"a": 1, "b": 2})), + digest(&serde_json::from_str(r#"{"b": 2, "a": 1}"#).unwrap()) + ); + } + + #[test] + fn digest_strings_are_prefixed_lowercase_hex() { + let text = super::format_digest(&super::sha256(b"{}")); + assert_eq!(text, digest(&json!({}))); + } +} diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs new file mode 100644 index 00000000000..aec93046179 --- /dev/null +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -0,0 +1,952 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU profiles pinned in this OpenVMM, profile selection, and the +//! profile checks of cold boot and restore. + +use crate::canonical; +use crate::derive::HOST_GENERATION; +use crate::derive::KnownGeneration; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::pinned::PinnedRecord; +use crate::pinned_data::PINNED; +use crate::profile::CpuProfile; +use crate::profile::is_id_component; +use crate::profile::is_revision; +use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; +use std::fmt; +use std::ops::Deref; +use std::sync::Arc; +use std::sync::OnceLock; + +/// The `--cpu-profile` value that selects the profile of the host's +/// generation. +pub const AUTO: &str = "auto"; + +/// The `--cpu-profile` value that selects a host profile: a profile that the +/// VM worker derives from the backend's fingerprint of this host +/// ([`derive_host_profile`](crate::derive::derive_host_profile)) and that +/// only its partition holds ([`PartitionProfile::host`]). It is for +/// development hosts that no pinned profile serves. [`select`] never selects +/// it, but the VM worker falls back to it from [`AUTO`] on a CPU that no +/// pinned profile serves ([`pinned_profile_serves`]) and that host profiles +/// serve ([`supports_host_profiles`](crate::derive::supports_host_profiles)), +/// and warns. +pub const HOST: &str = "host"; + +/// Each pinned profile, built from its static data on first use, so that a +/// cold boot or a restore builds only its own. +static PROFILES: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; + +/// Returns the pinned profile at `index` in [`PINNED`], building it from its +/// constants on first use: no parsing, no validation, and no hashing, which +/// the tests do for every pinned profile instead. +fn load(index: usize) -> &'static CpuProfile { + PROFILES[index].get_or_init(|| PINNED[index].to_profile()) +} + +/// Returns the index in [`PINNED`] of the profile `id`. +fn position(id: &str) -> Option { + PINNED.iter().position(|pinned| pinned.id == id) +} + +/// Returns every pinned profile. Selecting by ID with [`pinned`] or +/// [`pinned_for_restore`] builds only that profile. +/// +/// Offline only: this builds every profile, for tools and tests. A cold boot +/// or restore selects its one profile with [`select`] or +/// [`pinned_for_restore`]. +pub fn pinned_profiles() -> &'static [CpuProfile] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| (0..PINNED.len()).map(|index| load(index).clone()).collect()) +} + +/// Returns the pinned profile `id`. +pub fn pinned(id: &str) -> Option<&'static CpuProfile> { + position(id).map(load) +} + +/// Returns the precomputed record of the pinned profile `id`: its canonical +/// encoding and digest, constants that a snapshot's CPU profile record +/// copies at capture and that the restore preflight compares byte for byte. +pub fn pinned_record(id: &str) -> Option> { + position(id).map(|index| PINNED[index].record()) +} + +/// A host profile with its record, computed once, when the VM worker selects +/// the profile. +struct HostProfile { + profile: CpuProfile, + encoding: Vec, + digest: [u8; 32], +} + +/// The CPU profile of a partition: a pinned profile, or a host profile. +/// +/// A host profile has no constants in this OpenVMM. The VM worker derives it +/// for `--cpu-profile host` +/// ([`derive_host_profile`](crate::derive::derive_host_profile)) or decodes +/// it from the snapshot that it restores ([`host_profile_for_restore`]), and +/// only the partitions that use it hold it, so each VM of a process can have +/// its own. Core hands the partition's profile to the backend in its time ABI +/// configuration, and capture copies its [`record`](Self::record). Clones +/// share the profile. +#[derive(Clone)] +pub struct PartitionProfile(Source); + +#[derive(Clone)] +enum Source { + /// The pinned profile at this index in [`PINNED`]. + Pinned(usize), + /// A host profile. + Host(Arc), +} + +impl PartitionProfile { + /// Returns the partition profile of the pinned profile `id`, if this + /// OpenVMM pins it. + pub fn pinned(id: &str) -> Option { + position(id).map(|index| Self(Source::Pinned(index))) + } + + /// Returns the partition profile of `profile`, a host profile, and + /// computes its record. + /// + /// Fails with `E_PROFILE_UNKNOWN` for a profile that is not a host + /// profile. + pub fn host(profile: CpuProfile) -> Result { + if profile.generation().name != HOST_GENERATION || !is_host_profile_id(profile.id()) { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!("CPU profile {} is not a host profile", profile.id()), + )); + } + let encoding = profile.encode(); + let digest = canonical::sha256(&encoding); + Ok(Self(Source::Host(Arc::new(HostProfile { + profile, + encoding, + digest, + })))) + } + + /// Returns whether the profile is a host profile. + pub fn is_host(&self) -> bool { + matches!(self.0, Source::Host(_)) + } + + /// Returns the profile's record, which capture copies into a snapshot: + /// the pinned profile's precomputed constants, or the host profile's + /// encoding and digest, computed when it was selected. + pub fn record(&self) -> PinnedRecord<'_> { + match &self.0 { + Source::Pinned(index) => PINNED[*index].record(), + Source::Host(host) => PinnedRecord { + id: host.profile.id(), + encoding: &host.encoding, + digest: host.digest, + }, + } + } +} + +impl Deref for PartitionProfile { + type Target = CpuProfile; + + fn deref(&self) -> &CpuProfile { + match &self.0 { + Source::Pinned(index) => load(*index), + Source::Host(host) => &host.profile, + } + } +} + +impl fmt::Debug for PartitionProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PartitionProfile") + .field("id", &self.id()) + .field("host", &self.is_host()) + .finish() + } +} + +/// Returns whether `id` names a host profile, `.host.v`, +/// with the syntax of every profile ID: the vendor is lowercase ASCII +/// letters, digits, and hyphens, and the revision a decimal number without a +/// leading zero. No pinned profile has such an ID. +pub fn is_host_profile_id(id: &str) -> bool { + let mut components = id.split('.'); + matches!( + (components.next(), components.next(), components.next(), components.next()), + (Some(vendor), Some(HOST_GENERATION), Some(revision), None) + if is_id_component(vendor) && revision.strip_prefix('v').is_some_and(is_revision) + ) +} + +/// Returns the host profile that a snapshot recorded by ID, SHA-256, and +/// canonical document, for a restore, after checking that the document is +/// the canonical encoding of a valid host profile with that ID and digest +/// (`E_PROFILE_DIGEST`). An ID that does not name a host profile fails with +/// `E_PROFILE_UNKNOWN`. +/// +/// Unlike [`pinned_for_restore`], this decodes and hashes the document: no +/// constant exists to compare it with. The restore preflight then checks +/// the destination host with [`check_generation`], and the VM worker +/// selects the profile for the partition ([`PartitionProfile::host`]). +pub fn host_profile_for_restore( + id: &str, + sha256: &[u8], + document: &[u8], +) -> Result { + if !is_host_profile_id(id) { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!("CPU profile {id:?} is not a host profile"), + )); + } + if canonical::sha256(document).as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!("the snapshot's host CPU profile {id} does not match its recorded digest"), + )); + } + let profile = CpuProfile::decode(document)?; + if profile.id() != id || profile.generation().name != HOST_GENERATION { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!( + "the snapshot records CPU profile {id}, but its embedded document is {}", + profile.id() + ), + )); + } + Ok(profile) +} + +/// Returns the name of the generation of the host CPU, if a pinned profile +/// serves it: the generation of the profile [`select_auto`] selects. +pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { + select_auto(host) + .ok() + .map(|profile| profile.generation().name.as_str()) +} + +/// Returns whether a pinned profile serves the host CPU: whether the +/// generation of any pinned profile covers it. Unlike [`generation_of`], it +/// holds for a CPU that profiles of more than one generation cover, which +/// [`select_auto`] rejects as a catalog defect. The VM worker falls back from +/// [`AUTO`] to a host profile only on a CPU that no pinned profile serves. +/// Unlike [`select_auto`], which builds the profiles that cover the host, it +/// builds no profile: it reads only the pinned entries' generations. +pub fn pinned_profile_serves(host: &HostCpuSignature) -> bool { + PINNED.iter().any(|pinned| covers(pinned.generation, host)) +} + +/// Selects the profile of the host CPU's generation: its latest pinned +/// revision. Only the profiles whose generation covers the host are built. +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN` when no pinned profile serves the +/// host, or when profiles of more than one generation do; there is no host +/// CPUID passthrough. +pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { + let candidates = (0..PINNED.len()) + .filter(|&index| covers(PINNED[index].generation, host)) + .map(load) + .collect::>(); + select_auto_in(&candidates, host, pinned_ids) +} + +/// Returns whether `generation` covers the host CPU, as +/// [`Generation::contains`](crate::Generation::contains) does for the +/// generation of a parsed profile. +fn covers(generation: &KnownGeneration, host: &HostCpuSignature) -> bool { + let vendor = generation.vendor.cpuid_vendor().as_bytes(); + let (family, model, stepping) = decode_signature(vendor, host.signature()); + host.vendor().as_slice() == vendor + && generation + .cpus + .iter() + .any(|&(cpu_family, cpu_model, [first, last])| { + (cpu_family, cpu_model) == (family, model) && (first..=last).contains(&stepping) + }) +} + +/// Selects among `profiles`, as [`select_auto`] does; `ids` lists every +/// pinned profile for the failure messages. +fn select_auto_in<'a>( + profiles: &[&'a CpuProfile], + host: &HostCpuSignature, + ids: impl Fn() -> String, +) -> Result<&'a CpuProfile, ProfileError> { + let matches = profiles + .iter() + .copied() + .filter(|profile| profile.generation().contains(profile.vendor(), host)) + .collect::>(); + let mut generations = matches + .iter() + .map(|profile| profile.generation().name.as_str()) + .collect::>(); + generations.sort_unstable(); + generations.dedup(); + if generations.len() > 1 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!( + "the host CPU, {host}, is in more than one CPU generation ({}); pinned profiles: {}", + generations.join(", "), + ids() + ), + )); + } + matches + .into_iter() + .max_by_key(|profile| revision(profile.id())) + .ok_or_else(|| { + ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!( + "no pinned CPU profile serves the host CPU, {host}; pinned profiles: {}", + ids() + ), + ) + }) +} + +/// Selects the pinned profile for `--cpu-profile spec`, [`AUTO`] or a pinned +/// ID, and checks that the host CPU is in its generation. A host profile has +/// no ID to select it by: the VM worker derives it for [`HOST`], and for +/// [`AUTO`] on a CPU that no pinned profile serves, or takes a restored +/// snapshot's ([`PartitionProfile::host`]). +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN` for an ID that is +/// not pinned, or `E_CPU_GENERATION`. +pub fn select(spec: &str, host: &HostCpuSignature) -> Result { + let id = if spec == AUTO { + select_auto(host)?.id() + } else { + spec + }; + let profile = PartitionProfile::pinned(id).ok_or_else(|| unknown(spec))?; + check_generation(&profile, host)?; + Ok(profile) +} + +/// Checks that the host CPU is in the profile's generation +/// (`E_CPU_GENERATION`). +pub fn check_generation(profile: &CpuProfile, host: &HostCpuSignature) -> Result<(), ProfileError> { + if profile.generation().contains(profile.vendor(), host) { + Ok(()) + } else { + Err(ProfileError::new( + ProfileErrorCode::CpuGeneration, + format!( + "the host CPU, {host}, is not in generation {} of CPU profile {}", + profile.generation().name, + profile.id() + ), + )) + } +} + +/// Returns the pinned profile that a snapshot recorded by ID and SHA-256. +/// +/// Fails with `E_PROFILE_UNKNOWN` when this OpenVMM does not pin the ID, and +/// with `E_PROFILE_DIGEST` when its pinned profile has another digest. The +/// pinned profile's digest is a constant, which the catalog's tests check, so +/// a restore encodes and hashes nothing here. The restore preflight also +/// compares the recorded document with [`pinned_record`]'s encoding, and +/// checks the destination host with [`check_generation`]. +pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile, ProfileError> { + let index = position(id).ok_or_else(|| unknown(id))?; + let golden = &PINNED[index].digest; + if sha256 != golden { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!( + "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({})", + canonical::format_digest(golden) + ), + )); + } + Ok(load(index)) +} + +fn unknown(id: &str) -> ProfileError { + ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!( + "CPU profile {id:?} is not pinned in this OpenVMM; pinned profiles: {}", + pinned_ids() + ), + ) +} + +fn pinned_ids() -> String { + PINNED + .iter() + .map(|pinned| pinned.id) + .collect::>() + .join(", ") +} + +/// Returns the revision of a valid profile ID. +fn revision(id: &str) -> u32 { + id.rsplit_once(".v") + .and_then(|(_, revision)| revision.parse().ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive; + use crate::test_support::fingerprint; + use crate::test_support::profile; + use test_with_tracing::test; + + const SKYLAKE: u32 = 0x0005_0654; + const CASCADE_LAKE: u32 = 0x0005_0657; + const ICELAKE: u32 = 0x0006_06a6; + const EMERALDRAPIDS: u32 = 0x000c_06f2; + const ALDER_LAKE: u32 = 0x0009_06a3; + const ALDER_LAKE_S: u32 = 0x0009_0672; + const TIGER_LAKE: u32 = 0x0008_06c1; + const MILAN: u32 = 0x00a0_0f11; + const MILAN_X: u32 = 0x00a0_0f12; + const GENOA: u32 = 0x00a1_0f11; + const GENOA_STEPPING_2: u32 = 0x00a1_0f12; + const RAPHAEL: u32 = 0x00a6_0f12; + const TURIN: u32 = 0x00b0_0f21; + const TURIN_DENSE: u32 = 0x00b1_0f10; + + fn intel(signature: u32) -> HostCpuSignature { + HostCpuSignature::new(*b"GenuineIntel", signature) + } + + fn amd(signature: u32) -> HostCpuSignature { + HostCpuSignature::new(*b"AuthenticAMD", signature) + } + + fn code(result: Result) -> ProfileErrorCode { + result.unwrap_err().code + } + + /// The pinned JSON files, the source of the static data, and their golden + /// digests. Released profiles are immutable: changing a golden digest is + /// a deliberate act, here. + const FILES: [(&str, &str, &str); 7] = [ + ( + "intel.skylake-sp.v1", + include_str!("../profiles/intel.skylake-sp.v1.json"), + "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", + ), + ( + "intel.icelake-sp.v1", + include_str!("../profiles/intel.icelake-sp.v1.json"), + "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", + ), + ( + "intel.emeraldrapids.v1", + include_str!("../profiles/intel.emeraldrapids.v1.json"), + "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", + ), + ( + "intel.alderlake.v1", + include_str!("../profiles/intel.alderlake.v1.json"), + "sha256:2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e", + ), + ( + "amd.milan.v1", + include_str!("../profiles/amd.milan.v1.json"), + "sha256:463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543", + ), + ( + "amd.genoa.v1", + include_str!("../profiles/amd.genoa.v1.json"), + "sha256:894ac647399039242eb0f0e6582e91691234446ebc9362576d7c5613f34b966b", + ), + ( + "amd.turin.v1", + include_str!("../profiles/amd.turin.v1.json"), + "sha256:0d9d044922eae3f27442178249f0b60feb0db07efe446e6641a1fcbfbb3a04ff", + ), + ]; + + /// The checks that an OpenVMM start skips: every pinned file is a valid + /// profile in pretty canonical form with its golden digest, and the static + /// data generated from it holds the same profile, canonical encoding, + /// digest, and generation. + #[test] + fn the_static_data_is_the_pinned_files() { + assert_eq!(PINNED.len(), FILES.len()); + for (index, (pinned, (id, json, golden))) in PINNED.iter().zip(FILES).enumerate() { + let profile = CpuProfile::from_pretty_json(json).unwrap(); + assert_eq!(profile.id(), id); + assert_eq!(profile.digest_string(), golden, "{id}"); + assert_eq!(pinned.id, id); + assert_eq!(&pinned.to_profile(), &profile, "{id}"); + assert_eq!(load(index), &profile, "{id}"); + assert_eq!(pinned.encoding.as_bytes(), profile.encode(), "{id}"); + assert_eq!(canonical::format_digest(&pinned.digest), golden, "{id}"); + assert_eq!( + canonical::sha256(pinned.encoding.as_bytes()), + pinned.digest, + "{id}" + ); + assert_eq!(profile.vendor(), pinned.generation.vendor.cpuid_vendor()); + assert_eq!(profile.generation(), &pinned.generation.generation()); + assert_eq!( + pinned_record(id), + Some(PinnedRecord { + id: pinned.id, + encoding: pinned.encoding.as_bytes(), + digest: profile.digest(), + }) + ); + assert!(std::ptr::eq( + pinned_for_restore(id, &profile.digest()).unwrap(), + load(index) + )); + } + assert_eq!(pinned_record("intel.icelake-sp.v2"), None); + assert_eq!( + pinned_profiles() + .iter() + .map(CpuProfile::id) + .collect::>(), + FILES.map(|(id, ..)| id) + ); + } + + #[test] + fn generations_are_known_and_disjoint() { + for (i, profile) in pinned_profiles().iter().enumerate() { + let generation = profile.generation(); + let known = derive::known_generation(&generation.name).unwrap(); + assert_eq!(profile.vendor(), known.vendor.cpuid_vendor()); + assert_eq!(profile.cpu_vendor(), known.vendor); + assert_eq!( + generation + .cpus + .iter() + .map(|cpu| (cpu.family, cpu.model, cpu.steppings)) + .collect::>(), + known.cpus + ); + for other in &pinned_profiles()[i + 1..] { + assert_ne!(other.id(), profile.id()); + if other.generation().name != generation.name { + for cpu in &generation.cpus { + assert!(!other.generation().cpus.iter().any(|other| { + (other.family, other.model) == (cpu.family, cpu.model) + && other.steppings[0] <= cpu.steppings[1] + && cpu.steppings[0] <= other.steppings[1] + })); + } + } + } + } + } + + #[test] + fn auto_selects_the_host_generation_or_fails() { + for (host, id, generation) in [ + (intel(SKYLAKE), "intel.skylake-sp.v1", "skylake-sp"), + (intel(ICELAKE), "intel.icelake-sp.v1", "icelake-sp"), + ( + intel(EMERALDRAPIDS), + "intel.emeraldrapids.v1", + "emeraldrapids", + ), + (intel(ALDER_LAKE), "intel.alderlake.v1", "alderlake"), + (intel(ALDER_LAKE_S), "intel.alderlake.v1", "alderlake"), + (amd(MILAN), "amd.milan.v1", "milan"), + (amd(MILAN_X), "amd.milan.v1", "milan"), + (amd(GENOA), "amd.genoa.v1", "genoa"), + (amd(GENOA_STEPPING_2), "amd.genoa.v1", "genoa"), + (amd(TURIN), "amd.turin.v1", "turin"), + ] { + assert_eq!(select_auto(&host).unwrap().id(), id); + assert_eq!(select(AUTO, &host).unwrap().id(), id); + assert_eq!(generation_of(&host), Some(generation)); + assert!(pinned_profile_serves(&host), "{host}"); + } + for host in [ + intel(CASCADE_LAKE), + intel(TIGER_LAKE), + amd(RAPHAEL), + amd(TURIN_DENSE), + // The vendor decides too: no Intel CPU has Milan's signature. + intel(MILAN), + HostCpuSignature::new(*b"HygonGenuine", MILAN), + ] { + assert_eq!( + code(select_auto(&host)), + ProfileErrorCode::ProfileHostUnknown + ); + assert_eq!(generation_of(&host), None); + assert!(!pinned_profile_serves(&host), "{host}"); + } + let error = select_auto(&intel(CASCADE_LAKE)).unwrap_err(); + assert!( + error.to_string().starts_with("[E_PROFILE_HOST_UNKNOWN] "), + "{error}" + ); + assert!(error.message.contains("model 85 stepping 7"), "{error}"); + } + + #[test] + fn auto_takes_the_latest_revision_and_rejects_ambiguous_generations() { + let json = profile("intel.skylake-sp.v1").to_pretty_json(); + let variant = + |from: &str, to: &str| CpuProfile::from_pretty_json(&json.replace(from, to)).unwrap(); + let host = intel(SKYLAKE); + let ids = || "the test's".to_owned(); + let revisions = [ + variant("intel.skylake-sp.v1", "intel.skylake-sp.v2"), + variant("intel.skylake-sp.v1", "intel.skylake-sp.v10"), + CpuProfile::from_pretty_json(&json).unwrap(), + ]; + assert_eq!( + select_auto_in(&revisions.iter().collect::>(), &host, ids) + .unwrap() + .id(), + "intel.skylake-sp.v10" + ); + + // Two generations that both cover the host. + let ambiguous = [ + CpuProfile::from_pretty_json(&json).unwrap(), + variant("skylake-sp", "skylake-x"), + ]; + let ambiguous = ambiguous.iter().collect::>(); + let error = select_auto_in(&ambiguous, &host, ids).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); + assert!( + error.message.contains( + "is in more than one CPU generation (skylake-sp, skylake-x); \ + pinned profiles: the test's" + ), + "{error}" + ); + assert_eq!( + code(select_auto_in(&ambiguous, &intel(ICELAKE), ids)), + ProfileErrorCode::ProfileHostUnknown + ); + } + + /// Selection matches the host against each pinned entry's generation, to + /// parse only the profile it selects; that agrees with the profiles' own + /// generations. + #[test] + fn the_static_generations_cover_what_the_profiles_do() { + for (index, pinned) in PINNED.iter().enumerate() { + let profile = load(index); + for vendor in [*b"GenuineIntel", *b"AuthenticAMD"] { + for (family, model) in [ + (6u32, 85u32), + (6, 106), + (6, 140), + (6, 143), + (6, 151), + (6, 154), + (6, 207), + (25, 1), + (25, 17), + (25, 97), + (26, 2), + (26, 17), + ] { + for stepping in 0..16 { + // Encode the display family and model as CPUID.1:EAX does. + let extended_family = family.saturating_sub(15); + let base_family = family - extended_family; + let signature = extended_family << 20 + | (model >> 4) << 16 + | base_family << 8 + | (model & 0xf) << 4 + | stepping; + let host = HostCpuSignature::new(vendor, signature); + assert_eq!( + covers(pinned.generation, &host), + profile.generation().contains(profile.vendor(), &host), + "{} on {host}", + pinned.id + ); + } + } + } + } + } + + #[test] + fn explicit_selection_checks_the_id_and_the_generation() { + let host = intel(ICELAKE); + assert_eq!( + select("intel.icelake-sp.v1", &host).unwrap().id(), + "intel.icelake-sp.v1" + ); + assert_eq!( + code(select("intel.skylake-sp.v1", &host)), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("intel.icelake-sp.v2", &host)), + ProfileErrorCode::ProfileUnknown + ); + assert_eq!(code(select("", &host)), ProfileErrorCode::ProfileUnknown); + + // Another vendor's generation. + assert_eq!( + select("amd.milan.v1", &amd(MILAN)).unwrap().id(), + "amd.milan.v1" + ); + assert_eq!( + code(select("intel.skylake-sp.v1", &amd(MILAN))), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("amd.milan.v1", &host)), + ProfileErrorCode::CpuGeneration + ); + + // Another generation of the same vendor. + assert_eq!( + select("amd.turin.v1", &amd(TURIN)).unwrap().id(), + "amd.turin.v1" + ); + assert_eq!( + code(select("amd.genoa.v1", &amd(TURIN))), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("amd.milan.v1", &amd(GENOA))), + ProfileErrorCode::CpuGeneration + ); + } + + #[test] + fn restore_checks_the_pinned_record_and_the_host() { + let profile = profile("intel.icelake-sp.v1"); + let digest = profile.digest(); + let host = intel(ICELAKE); + assert_eq!(pinned_for_restore(profile.id(), &digest).unwrap(), profile); + check_generation(profile, &host).unwrap(); + // The preflight compares the recorded document with the pinned + // encoding, which is the profile's. + assert_eq!( + pinned_record(profile.id()).unwrap().encoding, + profile.encode().as_slice() + ); + + // A corrupted, short, or another profile's digest. + let mut corrupted = digest; + corrupted[10] ^= 1; + assert_eq!( + code(pinned_for_restore(profile.id(), &corrupted)), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(pinned_for_restore(profile.id(), &digest[..31])), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(pinned_for_restore("intel.skylake-sp.v1", &digest)), + ProfileErrorCode::ProfileDigest + ); + + // A profile this OpenVMM does not pin. + let mut unknown = profile.clone(); + unknown.id = "intel.icelake-sp.v9".to_owned(); + assert_eq!( + code(pinned_for_restore(unknown.id(), &unknown.digest())), + ProfileErrorCode::ProfileUnknown + ); + + // A pinned ID whose content differs. + let mut changed = profile.clone(); + changed.description.push('!'); + assert_eq!( + code(pinned_for_restore(changed.id(), &changed.digest())), + ProfileErrorCode::ProfileDigest + ); + + // Another generation on the destination. + assert_eq!( + code(check_generation(profile, &intel(EMERALDRAPIDS))), + ProfileErrorCode::CpuGeneration + ); + } + + /// The host profile of this crate's test fixture: Alder Lake's pinned + /// surface, fingerprinted on WHP. + fn host_profile() -> CpuProfile { + derive::derive_host_profile(&fingerprint(profile("intel.alderlake.v1"), "whp")).unwrap() + } + + #[test] + fn host_profile_ids_have_the_host_generation() { + for (id, host) in [ + ("intel.host.v1", true), + ("intel.host.v12", true), + ("amd.host.v1", true), + ("intel.icelake-sp.v1", false), + ("interim.host.kvm.v1", false), + ("intel.host", false), + (".host.v1", false), + ("Intel.host.v1", false), + ("intel.host.v", false), + ("intel.host.v0", false), + ("intel.host.v01", false), + ("intel.host.vfoo", false), + ("intel.host.1", false), + ("host", false), + (HOST, false), + (AUTO, false), + ] { + assert_eq!(is_host_profile_id(id), host, "{id}"); + } + assert!(is_host_profile_id(host_profile().id())); + assert!(pinned_profiles().iter().all(|profile| { + !is_host_profile_id(profile.id()) && profile.generation().name != HOST_GENERATION + })); + } + + #[test] + fn partition_profiles_carry_their_records() { + let pinned = PartitionProfile::pinned("intel.icelake-sp.v1").unwrap(); + assert!(!pinned.is_host()); + assert!(std::ptr::eq( + &*pinned, + super::pinned("intel.icelake-sp.v1").unwrap() + )); + assert_eq!( + pinned.record(), + pinned_record("intel.icelake-sp.v1").unwrap() + ); + assert!(PartitionProfile::pinned("intel.host.v1").is_none()); + + let host = host_profile(); + let selected = PartitionProfile::host(host.clone()).unwrap(); + assert!(selected.is_host()); + assert_eq!(*selected, host); + let record = selected.record(); + assert_eq!(record.id, host.id()); + assert_eq!(record.encoding, host.encode().as_slice()); + assert_eq!(record.digest, host.digest()); + // Clones share the profile. + assert!(std::ptr::eq(&*selected.clone(), &*selected)); + + // A pinned profile is not a host profile. + assert_eq!( + code(PartitionProfile::host( + profile("intel.alderlake.v1").clone() + )), + ProfileErrorCode::ProfileUnknown + ); + } + + /// Every VM of a process holds its own host profile: two host profiles + /// with the same ID, here derived from the fingerprints of two backends, + /// coexist, as a later VM's restore of another host's snapshot needs. + #[test] + fn host_profiles_of_the_same_id_coexist() { + let whp = PartitionProfile::host(host_profile()).unwrap(); + let kvm = PartitionProfile::host( + derive::derive_host_profile(&fingerprint(profile("intel.alderlake.v1"), "kvm")) + .unwrap(), + ) + .unwrap(); + assert_eq!(whp.id(), kvm.id()); + assert_ne!(whp.record().digest, kvm.record().digest); + assert_eq!(whp.record().digest, host_profile().digest()); + } + + #[test] + fn selection_takes_pinned_profiles_only() { + // A host profile has no ID to select it by, and `host` is not an ID. + let host = host_profile(); + for spec in [host.id(), HOST] { + assert_eq!( + code(select(spec, &intel(ALDER_LAKE))), + ProfileErrorCode::ProfileUnknown, + "{spec}" + ); + } + let selected = select(AUTO, &intel(ALDER_LAKE)).unwrap(); + assert!(!selected.is_host()); + assert_eq!(selected.id(), "intel.alderlake.v1"); + assert_eq!( + selected.record(), + pinned_record("intel.alderlake.v1").unwrap() + ); + } + + #[test] + fn restore_decodes_and_checks_a_host_profile() { + let host = host_profile(); + let encoding = host.encode(); + let digest = host.digest(); + assert_eq!( + host_profile_for_restore(host.id(), &digest, &encoding).unwrap(), + host + ); + + // A corrupted document or digest. + let mut corrupted = encoding.clone(); + let last = corrupted.len() - 3; + corrupted[last] ^= 1; + assert_eq!( + code(host_profile_for_restore(host.id(), &digest, &corrupted)), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &digest[..31], + &encoding + )), + ProfileErrorCode::ProfileDigest + ); + // A document that is not canonical, with its own digest. + let pretty = host.to_pretty_json().into_bytes(); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &canonical::sha256(&pretty), + &pretty + )), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's document under a host profile's ID. + let pinned = pinned_record("intel.icelake-sp.v1").unwrap(); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &pinned.digest, + pinned.encoding + )), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's ID is not a host profile's. + assert_eq!( + code(host_profile_for_restore( + pinned.id, + &pinned.digest, + pinned.encoding + )), + ProfileErrorCode::ProfileUnknown + ); + // Nor is an ID with a malformed revision, whatever the document. + for id in ["intel.host.v", "intel.host.v0", "intel.host.vfoo"] { + assert_eq!( + code(host_profile_for_restore(id, &digest, &encoding)), + ProfileErrorCode::ProfileUnknown, + "{id}" + ); + } + } +} diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs new file mode 100644 index 00000000000..fa31222e80f --- /dev/null +++ b/vmm_core/cpu_profile/src/check.rs @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Checking a host fingerprint against the profile of its generation, the +//! CPU part of host qualification. + +use crate::catalog; +use crate::cpuid::CpuidEntry; +use crate::error::ProfileError; +use crate::fingerprint::CpuFingerprint; +use crate::profile::CpuProfile; +use crate::signature::HostCpuSignature; +use crate::surface::CpuidPresentation; +use crate::surface::HostCpuSurface; +use crate::surface::support_violations; +use crate::surface::verify_support; +use crate::surface::verify_support_with_unlisted; + +/// The prefix of the summary line of a fingerprint check. +pub const SUMMARY_PREFIX: &str = "NVX-CPU-PROFILE:"; + +/// The outcome of checking a fingerprint against the profile that `auto` +/// selects for its host. +#[derive(Debug)] +pub struct FingerprintCheck { + /// The host CPU. + pub host: HostCpuSignature, + /// The profile that `auto` selects, if any. + pub profile: Option<&'static CpuProfile>, + /// Whether the backend supports the profile, or why not. + pub result: Result<(), ProfileError>, +} + +impl FingerprintCheck { + /// Returns the one-line summary of the check, for logs and CI: + /// + /// ```text + /// NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp profile=intel.icelake-sp.v1 profile_digest=sha256:… surface_digest=sha256:… host_invariant_tsc=yes + /// NVX-CPU-PROFILE: status=fail backend=whp generation=none profile=none surface_digest=sha256:… host_invariant_tsc=yes code=E_PROFILE_HOST_UNKNOWN detail="…" + /// ``` + /// + /// `host_invariant_tsc` reports the host OS's view, which host + /// qualification evaluates; it does not affect `status`. + pub fn summary_line(&self, fingerprint: &CpuFingerprint) -> String { + let mut line = format!( + "{SUMMARY_PREFIX} status={} backend={} generation={} profile={}", + if self.result.is_ok() { "pass" } else { "fail" }, + fingerprint.backend.name, + self.profile + .map_or("none", |profile| profile.generation().name.as_str()), + self.profile.map_or("none", |profile| profile.id()), + ); + if let Some(profile) = self.profile { + line.push_str(&format!(" profile_digest={}", profile.digest_string())); + } + line.push_str(&format!( + " surface_digest={} host_invariant_tsc={}", + fingerprint.surface_digest, + if fingerprint.host.cpu.invariant_tsc { + "yes" + } else { + "no" + } + )); + if let Err(error) = &self.result { + line.push_str(&format!(" code={} detail={:?}", error.code, error.message)); + } + line + } +} + +/// Checks a host fingerprint: selects the profile of the host's generation +/// (`E_PROFILE_HOST_UNKNOWN`) and verifies that the backend supports it +/// (`E_PROFILE_UNSUPPORTED`) and, for MSHV and WHP, whose fingerprints record +/// a probe partition's view, that it presents zero at every CPUID entry +/// outside the profile's tables (`E_CPU_UNLISTED`). +pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { + check_fingerprint_with(fingerprint, |_| Ok(None)) +} + +/// Checks a host fingerprint as [`check_fingerprint`] does, but for MSHV and +/// WHP takes the CPUID entries outside the selected profile's tables from +/// `configured`, as a cold boot checks them: `configured` returns what VP 0 +/// of a partition configured from the profile reads at the host's +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates), or +/// `None` to check the fingerprint's own probe partition instead. +/// +/// The fingerprint's probe partition enables every feature the backend +/// offers, so it can present entries that a partition of the profile does +/// not, such as the XSAVE components of CET. `configured` runs only when the +/// backend supports the profile otherwise; its failure fails the check. +pub fn check_fingerprint_with( + fingerprint: &CpuFingerprint, + configured: impl FnOnce(&CpuProfile) -> Result>, ProfileError>, +) -> FingerprintCheck { + let cpu = &fingerprint.host.cpu; + let mut vendor = [0; 12]; + if cpu.vendor.len() == vendor.len() { + vendor.copy_from_slice(cpu.vendor.as_bytes()); + } + let host = HostCpuSignature::new(vendor, cpu.signature.0); + match catalog::select_auto(&host) { + Ok(profile) => FingerprintCheck { + host, + profile: Some(profile), + result: check_support(profile, fingerprint, configured), + }, + Err(error) => FingerprintCheck { + host, + profile: None, + result: Err(error), + }, + } +} + +/// Checks that the backend of `fingerprint` supports `profile`, reading the +/// entries outside the profile's tables from `configured` if it returns +/// them. +fn check_support( + profile: &CpuProfile, + fingerprint: &CpuFingerprint, + configured: impl FnOnce(&CpuProfile) -> Result>, ProfileError>, +) -> Result<(), ProfileError> { + let surface = HostCpuSurface::from_fingerprint(&fingerprint.backend); + if surface.presentation != CpuidPresentation::PassThroughGuestView + || !support_violations(profile, &surface).is_empty() + { + return verify_support(profile, &surface); + } + match configured(profile)? { + Some(presented) => verify_support_with_unlisted(profile, &surface, &presented), + None => verify_support(profile, &surface), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Hex32; + use crate::ProfileErrorCode; + use crate::cpuid::CpuidEntry; + use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + #[test] + fn passes_a_host_that_supports_its_generation() { + let profile = profile("intel.icelake-sp.v1"); + let fingerprint = fingerprint(profile, "kvm"); + let check = check_fingerprint(&fingerprint); + check.result.as_ref().unwrap(); + assert_eq!( + check.profile.map(CpuProfile::id), + Some("intel.icelake-sp.v1") + ); + assert_eq!( + check.summary_line(&fingerprint), + format!( + "NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp \ + profile=intel.icelake-sp.v1 profile_digest={} surface_digest={} \ + host_invariant_tsc=yes", + profile.digest_string(), + fingerprint.surface_digest + ) + ); + } + + #[test] + fn fails_unknown_hosts_and_unsupported_profiles() { + let profile = profile("intel.icelake-sp.v1"); + let mut tiger_lake = fingerprint(profile, "whp"); + tiger_lake.host.cpu.signature = Hex32(0x0008_06c1); + tiger_lake.host.cpu.invariant_tsc = false; + let check = check_fingerprint(&tiger_lake); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::ProfileHostUnknown + ); + let line = check.summary_line(&tiger_lake); + assert!( + line.starts_with("NVX-CPU-PROFILE: status=fail backend=whp generation=none profile=none surface_digest="), + "{line}" + ); + assert!( + line.contains( + " host_invariant_tsc=no code=E_PROFILE_HOST_UNKNOWN detail=\"no pinned CPU profile" + ), + "{line}" + ); + + let mut entries = profile_entries(profile); + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (7, Some(0))) + .unwrap(); + let [eax, ebx, ecx, edx] = entry.registers(); + *entry = CpuidEntry::new(7, Some(0), [eax, ebx & !(1 << 16), ecx, edx]); + let no_avx512 = fingerprint_with(profile, "kvm", entries); + let check = check_fingerprint(&no_avx512); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::ProfileUnsupported + ); + assert!( + check + .summary_line(&no_avx512) + .contains("code=E_PROFILE_UNSUPPORTED detail=\"the backend does not support CPU profile intel.icelake-sp.v1: CPUID 0x7.0 EBX bit 16 is not supported\""), + "{}", + check.summary_line(&no_avx512) + ); + } + + #[test] + fn fails_pass_through_hosts_that_present_unlisted_entries() { + let profile = profile("intel.skylake-sp.v1"); + // The RDT monitoring subleaf, which the profile does not list. + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7])); + entries.sort_by_key(CpuidEntry::key); + for backend in ["mshv", "whp"] { + let fingerprint = fingerprint_with(profile, backend, entries.clone()); + let check = check_fingerprint(&fingerprint); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::CpuUnlisted + ); + let line = check.summary_line(&fingerprint); + assert!( + line.starts_with(&format!( + "NVX-CPU-PROFILE: status=fail backend={backend} generation=skylake-sp \ + profile=intel.skylake-sp.v1 profile_digest=" + )), + "{line}" + ); + assert!( + line.ends_with( + " code=E_CPU_UNLISTED detail=\"the backend presents CPUID entries \ + outside CPU profile intel.skylake-sp.v1: CPUID 0xf.1 is outside the profile \ + and reads EAX 0x0, EBX 0xb, ECX 0x6f, EDX 0x7\"" + ), + "{line}" + ); + } + + // KVM answers unlisted entries from its table, so the check does not + // apply. + let fingerprint = fingerprint_with(profile, "kvm", entries); + check_fingerprint(&fingerprint).result.unwrap(); + } + + /// A WHP host whose probe partition, with every available feature, + /// presents CET's XSAVE components, which the profile does not enable, + /// as the root partition of a 12th generation Intel Core host reports + /// them. + fn cet_host() -> CpuFingerprint { + let profile = profile("intel.alderlake.v1"); + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new(0xd, Some(11), [0x10, 0, 1, 0])); + entries.push(CpuidEntry::new(0xd, Some(12), [0x18, 0, 1, 0])); + entries.sort_by_key(CpuidEntry::key); + fingerprint_with(profile, "whp", entries) + } + + #[test] + fn checks_unlisted_entries_on_a_partition_configured_from_the_profile() { + let fingerprint = cet_host(); + // The probe partition's own view fails, though a cold boot passes. + let check = check_fingerprint(&fingerprint); + let error = check.result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error.message.ends_with( + "CPUID 0xd.11 is outside the profile and reads EAX 0x10, EBX 0x0, ECX 0x1, \ + EDX 0x0; CPUID 0xd.12 is outside the profile and reads EAX 0x18, EBX 0x0, \ + ECX 0x1, EDX 0x0" + ), + "{error}" + ); + + // A partition configured from the profile reads zero there. + let zeros = [ + (0xd, Some(11)), + (0xd, Some(12)), + (0x14, Some(1)), + (0x20, None), + ] + .map(|(leaf, subleaf)| CpuidEntry::new(leaf, subleaf, [0; 4])); + let mut asked = None; + let check = check_fingerprint_with(&fingerprint, |profile| { + asked = Some(profile.id().to_owned()); + Ok(Some(zeros.to_vec())) + }); + check.result.as_ref().unwrap(); + assert_eq!(asked.as_deref(), Some("intel.alderlake.v1")); + assert!( + check + .summary_line(&fingerprint) + .starts_with("NVX-CPU-PROFILE: status=pass backend=whp generation=alderlake "), + "{}", + check.summary_line(&fingerprint) + ); + + // A non-zero entry of the configured partition fails. + let mut presented = zeros.to_vec(); + presented[2] = CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_003f, 0, 0]); + let error = check_fingerprint_with(&fingerprint, |_| Ok(Some(presented))) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error.message.ends_with( + "CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, EBX 0x3f003f, \ + ECX 0x0, EDX 0x0" + ), + "{error}" + ); + + // Without a configured partition, the probe's own view counts. + assert_eq!( + check_fingerprint_with(&fingerprint, |_| Ok(None)) + .result + .unwrap_err() + .code, + ProfileErrorCode::CpuUnlisted + ); + // A backend that cannot configure the partition fails the check. + let error = check_fingerprint_with(&fingerprint, |_| { + Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + "WHP rejects the features", + )) + }) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + } + + /// The host that `amd.milan.v1` was derived from supports it: an AMD EPYC + /// 7763 that WHP serves on an Azure host. Its probe partition presents + /// CET's XSAVE components, which a partition configured from the profile + /// does not, as on the 12th generation Intel Core host. + #[test] + fn the_milan_host_supports_its_profile() { + let fingerprint = host_fingerprint("whp", milan_whp_entries()); + let error = check_fingerprint(&fingerprint).result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error + .message + .contains("CPUID 0xd.11 is outside the profile"), + "{error}" + ); + let check = check_fingerprint_with(&fingerprint, |profile| { + assert_eq!(profile.id(), "amd.milan.v1"); + Ok(Some(vec![ + CpuidEntry::new(0xd, Some(11), [0; 4]), + CpuidEntry::new(0xd, Some(12), [0; 4]), + ])) + }); + check.result.as_ref().unwrap(); + assert!( + check.summary_line(&fingerprint).starts_with( + "NVX-CPU-PROFILE: status=pass backend=whp generation=milan \ + profile=amd.milan.v1 profile_digest=sha256:463ee036" + ), + "{}", + check.summary_line(&fingerprint) + ); + } + + #[test] + fn configures_a_partition_only_when_it_decides_the_check() { + let unused = |_: &CpuProfile| -> Result>, ProfileError> { + panic!("the check configured a partition") + }; + // KVM presents no host data outside the profile. + let profile = profile("intel.alderlake.v1"); + check_fingerprint_with(&fingerprint(profile, "kvm"), unused) + .result + .unwrap(); + // An unsupported profile fails first, naming every violation. + let mut entries = profile_entries(profile); + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (7, Some(0))) + .unwrap(); + let [eax, ebx, ecx, edx] = entry.registers(); + *entry = CpuidEntry::new(7, Some(0), [eax, ebx & !(1 << 5), ecx, edx]); + entries.push(CpuidEntry::new(0xd, Some(11), [0x10, 0, 1, 0])); + entries.sort_by_key(CpuidEntry::key); + let error = check_fingerprint_with(&fingerprint_with(profile, "whp", entries), unused) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error + .message + .contains("CPUID 0x7.0 EBX bit 5 is not supported") + ); + assert!( + error + .message + .contains("CPUID 0xd.11 is outside the profile") + ); + // An unknown host has no profile to configure. + let mut unknown = cet_host(); + unknown.host.cpu.signature = Hex32(0x0008_06c1); + assert_eq!( + check_fingerprint_with(&unknown, unused) + .result + .unwrap_err() + .code, + ProfileErrorCode::ProfileHostUnknown + ); + } +} diff --git a/vmm_core/cpu_profile/src/cpuid.rs b/vmm_core/cpu_profile/src/cpuid.rs new file mode 100644 index 00000000000..dc6e4e45a02 --- /dev/null +++ b/vmm_core/cpu_profile/src/cpuid.rs @@ -0,0 +1,611 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID tables: entries, subleaf enumeration, the XSAVE layout they +//! describe, and the normalization of fields that differ between processors +//! and virtual processor states rather than between CPU surfaces. + +use crate::Hex32; +use serde::Deserialize; +use serde::Serialize; + +/// The first leaf of the hypervisor range. +pub const HYPERVISOR_LEAF_BASE: u32 = 0x4000_0000; +/// The first leaf of the extended range. +pub const EXTENDED_LEAF_BASE: u32 = 0x8000_0000; + +/// The most leaves enumerated in each of the basic, hypervisor, and extended +/// ranges, which bounds the enumeration of a hypervisor reporting garbage. +const MAX_LEAVES_PER_RANGE: u32 = 0x100; +/// The most subleaves enumerated for one leaf. +const MAX_SUBLEAVES: u32 = 64; + +/// The size of the legacy region and the header of an XSAVE area. +const XSAVE_LEGACY_AND_HEADER_SIZE: u32 = 512 + 64; + +/// `CPUID.1:EBX[31:24]`, the initial APIC ID of the executing processor. +const LEAF1_EBX_INITIAL_APIC_ID: u32 = 0xff00_0000; +/// `CPUID.1:ECX[27]`, OSXSAVE, which mirrors CR4.OSXSAVE. +const LEAF1_ECX_OSXSAVE: u32 = 1 << 27; +/// `CPUID.(7,0):ECX[4]`, OSPKE, which mirrors CR4.PKE. +const LEAF7_ECX_OSPKE: u32 = 1 << 4; + +/// One CPUID leaf, or one subleaf of an indexed leaf, with its full register +/// values. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuidEntry { + /// The leaf, the input value of `EAX`. + pub leaf: Hex32, + /// The subleaf, the input value of `ECX`, or `None` for a leaf whose + /// output does not depend on `ECX`. + pub subleaf: Option, + /// The output value of `EAX`. + pub eax: Hex32, + /// The output value of `EBX`. + pub ebx: Hex32, + /// The output value of `ECX`. + pub ecx: Hex32, + /// The output value of `EDX`. + pub edx: Hex32, +} + +impl CpuidEntry { + /// Returns an entry for `leaf` and `subleaf` with the output registers + /// `[eax, ebx, ecx, edx]`. + pub fn new(leaf: u32, subleaf: Option, registers: [u32; 4]) -> Self { + let [eax, ebx, ecx, edx] = registers; + Self { + leaf: Hex32(leaf), + subleaf: subleaf.map(Hex32), + eax: Hex32(eax), + ebx: Hex32(ebx), + ecx: Hex32(ecx), + edx: Hex32(edx), + } + } + + /// Returns the output registers as `[eax, ebx, ecx, edx]`. + pub fn registers(&self) -> [u32; 4] { + [self.eax.0, self.ebx.0, self.ecx.0, self.edx.0] + } + + /// Returns the sort key of the entry: the leaf, then the subleaf, with a + /// subleaf-independent entry first. + pub fn key(&self) -> (u32, Option) { + (self.leaf.0, self.subleaf.map(|subleaf| subleaf.0)) + } +} + +/// Returns whether the output of `leaf` depends on the subleaf in `ECX`. +/// +/// These are the leaves for which KVM sets `KVM_CPUID_FLAG_SIGNIFCANT_INDEX`, +/// so tables enumerated here and tables from `KVM_GET_SUPPORTED_CPUID` index +/// the same leaves. +pub fn is_indexed_leaf(leaf: u32) -> bool { + matches!( + leaf, + 0x4 | 0x7 + | 0xb + | 0xd + | 0xf + | 0x10 + | 0x12 + | 0x14 + | 0x17 + | 0x18 + | 0x1d + | 0x1e + | 0x1f + | 0x24 + | 0x8000_001d + ) +} + +/// Returns the output registers that `entries` define for `leaf` and +/// `subleaf`, if any. A subleaf-independent entry matches every subleaf. +pub fn lookup(entries: &[CpuidEntry], leaf: u32, subleaf: u32) -> Option<[u32; 4]> { + entries + .iter() + .find(|entry| { + entry.leaf.0 == leaf + && entry + .subleaf + .is_none_or(|entry_subleaf| entry_subleaf.0 == subleaf) + }) + .map(CpuidEntry::registers) +} + +/// Returns whether bit `bit` of register `register` (0 for `EAX` through 3 +/// for `EDX`) is set for `leaf` and `subleaf`. +pub fn has_bit(entries: &[CpuidEntry], leaf: u32, subleaf: u32, register: usize, bit: u32) -> bool { + lookup(entries, leaf, subleaf).is_some_and(|registers| registers[register] & (1 << bit) != 0) +} + +/// Enumerates the CPUID table reported by `query(leaf, subleaf)`. +/// +/// This walks the basic, hypervisor, and extended ranges up to the maximum +/// leaf that each range reports, and the subleaves of every indexed leaf as +/// the architecture enumerates them: until an invalid level or cache type, +/// up to the maximum subleaf reported in subleaf 0, or, for XSAVE, one +/// subleaf per supported state component. The hypervisor range is walked +/// only when it reports a plausible maximum leaf; otherwise only its first +/// leaf is recorded. The result is sorted. +pub fn enumerate( + query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + enumerate_ranges(&[0, HYPERVISOR_LEAF_BASE, EXTENDED_LEAF_BASE], query) +} + +/// Enumerates the basic and extended ranges of the CPUID table reported by +/// `query(leaf, subleaf)` as [`enumerate`] does, without querying the +/// hypervisor range `0x40000000..=0x4fffffff`. +/// +/// A host's hypervisor leaves describe the hypervisor it runs on, not its +/// processor. Nothing that checks a profile against the host reads them: +/// profiles list no hypervisor leaves, and +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates) exempts +/// the range. Every CPUID instruction in a Hyper-V root partition exits to the +/// hypervisor, so a backend that enumerates its host's CPUID at partition +/// creation saves the range's queries, about a fifth of the total on the +/// fleet's Hyper-V hosts. +pub fn enumerate_basic_and_extended( + query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + enumerate_ranges(&[0, EXTENDED_LEAF_BASE], query) +} + +/// Enumerates the ranges that start at `bases`, as [`enumerate`] describes. +fn enumerate_ranges( + bases: &[u32], + mut query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + let mut entries = Vec::new(); + for &base in bases { + let max = query(base, 0)?[0]; + let last = if (base..base + MAX_LEAVES_PER_RANGE).contains(&max) { + max + } else { + base + }; + for leaf in base..=last { + enumerate_leaf(&mut query, leaf, &mut entries)?; + } + } + entries.sort_by_key(CpuidEntry::key); + Ok(entries) +} + +fn enumerate_leaf( + query: &mut impl FnMut(u32, u32) -> Result<[u32; 4], E>, + leaf: u32, + entries: &mut Vec, +) -> Result<(), E> { + if !is_indexed_leaf(leaf) { + entries.push(CpuidEntry::new(leaf, None, query(leaf, 0)?)); + return Ok(()); + } + + let mut subleaf_query = |subleaf: u32| -> Result<[u32; 4], E> { + let registers = query(leaf, subleaf)?; + entries.push(CpuidEntry::new(leaf, Some(subleaf), registers)); + Ok(registers) + }; + let first = subleaf_query(0)?; + match leaf { + // Deterministic cache parameters, up to and including the first + // subleaf with a null cache type. + 0x4 | 0x8000_001d => { + let mut registers = first; + for subleaf in 1..MAX_SUBLEAVES { + if registers[0] & 0x1f == 0 { + break; + } + registers = subleaf_query(subleaf)?; + } + } + // Extended topology, up to and including the first subleaf with an + // invalid level type. + 0xb | 0x1f => { + let mut registers = first; + for subleaf in 1..MAX_SUBLEAVES { + if (registers[2] >> 8) & 0xff == 0 { + break; + } + registers = subleaf_query(subleaf)?; + } + } + // Subleaf 0 reports the maximum subleaf in EAX. + 0x7 | 0x14 | 0x17 | 0x18 | 0x1d | 0x24 => { + for subleaf in 1..=first[0].min(MAX_SUBLEAVES - 1) { + subleaf_query(subleaf)?; + } + } + // XSAVE: the two fixed subleaves, then one per supported state + // component. + 0xd => { + let second = subleaf_query(1)?; + let components = xsave_mask(first[0], first[3]) | xsave_mask(second[2], second[3]); + for subleaf in 2..63 { + if components & (1 << subleaf) != 0 { + subleaf_query(subleaf)?; + } + } + } + // Resource director technology monitoring and allocation. + 0xf => { + subleaf_query(1)?; + } + 0x10 => { + for subleaf in 1..=3 { + subleaf_query(subleaf)?; + } + } + // SGX: the two fixed subleaves, then the EPC sections up to and + // including the first invalid one. + 0x12 => { + subleaf_query(1)?; + for subleaf in 2..MAX_SUBLEAVES { + if subleaf_query(subleaf)?[0] & 0xf == 0 { + break; + } + } + } + // TMUL information has a single subleaf. + _ => {} + } + Ok(()) +} + +/// Normalizes a CPUID table so that tables from different runs, hosts, and +/// backends compare equal when they offer guests the same CPU. +/// +/// This sorts the entries, drops duplicate keys (keeping the first), and +/// rewrites the fields that describe the executing processor or the current +/// virtual processor state rather than the CPU surface: +/// +/// - The initial APIC ID, `CPUID.1:EBX[31:24]`, and the x2APIC IDs of the +/// extended topology leaves 0xb and 0x1f in EDX, are zeroed. A backend +/// reports the ID of whichever processor executed the query, and the VMM +/// sets each virtual processor's own ID. +/// - The AMD extended APIC ID, CPUID.0x8000001e:EAX, is zeroed for the same +/// reason. +/// - OSXSAVE, `CPUID.1:ECX[27]`, and OSPKE, `CPUID.(7,0):ECX[4]`, are cleared. +/// They mirror control register bits, which are clear at reset. +/// - The XSAVE area sizes of the enabled features, CPUID.(0xd,0):EBX and +/// CPUID.(0xd,1):EBX, are set to the standard size of every supported user +/// state component and to the compacted size of every supported user and +/// supervisor state component. They otherwise depend on the current XCR0 +/// and IA32_XSS. These are the values that `KVM_GET_SUPPORTED_CPUID` +/// reports. +pub fn normalize(entries: &mut Vec) { + entries.sort_by_key(CpuidEntry::key); + entries.dedup_by_key(|entry| entry.key()); + let (xcr0, xss) = xsave_supported(entries); + let components = xsave_components(entries); + let standard_size = xsave_standard_size(&components, xcr0); + let compacted_size = xsave_compacted_size(&components, xcr0 | xss); + for entry in entries.iter_mut() { + match entry.key() { + (0x1, _) => { + entry.ebx.0 &= !LEAF1_EBX_INITIAL_APIC_ID; + entry.ecx.0 &= !LEAF1_ECX_OSXSAVE; + } + (0x7, Some(0)) => entry.ecx.0 &= !LEAF7_ECX_OSPKE, + (0xb | 0x1f, _) => entry.edx.0 = 0, + (0xd, Some(0)) => entry.ebx.0 = standard_size, + (0xd, Some(1)) => entry.ebx.0 = compacted_size, + (0x8000_001e, _) => entry.eax.0 = 0, + _ => {} + } + } +} + +fn xsave_mask(low: u32, high: u32) -> u64 { + u64::from(low) | (u64::from(high) << 32) +} + +/// One XSAVE state component, from CPUID.(0xd,n) for component `n >= 2`. +/// +/// The fields are declared in the byte order of their keys, so profiles can +/// serialize it directly in canonical form. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct XsaveComponent { + /// The component is 64-byte aligned in the compacted format (`ECX[1]`). + pub align64: bool, + /// The component number, which is its bit in XCR0 or IA32_XSS. + pub index: u32, + /// The offset of the component in the standard format, from EBX. It is + /// zero for supervisor components, which only the compacted format holds. + pub offset: u32, + /// The size of the component in bytes, from EAX. + pub size: u32, + /// The component is a supervisor state, enabled in IA32_XSS (`ECX[0]`). + pub supervisor: bool, + /// The component supports extended feature disable (`ECX[2]`). + pub xfd: bool, +} + +/// Returns the supported XCR0 and IA32_XSS bits, from CPUID.(0xd,0):EDX:EAX +/// and CPUID.(0xd,1):EDX:ECX. +pub fn xsave_supported(entries: &[CpuidEntry]) -> (u64, u64) { + let first = lookup(entries, 0xd, 0).unwrap_or_default(); + let second = lookup(entries, 0xd, 1).unwrap_or_default(); + ( + xsave_mask(first[0], first[3]), + xsave_mask(second[2], second[3]), + ) +} + +/// Returns the supported XSAVE state components beyond x87 and SSE, ordered +/// by component number. +pub fn xsave_components(entries: &[CpuidEntry]) -> Vec { + let (xcr0, xss) = xsave_supported(entries); + (2..63) + .filter(|index| (xcr0 | xss) & (1 << index) != 0) + .filter_map(|index| { + let [size, offset, flags, _] = lookup(entries, 0xd, index)?; + Some(XsaveComponent { + index, + size, + offset, + supervisor: flags & 1 != 0, + align64: flags & 2 != 0, + xfd: flags & 4 != 0, + }) + }) + .collect() +} + +/// Returns the size of a standard-format XSAVE area holding the user state +/// components in `xcr0`. +pub fn xsave_standard_size(components: &[XsaveComponent], xcr0: u64) -> u32 { + components + .iter() + .filter(|component| xcr0 & (1 << component.index) != 0 && !component.supervisor) + .map(|component| component.offset.saturating_add(component.size)) + .fold(XSAVE_LEGACY_AND_HEADER_SIZE, u32::max) +} + +/// Returns the size of a compacted-format XSAVE area holding the state +/// components in `mask`. +pub fn xsave_compacted_size(components: &[XsaveComponent], mask: u64) -> u32 { + components + .iter() + .filter(|component| mask & (1 << component.index) != 0) + .fold(XSAVE_LEGACY_AND_HEADER_SIZE, |size, component| { + let start = if component.align64 { + size.checked_next_multiple_of(64).unwrap_or(u32::MAX) + } else { + size + }; + start.saturating_add(component.size) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use test_with_tracing::test; + + /// A small Skylake-like CPUID table, keyed by leaf and subleaf. + fn table() -> BTreeMap<(u32, u32), [u32; 4]> { + BTreeMap::from([ + ((0x0, 0), [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + ( + (0x1, 0), + [0x0005_0654, 0x2a20_0800, 0xfffa_3203, 0x0f8b_fbff], + ), + ((0x4, 0), [0x0c00_4121, 0x01c0_003f, 0x3f, 0]), + ((0x4, 1), [0x0c00_4122, 0x00c0_003f, 0x3f, 0]), + ((0x4, 2), [0x0c00_4143, 0x03c0_003f, 0x3ff, 0]), + ( + (0x7, 0), + [0, 0xd19f_27eb, 0x8 | LEAF7_ECX_OSPKE, 0xbc00_0400], + ), + ((0xb, 0), [1, 2, 0x100, 0x2a]), + ((0xb, 1), [5, 0x14, 0x201, 0x2a]), + // XCR0: x87, SSE, AVX (2), opmask/ZMM (5-7). XSS: PT (8). + ((0xd, 0), [0xe7, 0x340, 0xa88, 0]), + ((0xd, 1), [0xf, 0x3c0, 0x100, 0]), + ((0xd, 2), [0x100, 0x240, 0, 0]), + ((0xd, 5), [0x40, 0x440, 0, 0]), + ((0xd, 6), [0x200, 0x480, 0, 0]), + ((0xd, 7), [0x400, 0x680, 0, 0]), + ((0xd, 8), [0x80, 0, 1, 0]), + ((0x40000000, 0), [0, 0, 0, 0]), + ((0x8000_0000, 0), [0x8000_0001, 0, 0, 0]), + ((0x8000_0001, 0), [0, 0, 0x121, 0x2c10_0800]), + ]) + } + + fn query( + table: &BTreeMap<(u32, u32), [u32; 4]>, + ) -> impl FnMut(u32, u32) -> Result<[u32; 4], ()> { + |leaf, subleaf| Ok(table.get(&(leaf, subleaf)).copied().unwrap_or_default()) + } + + #[test] + fn enumerates_ranges_and_subleaves() { + let table = table(); + let entries = enumerate(query(&table)).unwrap(); + let keys = entries.iter().map(CpuidEntry::key).collect::>(); + + // Basic leaves 0 through 0x16, with cache subleaves up to and + // including the null entry (subleaf 3). + assert!(keys.contains(&(0x16, None))); + assert!(!keys.contains(&(0x17, Some(0)))); + for subleaf in 0..=3 { + assert!(keys.contains(&(0x4, Some(subleaf))), "subleaf {subleaf}"); + } + assert!(!keys.contains(&(0x4, Some(4)))); + // Leaf 7 reports no subleaves beyond 0. + assert!(keys.contains(&(0x7, Some(0)))); + assert!(!keys.contains(&(0x7, Some(1)))); + // Topology up to and including the invalid level (subleaf 2). + assert!(keys.contains(&(0xb, Some(2)))); + assert!(!keys.contains(&(0xb, Some(3)))); + // XSAVE: subleaves 0, 1, and one per supported component. + let xsave = keys + .iter() + .filter(|(leaf, _)| *leaf == 0xd) + .map(|(_, subleaf)| subleaf.unwrap()) + .collect::>(); + assert_eq!(xsave, [0, 1, 2, 5, 6, 7, 8]); + // An implausible hypervisor maximum leaf records only the first leaf. + assert!(keys.contains(&(0x4000_0000, None))); + assert!(!keys.contains(&(0x4000_0001, None))); + // Extended leaves. + assert_eq!(keys.last(), Some(&(0x8000_0001, None))); + // Sorted. + assert!(keys.windows(2).all(|pair| pair[0] < pair[1])); + } + + #[test] + fn basic_and_extended_enumeration_skips_only_the_hypervisor_range() { + // A root partition's plausible hypervisor range, 0x40000000 to + // 0x4000000c as on the fleet's Hyper-V hosts, and the fixture's + // implausible one. + let mut hyperv = table(); + hyperv.insert( + (0x4000_0000, 0), + [0x4000_000c, 0x7263_694d, 0x666f_736f, 0x7648_2074], + ); + for leaf in 0x4000_0001..=0x4000_000c { + hyperv.insert((leaf, 0), [leaf, 1, 2, 3]); + } + let hypervisor = HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE; + for host in [hyperv, table()] { + let all = enumerate(query(&host)).unwrap(); + assert!(all.iter().any(|entry| hypervisor.contains(&entry.leaf.0))); + + let mut queried = Vec::new(); + let mut lookup = query(&host); + let entries = enumerate_basic_and_extended(|leaf, subleaf| { + queried.push(leaf); + lookup(leaf, subleaf) + }) + .unwrap(); + assert!(queried.iter().all(|leaf| !hypervisor.contains(leaf))); + let expected = all + .into_iter() + .filter(|entry| !hypervisor.contains(&entry.leaf.0)) + .collect::>(); + assert_eq!(entries, expected); + } + } + + #[test] + fn enumeration_is_bounded() { + // A hypervisor that reports endless valid subleaves and leaves. + let entries = enumerate(|leaf, _| -> Result<_, ()> { + Ok(match leaf { + 0 => [0xff, 0, 0, 0], + 0x4000_0000 => [0x4000_00ff, 0, 0, 0], + 0x8000_0000 => [0x8000_00ff, 0, 0, 0], + _ => [!0, !0, !0, !0], + }) + }) + .unwrap(); + assert!(entries.len() < 3 * 0x100 + 16 * 64 * 2); + } + + #[test] + fn enumeration_propagates_errors() { + let result = enumerate(|leaf, _| { + if leaf == 2 { + Err("boom") + } else { + Ok([2, 0, 0, 0]) + } + }); + assert_eq!(result, Err("boom")); + } + + #[test] + fn normalizes_processor_and_state_fields() { + let table = table(); + let mut entries = enumerate(query(&table)).unwrap(); + let mut duplicated = entries.clone(); + duplicated.reverse(); + duplicated.push(CpuidEntry::new(0x1, None, [0; 4])); + normalize(&mut entries); + normalize(&mut duplicated); + + let [_, ebx, ecx, _] = lookup(&entries, 1, 0).unwrap(); + assert_eq!(ebx, 0x0020_0800); + assert_eq!(ecx & LEAF1_ECX_OSXSAVE, 0); + assert_eq!(lookup(&entries, 7, 0).unwrap()[2], 0x8); + assert_eq!(lookup(&entries, 0xb, 0).unwrap()[3], 0); + assert_eq!(lookup(&entries, 0xb, 1).unwrap()[3], 0); + // Standard size: ZMM_Hi256 (7) ends at 0x680 + 0x400. + assert_eq!(lookup(&entries, 0xd, 0).unwrap()[1], 0xa80); + // Compacted: 576 + AVX 0x100 + 0x40 + 0x200 + 0x400 + PT 0x80. + assert_eq!( + lookup(&entries, 0xd, 1).unwrap()[1], + 576 + 0x100 + 0x40 + 0x200 + 0x400 + 0x80 + ); + // Normalization is independent of the input order and idempotent. + assert_eq!(entries, duplicated); + let again = { + let mut again = entries.clone(); + normalize(&mut again); + again + }; + assert_eq!(entries, again); + } + + #[test] + fn computes_xsave_layout() { + let table = table(); + let entries = enumerate(query(&table)).unwrap(); + assert_eq!(xsave_supported(&entries), (0xe7, 0x100)); + let components = xsave_components(&entries); + assert_eq!( + components.iter().map(|c| c.index).collect::>(), + [2, 5, 6, 7, 8] + ); + assert!(components[4].supervisor); + assert_eq!(xsave_standard_size(&components, 0xe7), 0xa80); + // The supervisor component never counts towards the standard size. + assert_eq!(xsave_standard_size(&components, 0x1e7), 0xa80); + assert_eq!(xsave_standard_size(&components, 0x3), 576); + + let aligned = [ + XsaveComponent { + index: 2, + size: 10, + offset: 576, + supervisor: false, + align64: false, + xfd: false, + }, + XsaveComponent { + index: 3, + size: 8, + offset: 0, + supervisor: false, + align64: true, + xfd: false, + }, + ]; + assert_eq!(xsave_compacted_size(&aligned, 0xc), 640 + 8); + assert_eq!(xsave_compacted_size(&aligned, 0x4), 586); + } + + #[test] + fn lookup_matches_subleaf_independent_entries() { + let entries = [ + CpuidEntry::new(0x1, None, [1, 2, 3, 4]), + CpuidEntry::new(0x7, Some(0), [5, 6, 7, 8]), + ]; + assert_eq!(lookup(&entries, 0x1, 9), Some([1, 2, 3, 4])); + assert_eq!(lookup(&entries, 0x7, 0), Some([5, 6, 7, 8])); + assert_eq!(lookup(&entries, 0x7, 1), None); + assert!(has_bit(&entries, 0x7, 0, 1, 1)); + assert!(!has_bit(&entries, 0x7, 0, 1, 0)); + assert!(!has_bit(&entries, 0x8, 0, 1, 1)); + } +} diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs new file mode 100644 index 00000000000..fa8a50be069 --- /dev/null +++ b/vmm_core/cpu_profile/src/derive.rs @@ -0,0 +1,1765 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Deriving a CPU profile from host fingerprints. +//! +//! A profile serves one CPU generation on every backend, so it is the +//! intersection of what every backend supports on every fingerprinted host +//! of the generation, under a fixed policy: +//! +//! 1. Within each backend, and then across backends, each CPUID register is +//! combined by its class: feature flags by intersection, numeric limits +//! by minimum, and descriptive values (signature, cache, TLB) by majority, +//! one vote per host within a backend and one per backend across them. A +//! tie is an error. +//! 2. Only the leaves of [`DATA_LEAVES`] keep data, and in AMD profiles also +//! those of [`AMD_DATA_LEAVES`], AMD's cache, topology, and speculation +//! leaves; every other leaf in range is zero. The time ABI's CPU time bits +//! are applied, and features a VM must not see (virtualization, power +//! management, performance monitoring, debug, SGX, RDT, PT, CET, key +//! locker) are cleared, in AMD profiles also where AMD enumerates them +//! (SVM, IBS, LWP, the performance counter extensions, MONITORX, RDPRU, +//! PPIN, CPPC, and the like). AMD's speculation controls and immunities +//! stay, those of `0x80000021` included, but for what KVM cannot present: +//! `BTC_NO` goes, and PSFD stays only beside a `SPEC_CTRL` control +//! ([`has_spec_ctrl_control`]). Intel's enumerations of its speculation +//! controls and `IA32_ARCH_CAPABILITIES`, which KVM adds on AMD hosts +//! ([`INTEL_SPECULATION_ENUMERATIONS`]), go too. +//! 3. The XSAVE features are the intersection, within [`ALLOWED_XCR0`], and +//! the XSAVE leaf is rebuilt from them; every host must agree on the +//! layout. A feature whose XSAVE state is not enabled is cleared. +//! 4. The brand string is generic per generation, such as +//! `Intel(R) Xeon(R) Processor ()`, +//! `Intel(R) Core(TM) Processor ()`, or +//! `AMD EPYC Processor ()`, without a frequency +//! ([`KnownGeneration::brand`]), so every host of a generation presents +//! it whatever its SKU, and the hosts' brands are not compared. +//! 5. `IA32_ARCH_CAPABILITIES` is pinned in [`ARCH_CAPABILITIES_PINNED_MASK`] +//! to what every backend can present: KVM's value, and for MSHV and WHP +//! the bits their processor feature banks derive. `ITS_NO` is pinned +//! clear because the Hyper-V backends cannot present it. +//! 6. The bits OpenVMM sets per VM ([`vm_owned_bits`](crate::vm_owned_bits)) +//! are zero and unmasked. +//! +//! The AMD rules apply only to AMD profiles, so the policy derives every +//! Intel profile as it did before AMD profiles existed. +//! +//! [`derive_host_profile`] applies the same policy to one fingerprint of the +//! host that OpenVMM runs on, for `--cpu-profile host`: a development profile +//! that serves only that host's CPU model and stepping, and that no catalog +//! pins. + +use crate::Hex32; +use crate::Hex64; +use crate::cpuid; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::cpuid::XsaveComponent; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::CpuFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::hv_banks; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::Generation; +use crate::profile::GenerationCpu; +use crate::profile::PinnedMsr; +use crate::profile::Provenance; +use crate::profile::ProvenanceSource; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use crate::profile::pinned_mask; +use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; +use crate::surface::RegisterClass; +use crate::surface::field_value; +use crate::surface::register_class; +use crate::vendor::CpuVendor; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use thiserror::Error; + +/// The version of the derivation policy, recorded in each profile's +/// provenance. +pub const POLICY: &str = "openvmm-cpu-profile-policy/v1"; + +/// The leaves that keep data; every other leaf in range is zero. +pub const DATA_LEAVES: [u32; 16] = [ + 0x0, + 0x1, + 0x2, + 0x4, + 0x6, + 0x7, + 0xd, + 0x18, + EXTENDED_LEAF_BASE, + EXTENDED_LEAF_BASE + 1, + EXTENDED_LEAF_BASE + 2, + EXTENDED_LEAF_BASE + 3, + EXTENDED_LEAF_BASE + 4, + EXTENDED_LEAF_BASE + 6, + EXTENDED_LEAF_BASE + 7, + EXTENDED_LEAF_BASE + 8, +]; + +/// The leaves that also keep data in AMD profiles: the L1 cache and TLB leaf +/// `0x80000005`, the cache topology leaf `0x8000001D`, the processor topology +/// leaf `0x8000001E`, whose fields OpenVMM sets per VM, and `0x80000021`, +/// which enumerates AutoIBRS, `LFENCE` serialization, SBPB, `SRSO_NO`, and +/// the TSA immunities. Intel CPUs leave them zero or out of range. +pub const AMD_DATA_LEAVES: [u32; 4] = [ + EXTENDED_LEAF_BASE + 5, + EXTENDED_LEAF_BASE + 0x1d, + EXTENDED_LEAF_BASE + 0x1e, + EXTENDED_LEAF_BASE + 0x21, +]; + +/// The XCR0 bits a profile may enable: x87, SSE, AVX, MPX, AVX-512, and +/// PKRU. AMX needs dynamic XSAVE permissions that no backend grants yet. +pub const ALLOWED_XCR0: u64 = 0x2ff; + +/// The IA32_XSS bits a profile may enable: none yet. +pub const ALLOWED_XSS: u64 = 0; + +/// The XSAVE extensions of `CPUID.(0xd,1):EAX` a profile may enable: +/// XSAVEOPT, XSAVEC, XGETBV with ECX=1, and XSAVES. XFD only matters with +/// AMX. +pub const ALLOWED_XSAVE_EXTENSIONS: u32 = 0xf; + +/// `IA32_ARCH_CAPABILITIES.ITS_NO`. +pub const ARCH_CAPABILITIES_ITS_NO: u64 = 1 << 62; + +/// The `IA32_ARCH_CAPABILITIES` bits that profiles pin: those the Hyper-V +/// processor feature banks derive, and `ITS_NO`. +pub const ARCH_CAPABILITIES_PINNED_MASK: u64 = + hv_banks::ARCH_CAPABILITIES_BANK_MASK | ARCH_CAPABILITIES_ITS_NO; + +/// `CPUID.0x80000008:EBX[28]`, PSFD: predictive store forwarding can be +/// disabled, through `SPEC_CTRL`. +pub const AMD_PSFD: u32 = 1 << 28; + +/// `CPUID.0x80000008:EBX[29]`, `BTC_NO`: the CPU is not affected by branch +/// type confusion. +pub const AMD_BTC_NO: u32 = 1 << 29; + +/// The bits of `CPUID.(7,0):EDX` that enumerate Intel's speculation controls +/// and `IA32_ARCH_CAPABILITIES`: IBRS and IBPB (26), STIBP (27), +/// `IA32_ARCH_CAPABILITIES` (29), and SSBD (31). KVM sets them on AMD hosts +/// too, and emulates the MSR, but AMD CPUs enumerate their speculation +/// controls in `0x80000008` EBX and have no `IA32_ARCH_CAPABILITIES`, so AMD +/// profiles clear them. +pub const INTEL_SPECULATION_ENUMERATIONS: u32 = 1 << 26 | 1 << 27 | 1 << 29 | 1 << 31; + +/// Returns whether a CPUID whose `CPUID.(7,0):EDX` is `leaf7_edx` and whose +/// `CPUID.0x80000008:EBX` is `ext8_ebx` enumerates a `SPEC_CTRL` control, as +/// KVM's `guest_has_spec_ctrl_msr()` decides whether a guest may access +/// `SPEC_CTRL`: Intel's IBRS (`EDX[26]`), or AMD's IBRS, STIBP, or SSBD +/// (`EBX` bits 14, 15, and 24). AMD profiles keep [`AMD_PSFD`] only where it +/// holds, and OpenVMM's KVM backend applies it to KVM's supported CPUID. +pub fn has_spec_ctrl_control(leaf7_edx: u32, ext8_ebx: u32) -> bool { + leaf7_edx & 1 << 26 != 0 || ext8_ebx & (1 << 14 | 1 << 15 | 1 << 24) != 0 +} + +/// A CPU generation that profiles are derived for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KnownGeneration { + /// The vendor of the generation's CPUs. + pub vendor: CpuVendor, + /// The generation name, as logs and reports spell it. + pub name: &'static str, + /// The CPUs of the generation: family, model, and inclusive stepping + /// range. + pub cpus: &'static [(u32, u32, [u32; 2])], + /// A human-readable name. + pub description: &'static str, + /// The brand string of every profile of the generation: generic, with + /// the generation's display name and without a SKU or frequency. + pub brand: &'static str, +} + +impl KnownGeneration { + /// Returns the generation as profiles record it. + pub(crate) fn generation(&self) -> Generation { + Generation { + name: self.name.to_owned(), + cpus: self + .cpus + .iter() + .map(|&(family, model, steppings)| GenerationCpu { + family, + model, + steppings, + }) + .collect(), + } + } +} + +/// The generations profiles exist for. +pub const KNOWN_GENERATIONS: [KnownGeneration; 7] = [ + KnownGeneration { + vendor: CpuVendor::Intel, + name: "skylake-sp", + // Steppings 5 to 7 are Cascade Lake, and 10 and 11 Cooper Lake. + cpus: &[(6, 85, [0, 4])], + description: "Intel Xeon Scalable, first generation (Skylake-SP)", + brand: "Intel(R) Xeon(R) Processor (Skylake-SP)", + }, + KnownGeneration { + vendor: CpuVendor::Intel, + name: "icelake-sp", + cpus: &[(6, 106, [0, 15])], + description: "Intel Xeon Scalable, third generation (Ice Lake-SP)", + brand: "Intel(R) Xeon(R) Processor (Ice Lake-SP)", + }, + KnownGeneration { + vendor: CpuVendor::Intel, + name: "emeraldrapids", + cpus: &[(6, 207, [0, 15])], + description: "Intel Xeon Scalable, fifth generation (Emerald Rapids)", + brand: "Intel(R) Xeon(R) Processor (Emerald Rapids)", + }, + KnownGeneration { + vendor: CpuVendor::Intel, + name: "alderlake", + // Alder Lake-S (151) and Alder Lake-P and -H (154). + cpus: &[(6, 151, [0, 15]), (6, 154, [0, 15])], + description: "Intel Core, twelfth generation (Alder Lake)", + brand: "Intel(R) Core(TM) Processor (Alder Lake)", + }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "milan", + // Every stepping of the Milan die, Milan-X's included (stepping 2): + // no other generation uses family 25 model 1. Genoa is model 17. + cpus: &[(25, 1, [0, 15])], + description: "AMD EPYC, third generation (Milan)", + brand: "AMD EPYC Processor (Milan)", + }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "genoa", + // Every stepping of the Genoa die, Genoa-X's included: no other + // generation uses family 25 model 17. Bergamo and Siena (Zen 4c), + // Storm Peak, and the Zen 4 client CPUs are other models. + cpus: &[(25, 17, [0, 15])], + description: "AMD EPYC, fourth generation (Genoa)", + brand: "AMD EPYC Processor (Genoa)", + }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "turin", + // Every stepping of the Turin die (Zen 5): family 26 model 2. Turin + // Dense (Zen 5c) and the Zen 5 client CPUs are other models. + cpus: &[(26, 2, [0, 15])], + description: "AMD EPYC, fifth generation (Turin)", + brand: "AMD EPYC Processor (Turin)", + }, +]; + +/// Returns the known generation named `name`. +pub fn known_generation(name: &str) -> Option<&'static KnownGeneration> { + KNOWN_GENERATIONS + .iter() + .find(|generation| generation.name == name) +} + +/// A failure to derive a profile. +#[derive(Debug, Error)] +pub enum DeriveError { + /// No fingerprint was given. + #[error("no fingerprints")] + NoFingerprints, + /// A fingerprint's host is not in the generation. + #[error("the {backend} fingerprint {digest} is of {host}, not generation {generation}")] + WrongGeneration { + /// The fingerprint's backend. + backend: String, + /// The fingerprint's digest. + digest: String, + /// The host CPU. + host: String, + /// The generation. + generation: String, + }, + /// The values of a descriptive register tie. + #[error("no majority for {what}: {values}")] + Tie { + /// The register. + what: String, + /// The tied values and their votes. + values: String, + }, + /// The hosts disagree on the layout of an XSAVE component. + #[error("the hosts disagree on the layout of XSAVE component {0}")] + XsaveLayout(u32), + /// A feature the time ABI requires is missing. + #[error("not every backend supports {0}, which the time ABI requires")] + TimeFeature(&'static str), + /// The derived profile is invalid. + #[error("the derived profile is invalid: {0}")] + Invalid(String), +} + +/// A CPUID table: the four registers of each leaf and subleaf. +type Table = BTreeMap<(u32, Option), [u32; 4]>; + +/// The generation name of every host profile ([`derive_host_profile`]). No +/// known generation uses it. +pub const HOST_GENERATION: &str = "host"; + +/// Returns the brand string of every host profile of `vendor`'s CPUs. +fn host_brand(vendor: CpuVendor) -> &'static str { + match vendor { + CpuVendor::Intel => "Intel(R) Processor (host profile)", + CpuVendor::Amd => "AMD Processor (host profile)", + } +} + +/// What a derivation derives a profile for: a known generation, or one host. +struct Target<'a> { + /// The vendor of the profile's CPUs. + vendor: CpuVendor, + /// The generation that the profile records. + generation: Generation, + /// The profile's description. + description: String, + /// The profile's brand string. + brand: &'a str, +} + +/// Derives revision `revision` of the profile of `generation` from the +/// `fingerprints` of its hosts, on any backends. +pub fn derive_profile( + generation: &KnownGeneration, + revision: u32, + fingerprints: &[CpuFingerprint], +) -> Result { + derive( + &Target { + vendor: generation.vendor, + generation: generation.generation(), + description: format!( + "{}, on every backend; NVX time ABI v1", + generation.description + ), + brand: generation.brand, + }, + revision, + fingerprints, + ) +} + +/// Returns whether host profiles serve the CPU `host`, so that +/// `--cpu-profile host`, and `auto` on a CPU that no pinned profile serves, +/// can boot on it: [`derive_host_profile`] derives them for the CPUs of every +/// vendor that profiles serve, Intel's and AMD's. +pub fn supports_host_profiles(host: &HostCpuSignature) -> bool { + CpuVendor::from_cpuid_vendor(&host.vendor()).is_some() +} + +/// Derives the host profile of the host and backend that `fingerprint` +/// describes, for `--cpu-profile host` and for `auto`'s fallback on a CPU +/// that no pinned profile serves: the profile that the derivation +/// policy gives for this one fingerprint, with the ID +/// `.host.v1`, the generation [`HOST_GENERATION`] limited to the +/// host's family, model, and stepping, and a generic brand string of the +/// vendor. +/// +/// Unlike a pinned profile, a host profile is neither reviewed nor +/// immutable: a microcode, firmware, or hypervisor update can change it. +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN` for a CPU of a vendor that profiles do +/// not serve ([`supports_host_profiles`]), and with `E_PROFILE_UNSUPPORTED` +/// when the backend lacks a feature that the time ABI requires. +pub fn derive_host_profile(fingerprint: &CpuFingerprint) -> Result { + let host = host_signature(fingerprint); + let cpu = &fingerprint.host.cpu; + let Some(vendor) = CpuVendor::from_cpuid_vendor(cpu.vendor.as_bytes()) else { + return Err(ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!( + "host CPU profiles support only Intel and AMD CPUs, and the host CPU is {host}" + ), + )); + }; + let (family, model, stepping) = decode_signature(cpu.vendor.as_bytes(), cpu.signature.0); + derive( + &Target { + vendor, + generation: Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family, + model, + steppings: [stepping, stepping], + }], + }, + description: format!( + "Host profile of {host}, derived from one {} CPU fingerprint for development; \ + not pinned; NVX time ABI v1", + fingerprint.backend.name + ), + brand: host_brand(vendor), + }, + 1, + std::slice::from_ref(fingerprint), + ) + .map_err(|error| { + let code = match error { + DeriveError::TimeFeature(_) => ProfileErrorCode::ProfileUnsupported, + _ => ProfileErrorCode::ProfileHostUnknown, + }; + ProfileError::new( + code, + format!("cannot derive a host CPU profile for {host}: {error}"), + ) + }) +} + +/// Returns the vendor and signature of the host that `fingerprint` +/// describes. +fn host_signature(fingerprint: &CpuFingerprint) -> HostCpuSignature { + let cpu = &fingerprint.host.cpu; + let mut vendor = [0; 12]; + let host_vendor = cpu.vendor.as_bytes(); + if host_vendor.len() == vendor.len() { + vendor.copy_from_slice(host_vendor); + } + HostCpuSignature::new(vendor, cpu.signature.0) +} + +/// Derives revision `revision` of the profile of `target` from +/// `fingerprints`; see the module documentation. +fn derive( + target: &Target<'_>, + revision: u32, + fingerprints: &[CpuFingerprint], +) -> Result { + if fingerprints.is_empty() { + return Err(DeriveError::NoFingerprints); + } + let profile_generation = &target.generation; + let vendor = target.vendor; + for fingerprint in fingerprints { + let host = host_signature(fingerprint); + if !profile_generation.contains(vendor.cpuid_vendor(), &host) { + return Err(DeriveError::WrongGeneration { + backend: fingerprint.backend.name.clone(), + digest: fingerprint.digest.clone(), + host: host.to_string(), + generation: profile_generation.name.clone(), + }); + } + } + + let mut backends = BTreeMap::<&str, Vec<&CpuFingerprint>>::new(); + for fingerprint in fingerprints { + backends + .entry(&fingerprint.backend.name) + .or_default() + .push(fingerprint); + } + let backend_tables = backends + .iter() + .map(|(backend, members)| { + let tables = members + .iter() + .map(|member| table(vendor, &member.backend.cpuid)) + .collect::>(); + combine(&tables, &format!("{backend} hosts")) + }) + .collect::, _>>()?; + let combined = combine(&backend_tables, "backends")?; + + let mut leaves = apply_policy(vendor, &combined)?; + + // The brand string: generic per generation. + for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand_leaves(target.brand)) { + if let Some(entry) = leaves.get_mut(&(leaf, None)) { + *entry = value; + } + } + + // XSAVE. + let xcr0 = fingerprints.iter().fold(ALLOWED_XCR0, |xcr0, fingerprint| { + xcr0 & fingerprint.backend.xsave.xcr0_supported.0 + }); + let xss = fingerprints.iter().fold(ALLOWED_XSS, |xss, fingerprint| { + xss & fingerprint.backend.xsave.xss_supported.0 + }); + let mut components = Vec::::new(); + for index in (2..63).filter(|index| (xcr0 | xss) & (1 << index) != 0) { + let mut layouts = fingerprints.iter().map(|fingerprint| { + fingerprint + .backend + .xsave + .components + .iter() + .find(|component| component.index == index) + }); + let first = layouts.next().flatten(); + match first { + Some(component) if layouts.all(|layout| layout == Some(component)) => { + components.push(component.clone()); + } + _ => return Err(DeriveError::XsaveLayout(index)), + } + } + let extensions = fingerprints + .iter() + .fold(ALLOWED_XSAVE_EXTENSIONS, |extensions, fingerprint| { + extensions & fingerprint.backend.xsave.extensions.0 + }); + leaves.retain(|&(leaf, _), _| leaf != 0xd); + leaves.insert( + (0xd, Some(0)), + [ + xcr0 as u32, + 0, + cpuid::xsave_standard_size(&components, xcr0), + (xcr0 >> 32) as u32, + ], + ); + leaves.insert( + (0xd, Some(1)), + [extensions, 0, xss as u32, (xss >> 32) as u32], + ); + for component in &components { + let flags = u32::from(component.supervisor) + | u32::from(component.align64) << 1 + | u32::from(component.xfd) << 2; + leaves.insert( + (0xd, Some(component.index)), + [component.size, component.offset, flags, 0], + ); + } + clear_stateless_features(&mut leaves, xcr0); + + // IA32_ARCH_CAPABILITIES. + let mut msrs = Vec::new(); + if leaves.get(&(0x7, Some(0))).copied().unwrap_or_default()[3] & 1 << 29 != 0 { + let value = fingerprints + .iter() + .map(|fingerprint| { + let backend = &fingerprint.backend; + match backend.msrs.arch_capabilities { + Some(value) => value.0, + None => hv_banks::fingerprint_banks(backend) + .map_or(0, hv_banks::arch_capabilities_from_banks), + } + }) + .fold(ARCH_CAPABILITIES_PINNED_MASK, |value, presentable| { + value & presentable + }) + & !ARCH_CAPABILITIES_ITS_NO; + msrs.push(PinnedMsr { + index: Hex32(IA32_ARCH_CAPABILITIES), + value: Hex64(value), + mask: Hex64(ARCH_CAPABILITIES_PINNED_MASK), + }); + } + + // Unmask the bits OpenVMM sets per VM. + let leaves = leaves + .into_iter() + .map(|((leaf, subleaf), value)| { + let mask = pinned_mask(vendor, leaf, subleaf, value); + let value = [0, 1, 2, 3].map(|register| value[register] & mask[register]); + CpuidLeafValue::new(leaf, subleaf, value, mask) + }) + .collect::>(); + let physical_address_width = combined + .get(&(EXTENDED_LEAF_BASE + 8, None)) + .copied() + .unwrap_or_default()[0] as u8; + + let provenance = Provenance { + method: format!( + "derived by cpu_profile::derive under {POLICY}: per-register consensus within each \ + backend, then across backends, of the hosts' supported CPU surfaces" + ), + sources: backends + .iter() + .map(|(backend, members)| ProvenanceSource { + backend: (*backend).to_owned(), + hosts: members.len() as u32, + surface_digests: members + .iter() + .map(|member| member.surface_digest.clone()) + .collect::>() + .into_iter() + .collect(), + }) + .collect(), + }; + CpuProfile::from_parts( + format!("{}.{}.v{revision}", vendor.id(), profile_generation.name), + target.description.clone(), + vendor.cpuid_vendor().to_owned(), + profile_generation.clone(), + leaves, + xcr0, + xss, + physical_address_width, + msrs, + provenance, + ) + .map_err(DeriveError::Invalid) +} + +/// Returns the table of a fingerprint's CPUID for combining: without the +/// hypervisor range, the XSAVE leaf (rebuilt from the fingerprints' XSAVE +/// information), and the brand string (generic per generation), and with +/// the bits OpenVMM sets per VM in a profile of `vendor`'s CPUs cleared, so +/// hosts are compared only on what a profile pins. +fn table(vendor: CpuVendor, entries: &[cpuid::CpuidEntry]) -> Table { + entries + .iter() + .filter(|entry| { + let leaf = entry.leaf.0; + !(cpuid::HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&leaf) + && leaf != 0xd + && !(EXTENDED_LEAF_BASE + 2..=EXTENDED_LEAF_BASE + 4).contains(&leaf) + }) + .map(|entry| { + let (leaf, subleaf) = entry.key(); + let value = entry.registers(); + let mask = pinned_mask(vendor, leaf, subleaf, value); + ( + (leaf, subleaf), + [0, 1, 2, 3].map(|register| value[register] & mask[register]), + ) + }) + .collect() +} + +/// Combines `tables` register by register; see the module documentation. +fn combine(tables: &[Table], voters: &str) -> Result { + let keys = tables + .iter() + .flat_map(|table| table.keys().copied()) + .collect::>(); + let mut combined = Table::new(); + for (leaf, subleaf) in keys { + let mut registers = [0; 4]; + for (register, out) in registers.iter_mut().enumerate() { + let values = tables + .iter() + .map(|table| table.get(&(leaf, subleaf)).copied().unwrap_or_default()[register]) + .collect::>(); + *out = match register_class(leaf, subleaf.unwrap_or(0), register) { + RegisterClass::Features => values.iter().fold(!0, |bits, value| bits & value), + RegisterClass::Limits(fields) => fields.iter().fold(0, |out, &(shift, width)| { + let minimum = values + .iter() + .map(|&value| field_value(value, shift, width)) + .min() + .unwrap_or(0); + out | minimum << shift + }), + RegisterClass::Informational => majority(&values).ok_or_else(|| { + let mut votes = BTreeMap::::new(); + for &value in &values { + *votes.entry(value).or_default() += 1; + } + DeriveError::Tie { + what: format!( + "{} {} among the {voters}", + describe_leaf(leaf, subleaf), + ["EAX", "EBX", "ECX", "EDX"][register] + ), + values: votes + .iter() + .map(|(value, count)| format!("{value:#x} ({count})")) + .collect::>() + .join(", "), + } + })?, + }; + } + combined.insert((leaf, subleaf), registers); + } + Ok(combined) +} + +/// Returns the value with the most votes, or `None` on a tie. +fn majority(values: &[u32]) -> Option { + let mut votes = BTreeMap::::new(); + for &value in values { + *votes.entry(value).or_default() += 1; + } + let best = votes.values().copied().max()?; + let mut winners = votes.iter().filter(|&(_, &count)| count == best); + let (&winner, _) = winners.next()?; + winners.next().is_none().then_some(winner) +} + +/// Lays out the profile's leaves from the combined table: the dense leaf and +/// subleaf set, the [`DATA_LEAVES`] allowlist (and [`AMD_DATA_LEAVES`] when +/// `vendor` is AMD), the CPU time bits, and the features a VM must not see. +/// The XSAVE leaf and the brand are filled in later. +fn apply_policy(vendor: CpuVendor, combined: &Table) -> Result { + let get = |leaf: u32, subleaf: Option| { + combined.get(&(leaf, subleaf)).copied().unwrap_or_default() + }; + let max_basic = get(0, None)[0]; + let max_extended = get(EXTENDED_LEAF_BASE, None)[0]; + let mut leaves = Table::new(); + let all_leaves = (0..=max_basic).chain(EXTENDED_LEAF_BASE..=max_extended); + for leaf in all_leaves.filter(|leaf| !VM_OWNED_LEAVES.contains(leaf)) { + let data = DATA_LEAVES.contains(&leaf) + || (vendor == CpuVendor::Amd && AMD_DATA_LEAVES.contains(&leaf)); + if !cpuid::is_indexed_leaf(leaf) { + leaves.insert((leaf, None), if data { get(leaf, None) } else { [0; 4] }); + continue; + } + let last = match leaf { + _ if !data => 0, + // Up to and including the first null cache type. + 0x4 | 0x8000_001d => (0..0x40) + .find(|&subleaf| get(leaf, Some(subleaf))[0] & 0x1f == 0) + .unwrap_or(0x3f), + // Rebuilt from the XSAVE features later. + 0xd => continue, + _ => get(leaf, Some(0))[0].min(0x3f), + }; + for subleaf in 0..=last { + let value = if data { + get(leaf, Some(subleaf)) + } else { + [0; 4] + }; + leaves.insert((leaf, Some(subleaf)), value); + } + } + + let edit = + |leaves: &mut Table, key: (u32, Option), register: usize, set: u32, clear: u32| { + if let Some(value) = leaves.get_mut(&key) { + value[register] = (value[register] | set) & !clear; + } + }; + let require = + |leaves: &Table, key: (u32, Option), register: usize, bit: u32, what: &'static str| { + if leaves + .get(&key) + .is_none_or(|value| value[register] & bit == 0) + { + Err(DeriveError::TimeFeature(what)) + } else { + Ok(()) + } + }; + // CPUID.1: the hypervisor bit (time ABI); no VMX, SMX, EST, TM2, DTES64, + // MONITOR, DS-CPL, SDBG, xTPR, PDCM (time ABI, no PMU), DCA, or + // TSC-deadline (time ABI); no DS, ACPI, TM, or PBE. + edit( + &mut leaves, + (0x1, None), + 2, + 1 << 31, + 1 << 2 + | 1 << 3 + | 1 << 4 + | 1 << 5 + | 1 << 6 + | 1 << 7 + | 1 << 8 + | 1 << 11 + | 1 << 14 + | 1 << 15 + | 1 << 18 + | 1 << 24, + ); + edit( + &mut leaves, + (0x1, None), + 3, + 0, + 1 << 21 | 1 << 22 | 1 << 29 | 1 << 31, + ); + require(&leaves, (0x1, None), 3, 1 << 4, "the TSC")?; + // CPUID.6: ARAT only (time ABI). + leaves.insert((0x6, None), [1 << 2, 0, 0, 0]); + // CPUID.(7,0): no TSC_ADJUST (time ABI), SGX, RDT, or Intel PT; no CET, + // key locker, or SGX launch control; no hybrid, PCONFIG, or arch LBR. + edit( + &mut leaves, + (0x7, Some(0)), + 1, + 0, + 1 << 1 | 1 << 2 | 1 << 12 | 1 << 15 | 1 << 25, + ); + edit( + &mut leaves, + (0x7, Some(0)), + 2, + 0, + 1 << 7 | 1 << 23 | 1 << 30, + ); + edit( + &mut leaves, + (0x7, Some(0)), + 3, + 0, + 1 << 15 | 1 << 18 | 1 << 19 | 1 << 20, + ); + // CPUID.0x80000001: RDTSCP (time ABI). + require( + &leaves, + (EXTENDED_LEAF_BASE + 1, None), + 3, + 1 << 27, + "RDTSCP", + )?; + // CPUID.0x80000007: the invariant TSC only (time ABI). + leaves.insert((EXTENDED_LEAF_BASE + 7, None), [0, 0, 0, 1 << 8]); + // CPUID.0x80000008: the address widths only; no GuestPhysBits, which only + // newer KVM reports, and no AMD core count. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 8, None), + 0, + 0, + 0xffff_0000, + ); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 2, 0, !0); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 3, 0, !0); + if vendor == CpuVendor::Amd { + // CPUID.0x80000001 EBX: no brand ID or package type, which describe + // the host's SKU and socket rather than the CPU's features. + edit(&mut leaves, (EXTENDED_LEAF_BASE + 1, None), 1, 0, !0); + // CPUID.0x80000001 ECX: no SVM, IBS, SKINIT, watchdog timer, LWP, or + // node ID MSR; no core, northbridge, or LLC performance counter + // extensions, performance TSC, data breakpoint or address mask + // extensions, or MONITORX. No 3DNowPrefetch either: the Hyper-V root + // of a nested Azure host does not see it, so that host's cold-boot + // support check, which reads the root's CPUID, would fail a profile + // that pins it, although WHP's guests see it; Linux infers PREFETCHW + // from long mode whatever the bit says. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 1, None), + 2, + 0, + 1 << 2 + | 1 << 8 + | 1 << 10 + | 1 << 12 + | 1 << 13 + | 1 << 15 + | 1 << 19 + | 1 << 23 + | 1 << 24 + | 1 << 26 + | 1 << 27 + | 1 << 28 + | 1 << 29 + | 1 << 30, + ); + // CPUID.0x80000008 EBX: no instructions-retired counter, INVLPGB + // (nested pages included), RDPRU, which reads APERF and MPERF, memory + // bandwidth enforcement, PPIN, CPPC, or branch sampling. No BTC_NO + // either: KVM never enumerates it in KVM_GET_SUPPORTED_CPUID, so every + // KVM host would fail a profile that set it, and a guest loses + // nothing without it, because Linux marks no CPU of family 0x19 or + // later as affected by RETBLEED, which is all the bit decides. The + // speculation controls and the other immunities stay. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 8, None), + 1, + 0, + 1 << 1 | 1 << 3 | 1 << 4 | 1 << 6 | 1 << 21 | 1 << 23 | 1 << 27 | AMD_BTC_NO | 1 << 31, + ); + // CPUID.(7,0) EDX: none of Intel's speculation controls or + // IA32_ARCH_CAPABILITIES, which KVM enumerates on AMD hosts beside + // AMD's own controls in 0x80000008 EBX, and emulates. No AMD CPU has + // them: AMD enumerates its controls and immunities in 0x80000008 and + // 0x80000021, which the Hyper-V banks map, and the Milan host's WHP + // presents none of the bits, so a profile derived from KVM alone + // would otherwise fail the Hyper-V hosts of its generation. A guest + // loses nothing: it uses AMD's controls, and Linux takes no AMD CPU + // to be affected by the vulnerabilities whose immunities the MSR + // reports. With the bit clear, the profile pins no + // IA32_ARCH_CAPABILITIES value. + edit( + &mut leaves, + (0x7, Some(0)), + 3, + 0, + INTEL_SPECULATION_ENUMERATIONS, + ); + // PSFD is a bit of SPEC_CTRL, so it means nothing to a guest that + // cannot access SPEC_CTRL, and KVM lets a guest access it only if the + // guest's CPUID has a SPEC_CTRL control. The rule reads the derived + // table, the CPUID that a guest of the profile has: applied to each + // fingerprint instead, it would keep PSFD where every backend offers + // it beside a different control, which the intersection then drops. + let register = |leaves: &Table, key, register: usize| { + leaves + .get(&key) + .map_or(0, |value: &[u32; 4]| value[register]) + }; + if !has_spec_ctrl_control( + register(&leaves, (0x7, Some(0)), 3), + register(&leaves, (EXTENDED_LEAF_BASE + 8, None), 1), + ) { + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 1, 0, AMD_PSFD); + } + // CPUID.0x80000021: AutoIBRS, LFENCE serialization, SBPB, SRSO_NO, + // and the TSA immunities stay; no CPUID faulting, which writes HWCR, + // or workload classification; nothing in EBX (the microcode patch and + // return address predictor sizes) or EDX. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 0x21, None), + 0, + 0, + 1 << 17 | 1 << 22, + ); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 0x21, None), 1, 0, !0); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 0x21, None), 3, 0, !0); + } + Ok(leaves) +} + +/// Clears the features whose XSAVE state `xcr0` does not enable. +fn clear_stateless_features(leaves: &mut Table, xcr0: u64) { + const AVX: u64 = 1 << 2; + const MPX: u64 = 3 << 3; + const AVX512: u64 = 7 << 5; + const PKRU: u64 = 1 << 9; + let enabled = |state: u64| xcr0 & state == state; + if let Some(value) = leaves.get_mut(&(0x1, None)) { + if !enabled(AVX) { + // AVX, FMA, and F16C. + value[2] &= !(1 << 28 | 1 << 12 | 1 << 29); + } + } + if let Some(value) = leaves.get_mut(&(0x7, Some(0))) { + if !enabled(AVX) { + // AVX2, VAES, and VPCLMULQDQ. + value[1] &= !(1 << 5); + value[2] &= !(1 << 9 | 1 << 10); + } + if !enabled(MPX) { + value[1] &= !(1 << 14); + } + if !enabled(AVX512) { + // AVX512F, DQ, IFMA, PF, ER, CD, BW, and VL; VBMI, VBMI2, VNNI, + // BITALG, and VPOPCNTDQ; 4VNNIW, 4FMAPS, VP2INTERSECT, and FP16. + value[1] &= + !(1 << 16 | 1 << 17 | 1 << 21 | 1 << 26 | 1 << 27 | 1 << 28 | 1 << 30 | 1 << 31); + value[2] &= !(1 << 1 | 1 << 6 | 1 << 11 | 1 << 12 | 1 << 14); + value[3] &= !(1 << 2 | 1 << 3 | 1 << 8 | 1 << 23); + } + if !enabled(PKRU) { + // PKU. + value[2] &= !(1 << 3); + } + } + if let Some(value) = leaves.get_mut(&(0x7, Some(1))) { + if !enabled(AVX) { + // AVX-VNNI. + value[0] &= !(1 << 4); + } + if !enabled(AVX512) { + // AVX512-BF16. + value[0] &= !(1 << 5); + } + } +} + +/// Returns the CPUID brand string leaves `0x80000002..=0x80000004` for +/// `brand`, padded with zeros. +fn brand_leaves(brand: &str) -> [[u32; 4]; 3] { + let mut bytes = [0u8; 48]; + let brand = brand.as_bytes(); + let len = brand.len().min(47); + bytes[..len].copy_from_slice(&brand[..len]); + let mut leaves = [[0; 4]; 3]; + for (i, word) in bytes.as_chunks::<4>().0.iter().enumerate() { + leaves[i / 4][i % 4] = u32::from_le_bytes(*word); + } + leaves +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cpuid::CpuidEntry; + use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; + use crate::test_support::genoa_kvm_entries; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use crate::test_support::turin_kvm_entries; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + fn generation(name: &str) -> &'static KnownGeneration { + known_generation(name).unwrap() + } + + /// Returns the fingerprint of a `backend` host of `profile`'s CPU whose + /// CPUID is the profile's, changed by `edit`. + fn edited( + profile: &CpuProfile, + backend: &str, + edit: impl FnOnce(&mut Vec), + ) -> CpuFingerprint { + let mut entries = profile_entries(profile); + edit(&mut entries); + fingerprint_with(profile, backend, entries) + } + + /// Changes register `register` of `leaf` and `subleaf`. + fn set( + entries: &mut [CpuidEntry], + leaf: u32, + subleaf: Option, + register: usize, + f: impl FnOnce(u32) -> u32, + ) { + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (leaf, subleaf)) + .unwrap(); + let mut registers = entry.registers(); + registers[register] = f(registers[register]); + *entry = CpuidEntry::new(leaf, subleaf, registers); + } + + #[test] + fn rederiving_a_pinned_profile_reproduces_it() { + for pinned in crate::pinned_profiles() { + let known = generation(&pinned.generation().name); + let derived = derive_profile( + known, + 1, + &[fingerprint(pinned, "kvm"), fingerprint(pinned, "whp")], + ) + .unwrap(); + assert_eq!(derived.id(), pinned.id()); + assert_eq!(derived.description(), pinned.description()); + assert_eq!(derived.generation(), pinned.generation()); + assert_eq!(derived.cpuid(), pinned.cpuid()); + assert_eq!(derived.xcr0(), pinned.xcr0()); + assert_eq!(derived.xss(), pinned.xss()); + assert_eq!(derived.xsave_components(), pinned.xsave_components()); + assert_eq!( + derived.physical_address_width(), + pinned.physical_address_width() + ); + assert_eq!(derived.msrs(), pinned.msrs()); + assert_eq!( + derived + .provenance() + .sources + .iter() + .map(|source| (source.backend.as_str(), source.hosts)) + .collect::>(), + [("kvm", 1), ("whp", 1)] + ); + } + } + + #[test] + fn votes_only_on_pinned_bits() { + // KVM reports the host's topology fields and no brand string; two + // backends must still agree. + let pinned = profile(ICELAKE); + let kvm = edited(pinned, "kvm", |entries| { + set(entries, 1, None, 1, |ebx| ebx | 8 << 16); + set(entries, 4, Some(0), 0, |eax| eax | 0xc000_0000); + for leaf in 0x8000_0002..=0x8000_0004 { + set(entries, leaf, None, 0, |_| 0); + } + }); + let derived = derive_profile( + generation("icelake-sp"), + 1, + &[kvm, fingerprint(pinned, "whp")], + ) + .unwrap(); + assert_eq!(derived.cpuid(), pinned.cpuid()); + } + + /// Returns the brand string of `profile`. + fn brand(profile: &CpuProfile) -> String { + let bytes = (0x8000_0002..=0x8000_0004) + .flat_map(|leaf| profile.lookup(leaf, 0)) + .flat_map(u32::to_le_bytes) + .collect::>(); + let end = bytes.iter().position(|&byte| byte == 0).unwrap(); + String::from_utf8(bytes[..end].to_vec()).unwrap() + } + + #[test] + fn the_brand_is_generic_per_generation() { + for known in &KNOWN_GENERATIONS { + // The brand string holds 47 bytes and a terminator. + assert!(known.brand.len() < 48, "{}", known.brand); + } + for pinned in crate::pinned_profiles() { + assert_eq!(brand(pinned), generation(&pinned.generation().name).brand); + } + + // A host of another SKU derives the same brand. + let pinned = profile(ICELAKE); + let mut other_sku = edited(pinned, "whp", |entries| { + for leaf in 0x8000_0002..=0x8000_0004 { + set(entries, leaf, None, 0, |eax| eax ^ 0x0101_0101); + } + }); + other_sku.host.cpu.brand = "Intel(R) Xeon(R) Gold 6338 CPU @ 2.00GHz".to_owned(); + let derived = derive_profile( + generation("icelake-sp"), + 1, + &[fingerprint(pinned, "kvm"), other_sku], + ) + .unwrap(); + assert_eq!(brand(&derived), "Intel(R) Xeon(R) Processor (Ice Lake-SP)"); + assert_eq!(derived.cpuid(), pinned.cpuid()); + } + + #[test] + fn intersects_features_and_votes_on_descriptions() { + let pinned = profile(ICELAKE); + let fingerprints = [ + // PKU, and another TLB descriptor. + edited(pinned, "kvm", |entries| { + set(entries, 7, Some(0), 2, |ecx| ecx | 1 << 3); + set(entries, 2, None, 1, |ebx| ebx + 1); + }), + // No AVX512-VBMI. + edited(pinned, "mshv", |entries| { + set(entries, 7, Some(0), 2, |ecx| ecx & !(1 << 1)); + }), + fingerprint(pinned, "whp"), + ]; + let derived = derive_profile(generation("icelake-sp"), 2, &fingerprints).unwrap(); + assert_eq!(derived.id(), "intel.icelake-sp.v2"); + assert_eq!(derived.lookup(7, 0)[2], pinned.lookup(7, 0)[2] & !(1 << 1)); + assert_eq!(derived.lookup(2, 0), pinned.lookup(2, 0)); + + // Two backends that disagree have no majority. + let tie = derive_profile(generation("icelake-sp"), 2, &fingerprints[..2]).unwrap_err(); + assert!( + matches!(&tie, DeriveError::Tie { what, .. } if what == "CPUID 0x2 EBX among the backends"), + "{tie}" + ); + } + + #[test] + fn pins_the_arch_capabilities_every_backend_can_present() { + let pinned = profile(ICELAKE); + let mut kvm = fingerprint(pinned, "kvm"); + kvm.backend.msrs.arch_capabilities = Some(Hex64(0x4000_0000_0c00_016d)); + let mut whp = fingerprint(pinned, "whp"); + whp.backend.msrs.arch_capabilities = None; + whp.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank0", + 0x2e0a_8bff_e7f7_859f, + ); + whp.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank1", + 0x0001_000e_0000_00f1, + ); + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm.clone(), whp]).unwrap(); + assert_eq!( + derived.msr(IA32_ARCH_CAPABILITIES), + Some((0x0800_0121, ARCH_CAPABILITIES_PINNED_MASK)) + ); + // ITS_NO stays clear even where every backend presents it. + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.msr(IA32_ARCH_CAPABILITIES), + Some((0x0c00_0129, ARCH_CAPABILITIES_PINNED_MASK)) + ); + } + + #[test] + fn clears_features_whose_xsave_state_is_disabled() { + let pinned = profile(ICELAKE); + // No AVX-512 state. + let fingerprint = edited(pinned, "kvm", |entries| { + set(entries, 0xd, Some(0), 0, |eax| eax & !0xe0); + entries.retain(|entry| !matches!(entry.key(), (0xd, Some(5..=7)))); + }); + let derived = derive_profile(generation("icelake-sp"), 1, &[fingerprint]).unwrap(); + assert_eq!(derived.xcr0(), 0x7); + assert_eq!(derived.lookup(7, 0)[1] & (1 << 16 | 1 << 31), 0); + assert_eq!(derived.lookup(7, 0)[2] & (1 << 1 | 1 << 14), 0); + assert_eq!(derived.lookup(0xd, 0)[2], 832); + assert_eq!(derived.xsave_components().len(), 1); + } + + #[test] + fn rejects_inconsistent_inputs() { + let pinned = profile(ICELAKE); + assert!(matches!( + derive_profile(generation("icelake-sp"), 1, &[]), + Err(DeriveError::NoFingerprints) + )); + assert!(matches!( + derive_profile(generation("skylake-sp"), 1, &[fingerprint(pinned, "kvm")]), + Err(DeriveError::WrongGeneration { .. }) + )); + + let moved = edited(pinned, "whp", |entries| { + set(entries, 0xd, Some(7), 1, |offset| offset + 64); + }); + assert!(matches!( + derive_profile( + generation("icelake-sp"), + 1, + &[fingerprint(pinned, "kvm"), moved] + ), + Err(DeriveError::XsaveLayout(7)) + )); + + let no_rdtscp = edited(pinned, "whp", |entries| { + set(entries, 0x8000_0001, None, 3, |edx| edx & !(1 << 27)); + }); + assert!(matches!( + derive_profile(generation("icelake-sp"), 1, &[no_rdtscp]), + Err(DeriveError::TimeFeature("RDTSCP")) + )); + } + + #[test] + fn a_host_profile_applies_the_policy_to_one_host() { + let pinned = profile("intel.alderlake.v1"); + let host = derive_host_profile(&fingerprint(pinned, "whp")).unwrap(); + assert_eq!(host.id(), "intel.host.v1"); + assert_eq!(host.vendor(), "GenuineIntel"); + assert_eq!( + host.generation(), + &Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family: 6, + model: 154, + steppings: [3, 3], + }], + } + ); + assert!( + host.description() + .starts_with("Host profile of GenuineIntel family 6 model 154 stepping 3"), + "{}", + host.description() + ); + assert!(host.description().contains("one whp CPU fingerprint")); + assert_eq!(brand(&host), "Intel(R) Processor (host profile)"); + // Everything else is what the catalog's derivation gives for the + // same fingerprint. + let brand_leaves = 0x8000_0002..=0x8000_0004; + let without_brand = |profile: &CpuProfile| { + profile + .cpuid() + .iter() + .filter(|entry| !brand_leaves.contains(&entry.leaf.0)) + .cloned() + .collect::>() + }; + assert_eq!(without_brand(&host), without_brand(pinned)); + assert_eq!(host.xcr0(), pinned.xcr0()); + assert_eq!(host.xss(), pinned.xss()); + assert_eq!(host.xsave_components(), pinned.xsave_components()); + assert_eq!(host.msrs(), pinned.msrs()); + assert_eq!( + host.physical_address_width(), + pinned.physical_address_width() + ); + // The derivation is deterministic, and the profile is canonical. + let again = derive_host_profile(&fingerprint(pinned, "whp")).unwrap(); + assert_eq!(again.digest(), host.digest()); + assert_eq!(CpuProfile::decode(&host.encode()).unwrap(), host); + } + + #[test] + fn host_profiles_fail_with_the_time_abi_codes() { + let pinned = profile("intel.alderlake.v1"); + let mut hygon = host_fingerprint("kvm", milan_whp_entries()); + hygon.host.cpu.vendor = "HygonGenuine".to_owned(); + let error = derive_host_profile(&hygon).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); + assert!(error.message.contains("only Intel and AMD CPUs"), "{error}"); + + let no_rdtscp = edited(pinned, "whp", |entries| { + set(entries, 0x8000_0001, None, 3, |edx| edx & !(1 << 27)); + }); + let error = derive_host_profile(&no_rdtscp).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error.message.starts_with( + "cannot derive a host CPU profile for GenuineIntel family 6 model 154 stepping 3" + ), + "{error}" + ); + assert!( + error + .message + .ends_with("RDTSCP, which the time ABI requires") + ); + } + + #[test] + fn no_known_generation_is_the_host_generation() { + assert!(known_generation(HOST_GENERATION).is_none()); + } + + /// Host profiles serve the CPUs that [`derive_host_profile`] accepts: + /// Intel's and AMD's. + #[test] + fn host_profiles_serve_intel_and_amd_cpus() { + let intel = fingerprint(profile("intel.alderlake.v1"), "whp"); + let amd = host_fingerprint("whp", milan_whp_entries()); + let mut hygon = amd.clone(); + hygon.host.cpu.vendor = "HygonGenuine".to_owned(); + for (fingerprint, served) in [(intel, true), (amd, true), (hygon, false)] { + let host = host_signature(&fingerprint); + assert_eq!(supports_host_profiles(&host), served, "{host}"); + assert_eq!(derive_host_profile(&fingerprint).is_ok(), served, "{host}"); + } + } + + /// Returns the profile of AMD's Milan generation that `fingerprints` + /// derive, as the catalog pins it. + fn derive_milan(fingerprints: &[CpuFingerprint]) -> Result { + derive_profile(generation("milan"), 1, fingerprints) + } + + /// The pinned Milan profile is the policy's profile of the one host it was + /// derived from: an AMD EPYC 7763 that WHP serves on an Azure host, whose + /// CPUID `test_support::MILAN_WHP_CPUID` records. + #[test] + fn the_milan_profile_is_its_hosts_derivation() { + let pinned = profile("amd.milan.v1"); + let derived = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(derived.id(), pinned.id()); + assert_eq!(derived.description(), pinned.description()); + assert_eq!(derived.generation(), pinned.generation()); + assert_eq!(derived.cpuid(), pinned.cpuid()); + assert_eq!(derived.xcr0(), pinned.xcr0()); + assert_eq!(derived.xss(), pinned.xss()); + assert_eq!(derived.xsave_components(), pinned.xsave_components()); + assert_eq!(derived.msrs(), pinned.msrs()); + assert_eq!(brand(pinned), "AMD EPYC Processor (Milan)"); + } + + /// A KVM host of the same CPU supports the Milan profile: KVM never + /// enumerates `BTC_NO`, and OpenVMM's KVM backend withholds PSFD without + /// a `SPEC_CTRL` control, which that host's KVM, nested on Azure, would + /// not offer either; the profile has neither. + #[test] + fn a_kvm_host_of_its_cpu_supports_the_milan_profile() { + let mut entries = milan_whp_entries(); + set(&mut entries, 0x8000_0008, None, 1, |ebx| { + ebx & !(AMD_PSFD | AMD_BTC_NO) + }); + let surface = + crate::HostCpuSurface::from_fingerprint(&host_fingerprint("kvm", entries).backend); + assert_eq!( + crate::support_violations(profile("amd.milan.v1"), &surface), + Vec::::new() + ); + } + + /// The pinned Genoa and Turin profiles are the policy's profiles of the + /// one host each was derived from: an AMD EPYC 9V74 and an AMD EPYC 9V45 + /// that KVM serves in Azure VMs of GitHub-hosted Actions runners, whose + /// CPUID `test_support::GENOA_KVM_CPUID` and `TURIN_KVM_CPUID` record. Each + /// host supports its profile. + #[test] + fn the_genoa_and_turin_profiles_are_their_hosts_derivations() { + for (id, name, entries, brand_string, xcr0) in [ + ( + "amd.genoa.v1", + "genoa", + genoa_kvm_entries(), + "AMD EPYC Processor (Genoa)", + 0x7, + ), + ( + "amd.turin.v1", + "turin", + turin_kvm_entries(), + "AMD EPYC Processor (Turin)", + 0xe7, + ), + ] { + let pinned = profile(id); + let host = host_fingerprint("kvm", entries); + let derived = derive_profile(generation(name), 1, std::slice::from_ref(&host)).unwrap(); + assert_eq!(derived.id(), pinned.id()); + assert_eq!(derived.description(), pinned.description()); + assert_eq!(derived.generation(), pinned.generation()); + assert_eq!(derived.cpuid(), pinned.cpuid(), "{id}"); + assert_eq!(derived.xcr0(), pinned.xcr0(), "{id}"); + assert_eq!(derived.xss(), pinned.xss(), "{id}"); + assert_eq!(derived.xsave_components(), pinned.xsave_components()); + assert_eq!(derived.msrs(), pinned.msrs(), "{id}"); + assert_eq!(pinned.xcr0(), xcr0, "{id}"); + assert_eq!(brand(pinned), brand_string); + let surface = crate::HostCpuSurface::from_fingerprint(&host.backend); + assert_eq!( + crate::support_violations(pinned, &surface), + Vec::::new(), + "{id}" + ); + } + } + + /// KVM enumerates Intel's speculation controls and + /// `IA32_ARCH_CAPABILITIES` on AMD hosts too, as the Genoa and Turin + /// hosts' KVM does, and emulates the MSR. An AMD profile has none of + /// them and pins no `IA32_ARCH_CAPABILITIES` value, so a profile derived + /// from KVM alone stays one that the Hyper-V backends can serve; AMD's + /// own controls stay. + #[test] + fn an_amd_profile_has_none_of_intels_speculation_enumerations() { + let amd_controls = 1 << 12 | 1 << 14 | 1 << 15 | 1 << 24; + let mut entries = milan_whp_entries(); + set(&mut entries, 0x7, Some(0), 3, |edx| { + edx | INTEL_SPECULATION_ENUMERATIONS + }); + set(&mut entries, 0x8000_0008, None, 1, |ebx| ebx | amd_controls); + let mut kvm = host_fingerprint("kvm", entries); + kvm.backend.msrs.arch_capabilities = Some(Hex64(0x4000_0000_0c00_0069)); + let derived = derive_milan(&[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x7, 0)[3] & INTEL_SPECULATION_ENUMERATIONS, + 0 + ); + assert_eq!(derived.msr(IA32_ARCH_CAPABILITIES), None); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1] & amd_controls, + amd_controls + ); + for pinned in crate::pinned_profiles() + .iter() + .filter(|pinned| pinned.cpu_vendor() == CpuVendor::Amd) + { + assert_eq!( + pinned.lookup(0x7, 0)[3] & INTEL_SPECULATION_ENUMERATIONS, + 0, + "{}", + pinned.id() + ); + assert_eq!(pinned.msr(IA32_ARCH_CAPABILITIES), None, "{}", pinned.id()); + } + } + + /// The CPUID entry of `profile` at `leaf` and `subleaf`. + fn entry(profile: &CpuProfile, leaf: u32, subleaf: Option) -> &CpuidLeafValue { + profile + .cpuid() + .iter() + .find(|entry| entry.key() == (leaf, subleaf)) + .unwrap_or_else(|| panic!("{} lacks {}", profile.id(), describe_leaf(leaf, subleaf))) + } + + #[test] + fn an_amd_host_profile_leaves_amd_topology_to_the_vm() { + let host = derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap(); + assert_eq!(host.id(), "amd.host.v1"); + assert_eq!(host.vendor(), "AuthenticAMD"); + assert_eq!(host.cpu_vendor(), CpuVendor::Amd); + assert_eq!( + host.generation(), + &Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family: 0x19, + model: 1, + steppings: [1, 1], + }], + } + ); + assert!( + host.description() + .starts_with("Host profile of AuthenticAMD family 25 model 1 stepping 1"), + "{}", + host.description() + ); + assert_eq!(brand(&host), "AMD Processor (host profile)"); + + // The cache topology lists every cache up to the null cache type, as + // leaf 4 does on Intel, with the sharing count left to the VM. + let caches = host + .cpuid() + .iter() + .filter(|entry| entry.leaf.0 == 0x8000_001d) + .map(|entry| entry.subleaf.map(|subleaf| subleaf.0)) + .collect::>(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3), Some(4)]); + let l3 = entry(&host, 0x8000_001d, Some(3)); + assert_eq!(l3.values(), [0x163, 0x03c0_003f, 0x7fff, 0x1]); + assert_eq!(l3.masks(), [0xfc00_3fff, !0, !0, !0]); + assert_eq!(entry(&host, 0x8000_001d, Some(4)).masks(), [!0; 4]); + // The core count and APIC ID size, and the processor topology leaf, + // are the VM's. + assert_eq!(entry(&host, 0x8000_0008, None).masks()[2], !0xf0ff); + assert_eq!( + entry(&host, 0x8000_001e, None).masks(), + [0, 0xffff_0000, 0xffff_f800, !0] + ); + // The L1 cache and TLB leaf keeps its data; leaf 4, which AMD CPUs + // leave zero, is pinned zero. + assert_eq!( + host.lookup(0x8000_0005, 0), + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140] + ); + assert_eq!(entry(&host, 0x4, Some(0)).masks(), [!0; 4]); + // Topology extensions stay: the cache and processor topology leaves + // depend on them. + assert_ne!(host.lookup(0x8000_0001, 0)[2] & 1 << 22, 0); + // The policy's time bits apply as on Intel. + assert_eq!(host.lookup(0x8000_0007, 0), [0, 0, 0, 1 << 8]); + assert_eq!(host.lookup(6, 0), [1 << 2, 0, 0, 0]); + // The derivation is deterministic, and the profile is canonical. + let again = derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap(); + assert_eq!(again.digest(), host.digest()); + assert_eq!(CpuProfile::decode(&host.encode()).unwrap(), host); + } + + #[test] + fn the_amd_policy_keeps_the_speculation_controls_and_clears_what_a_vm_must_not_see() { + // A Milan host whose backend offers SVM, IBS, LWP, the performance + // counter extensions, MONITORX, the speculation controls and + // immunities, RDPRU, PPIN, CPPC, and 0x80000021's features, and + // reports its own core count, cache sharing, and topology, as KVM + // can on bare metal, and BTC_NO, as WHP does. + let mut entries = milan_whp_entries(); + set(&mut entries, 0x8000_0001, None, 2, |ecx| { + ecx | 1 << 2 | 1 << 10 | 1 << 15 | 1 << 23 | 1 << 24 | 1 << 28 | 1 << 29 + }); + set(&mut entries, 0x8000_0008, None, 1, |ebx| { + ebx | 1 << 1 | 1 << 12 | 1 << 14 | 1 << 15 | 1 << 23 | 1 << 24 | 1 << 27 + }); + set(&mut entries, 0x8000_0008, None, 2, |_| 0x0000_707f); + set(&mut entries, 0x8000_001d, Some(3), 0, |eax| eax | 15 << 14); + set(&mut entries, 0x8000_001e, None, 1, |_| 0x0000_0103); + set(&mut entries, 0x8000_0021, None, 0, |_| { + 1 << 2 | 1 << 6 | 1 << 8 | 1 << 17 | 1 << 22 | 1 << 27 | 1 << 29 + }); + set(&mut entries, 0x8000_0021, None, 1, |_| 0x10); + set(&mut entries, 0x8000_0021, None, 2, |_| 1 << 1 | 1 << 2); + let derived = derive_milan(&[host_fingerprint("kvm", entries)]).unwrap(); + assert_eq!(derived.id(), "amd.milan.v1"); + assert_eq!(brand(&derived), "AMD EPYC Processor (Milan)"); + + let x1 = derived.lookup(0x8000_0001, 0)[2]; + for (bit, name) in [(0, "LAHF"), (5, "ABM"), (6, "SSE4A"), (22, "TOPOEXT")] { + assert_ne!(x1 & 1 << bit, 0, "{name}"); + } + for (bit, name) in [ + (2, "SVM"), + (8, "3DNowPrefetch"), + (10, "IBS"), + (15, "LWP"), + (23, "PERFCTR_CORE"), + (24, "PERFCTR_NB"), + (28, "PERFCTR_LLC"), + (29, "MONITORX"), + ] { + assert_eq!(x1 & 1 << bit, 0, "{name}"); + } + let x8 = derived.lookup(0x8000_0008, 0)[1]; + // PSFD stays beside IBRS, STIBP, and SSBD, the SPEC_CTRL controls. + for (bit, name) in [ + (0, "CLZERO"), + (12, "IBPB"), + (14, "IBRS"), + (15, "STIBP"), + (24, "SSBD"), + (28, "PSFD"), + ] { + assert_ne!(x8 & 1 << bit, 0, "{name}"); + } + for (bit, name) in [ + (1, "IRPERF"), + (4, "RDPRU"), + (23, "PPIN"), + (27, "CPPC"), + (29, "BTC_NO"), + ] { + assert_eq!(x8 & 1 << bit, 0, "{name}"); + } + // AutoIBRS, LFENCE serialization, the null selector rule, SBPB, + // SRSO_NO, and the TSA immunities stay; CPUID faulting and workload + // classification go, and so does EBX. + assert_eq!( + derived.lookup(0x8000_0021, 0), + [ + 1 << 2 | 1 << 6 | 1 << 8 | 1 << 27 | 1 << 29, + 0, + 1 << 1 | 1 << 2, + 0 + ] + ); + // The host's core count, cache sharing, and topology are not the + // profile's, and neither are its brand ID and package type. + assert_eq!(derived.lookup(0x8000_0008, 0)[2], 0); + assert_eq!(derived.lookup(0x8000_001d, 3)[0], 0x163); + assert_eq!(derived.lookup(0x8000_001e, 0), [0; 4]); + assert_eq!(derived.lookup(0x8000_0001, 0)[1], 0); + } + + /// PSFD is a bit of `SPEC_CTRL`, so an AMD profile keeps it only beside a + /// `SPEC_CTRL` control, as KVM's `guest_has_spec_ctrl_msr()` requires of + /// a guest's CPUID and OpenVMM's KVM backend of KVM's supported CPUID: + /// AMD's IBRS, STIBP, or SSBD. Intel's IBRS, which KVM enumerates on AMD + /// hosts too, is no control of an AMD profile, and IBPB controls another + /// MSR. The rule reads the derived profile, so controls that the + /// backends do not share do not keep PSFD. `BTC_NO`, which KVM never + /// offers, always goes. + #[test] + fn an_amd_profile_keeps_psfd_only_beside_a_spec_ctrl_control() { + let with = |leaf: u32, register: usize, bits: u32| { + let mut entries = milan_whp_entries(); + let subleaf = (leaf == 7).then_some(0); + set(&mut entries, leaf, subleaf, register, |value| value | bits); + entries + }; + let spec = + |profile: &CpuProfile| profile.lookup(0x8000_0008, 0)[1] & (AMD_PSFD | AMD_BTC_NO); + // The Milan WHP host offers PSFD and BTC_NO, and no SPEC_CTRL control. + let whp = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(spec(&whp), 0); + for (leaf, register, bit, name) in + [(0x8000_0008, 1, 12, "AMD IBPB"), (0x7, 3, 26, "Intel IBRS")] + { + let derived = + derive_milan(&[host_fingerprint("kvm", with(leaf, register, 1 << bit))]).unwrap(); + assert_eq!(spec(&derived), 0, "{name}"); + } + for (leaf, register, bit, name) in [ + (0x8000_0008, 1, 14, "AMD IBRS"), + (0x8000_0008, 1, 15, "AMD STIBP"), + (0x8000_0008, 1, 24, "AMD SSBD"), + ] { + let derived = + derive_milan(&[host_fingerprint("kvm", with(leaf, register, 1 << bit))]).unwrap(); + assert_eq!(spec(&derived), AMD_PSFD, "{name}"); + assert!( + has_spec_ctrl_control(derived.lookup(7, 0)[3], derived.lookup(0x8000_0008, 0)[1]), + "{name}" + ); + } + // Each backend offers PSFD with another control, so the profile has + // neither control and no PSFD. + let derived = derive_milan(&[ + host_fingerprint("kvm", with(0x8000_0008, 1, 1 << 14)), + host_fingerprint("whp", with(0x8000_0008, 1, 1 << 24)), + ]) + .unwrap(); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1] & (1 << 14 | 1 << 24 | AMD_PSFD | AMD_BTC_NO), + 0 + ); + } + + /// The AMD policy leaves nothing pinned that differs between the hosts of + /// a generation or between a Hyper-V root's view and its guests': AMD + /// CPUs repeat their signature in `0x80000001` EAX, which describes the + /// CPU as leaf 1's does, so a host of another stepping, such as + /// Milan-X's 2, supports the profile, and so does a host whose root does + /// not see 3DNowPrefetch. + #[test] + fn other_hosts_of_the_generation_support_an_amd_profile() { + let derived = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(derived.lookup(0x8000_0001, 0)[0], 0x00a0_0f11); + let mut stepping2 = milan_whp_entries(); + set(&mut stepping2, 1, None, 0, |_| 0x00a0_0f12); + set(&mut stepping2, 0x8000_0001, None, 0, |_| 0x00a0_0f12); + // Its backend reports another package type, too, and, as the + // Hyper-V root of a nested Azure host sees it, no 3DNowPrefetch. + set(&mut stepping2, 0x8000_0001, None, 1, |_| 0); + set(&mut stepping2, 0x8000_0001, None, 2, |ecx| ecx & !(1 << 8)); + let surface = + crate::HostCpuSurface::from_fingerprint(&host_fingerprint("whp", stepping2).backend); + assert_eq!( + crate::support_violations(&derived, &surface), + Vec::::new() + ); + } + + #[test] + fn amd_cache_topology_is_descriptive() { + // Two backends report the reference host's L3, and a third another + // SKU's: the majority's stays, where intersecting would mix them. + let mut other_sku = milan_whp_entries(); + set(&mut other_sku, 0x8000_001d, Some(3), 2, |_| 0x3fff); + let fingerprints = [ + host_fingerprint("kvm", other_sku.clone()), + host_fingerprint("mshv", milan_whp_entries()), + host_fingerprint("whp", milan_whp_entries()), + ]; + let derived = derive_milan(&fingerprints).unwrap(); + assert_eq!(derived.lookup(0x8000_001d, 3)[2], 0x7fff); + // Two backends that disagree have no majority. + let tie = derive_milan(&fingerprints[..2]).unwrap_err(); + assert!( + matches!(&tie, DeriveError::Tie { what, .. } if what == "CPUID 0x8000001d.3 ECX among the backends"), + "{tie}" + ); + } + + /// The AMD rules do not reach Intel profiles: an Intel host whose + /// backend reports values in AMD's leaves and fields derives the + /// profile that the Intel rules alone give. + #[test] + fn the_amd_rules_leave_intel_profiles_unchanged() { + let pinned = profile("intel.alderlake.v1"); + let kvm = edited(pinned, "kvm", |entries| { + // MONITORX and PPIN, which AMD profiles clear; AMD's L1 cache + // leaf, which AMD profiles keep; and an AMD core count, which AMD + // profiles leave to the VM. + set(entries, 0x8000_0001, None, 2, |ecx| ecx | 1 << 29); + set(entries, 0x8000_0005, None, 2, |_| 0x2008_0140); + set(entries, 0x8000_0008, None, 1, |ebx| ebx | 1 << 23); + set(entries, 0x8000_0008, None, 2, |_| 0x0000_701f); + }); + let derived = derive_profile(generation("alderlake"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x8000_0001, 0)[2], + pinned.lookup(0x8000_0001, 0)[2] | 1 << 29 + ); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1], + pinned.lookup(0x8000_0008, 0)[1] | 1 << 23 + ); + assert_eq!(derived.lookup(0x8000_0005, 0), [0; 4]); + assert_eq!(derived.lookup(0x8000_0008, 0)[2], 0); + assert_eq!(entry(&derived, 0x8000_0008, None).masks(), [!0; 4]); + + // PSFD without a SPEC_CTRL control, and BTC_NO, which AMD profiles + // clear, stay too: the Ice Lake-SP profile has no SPEC_CTRL control, + // which Azure withholds from its hosts. + let pinned = profile("intel.icelake-sp.v1"); + assert!(!has_spec_ctrl_control( + pinned.lookup(7, 0)[3], + pinned.lookup(0x8000_0008, 0)[1] + )); + let kvm = edited(pinned, "kvm", |entries| { + set(entries, 0x8000_0008, None, 1, |ebx| { + ebx | AMD_PSFD | AMD_BTC_NO + }); + }); + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1], + pinned.lookup(0x8000_0008, 0)[1] | AMD_PSFD | AMD_BTC_NO + ); + } +} diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs new file mode 100644 index 00000000000..4bb752e5313 --- /dev/null +++ b/vmm_core/cpu_profile/src/effective.rs @@ -0,0 +1,773 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective guest CPUID of a VM: its profile's pinned values, the fields +//! its topology and APIC mode define, and the hypervisor identity leaves. + +use crate::Hex32; +use crate::canonical; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use crate::profile::find; +use crate::profile::vm_owned_bits; +use serde::Deserialize; +use std::cmp::Ordering; + +/// The schema of the effective-CPUID records this crate reads and writes. +pub const EFFECTIVE_CPUID_SCHEMA: &str = "openvmm-effective-cpuid/v1"; + +/// The leaves a hypervisor may use, which the time ABI's identity owns. +const HYPERVISOR_LEAVES: std::ops::RangeInclusive = 0x4000_0000..=0x4fff_ffff; + +/// The register names, for messages. +const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + +/// One CPUID result and the mask of the bits it defines. +/// +/// The fields match `virt::CpuidLeaf` one for one, and `virt` converts a +/// `CpuidLeaf` to a `CpuidResult` field for field. `index` is `None` for a +/// result that applies to every subleaf. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CpuidResult { + /// The leaf, the input value of `EAX`. + pub function: u32, + /// The subleaf, the input value of `ECX`, or `None` for every subleaf. + pub index: Option, + /// The output values of `EAX`, `EBX`, `ECX`, and `EDX`. + pub result: [u32; 4], + /// The bits of each output register that `result` defines. + pub mask: [u32; 4], +} + +impl CpuidResult { + /// Returns a result for every subleaf of `function` that defines every + /// bit. + pub const fn new(function: u32, result: [u32; 4]) -> Self { + Self { + function, + index: None, + result, + mask: [!0; 4], + } + } + + /// Returns the result for subleaf `index` only. + pub const fn indexed(self, index: u32) -> Self { + Self { + index: Some(index), + ..self + } + } + + /// Returns the result defining only the bits in `mask`. + pub const fn masked(self, mask: [u32; 4]) -> Self { + Self { mask, ..self } + } +} + +/// Returns the CPUID result that reports the x2APIC as the VM's APIC mode +/// decides: `CPUID.1:ECX[21]`, set unless the APIC is xAPIC-only. +pub const fn x2apic_cpuid(x2apic: bool) -> CpuidResult { + const X2APIC: u32 = 1 << 21; + CpuidResult::new(0x1, [0, 0, if x2apic { X2APIC } else { 0 }, 0]).masked([0, 0, X2APIC, 0]) +} + +/// The effective guest CPUID of a VM. +/// +/// It defines every bit of every leaf and subleaf the guest can observe, +/// except the runtime state that the processor maintains (OSXSAVE, OSPKE, and +/// the XSAVE sizes of the enabled features). Leaves it does not list read as +/// zero. The per-VP fields carry the BSP's APIC identity; backends set each +/// VP's own. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EffectiveCpuid { + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveCpuidDocument { + cpuid: Vec, + schema: String, +} + +/// The document that [`EffectiveCpuid::encode`] writes, as `serde` would +/// serialize it, for the tests' comparison with the sorting encoder. +#[cfg(test)] +#[derive(serde::Serialize)] +struct EffectiveCpuidDocumentRef<'a> { + cpuid: &'a [CpuidLeafValue], + schema: &'a str, +} + +/// The size of one entry in the canonical encoding, with a null subleaf. +const ENCODED_ENTRY_BYTES: usize = 167; + +/// Appends `"0x`, the eight hex digits of `value`, and `"`. +fn push_hex32(out: &mut Vec, value: u32) { + let mut text = *b"\"0x00000000\""; + for (i, digit) in text[3..11].iter_mut().enumerate() { + *digit = b"0123456789abcdef"[((value >> (28 - 4 * i)) & 0xf) as usize]; + } + out.extend_from_slice(&text); +} + +/// Appends a JSON array of four register values. +fn push_registers(out: &mut Vec, registers: &[Hex32; 4]) { + out.push(b'['); + for (i, register) in registers.iter().enumerate() { + if i != 0 { + out.push(b','); + } + push_hex32(out, register.0); + } + out.push(b']'); +} + +impl EffectiveCpuid { + /// Returns the results, sorted by leaf and subleaf. + pub fn results(&self) -> impl Iterator + '_ { + self.entries.iter().map(|entry| CpuidResult { + function: entry.leaf.0, + index: entry.subleaf.map(|subleaf| subleaf.0), + result: entry.values(), + mask: entry.masks(), + }) + } + + /// Returns the output of CPUID for `leaf` and `subleaf`, with zeros in + /// the runtime-owned bits, or zeros for a leaf the table does not list. + pub fn lookup(&self, leaf: u32, subleaf: u32) -> [u32; 4] { + find(&self.entries, leaf, subleaf).map_or([0; 4], CpuidLeafValue::values) + } + + /// Returns the canonical encoding: compact canonical JSON. + /// + /// It writes the document's fixed shape directly, keys in byte order and + /// register values as `0x` and eight hex digits. Tests check it against + /// the sorting encoder. + /// + /// Offline only, for tools and tests: a snapshot records the effective + /// CPUID in core's packed binary form, built from [`Self::results`], and + /// a restore compares those bytes. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(64 + self.entries.len() * ENCODED_ENTRY_BYTES); + out.extend_from_slice(b"{\"cpuid\":["); + for (i, entry) in self.entries.iter().enumerate() { + if i != 0 { + out.push(b','); + } + out.extend_from_slice(b"{\"leaf\":"); + push_hex32(&mut out, entry.leaf.0); + out.extend_from_slice(b",\"mask\":"); + push_registers(&mut out, &entry.mask); + out.extend_from_slice(b",\"subleaf\":"); + match entry.subleaf { + Some(subleaf) => push_hex32(&mut out, subleaf.0), + None => out.extend_from_slice(b"null"), + } + out.extend_from_slice(b",\"value\":"); + push_registers(&mut out, &entry.value); + out.push(b'}'); + } + out.extend_from_slice(b"],\"schema\":\""); + out.extend_from_slice(EFFECTIVE_CPUID_SCHEMA.as_bytes()); + out.extend_from_slice(b"\"}"); + out + } + + /// Returns the SHA-256 of [`Self::encode`]. Offline only. + pub fn digest(&self) -> [u8; 32] { + canonical::sha256(&self.encode()) + } + + /// Decodes an effective CPUID from its canonical encoding. + /// + /// Bytes that are not exactly a canonical encoding fail with + /// `E_PROFILE_DIGEST`. Offline only. + pub fn decode(bytes: &[u8]) -> Result { + let invalid = |message: &str| { + ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!("invalid effective CPUID record: {message}"), + ) + }; + let document: EffectiveCpuidDocument = serde_json::from_slice(bytes) + .map_err(|error| invalid(&format!("malformed: {error}")))?; + if document.schema != EFFECTIVE_CPUID_SCHEMA { + return Err(invalid(&format!( + "unsupported schema {:?}", + document.schema + ))); + } + if document + .cpuid + .windows(2) + .any(|pair| pair[0].key() >= pair[1].key()) + { + return Err(invalid("entries are not strictly sorted")); + } + if document.cpuid.iter().any(|entry| { + entry + .values() + .iter() + .zip(entry.masks()) + .any(|(value, mask)| value & !mask != 0) + }) { + return Err(invalid("an entry sets bits outside its mask")); + } + let this = Self { + entries: document.cpuid, + }; + if this.encode() != bytes { + return Err(invalid("not in canonical form")); + } + Ok(this) + } + + /// Decodes a recorded effective CPUID and verifies it against its + /// recorded SHA-256 (`E_PROFILE_DIGEST`). Offline only. + pub fn decode_verified(bytes: &[u8], sha256: &[u8]) -> Result { + if canonical::sha256(bytes).as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + "the effective CPUID record does not match its digest", + )); + } + Self::decode(bytes) + } + + /// Checks that this effective CPUID, recomputed on the destination, + /// equals the `recorded` one (`E_CPU_SURFACE`, naming every difference). + pub fn check_matches(&self, recorded: &Self) -> Result<(), ProfileError> { + let differences = self.differences(recorded); + if differences.is_empty() { + return Ok(()); + } + Err(ProfileError::new( + ProfileErrorCode::CpuSurface, + format!( + "the effective CPUID differs from the snapshot's: {}", + differences.join("; ") + ), + )) + } + + /// Lists every entry that differs between this effective CPUID and the + /// `recorded` one; both are sorted by leaf and subleaf. + fn differences(&self, recorded: &Self) -> Vec { + let (ours, theirs) = (&self.entries, &recorded.entries); + let (mut i, mut j) = (0, 0); + let mut differences = Vec::new(); + loop { + let order = match (ours.get(i), theirs.get(j)) { + (None, None) => return differences, + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (Some(a), Some(b)) => a.key().cmp(&b.key()), + }; + match order { + Ordering::Less => { + differences.push(format!( + "{} exists only on the destination", + describe(&ours[i]) + )); + i += 1; + } + Ordering::Greater => { + differences.push(format!( + "{} exists only in the snapshot", + describe(&theirs[j]) + )); + j += 1; + } + Ordering::Equal => { + let (a, b) = (&ours[i], &theirs[j]); + if a != b { + differences.push(format!( + "{} is {:#010x?} under mask {:#010x?} on the destination, but {:#010x?} under mask {:#010x?} in the snapshot", + describe(a), + a.values(), + a.masks(), + b.values(), + b.masks() + )); + } + i += 1; + j += 1; + } + } + } + } +} + +fn describe(entry: &CpuidLeafValue) -> String { + describe_leaf(entry.leaf.0, entry.subleaf.map(|subleaf| subleaf.0)) +} + +impl CpuProfile { + /// Builds the effective guest CPUID of a VM that uses this profile. + /// + /// OpenVMM assembles the inputs from a VM's topology in one place, + /// `virt::time_abi::cpuid::effective_cpuid`, which core, the backends, and + /// their tests share. + /// + /// `vm` holds the results that the VM configuration defines: the topology + /// leaves and fields (`virt::x86::topology::topology_cpuid`, with the + /// profile's [`CpuProfile::lookup`] as its CPUID source), the subleaf that + /// terminates each extended topology leaf + /// (`virt::x86::topology::terminate_extended_topology`), and the APIC + /// mode ([`x2apic_cpuid`]). They may set only the bits that + /// [`vm_owned_bits`] assigns to the VM, and together they must set all of + /// them. A result for every subleaf (`index` of `None`) applies to each of + /// the profile's subleaves that has VM-owned bits. `identity` holds the + /// time ABI's hypervisor identity leaves, which must lie in + /// `0x40000000..=0x4fffffff`. Later results override earlier ones. + /// + /// Fails with `E_CPU_SURFACE` when the inputs do not complete the profile + /// exactly. + pub fn effective_cpuid( + &self, + vm: &[CpuidResult], + identity: &[CpuidResult], + ) -> Result { + let error = |message: String| { + ProfileError::new( + ProfileErrorCode::CpuSurface, + format!( + "cannot build the effective CPUID of profile {}: {message}", + self.id() + ), + ) + }; + // The profile's table is sorted by key, so a sorted vector serves as + // the map, with less code to run on a VM's first start than a tree. + let mut entries = self + .cpuid() + .iter() + .map(|entry| (entry.key(), (entry.values(), entry.masks()))) + .collect::>(); + let max_basic = self.lookup(0, 0)[0]; + let vendor = self.cpu_vendor(); + + for leaf in vm.iter().filter(|leaf| leaf.mask != [0; 4]) { + let what = || describe_leaf(leaf.function, leaf.index); + if VM_OWNED_LEAVES.contains(&leaf.function) { + if leaf.function > max_basic { + return Err(error(format!( + "{} is beyond the maximum basic leaf", + what() + ))); + } + if leaf.index.is_none() { + return Err(error(format!("{} has no subleaf", what()))); + } + merge(slot(&mut entries, (leaf.function, leaf.index)), leaf); + continue; + } + let mut applied = false; + let start = entries.partition_point(|&((function, _), _)| function < leaf.function); + for &mut ((function, index), ref mut slot) in &mut entries[start..] { + if function != leaf.function { + break; + } + if leaf.index.is_some() && index.is_some() && index != leaf.index { + continue; + } + let owned = vm_owned_bits(vendor, function, slot.0); + if leaf.index.is_none() && owned == [0; 4] { + // A result for every subleaf skips subleaves without VM + // fields, such as the null cache type that ends a cache + // leaf. + continue; + } + for (register, (bits, owned)) in leaf.mask.iter().zip(owned).enumerate() { + if bits & !owned != 0 { + return Err(error(format!( + "{} sets {} bits {:#x}, which the profile pins", + what(), + REGISTERS[register], + bits & !owned + ))); + } + } + merge(slot, leaf); + applied = true; + } + if !applied { + return Err(error(format!( + "{} matches no profile leaf with VM fields", + what() + ))); + } + } + + for leaf in identity { + if !HYPERVISOR_LEAVES.contains(&leaf.function) { + return Err(error(format!( + "identity {} is outside the hypervisor range", + describe_leaf(leaf.function, leaf.index) + ))); + } + merge(slot(&mut entries, (leaf.function, leaf.index)), leaf); + } + + for &leaf in &VM_OWNED_LEAVES { + let key = (leaf, Some(0)); + if leaf <= max_basic + && entries + .binary_search_by(|(entry, _)| entry.cmp(&key)) + .is_err() + { + return Err(error(format!( + "the VM did not define {}", + describe_leaf(leaf, Some(0)) + ))); + } + } + for &((function, index), (value, mask)) in &entries { + if HYPERVISOR_LEAVES.contains(&function) { + continue; + } + let owned = vm_owned_bits(vendor, function, value); + for (register, (owned, mask)) in owned.iter().zip(mask).enumerate() { + if owned & !mask != 0 { + return Err(error(format!( + "the VM did not define {} {} bits {:#x}", + describe_leaf(function, index), + REGISTERS[register], + owned & !mask + ))); + } + } + } + + Ok(EffectiveCpuid { + entries: entries + .into_iter() + .map(|((function, index), (value, mask))| { + CpuidLeafValue::new(function, index, value, mask) + }) + .collect(), + }) + } +} + +/// Returns the slot of `key` in the sorted `entries`, inserting an empty one +/// if there is none. +fn slot( + entries: &mut Vec<((u32, Option), ([u32; 4], [u32; 4]))>, + key: (u32, Option), +) -> &mut ([u32; 4], [u32; 4]) { + let index = match entries.binary_search_by(|(entry, _)| entry.cmp(&key)) { + Ok(index) => index, + Err(index) => { + entries.insert(index, (key, ([0; 4], [0; 4]))); + index + } + }; + &mut entries[index].1 +} + +fn merge(slot: &mut ([u32; 4], [u32; 4]), leaf: &CpuidResult) { + let (value, mask) = slot; + for register in 0..4 { + value[register] = (value[register] & !leaf.mask[register]) + | (leaf.result[register] & leaf.mask[register]); + mask[register] |= leaf.mask[register]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; + use crate::test_support::profile; + use crate::vendor::CpuVendor; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Returns hand-built VM leaves for `profile`: the fields OpenVMM's + /// topology code sets for one socket of `vp_count` VPs without SMT, of + /// the profile's vendor, and an xAPIC. + fn vm_leaves(profile: &CpuProfile, vp_count: u32) -> Vec { + let mut leaves = vec![ + CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), + x2apic_cpuid(false), + ]; + match profile.cpu_vendor() { + CpuVendor::Intel => leaves.push( + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + ), + CpuVendor::Amd => { + let apic_id_size = vp_count.next_power_of_two().trailing_zeros(); + leaves.push( + CpuidResult::new(0x8000_0008, [0, 0, (vp_count - 1) | apic_id_size << 12, 0]) + .masked([0, 0, 0xf0ff, 0]), + ); + // The L3 cache is the socket's; the others are each VP's. + for subleaf in 0.. { + let eax = profile.lookup(0x8000_001d, subleaf)[0]; + if eax & 0x1f == 0 { + break; + } + let sharing = if (eax >> 5) & 7 == 3 { vp_count - 1 } else { 0 }; + leaves.push( + CpuidResult::new(0x8000_001d, [sharing << 14, 0, 0, 0]) + .indexed(subleaf) + .masked([0x03ff_c000, 0, 0, 0]), + ); + } + leaves.push(CpuidResult::new(0x8000_001e, [0; 4]).masked([!0, 0xffff, 0x7ff, 0])); + } + } + let max_basic = profile.lookup(0, 0)[0]; + for leaf in VM_OWNED_LEAVES + .into_iter() + .filter(|&leaf| leaf <= max_basic) + { + leaves.push(CpuidResult::new(leaf, [0, 1, 0x100, 0]).indexed(0)); + leaves.push(CpuidResult::new(leaf, [0, vp_count, 0x201, 0]).indexed(1)); + } + leaves + } + + /// Returns the host profile of the AMD EPYC 7763 test host on WHP. + fn amd_host_profile() -> CpuProfile { + crate::derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap() + } + + fn identity() -> Vec { + vec![CpuidResult::new( + 0x4000_0000, + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074], + )] + } + + fn error_message(result: Result) -> String { + let error = result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuSurface, "{error}"); + error.message + } + + #[test] + fn completes_the_profile_with_vm_fields_and_identity() { + let profile = profile(ICELAKE); + let effective = profile + .effective_cpuid(&vm_leaves(profile, 4), &identity()) + .unwrap(); + // The profile's pinned bits and the VM's fields. + let leaf1 = effective.lookup(1, 0); + assert_eq!(leaf1[0], profile.lookup(1, 0)[0]); + assert_eq!(leaf1[1] >> 16, 4); + assert_eq!(leaf1[2] & (1 << 21), 0); + // Every subleaf of leaf 4 that describes a cache, but not the null + // cache type that ends it. + assert_eq!(effective.lookup(4, 0)[0] >> 26, 3); + assert_eq!(effective.lookup(4, 4), [0; 4]); + assert_eq!(effective.lookup(0xb, 1)[1], 4); + assert_eq!(effective.lookup(0x4000_0000, 0)[0], 0x4000_0005); + // Only the runtime state is left undefined. + let undefined = effective + .results() + .filter(|result| result.mask != [!0; 4]) + .map(|result| (result.function, result.index, result.mask)) + .collect::>(); + assert_eq!( + undefined, + [ + (1, None, [!0, !0, !(1 << 27), !0]), + (7, Some(0), [!0, !0, !(1 << 4), !0]), + (0xd, Some(0), [!0, 0, !0, !0]), + (0xd, Some(1), [!0, 0, !0, !0]), + ] + ); + } + + /// An AMD profile leaves AMD's topology fields to the VM: the core count + /// in `0x80000008`, each cache's sharing in `0x8000001D`, and the + /// processor topology in `0x8000001E`. + #[test] + fn completes_an_amd_profile_with_amd_topology_fields() { + let profile = amd_host_profile(); + let effective = profile + .effective_cpuid(&vm_leaves(&profile, 4), &identity()) + .unwrap(); + assert_eq!(effective.lookup(0x8000_0008, 0)[2], 3 | 2 << 12); + // Each VP has its own L1 and L2 caches, and the socket shares the + // L3; the null cache type that ends the leaf has no VM fields. + for (subleaf, sharing) in [(0, 0), (1, 0), (2, 0), (3, 3)] { + let eax = effective.lookup(0x8000_001d, subleaf)[0]; + assert_eq!((eax >> 14) & 0xfff, sharing, "subleaf {subleaf}"); + assert_eq!( + eax & !0x03ff_c000, + profile.lookup(0x8000_001d, subleaf)[0], + "subleaf {subleaf}" + ); + } + assert_eq!(effective.lookup(0x8000_001d, 4), [0; 4]); + assert_eq!(effective.lookup(0x8000_001e, 0), [0; 4]); + // Only the runtime state is left undefined, as on Intel. + let undefined = effective + .results() + .filter(|result| result.mask != [!0; 4]) + .map(|result| (result.function, result.index, result.mask)) + .collect::>(); + assert_eq!( + undefined, + [ + (1, None, [!0, !0, !(1 << 27), !0]), + (7, Some(0), [!0, !0, !(1 << 4), !0]), + (0xd, Some(0), [!0, 0, !0, !0]), + (0xd, Some(1), [!0, 0, !0, !0]), + ] + ); + + // Without the processor topology, the VM leaves bits undefined. + let leaves = vm_leaves(&profile, 4) + .into_iter() + .filter(|leaf| leaf.function != 0x8000_001e) + .collect::>(); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0x8000001e EAX bits 0xffffffff") + ); + } + + /// Intel profiles pin AMD's topology fields, so OpenVMM cannot set them + /// there. + #[test] + fn intel_profiles_pin_amd_topology_fields() { + let profile = profile(ICELAKE); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(0x8000_0008, [0, 0, 1, 0]).masked([0, 0, 0xf0ff, 0])); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("CPUID 0x80000008 matches no profile leaf with VM fields") + ); + } + + #[test] + fn rejects_inputs_that_do_not_complete_the_profile_exactly() { + let profile = profile(ICELAKE); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(1, [0, 0, 1, 0]).masked([0, 0, 1, 0])); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("CPUID 0x1 sets ECX bits 0x1, which the profile pins") + ); + + let mut leaves = vm_leaves(profile, 2); + leaves.retain(|leaf| *leaf != x2apic_cpuid(false)); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0x1 ECX bits 0x200000") + ); + + let leaves = vm_leaves(profile, 2) + .into_iter() + .filter(|leaf| leaf.function != 0xb) + .collect::>(); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0xb.0") + ); + + let leaves = vm_leaves(profile, 2); + assert!( + error_message(profile.effective_cpuid(&leaves, &[CpuidResult::new(3, [0; 4])])) + .contains("outside the hypervisor range") + ); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(0x1f, [0; 4]).indexed(0)); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("beyond the maximum basic leaf") + ); + } + + #[test] + fn records_round_trip_and_compare() { + let profile = profile(ICELAKE); + let effective = profile + .effective_cpuid(&vm_leaves(profile, 2), &identity()) + .unwrap(); + let bytes = effective.encode(); + assert_eq!(EffectiveCpuid::decode(&bytes).unwrap(), effective); + assert_eq!( + EffectiveCpuid::decode_verified(&bytes, &effective.digest()).unwrap(), + effective + ); + assert_eq!( + EffectiveCpuid::decode_verified(&bytes, &[0; 32]) + .unwrap_err() + .code, + ProfileErrorCode::ProfileDigest + ); + let mut spaced = bytes.clone(); + spaced.insert(1, b' '); + assert_eq!( + EffectiveCpuid::decode(&spaced).unwrap_err().code, + ProfileErrorCode::ProfileDigest + ); + + effective.check_matches(&effective).unwrap(); + let other = profile + .effective_cpuid(&vm_leaves(profile, 4), &identity()) + .unwrap(); + let error = other.check_matches(&effective).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuSurface); + // Every difference is named: the logical processor counts of leaves + // 1, 4, and 0xB. + for difference in ["CPUID 0x1 is", "CPUID 0x4.0 is", "CPUID 0xb.1 is"] { + assert!(error.message.contains(difference), "{error}"); + } + let without_identity = profile + .effective_cpuid(&vm_leaves(profile, 2), &[]) + .unwrap(); + let error = without_identity.check_matches(&effective).unwrap_err(); + assert!( + error + .message + .contains("CPUID 0x40000000 exists only in the snapshot"), + "{error}" + ); + let error = effective.check_matches(&without_identity).unwrap_err(); + assert!( + error + .message + .ends_with(": CPUID 0x40000000 exists only on the destination"), + "{error}" + ); + } + + /// `encode` serializes directly, which is canonical only while the + /// document and its entries declare their fields in key order. + #[test] + fn direct_serialization_matches_the_sorting_encoder() { + for profile in crate::pinned_profiles() { + for vp_count in [1, 8] { + let effective = profile + .effective_cpuid(&vm_leaves(profile, vp_count), &identity()) + .unwrap(); + let value = serde_json::to_value(EffectiveCpuidDocumentRef { + cpuid: &effective.entries, + schema: EFFECTIVE_CPUID_SCHEMA, + }) + .unwrap(); + assert_eq!( + String::from_utf8(effective.encode()).unwrap(), + canonical::to_compact(&value), + "{} at {vp_count} VPs", + profile.id() + ); + } + } + } +} diff --git a/vmm_core/cpu_profile/src/error.rs b/vmm_core/cpu_profile/src/error.rs new file mode 100644 index 00000000000..c9b848959ad --- /dev/null +++ b/vmm_core/cpu_profile/src/error.rs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPU profile failures, with the stable codes of the time ABI specification. + +use std::fmt; + +/// A stable failure code from the "Failure codes" table of the NVX time ABI +/// specification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfileErrorCode { + /// `E_PROFILE_UNKNOWN`: the profile ID is not pinned in this OpenVMM. + ProfileUnknown, + /// `E_PROFILE_DIGEST`: a profile or effective-CPUID record does not + /// verify against its digest, does not decode, or disagrees with the + /// pinned profile of the same ID. + ProfileDigest, + /// `E_PROFILE_HOST_UNKNOWN`: automatic selection maps the host CPU to no + /// pinned profile. + ProfileHostUnknown, + /// `E_CPU_GENERATION`: the host CPU is not in the profile's generation. + CpuGeneration, + /// `E_PROFILE_UNSUPPORTED`: the backend lacks a feature, limit, XSAVE + /// layout, or MSR value of the profile. + ProfileUnsupported, + /// `E_CPU_SURFACE`: an effective CPUID differs from the recorded one, or + /// the VM's CPUID inputs cannot complete the profile. + CpuSurface, + /// `E_CPU_UNLISTED`: a pass-through backend presents a non-zero CPUID + /// entry outside the profile's tables. + CpuUnlisted, +} + +impl ProfileErrorCode { + /// Every code, in specification order. + pub const ALL: [Self; 7] = [ + Self::ProfileUnknown, + Self::ProfileDigest, + Self::ProfileHostUnknown, + Self::CpuGeneration, + Self::ProfileUnsupported, + Self::CpuSurface, + Self::CpuUnlisted, + ]; + + /// Returns the code as the specification spells it, such as + /// `E_PROFILE_UNKNOWN`. + pub const fn as_str(self) -> &'static str { + match self { + Self::ProfileUnknown => "E_PROFILE_UNKNOWN", + Self::ProfileDigest => "E_PROFILE_DIGEST", + Self::ProfileHostUnknown => "E_PROFILE_HOST_UNKNOWN", + Self::CpuGeneration => "E_CPU_GENERATION", + Self::ProfileUnsupported => "E_PROFILE_UNSUPPORTED", + Self::CpuSurface => "E_CPU_SURFACE", + Self::CpuUnlisted => "E_CPU_UNLISTED", + } + } +} + +impl fmt::Display for ProfileErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A CPU profile failure. +/// +/// It displays as `[E_CODE] message`, the format of the time ABI's errors, so +/// the code survives when the error crosses a process boundary as a string. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("[{code}] {message}")] +pub struct ProfileError { + /// The stable code. + pub code: ProfileErrorCode, + /// What failed. + pub message: String, +} + +impl ProfileError { + /// Returns an error with `code` and `message`. + pub fn new(code: ProfileErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::ProfileError; + use super::ProfileErrorCode; + use test_with_tracing::test; + + #[test] + fn displays_the_bracketed_code_first() { + let error = ProfileError::new(ProfileErrorCode::CpuGeneration, "host is 6/85/7"); + assert_eq!(error.to_string(), "[E_CPU_GENERATION] host is 6/85/7"); + } + + #[test] + fn codes_are_distinct_and_spelled_like_the_specification() { + let mut codes = ProfileErrorCode::ALL.map(ProfileErrorCode::as_str).to_vec(); + assert!(codes.iter().all(|code| { + code.starts_with("E_") + && code + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte == b'_') + })); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), ProfileErrorCode::ALL.len()); + } +} diff --git a/vmm_core/cpu_profile/src/fingerprint.rs b/vmm_core/cpu_profile/src/fingerprint.rs new file mode 100644 index 00000000000..1d5355a69c1 --- /dev/null +++ b/vmm_core/cpu_profile/src/fingerprint.rs @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint document. +//! +//! A [`CpuFingerprint`] combines the [`HostIdentity`] of the host with the +//! [`BackendFingerprint`] of one hypervisor backend: the guest CPU surface it +//! supports and its time capabilities. It carries two digests: +//! +//! - `surface_digest` covers only the guest CPU surface: the CPUID table, the +//! XSAVE layout, the feature MSRs, and the feature banks. Hosts that offer +//! guests the same CPU through the same backend share it, regardless of +//! their TSC rates, software versions, or capability values. +//! - `digest` covers the whole document except itself. +//! +//! Both are SHA-256 over the compact canonical JSON encoding (object keys +//! sorted, no whitespace). The document itself is written as pretty +//! canonical JSON, so the same host, backend, and tool always produce the +//! same bytes. + +use crate::Hex32; +use crate::Hex64; +use crate::canonical; +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::XsaveComponent; +use crate::host::HostIdentity; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use thiserror::Error; + +/// The schema of the fingerprint documents this crate reads and writes. +pub const SCHEMA: &str = "openvmm-cpu-fingerprint/v1"; + +/// The index of `IA32_ARCH_CAPABILITIES`. +pub const IA32_ARCH_CAPABILITIES: u32 = 0x10a; + +/// An invalid fingerprint document. +#[derive(Debug, Error)] +pub enum FingerprintError { + /// The document is not valid JSON or does not match the schema's shape. + #[error("malformed CPU fingerprint")] + Json(#[source] serde_json::Error), + /// The document declares another schema. + #[error("unsupported CPU fingerprint schema {0:?}, expected {SCHEMA:?}")] + Schema(String), + /// A recorded digest does not match the content. + #[error("CPU fingerprint {field} mismatch: recorded {recorded}, computed {computed}")] + Digest { + /// The digest field. + field: &'static str, + /// The digest the document records. + recorded: String, + /// The digest of the document's content. + computed: String, + }, +} + +/// The tool that produced a fingerprint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolIdentity { + /// The tool name, such as `openvmm`. + pub name: String, + /// The tool version, including its source revision when known. + pub version: String, +} + +/// A host CPU fingerprint for one hypervisor backend. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuFingerprint { + /// The document schema, [`SCHEMA`]. + pub schema: String, + /// The tool that produced the fingerprint. + pub tool: ToolIdentity, + /// The host CPU, OS, and hypervisor. + pub host: HostIdentity, + /// The guest CPU surface and time capabilities of the backend. + pub backend: BackendFingerprint, + /// The digest of the guest CPU surface, see + /// [`BackendFingerprint::surface_digest`]. + pub surface_digest: String, + /// The digest of the whole document except this field. + pub digest: String, +} + +impl CpuFingerprint { + /// Assembles a fingerprint and computes its digests. + pub fn new(tool: ToolIdentity, host: HostIdentity, backend: BackendFingerprint) -> Self { + let mut this = Self { + schema: SCHEMA.to_owned(), + tool, + host, + backend, + surface_digest: String::new(), + digest: String::new(), + }; + this.surface_digest = this.backend.surface_digest(); + this.digest = this.content_digest(); + this + } + + /// Returns the fingerprint as pretty canonical JSON, with a final + /// newline. + pub fn to_json(&self) -> String { + canonical::to_pretty(&to_value(self)) + } + + /// Parses a fingerprint and verifies its schema and digests. + pub fn from_json(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(FingerprintError::Json)?; + let schema = value.get("schema").and_then(|schema| schema.as_str()); + if schema != Some(SCHEMA) { + return Err(FingerprintError::Schema( + schema.unwrap_or_default().to_owned(), + )); + } + let this: Self = serde_json::from_value(value).map_err(FingerprintError::Json)?; + this.verify()?; + Ok(this) + } + + /// Verifies that the recorded digests match the content. + pub fn verify(&self) -> Result<(), FingerprintError> { + let check = |field, recorded: &str, computed: String| { + if recorded == computed { + Ok(()) + } else { + Err(FingerprintError::Digest { + field, + recorded: recorded.to_owned(), + computed, + }) + } + }; + check( + "surface_digest", + &self.surface_digest, + self.backend.surface_digest(), + )?; + check("digest", &self.digest, self.content_digest()) + } + + fn content_digest(&self) -> String { + let mut value = to_value(self); + value + .as_object_mut() + .expect("a fingerprint serializes as an object") + .remove("digest"); + canonical::digest(&value) + } +} + +/// The guest CPU surface and time capabilities that one backend supports on +/// one host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendFingerprint { + /// The backend: `kvm`, `mshv`, or `whp`. + pub name: String, + /// How the backend reported its CPUID table. + pub method: String, + /// The supported CPUID table, normalized by [`cpuid::normalize`]. + pub cpuid: Vec, + /// The XSAVE features and layout that the CPUID table describes. + pub xsave: XsaveInfo, + /// The feature MSRs and the MSRs the backend supports. + pub msrs: MsrInfo, + /// The time capabilities of the backend. + pub time: TimeCapabilities, + /// Processor feature banks, which together with the CPUID table define + /// the guest CPU surface of hypervisors configured through banks. + pub feature_banks: BTreeMap, + /// Other raw capability and property values reported by the backend. + pub values: BTreeMap, + /// Other CPUID tables the backend reports, such as the Hyper-V + /// enlightenments it can emulate. + pub extra_cpuid: BTreeMap>, + /// Backend queries that failed, with their errors. + pub unavailable: BTreeMap, +} + +impl BackendFingerprint { + /// Returns a backend fingerprint for the CPUID table `cpuid`, obtained + /// through `method`. + /// + /// The table is normalized, and the XSAVE information and the CPUID time + /// bits are derived from it. + pub fn new(name: &str, method: &str, mut cpuid: Vec) -> Self { + cpuid::normalize(&mut cpuid); + let xsave = XsaveInfo::from_cpuid(&cpuid); + let time = TimeCapabilities::from_cpuid(&cpuid); + Self { + name: name.to_owned(), + method: method.to_owned(), + cpuid, + xsave, + msrs: MsrInfo::default(), + time, + feature_banks: BTreeMap::new(), + values: BTreeMap::new(), + extra_cpuid: BTreeMap::new(), + unavailable: BTreeMap::new(), + } + } + + /// Records a raw capability or property value. + pub fn set_value(&mut self, name: impl Into, value: u64) { + self.values.insert(name.into(), Hex64(value)); + } + + /// Records a processor feature bank. + pub fn set_feature_bank(&mut self, name: impl Into, value: u64) { + self.feature_banks.insert(name.into(), Hex64(value)); + } + + /// Records another CPUID table, normalized. + pub fn set_extra_cpuid(&mut self, name: impl Into, mut entries: Vec) { + cpuid::normalize(&mut entries); + self.extra_cpuid.insert(name.into(), entries); + } + + /// Records that the query `name` failed with `error`. + pub fn set_unavailable( + &mut self, + name: impl Into, + error: &(dyn std::error::Error + 'static), + ) { + self.unavailable.insert(name.into(), error_chain(error)); + } + + /// Records the outcome of the query `name`: its value as + /// [`values`](Self::values) when it succeeded, and its error as + /// [`unavailable`](Self::unavailable) when it failed. + pub fn record( + &mut self, + name: &str, + result: Result, + ) -> Option { + match result { + Ok(value) => { + self.set_value(name, value); + Some(value) + } + Err(error) => { + self.set_unavailable(name, &error); + None + } + } + } + + /// Records the feature MSRs and their values, sorted by index, and + /// derives [`MsrInfo::arch_capabilities`] from them. + pub fn set_feature_msrs(&mut self, msrs: impl IntoIterator) { + let mut msrs = msrs + .into_iter() + .map(|(index, value)| MsrEntry { + index: Hex32(index), + value: Hex64(value), + }) + .collect::>(); + msrs.sort_by_key(|msr| msr.index); + msrs.dedup_by_key(|msr| msr.index); + self.msrs.arch_capabilities = msrs + .iter() + .find(|msr| msr.index.0 == IA32_ARCH_CAPABILITIES) + .map(|msr| msr.value); + self.msrs.feature_msrs = msrs; + } + + /// Records the MSRs the backend supports, sorted. + pub fn set_supported_msrs(&mut self, msrs: impl IntoIterator) { + let mut msrs = msrs.into_iter().map(Hex32).collect::>(); + msrs.sort_unstable(); + msrs.dedup(); + self.msrs.supported = msrs; + } + + /// Returns the digest of the guest CPU surface: the backend name, the + /// CPUID table, the XSAVE information, the feature MSRs, and the feature + /// banks. + pub fn surface_digest(&self) -> String { + #[derive(Serialize)] + struct Surface<'a> { + backend: &'a str, + cpuid: &'a [CpuidEntry], + xsave: &'a XsaveInfo, + feature_msrs: &'a [MsrEntry], + arch_capabilities: Option, + feature_banks: &'a BTreeMap, + } + + canonical::digest(&to_value(&Surface { + backend: &self.name, + cpuid: &self.cpuid, + xsave: &self.xsave, + feature_msrs: &self.msrs.feature_msrs, + arch_capabilities: self.msrs.arch_capabilities, + feature_banks: &self.feature_banks, + })) + } +} + +/// The XSAVE features and layout of a CPUID table. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct XsaveInfo { + /// The supported XCR0 bits, CPUID.(0xd,0):EDX:EAX. + pub xcr0_supported: Hex64, + /// The supported IA32_XSS bits, CPUID.(0xd,1):EDX:ECX. + pub xss_supported: Hex64, + /// The XSAVE instruction extensions, CPUID.(0xd,1):EAX: XSAVEOPT, + /// XSAVEC, XGETBV with ECX=1, XSAVES, and XFD. + pub extensions: Hex32, + /// The size of a standard-format area for every supported user state + /// component as the CPU reports it, CPUID.(0xd,0):ECX. + pub max_standard_size: u32, + /// The size of a standard-format area for every supported user state + /// component, computed from the component layout. + pub standard_size: u32, + /// The size of a compacted-format area for every supported state + /// component, computed from the component layout. + pub compacted_size: u32, + /// The supported state components beyond x87 and SSE. + pub components: Vec, +} + +impl XsaveInfo { + /// Derives the XSAVE information from a CPUID table. + pub fn from_cpuid(entries: &[CpuidEntry]) -> Self { + let (xcr0, xss) = cpuid::xsave_supported(entries); + let components = cpuid::xsave_components(entries); + let first = cpuid::lookup(entries, 0xd, 0).unwrap_or_default(); + let second = cpuid::lookup(entries, 0xd, 1).unwrap_or_default(); + Self { + xcr0_supported: Hex64(xcr0), + xss_supported: Hex64(xss), + extensions: Hex32(second[0]), + max_standard_size: first[2], + standard_size: cpuid::xsave_standard_size(&components, xcr0), + compacted_size: cpuid::xsave_compacted_size(&components, xcr0 | xss), + components, + } + } +} + +/// An MSR index and value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MsrEntry { + /// The MSR index. + pub index: Hex32, + /// The MSR value. + pub value: Hex64, +} + +/// The MSRs of a backend. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MsrInfo { + /// The feature MSRs and the values the backend reports for guests, such + /// as the KVM MSR-based features, sorted by index. + pub feature_msrs: Vec, + /// The MSRs the backend can save and restore for a virtual processor, + /// sorted. + pub supported: Vec, + /// The `IA32_ARCH_CAPABILITIES` value exposed to guests, when the backend + /// reports one. + pub arch_capabilities: Option, +} + +/// The time capabilities of a backend. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimeCapabilities { + /// The guest TSC rate, in Hz, that the backend reports. + pub tsc_frequency_hz: Option, + /// The local APIC timer rate, in Hz, that the backend reports. + pub lapic_timer_frequency_hz: Option, + /// The backend can expose an invariant TSC, `CPUID.0x80000007:EDX[8]`. + pub invariant_tsc: bool, + /// The backend can expose the TSC-deadline timer, `CPUID.1:ECX[24]`. + pub tsc_deadline: bool, + /// The backend can expose `IA32_TSC_ADJUST`, `CPUID.(7,0):EBX[1]`. + pub tsc_adjust: bool, + /// The backend can expose RDTSCP, `CPUID.0x80000001:EDX[27]`. + pub rdtscp: bool, + /// The backend can expose an always-running APIC timer, `CPUID.6:EAX[2]`. + pub arat: bool, + /// The backend can set each virtual processor's TSC (or TSC offset) + /// without trapping RDTSC. + pub tsc_offset_control: Option, + /// The backend can run a guest at a TSC rate other than the host's. + pub tsc_scaling: Option, + /// The backend can freeze partition time while it changes VP state. + pub time_freeze: Option, + /// The MSR interception mechanisms the backend offers, by name. + pub msr_intercepts: BTreeMap, +} + +impl TimeCapabilities { + /// Returns the capabilities that a CPUID table determines; the backend + /// fills in the others. + pub fn from_cpuid(entries: &[CpuidEntry]) -> Self { + Self { + invariant_tsc: cpuid::has_bit(entries, 0x8000_0007, 0, 3, 8), + tsc_deadline: cpuid::has_bit(entries, 0x1, 0, 2, 24), + tsc_adjust: cpuid::has_bit(entries, 0x7, 0, 1, 1), + rdtscp: cpuid::has_bit(entries, 0x8000_0001, 0, 3, 27), + arat: cpuid::has_bit(entries, 0x6, 0, 0, 2), + ..Default::default() + } + } +} + +fn to_value(value: &T) -> serde_json::Value { + // Every type here has string map keys and infallible field + // serialization. + serde_json::to_value(value).expect("fingerprint serialization is infallible") +} + +fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(error) = source { + message.push_str(": "); + message.push_str(&error.to_string()); + source = error.source(); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::HostCpu; + use crate::host::HostOs; + use test_with_tracing::test; + + fn host() -> HostIdentity { + HostIdentity { + cpu: HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: Hex32(0x0005_0654), + family: 6, + model: 85, + stepping: 4, + brand: "Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz".to_owned(), + microcode: vec!["0x2007006".to_owned()], + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: HostOs { + kind: "linux".to_owned(), + release: Some("7.0.0-30-generic".to_owned()), + version: None, + cpu_flags: vec!["constant_tsc".to_owned(), "nonstop_tsc".to_owned()], + clocksource: Some("tsc".to_owned()), + available_clocksources: vec!["hpet".to_owned(), "tsc".to_owned()], + }, + hypervisor: None, + } + } + + fn backend(cpuid_order_reversed: bool) -> BackendFingerprint { + let mut cpuid = vec![ + CpuidEntry::new(0x0, None, [0xd, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + // The initial APIC ID differs between runs. + CpuidEntry::new( + 0x1, + None, + [ + 0x0005_0654, + if cpuid_order_reversed { + 0x1f00_0800 + } else { + 0x0300_0800 + }, + 1 << 24, + 0, + ], + ), + CpuidEntry::new(0x7, Some(0), [0, 0x2, 0, 0]), + CpuidEntry::new(0xd, Some(0), [0x7, 0x240, 0x340, 0]), + CpuidEntry::new(0xd, Some(1), [0x1, 0x240, 0, 0]), + CpuidEntry::new(0xd, Some(2), [0x100, 0x240, 0, 0]), + CpuidEntry::new(0x8000_0000, None, [0x8000_0007, 0, 0, 0]), + CpuidEntry::new(0x8000_0007, None, [0, 0, 0, 1 << 8]), + ]; + if cpuid_order_reversed { + cpuid.reverse(); + } + let mut backend = BackendFingerprint::new("kvm", "KVM_GET_SUPPORTED_CPUID", cpuid); + backend.set_feature_msrs([ + (IA32_ARCH_CAPABILITIES, 0x2b), + (0x8b, 0x0200_7006_0000_0000), + ]); + backend.set_supported_msrs([0x3b, 0x10, 0x3b]); + backend.set_value("kvm.api_version", 12); + backend.time.tsc_frequency_hz = Some(2_194_843_000); + backend + .time + .msr_intercepts + .insert("KVM_CAP_X86_MSR_FILTER".to_owned(), true); + backend + } + + fn fingerprint() -> CpuFingerprint { + CpuFingerprint::new( + ToolIdentity { + name: "openvmm".to_owned(), + version: "0.2.0".to_owned(), + }, + host(), + backend(false), + ) + } + + #[test] + fn derives_backend_sections_from_cpuid() { + let backend = backend(false); + assert_eq!(backend.xsave.xcr0_supported, Hex64(0x7)); + assert_eq!(backend.xsave.standard_size, 0x340); + assert_eq!(backend.xsave.compacted_size, 576 + 0x100); + assert_eq!(backend.xsave.max_standard_size, 0x340); + assert!(backend.time.invariant_tsc); + assert!(backend.time.tsc_deadline); + assert!(backend.time.tsc_adjust); + assert!(!backend.time.rdtscp); + assert_eq!(backend.msrs.arch_capabilities, Some(Hex64(0x2b))); + assert_eq!(backend.msrs.feature_msrs[0].index, Hex32(0x8b)); + assert_eq!(backend.msrs.supported, [Hex32(0x10), Hex32(0x3b)]); + } + + #[test] + fn output_is_deterministic() { + let left = CpuFingerprint::new(fingerprint().tool, host(), backend(false)); + let right = CpuFingerprint::new(fingerprint().tool, host(), backend(true)); + assert_eq!(left.to_json(), right.to_json()); + assert_eq!(left.surface_digest, right.surface_digest); + assert!(left.digest.starts_with("sha256:")); + assert_eq!(left.digest.len(), "sha256:".len() + 64); + } + + #[test] + fn round_trips_and_verifies() { + let fingerprint = fingerprint(); + let json = fingerprint.to_json(); + let parsed = CpuFingerprint::from_json(&json).unwrap(); + assert_eq!(parsed, fingerprint); + assert_eq!(parsed.to_json(), json); + } + + #[test] + fn surface_digest_ignores_rates_and_versions() { + let mut other = backend(false); + other.time.tsc_frequency_hz = Some(2_194_844_000); + other.set_value("kvm.api_version", 13); + let mut other_host = host(); + other_host.os.release = Some("6.6.150".to_owned()); + let other = CpuFingerprint::new(fingerprint().tool, other_host, other); + let fingerprint = fingerprint(); + assert_eq!(other.surface_digest, fingerprint.surface_digest); + assert_ne!(other.digest, fingerprint.digest); + + let mut changed = backend(false); + changed.set_feature_bank("bank0", 1); + assert_ne!(changed.surface_digest(), backend(false).surface_digest()); + } + + #[test] + fn rejects_tampering_and_other_schemas() { + let json = fingerprint().to_json(); + + let tampered = json.replace("\"0x000000000000002b\"", "\"0x000000000000002f\""); + assert_ne!(tampered, json); + assert!(matches!( + CpuFingerprint::from_json(&tampered), + Err(FingerprintError::Digest { + field: "surface_digest", + .. + }) + )); + + let tampered = json.replace("7.0.0-30-generic", "7.0.0-31-generic"); + assert!(matches!( + CpuFingerprint::from_json(&tampered), + Err(FingerprintError::Digest { + field: "digest", + .. + }) + )); + + let other_schema = json.replace(SCHEMA, "openvmm-cpu-fingerprint/v2"); + assert!(matches!( + CpuFingerprint::from_json(&other_schema), + Err(FingerprintError::Schema(schema)) if schema == "openvmm-cpu-fingerprint/v2" + )); + assert!(matches!( + CpuFingerprint::from_json("[]"), + Err(FingerprintError::Schema(_)) + )); + let unknown_field = json.replacen('{', "{\"extra\": 1,", 1); + assert!(matches!( + CpuFingerprint::from_json(&unknown_field), + Err(FingerprintError::Json(_)) + )); + } + + #[test] + fn records_failed_queries() { + let mut backend = backend(false); + let error = std::io::Error::other("not supported"); + assert_eq!(backend.record("a", Ok::<_, std::io::Error>(5)), Some(5)); + assert_eq!(backend.record("b", Err::(error)), None); + assert_eq!(backend.values["a"], Hex64(5)); + assert_eq!(backend.unavailable["b"], "not supported"); + } +} diff --git a/vmm_core/cpu_profile/src/hex.rs b/vmm_core/cpu_profile/src/hex.rs new file mode 100644 index 00000000000..7d2c99bb298 --- /dev/null +++ b/vmm_core/cpu_profile/src/hex.rs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixed-width hexadecimal JSON encodings for register values. +//! +//! Register values and bit masks are encoded as `0x`-prefixed strings with a +//! fixed number of digits. Unlike JSON numbers, these round-trip 64-bit +//! values through tools that use double-precision numbers, and they keep +//! fingerprints readable and line-diffable. + +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; +use std::fmt; +use thiserror::Error; + +/// A malformed hexadecimal register value. +#[derive(Debug, Error)] +#[error("invalid hexadecimal value {value:?}: expected 0x followed by up to {digits} hex digits")] +pub struct ParseHexError { + value: String, + digits: usize, +} + +fn parse_hex(value: &str, digits: usize) -> Result { + let error = || ParseHexError { + value: value.to_owned(), + digits, + }; + let hex = value.strip_prefix("0x").ok_or_else(error)?; + if hex.is_empty() || hex.len() > digits || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(error()); + } + u64::from_str_radix(hex, 16).map_err(|_| error()) +} + +/// A 32-bit register value, encoded as `0x` and eight hex digits. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hex32(pub u32); + +impl Hex32 { + /// Parses `0x` followed by one to eight hex digits. + pub fn parse(value: &str) -> Result { + // At most eight digits were accepted, so the value fits. + Ok(Self(parse_hex(value, 8)? as u32)) + } +} + +impl fmt::Display for Hex32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#010x}", self.0) + } +} + +impl From for Hex32 { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: Hex32) -> Self { + value.0 + } +} + +impl Serialize for Hex32 { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(format_hex(&mut [0; 10], self.0.into())) + } +} + +impl<'de> Deserialize<'de> for Hex32 { + fn deserialize>(deserializer: D) -> Result { + Ok(Self( + deserializer.deserialize_str(HexVisitor { digits: 8 })? as u32, + )) + } +} + +/// Formats `value` as `0x` and `buffer.len() - 2` hex digits in `buffer`, +/// without the formatting machinery, as profiles hold hundreds of values. +fn format_hex(buffer: &mut [u8], value: u64) -> &str { + let digits = buffer.len() - 2; + buffer[..2].copy_from_slice(b"0x"); + for (i, digit) in buffer[2..].iter_mut().enumerate() { + *digit = b"0123456789abcdef"[((value >> (4 * (digits - 1 - i))) & 0xf) as usize]; + } + std::str::from_utf8(buffer).expect("hex digits are ASCII") +} + +/// Parses a hex string without allocating, as profiles hold hundreds of them. +struct HexVisitor { + digits: usize, +} + +impl serde::de::Visitor<'_> for HexVisitor { + type Value = u64; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x followed by up to {} hex digits", self.digits) + } + + fn visit_str(self, value: &str) -> Result { + parse_hex(value, self.digits).map_err(E::custom) + } +} + +/// A 64-bit register value, encoded as `0x` and sixteen hex digits. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hex64(pub u64); + +impl Hex64 { + /// Parses `0x` followed by one to sixteen hex digits. + pub fn parse(value: &str) -> Result { + Ok(Self(parse_hex(value, 16)?)) + } +} + +impl fmt::Display for Hex64 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#018x}", self.0) + } +} + +impl From for Hex64 { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(value: Hex64) -> Self { + value.0 + } +} + +impl Serialize for Hex64 { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(format_hex(&mut [0; 18], self.0)) + } +} + +impl<'de> Deserialize<'de> for Hex64 { + fn deserialize>(deserializer: D) -> Result { + Ok(Self( + deserializer.deserialize_str(HexVisitor { digits: 16 })?, + )) + } +} + +#[cfg(test)] +mod tests { + use super::Hex32; + use super::Hex64; + use test_with_tracing::test; + + #[test] + fn formats_fixed_width() { + assert_eq!(Hex32(0x1f).to_string(), "0x0000001f"); + assert_eq!(Hex32(u32::MAX).to_string(), "0xffffffff"); + assert_eq!(Hex64(0x10a).to_string(), "0x000000000000010a"); + assert_eq!(Hex64(u64::MAX).to_string(), "0xffffffffffffffff"); + // Serialization formats the same digits as Display. + for value in [0, 0x1f, 0x8000_0000, 0x0123_4567, u32::MAX] { + assert_eq!( + serde_json::to_string(&Hex32(value)).unwrap(), + format!("\"{}\"", Hex32(value)) + ); + } + for value in [0, 0x10a, 0x0123_4567_89ab_cdef, u64::MAX] { + assert_eq!( + serde_json::to_string(&Hex64(value)).unwrap(), + format!("\"{}\"", Hex64(value)) + ); + } + } + + #[test] + fn round_trips_through_json() { + for value in [0, 1, 0x8000_0000, u32::MAX] { + let json = serde_json::to_string(&Hex32(value)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Hex32(value)); + } + for value in [0, 1 << 53 | 1, u64::MAX] { + let json = serde_json::to_string(&Hex64(value)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Hex64(value)); + } + } + + #[test] + fn rejects_malformed_values() { + for value in [ + "", + "0x", + "1f", + "0X1f", + "0x1g", + "0x123456789", + "-0x1", + "0x 1", + ] { + assert!(Hex32::parse(value).is_err(), "{value:?}"); + } + assert!(Hex64::parse("0x00000000000000001").is_err()); + assert!(serde_json::from_str::("31").is_err()); + } +} diff --git a/vmm_core/cpu_profile/src/host/linux.rs b/vmm_core/cpu_profile/src/host/linux.rs new file mode 100644 index 00000000000..7eed65f6c6a --- /dev/null +++ b/vmm_core/cpu_profile/src/host/linux.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Linux host OS information from procfs and sysfs. + +use super::HostOs; +use super::OsInfo; +use std::collections::BTreeSet; + +const CLOCKSOURCE_DIR: &str = "/sys/devices/system/clocksource/clocksource0"; + +fn read_trimmed(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|text| text.trim().to_owned()) + .filter(|text| !text.is_empty()) +} + +pub(super) fn os_info() -> OsInfo { + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap_or_default(); + let (cpu_flags, microcode) = parse_cpuinfo(&cpuinfo); + let mut available_clocksources = + read_trimmed(&format!("{CLOCKSOURCE_DIR}/available_clocksource")) + .map(|text| { + text.split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + available_clocksources.sort(); + OsInfo { + os: Some(HostOs { + kind: std::env::consts::OS.to_owned(), + release: read_trimmed("/proc/sys/kernel/osrelease"), + version: read_trimmed("/proc/sys/kernel/version"), + cpu_flags, + clocksource: read_trimmed(&format!("{CLOCKSOURCE_DIR}/current_clocksource")), + available_clocksources, + }), + microcode, + } +} + +/// Returns the sorted CPU flags of the first processor in `/proc/cpuinfo`, +/// and the distinct microcode revisions of all processors. +fn parse_cpuinfo(cpuinfo: &str) -> (Vec, Vec) { + let mut flags = None; + let mut microcode = BTreeSet::new(); + for line in cpuinfo.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + match key.trim() { + "flags" if flags.is_none() => { + flags = Some( + value + .split_whitespace() + .map(str::to_owned) + .collect::>(), + ); + } + "microcode" => { + microcode.insert(value.trim().to_ascii_lowercase()); + } + _ => {} + } + } + ( + flags.unwrap_or_default().into_iter().collect(), + microcode.into_iter().collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::parse_cpuinfo; + use test_with_tracing::test; + + #[test] + fn parses_flags_and_microcode() { + let cpuinfo = "\ +processor\t: 0 +vendor_id\t: GenuineIntel +microcode\t: 0x2007006 +flags\t\t: fpu tsc nonstop_tsc constant_tsc tsc +bugs\t\t: spectre_v1 + +processor\t: 1 +microcode\t: 0x2007006 +flags\t\t: fpu tsc +"; + let (flags, microcode) = parse_cpuinfo(cpuinfo); + assert_eq!(flags, ["constant_tsc", "fpu", "nonstop_tsc", "tsc"]); + assert_eq!(microcode, ["0x2007006"]); + + let (flags, microcode) = parse_cpuinfo("microcode : 0xFFFFFFFF\nmicrocode : 0x1\n"); + assert!(flags.is_empty()); + assert_eq!(microcode, ["0x1", "0xffffffff"]); + } +} diff --git a/vmm_core/cpu_profile/src/host/mod.rs b/vmm_core/cpu_profile/src/host/mod.rs new file mode 100644 index 00000000000..a132b700300 --- /dev/null +++ b/vmm_core/cpu_profile/src/host/mod.rs @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity of the host: its CPU, its OS, and the hypervisor that the +//! host OS itself runs under, if any. +//! +//! The host CPU identity comes from the CPUID instruction as the host OS sees +//! it, which on a root partition or a nested host is itself virtualized. Only +//! fields that do not depend on the executing processor are recorded. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(windows)] +mod windows; + +use crate::Hex32; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +/// A failure to identify the host. +#[derive(Debug, Error)] +pub enum HostIdentityError { + /// The host is not an x86-64 machine. + #[error("host CPU identification requires an x86-64 host")] + UnsupportedArchitecture, +} + +/// The identity of the host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostIdentity { + /// The host CPU. + pub cpu: HostCpu, + /// The host OS. + pub os: HostOs, + /// The hypervisor that the host OS runs under: the hypervisor of a root + /// partition, or the outer hypervisor of a nested host. + pub hypervisor: Option, +} + +/// The host CPU. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostCpu { + /// The vendor string of CPUID leaf 0, such as `GenuineIntel`. + pub vendor: String, + /// The processor signature, CPUID.1:EAX. + pub signature: Hex32, + /// The display family decoded from the signature. + pub family: u32, + /// The display model decoded from the signature. + pub model: u32, + /// The stepping decoded from the signature. + pub stepping: u32, + /// The brand string of CPUID leaves 0x80000002 through 0x80000004. + pub brand: String, + /// The microcode revisions that the host OS reports, in lowercase hex. + /// More than one value means that the processors disagree. It is empty + /// when the OS does not report a revision. + pub microcode: Vec, + /// The host OS sees an invariant TSC, `CPUID.0x80000007:EDX[8]`. + pub invariant_tsc: bool, + /// The host OS sees the TSC-deadline timer, `CPUID.1:ECX[24]`. + pub tsc_deadline: bool, + /// The host OS sees `IA32_TSC_ADJUST`, `CPUID.(7,0):EBX[1]`. + pub tsc_adjust: bool, +} + +/// The host OS. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostOs { + /// The OS family, such as `linux` or `windows`. + pub kind: String, + /// The OS release: the kernel release on Linux, and + /// `major.minor.build.revision` on Windows. + pub release: Option, + /// The OS version: the kernel version string on Linux, and the product + /// name and display version on Windows. + pub version: Option, + /// The CPU flags that the Linux kernel reports for its first processor, + /// sorted. + pub cpu_flags: Vec, + /// The current Linux clocksource. + pub clocksource: Option, + /// The available Linux clocksources, sorted. + pub available_clocksources: Vec, +} + +/// The hypervisor that the host OS runs under. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostHypervisor { + /// The vendor signature of CPUID leaf 0x40000000, such as `Microsoft Hv`. + pub vendor: String, + /// The maximum hypervisor leaf, CPUID.0x40000000:EAX. + pub max_leaf: Hex32, + /// The interface signature of CPUID leaf 0x40000001, such as `Hv#1`. + pub interface: String, + /// For the `Hv#1` interface, the hypervisor version of CPUID leaf + /// 0x40000002 as `major.minor.build.service`. + pub version: Option, +} + +/// The information that the host OS reports about itself and the CPU. +#[derive(Default)] +struct OsInfo { + os: Option, + microcode: Vec, +} + +impl HostIdentity { + /// Identifies the host this process runs on. + pub fn collect() -> Result { + let mut cpuid = host_cpuid()?; + let info = os_info(); + let (cpu, hypervisor) = identify_cpu(&mut cpuid, info.microcode); + let os = info.os.unwrap_or_else(|| HostOs { + kind: std::env::consts::OS.to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }); + Ok(Self { + cpu, + os, + hypervisor, + }) + } +} + +// The host CPU is identified with the CPUID instruction of the host, which +// exists only on x86-64 hosts. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +fn host_cpuid() -> Result [u32; 4], HostIdentityError> { + Ok(|leaf, subleaf| { + let result = safe_intrinsics::cpuid(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] + }) +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(not(target_arch = "x86_64"))] +fn host_cpuid() -> Result [u32; 4], HostIdentityError> { + Err(HostIdentityError::UnsupportedArchitecture) +} + +#[cfg(target_os = "linux")] +fn os_info() -> OsInfo { + linux::os_info() +} + +#[cfg(windows)] +fn os_info() -> OsInfo { + windows::os_info() +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn os_info() -> OsInfo { + OsInfo::default() +} + +fn signature_string(registers: &[u32]) -> String { + let bytes = registers + .iter() + .flat_map(|register| register.to_le_bytes()) + .collect::>(); + String::from_utf8_lossy(&bytes) + .trim_end_matches('\0') + .trim() + .to_owned() +} + +/// Identifies the CPU, and the hypervisor the host runs under, from the +/// CPUID results of `cpuid`. +fn identify_cpu( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + microcode: Vec, +) -> (HostCpu, Option) { + let [max_basic, ebx, ecx, edx] = cpuid(0, 0); + let vendor = signature_string(&[ebx, edx, ecx]); + let [signature, _, features_ecx, _] = if max_basic >= 1 { cpuid(1, 0) } else { [0; 4] }; + let extended_features_ebx = if max_basic >= 7 { cpuid(7, 0)[1] } else { 0 }; + let max_extended = cpuid(0x8000_0000, 0)[0]; + let brand = if max_extended >= 0x8000_0004 { + signature_string( + &(0x8000_0002..=0x8000_0004) + .flat_map(|leaf| cpuid(leaf, 0)) + .collect::>(), + ) + } else { + String::new() + }; + let invariant_tsc = max_extended >= 0x8000_0007 && cpuid(0x8000_0007, 0)[3] & (1 << 8) != 0; + let (family, model, stepping) = + crate::signature::decode_signature(vendor.as_bytes(), signature); + + let cpu = HostCpu { + vendor, + signature: Hex32(signature), + family, + model, + stepping, + brand, + microcode, + invariant_tsc, + tsc_deadline: features_ecx & (1 << 24) != 0, + tsc_adjust: extended_features_ebx & (1 << 1) != 0, + }; + + let hypervisor = (features_ecx & (1 << 31) != 0).then(|| { + let [max_leaf, ebx, ecx, edx] = cpuid(0x4000_0000, 0); + let interface = if max_leaf >= 0x4000_0001 { + signature_string(&[cpuid(0x4000_0001, 0)[0]]) + } else { + String::new() + }; + let version = (interface == "Hv#1" && max_leaf >= 0x4000_0002).then(|| { + let [build, major_minor, _, service] = cpuid(0x4000_0002, 0); + format!( + "{}.{}.{}.{}", + major_minor >> 16, + major_minor & 0xffff, + build, + service & 0xff_ffff + ) + }); + HostHypervisor { + vendor: signature_string(&[ebx, ecx, edx]), + max_leaf: Hex32(max_leaf), + interface, + version, + } + }); + + (cpu, hypervisor) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn word(text: &[u8; 4]) -> u32 { + u32::from_le_bytes(*text) + } + + /// CPUID of a Skylake-SP root partition under Hyper-V. + fn skylake_root(leaf: u32, _subleaf: u32) -> [u32; 4] { + let brand = b"Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz\0\0\0\0\0\0"; + let brand_word = |i: usize| u32::from_le_bytes(brand[i * 4..i * 4 + 4].try_into().unwrap()); + match leaf { + 0 => [0x16, word(b"Genu"), word(b"ntel"), word(b"ineI")], + 1 => [ + 0x0005_0654, + 0x0b20_0800, + 0xfefa_3203 | (1 << 31), + 0x1f8b_fbff, + ], + 7 => [0, 0x009c_6fbb, 0, 0], + 0x4000_0000 => [0x4000_000c, word(b"Micr"), word(b"osof"), word(b"t Hv")], + 0x4000_0001 => [word(b"Hv#1"), 0, 0, 0], + 0x4000_0002 => [26100, 0x000a_0000, 0, 0x0100_1234], + 0x8000_0000 => [0x8000_0008, 0, 0, 0], + 0x8000_0002..=0x8000_0004 => { + let base = (leaf - 0x8000_0002) as usize * 4; + [ + brand_word(base), + brand_word(base + 1), + brand_word(base + 2), + brand_word(base + 3), + ] + } + 0x8000_0007 => [0, 0, 0, 1 << 8], + _ => [0; 4], + } + } + + #[test] + fn identifies_intel_root_partition() { + let (cpu, hypervisor) = identify_cpu(&mut skylake_root, vec!["0x2007006".to_owned()]); + assert_eq!(cpu.vendor, "GenuineIntel"); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (6, 85, 4)); + assert_eq!(cpu.brand, "Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz"); + assert_eq!(cpu.microcode, ["0x2007006"]); + assert!(cpu.invariant_tsc); + assert!(!cpu.tsc_deadline); + assert!(cpu.tsc_adjust); + + let hypervisor = hypervisor.unwrap(); + assert_eq!(hypervisor.vendor, "Microsoft Hv"); + assert_eq!(hypervisor.interface, "Hv#1"); + assert_eq!(hypervisor.max_leaf, Hex32(0x4000_000c)); + assert_eq!(hypervisor.version.as_deref(), Some("10.0.26100.4660")); + } + + #[test] + fn decodes_extended_families_and_models() { + let mut emerald_rapids = |leaf: u32, _: u32| -> [u32; 4] { + match leaf { + 0 => [1, word(b"Genu"), word(b"ntel"), word(b"ineI")], + 1 => [0x000c_06f2, 0, 0, 0], + _ => [0; 4], + } + }; + let (cpu, hypervisor) = identify_cpu(&mut emerald_rapids, Vec::new()); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (6, 207, 2)); + assert!(hypervisor.is_none()); + assert!(cpu.brand.is_empty()); + + let mut zen4 = |leaf: u32, _: u32| -> [u32; 4] { + match leaf { + 0 => [1, word(b"Auth"), word(b"cAMD"), word(b"enti")], + 1 => [0x00a1_0f11, 0, 0, 0], + _ => [0; 4], + } + }; + let (cpu, _) = identify_cpu(&mut zen4, Vec::new()); + assert_eq!(cpu.vendor, "AuthenticAMD"); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (0x19, 0x11, 1)); + } + + #[test] + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + fn collects_this_host() { + let identity = HostIdentity::collect().unwrap(); + assert!(!identity.cpu.vendor.is_empty()); + assert_eq!(identity.os.kind, std::env::consts::OS); + // Collection is deterministic. + assert_eq!(HostIdentity::collect().unwrap(), identity); + } +} diff --git a/vmm_core/cpu_profile/src/host/windows.rs b/vmm_core/cpu_profile/src/host/windows.rs new file mode 100644 index 00000000000..376cf3fcd89 --- /dev/null +++ b/vmm_core/cpu_profile/src/host/windows.rs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows host OS information from the registry. + +// UNSAFETY: Calling the Win32 registry API. +#![expect(unsafe_code)] + +use super::HostOs; +use super::OsInfo; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE; +use windows_sys::Win32::System::Registry::RRF_RT_REG_BINARY; +use windows_sys::Win32::System::Registry::RRF_RT_REG_DWORD; +use windows_sys::Win32::System::Registry::RRF_RT_REG_SZ; +use windows_sys::Win32::System::Registry::RegGetValueW; + +const CURRENT_VERSION_KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; +const PROCESSOR0_KEY: &str = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0"; + +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} + +/// Reads the value `value` of the `HKEY_LOCAL_MACHINE` subkey `subkey`, if it +/// exists and has a type that `flags` allows. +fn read_value(subkey: &str, value: &str, flags: u32) -> Option> { + let subkey = wide(subkey); + let value = wide(value); + let mut len = 0_u32; + // SAFETY: The key and value names are NUL-terminated UTF-16 strings that + // outlive the call, and a null data pointer queries only the size. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + flags, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + let mut data = vec![0_u8; len as usize]; + // SAFETY: `data` is writable for `len` bytes, and the names are as + // above. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + flags, + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + data.truncate(len as usize); + Some(data) +} + +fn read_string(subkey: &str, value: &str) -> Option { + let data = read_value(subkey, value, RRF_RT_REG_SZ)?; + let wide = data + .as_chunks::<2>() + .0 + .iter() + .map(|&pair| u16::from_le_bytes(pair)) + .take_while(|&unit| unit != 0) + .collect::>(); + let text = String::from_utf16_lossy(&wide).trim().to_owned(); + (!text.is_empty()).then_some(text) +} + +fn read_dword(subkey: &str, value: &str) -> Option { + let data = read_value(subkey, value, RRF_RT_REG_DWORD)?; + Some(u32::from_le_bytes(data.get(..4)?.try_into().ok()?)) +} + +pub(super) fn os_info() -> OsInfo { + let major = read_dword(CURRENT_VERSION_KEY, "CurrentMajorVersionNumber"); + let minor = read_dword(CURRENT_VERSION_KEY, "CurrentMinorVersionNumber"); + let build = read_string(CURRENT_VERSION_KEY, "CurrentBuildNumber"); + let revision = read_dword(CURRENT_VERSION_KEY, "UBR"); + let release = match (major, minor, build) { + (Some(major), Some(minor), Some(build)) => Some(match revision { + Some(revision) => format!("{major}.{minor}.{build}.{revision}"), + None => format!("{major}.{minor}.{build}"), + }), + _ => None, + }; + let version = match ( + read_string(CURRENT_VERSION_KEY, "ProductName"), + read_string(CURRENT_VERSION_KEY, "DisplayVersion"), + ) { + (Some(product), Some(display)) => Some(format!("{product} {display}")), + (product, display) => product.or(display), + }; + let microcode = read_value(PROCESSOR0_KEY, "Update Revision", RRF_RT_REG_BINARY) + .and_then(|data| microcode_revision(&data)) + .into_iter() + .collect(); + OsInfo { + os: Some(HostOs { + kind: std::env::consts::OS.to_owned(), + release, + version, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }), + microcode, + } +} + +/// Decodes the processor's `Update Revision` registry value. Older Windows +/// releases store the 64-bit microcode signature MSR, with the Intel revision +/// in the high half and the AMD revision in the low half; newer releases +/// store the 32-bit revision itself. +fn microcode_revision(data: &[u8]) -> Option { + let revision = match data.len() { + 4 => u64::from(u32::from_le_bytes(data.try_into().ok()?)), + 8 => { + let signature = u64::from_le_bytes(data.try_into().ok()?); + match signature >> 32 { + 0 => signature & 0xffff_ffff, + high => high, + } + } + _ => return None, + }; + Some(format!("{revision:#x}")) +} + +#[cfg(test)] +mod tests { + use super::microcode_revision; + use test_with_tracing::test; + + #[test] + fn decodes_microcode_revision() { + let intel = 0x0200_7006_0000_0000_u64.to_le_bytes(); + assert_eq!(microcode_revision(&intel).as_deref(), Some("0x2007006")); + let amd = 0x0a10_1148_u64.to_le_bytes(); + assert_eq!(microcode_revision(&amd).as_deref(), Some("0xa101148")); + assert_eq!( + microcode_revision(&[0x3b, 0x04, 0, 0]).as_deref(), + Some("0x43b") + ); + assert_eq!(microcode_revision(&[1, 2, 3]), None); + assert_eq!(microcode_revision(&[0; 9]), None); + } +} diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs new file mode 100644 index 00000000000..1726c1eaa58 --- /dev/null +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -0,0 +1,1283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! How the Hyper-V backends, MSHV and WHP, present a CPU profile through a +//! partition's processor features. +//! +//! A Hyper-V partition presents a CPU feature only when the feature's +//! processor feature bank bit, or XSAVE feature bit, is set, and some of +//! those bits also decide which MSRs the guest may use (for example +//! `IA32_SPEC_CTRL`). [`HV_FEATURES`] maps each such bit to the CPUID feature +//! bit it controls on the CPUs of the profile's vendor: Intel and AMD +//! enumerate the speculation controls, PSFD, and nested virtualization in +//! different leaves. [`profile_features`] starts from the features the +//! hypervisor offers and clears every bit whose CPUID feature the profile +//! clears. The partition then behaves as the profile's CPUID describes. +//! +//! Neither backend lets the VMM intercept `IA32_ARCH_CAPABILITIES`: the +//! hypervisor answers it, deriving the immunity and capability bits from the +//! banks ([`ARCH_CAPABILITIES_BANK_BITS`]). A profile therefore pins those +//! bits through the banks too ([`restrict_banks_to_profile`]). AMD CPUs +//! enumerate their immunities in CPUID instead (`0x80000008` and +//! `0x80000021`), whose bank bits [`HV_FEATURES`] maps. +//! +//! The layout is the hypervisor's `HV_PARTITION_PROCESSOR_FEATURES` and +//! `HV_PARTITION_PROCESSOR_XSAVE_FEATURES`, which MSHV uses directly and WHP's +//! `WHV_PROCESSOR_FEATURES`, `WHV_PROCESSOR_FEATURES1`, and +//! `WHV_PROCESSOR_XSAVE_FEATURES` share. +//! +//! Bits without a CPUID feature, such as the nested-paging details, are the +//! backend's to decide. Features that no bit controls (for example PKU), the +//! descriptive leaves (caches, the brand string), and the time policy bits a +//! host lacks reach the guest through the backend's CPUID results. No XSAVE +//! feature bit controls PKRU either: a root that offered PKRU would let a +//! partition enable it in XCR0 under a profile without PKU, although the +//! guest's CPUID hides it. The roots of the fleet's hosts do not offer it. +//! +//! The hypervisor refuses some feature sets. MSHV fails to create a partition +//! with `mb_clear_support` cleared on its own on a Skylake-SP host, with +//! `rdtscp_support` cleared on its own on Azure, or with an XSAVE prerequisite +//! (`xsave_support`, `avx_support`, `avx512_support`, +//! `xsave_comp_support`) cleared while what depends on it stays set. No pinned +//! profile clears these where the fleet's hosts offer them, and a test keeps +//! it so. A profile that did would fail partition creation with the +//! hypervisor's error rather than `E_PROFILE_UNSUPPORTED`. The time ABI needs +//! RDTSCP, so no profile clears it. + +use crate::cpuid::CpuidEntry; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::profile::CpuProfile; +use crate::surface::SupportedMsr; +use crate::surface::TIME_POLICY_BITS; +use crate::vendor::CpuVendor; +use hvdef::HvX64PartitionProcessorFeatures as Bank0; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; +use std::fmt; + +const EAX: usize = 0; +const EBX: usize = 1; +const ECX: usize = 2; +const EDX: usize = 3; + +/// The extended feature leaf. +const X1: u32 = 0x8000_0001; +/// The advanced power management leaf. +const X7: u32 = 0x8000_0007; +/// The extended feature identifiers leaf. +const X8: u32 = 0x8000_0008; +/// AMD's extended feature leaf 2. +const X21: u32 = 0x8000_0021; + +/// The `IA32_ARCH_CAPABILITIES` bits that Hyper-V derives from the processor +/// feature banks, as (MSR bit, bank, bank bit). +pub const ARCH_CAPABILITIES_BANK_BITS: [(u32, usize, u32); 14] = [ + // RDCL_NO: RdclNo. + (0, 0, 51), + // IBRS_ALL: IbrsAllSupport. + (1, 0, 52), + // SKIP_L1DFL_VMENTRY: SkipL1df (reserved in WHP). + (3, 0, 53), + // SSB_NO: SsbNo. + (4, 0, 54), + // MDS_NO: MdsNoSupport. + (5, 0, 59), + // TSX_CTRL: TsxCtrlSupport. + (7, 0, 62), + // TAA_NO: TaaNoSupport. + (8, 0, 61), + // SBDR_SSDP_NO: SbdrSsdpNoSupport. + (13, 1, 25), + // FBSDP_NO: FbsdpNoSupport. + (14, 1, 26), + // PSDP_NO: PsdpNoSupport. + (15, 1, 27), + // FB_CLEAR: FbClearSupport. + (17, 1, 28), + // BHI_NO: BhiNoSupport. + (20, 1, 43), + // GDS_NO: GdsNoSupport. + (26, 1, 39), + // RFDS_NO: RfdsNoSupport. + (27, 1, 48), +]; + +/// The `IA32_ARCH_CAPABILITIES` bits that the banks control. +pub const ARCH_CAPABILITIES_BANK_MASK: u64 = { + let mut mask = 0; + let mut i = 0; + while i < ARCH_CAPABILITIES_BANK_BITS.len() { + mask |= 1 << ARCH_CAPABILITIES_BANK_BITS[i].0; + i += 1; + } + mask +}; + +/// Returns the `IA32_ARCH_CAPABILITIES` bits that a partition with processor +/// feature `banks` presents. +pub fn arch_capabilities_from_banks(banks: [u64; 2]) -> u64 { + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, bank, bank_bit)| banks[bank] & (1 << bank_bit) != 0) + .fold(0, |value, &(bit, ..)| value | 1 << bit) +} + +/// Returns what a Hyper-V backend whose host offers processor feature +/// `banks` can present in `IA32_ARCH_CAPABILITIES`. +/// +/// Bits the banks do not derive are reported neither supported nor +/// controllable, so a profile can pin them only to zero. +pub fn arch_capabilities_msr(banks: [u64; 2]) -> SupportedMsr { + SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: arch_capabilities_from_banks(banks), + controllable: ARCH_CAPABILITIES_BANK_MASK, + } +} + +/// Clears from `banks` every bank bit whose `IA32_ARCH_CAPABILITIES` bit +/// `profile` pins clear, so the hypervisor presents the profile's value. +/// +/// The backend must also verify the profile (see +/// [`verify_support`](crate::verify_support)), which ensures the banks +/// already have every bit the profile pins set. +pub fn restrict_banks_to_profile(profile: &CpuProfile, banks: &mut [u64; 2]) { + let Some((value, mask)) = profile.msr(IA32_ARCH_CAPABILITIES) else { + return; + }; + for &(bit, bank, bank_bit) in &ARCH_CAPABILITIES_BANK_BITS { + if mask & !value & (1 << bit) != 0 { + banks[bank] &= !(1 << bank_bit); + } + } +} + +/// A processor feature word of a Hyper-V partition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HvFeatureWord { + /// Processor feature bank 0. + Bank0, + /// Processor feature bank 1. + Bank1, + /// The XSAVE features. + Xsave, +} + +impl HvFeatureWord { + /// Every word. + pub const ALL: [Self; 3] = [Self::Bank0, Self::Bank1, Self::Xsave]; + + /// Returns the word's name, as messages spell it. + pub fn name(self) -> &'static str { + match self { + Self::Bank0 => "bank0", + Self::Bank1 => "bank1", + Self::Xsave => "xsave", + } + } +} + +/// A partition's processor features: feature banks 0 and 1, and the XSAVE +/// features. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HvFeatures { + /// Processor feature banks 0 and 1. + pub banks: [u64; 2], + /// The XSAVE features. + pub xsave: u64, +} + +impl HvFeatures { + /// Returns `word`. + pub fn word(&self, word: HvFeatureWord) -> u64 { + match word { + HvFeatureWord::Bank0 => self.banks[0], + HvFeatureWord::Bank1 => self.banks[1], + HvFeatureWord::Xsave => self.xsave, + } + } + + /// Returns `word`, to change it. + pub fn word_mut(&mut self, word: HvFeatureWord) -> &mut u64 { + match word { + HvFeatureWord::Bank0 => &mut self.banks[0], + HvFeatureWord::Bank1 => &mut self.banks[1], + HvFeatureWord::Xsave => &mut self.xsave, + } + } +} + +/// A CPUID feature bit: leaf, subleaf, register (0 for EAX through 3 for +/// EDX), and bit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CpuidBit { + /// The leaf. + pub leaf: u32, + /// The subleaf, 0 for a leaf without subleaves. + pub subleaf: u32, + /// The register: 0 for EAX, 1 for EBX, 2 for ECX, and 3 for EDX. + pub register: usize, + /// The bit. + pub bit: u32, +} + +impl CpuidBit { + /// Returns the CPUID bit `bit` of `register` in `leaf` and `subleaf`. + pub const fn new(leaf: u32, subleaf: u32, register: usize, bit: u32) -> Self { + Self { + leaf, + subleaf, + register, + bit, + } + } +} + +impl fmt::Display for CpuidBit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let register = ["eax", "ebx", "ecx", "edx"][self.register]; + write!( + f, + "{:#x}.{}:{}[{}]", + self.leaf, self.subleaf, register, self.bit + ) + } +} + +/// Where AMD CPUs enumerate the CPUID feature bit of an [`HvFeature`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AmdCpuid { + /// Where Intel CPUs do. + Same, + /// Elsewhere: the speculation controls in `0x80000008` EBX, and SVM in + /// `0x80000001` ECX. + At(CpuidBit), + /// Nowhere that the feature controls. `intel_prefetch_support` is + /// Intel's `PREFETCHW`, whose bit, `0x80000001` ECX bit 8, AMD CPUs + /// enumerate as 3DNowPrefetch, and the hypervisor presents it to AMD + /// guests whatever the feature: a WHP host with an AMD EPYC 7763 that + /// does not offer the feature presents it. + Uncontrolled, +} + +/// A processor feature bit and the CPUID feature bit it controls. +#[derive(Debug)] +pub struct HvFeature { + setter: &'static str, + /// The word that holds the bit. + pub word: HvFeatureWord, + /// The bit, as a mask of [`Self::word`]. + pub mask: u64, + /// The CPUID feature bit that a partition presents only with this bit + /// on Intel CPUs, and on AMD CPUs as [`Self::amd`] says. + pub cpuid: CpuidBit, + /// Where AMD CPUs enumerate the feature. + pub amd: AmdCpuid, +} + +impl HvFeature { + /// Returns the feature's name: hvdef's field name, such as + /// `sse3_support`. + pub fn name(&self) -> &'static str { + self.setter.trim_start_matches("with_") + } + + /// Returns the CPUID feature bit that the feature controls on `vendor`'s + /// CPUs, or `None` if it controls none there. + pub fn cpuid_for(&self, vendor: CpuVendor) -> Option { + match (vendor, self.amd) { + (CpuVendor::Intel, _) | (CpuVendor::Amd, AmdCpuid::Same) => Some(self.cpuid), + (CpuVendor::Amd, AmdCpuid::At(bit)) => Some(bit), + (CpuVendor::Amd, AmdCpuid::Uncontrolled) => None, + } + } + + /// Returns whether the CPUID bit is one of the time policy bits, which + /// the time ABI's CPUID supplies whatever the features. + pub fn is_time_policy(&self) -> bool { + [CpuVendor::Intel, CpuVendor::Amd] + .into_iter() + .filter_map(|vendor| self.cpuid_for(vendor)) + .any(|bit| { + TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) + }) + } + + /// Returns how `profile` pins the CPUID bit that the feature controls on + /// the CPUs of the profile's vendor: [`Pinned::Unpinned`] where it + /// controls none. + pub fn pinned_by(&self, profile: &CpuProfile) -> Pinned { + self.cpuid_for(profile.cpu_vendor()) + .map_or(Pinned::Unpinned, |bit| pinned(profile, bit)) + } +} + +macro_rules! feature { + ($word:ident, $ty:ident, $with:ident, $leaf:expr, $subleaf:expr, $register:expr, $bit:expr) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::Same, + } + }; + ( + $word:ident, + $ty:ident, + $with:ident, + $leaf:expr, + $subleaf:expr, + $register:expr, + $bit:expr; + amd uncontrolled + ) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::Uncontrolled, + } + }; + ( + $word:ident, + $ty:ident, + $with:ident, + $leaf:expr, + $subleaf:expr, + $register:expr, + $bit:expr; + amd $amd_leaf:expr, + $amd_subleaf:expr, + $amd_register:expr, + $amd_bit:expr + ) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::At(CpuidBit::new( + $amd_leaf, + $amd_subleaf, + $amd_register, + $amd_bit, + )), + } + }; +} + +macro_rules! bank0 { + ($with:ident, $($cpuid:tt)+) => { + feature!(Bank0, Bank0, $with, $($cpuid)+) + }; +} + +macro_rules! bank1 { + ($with:ident, $($cpuid:tt)+) => { + feature!(Bank1, Bank1, $with, $($cpuid)+) + }; +} + +macro_rules! xsave { + ($with:ident, $($cpuid:tt)+) => { + feature!(Xsave, XsaveBank, $with, $($cpuid)+) + }; +} + +/// The processor feature bits with a CPUID feature, as leaf, subleaf, +/// register, and bit. The leaves follow the Intel SDM and the AMD APM: where +/// the vendors enumerate a feature in different places, `amd` gives AMD's +/// (the speculation controls, PSFD, and SVM), and the features of AMD CPUs +/// alone (VIRT_SSBD, the always-on STIBP hint, `IBPB_RET`, `BTC_NO`, and the +/// SRSO and TSA bits of `0x80000021`) map to AMD's bits, which Intel +/// profiles pin clear. The bits that the banks derive into +/// `IA32_ARCH_CAPABILITIES` are [`ARCH_CAPABILITIES_BANK_BITS`]. +/// +/// WHP's hardware test `features_control_the_mapped_cpuid_bits` checks the +/// table against what WHP presents on a host, of either vendor. +pub const HV_FEATURES: &[HvFeature] = &[ + // Bank 0. + bank0!(with_sse3_support, 1, 0, ECX, 0), + bank0!(with_lahf_sahf_support, X1, 0, ECX, 0), + bank0!(with_ssse3_support, 1, 0, ECX, 9), + bank0!(with_sse4_1_support, 1, 0, ECX, 19), + bank0!(with_sse4_2_support, 1, 0, ECX, 20), + bank0!(with_sse4a_support, X1, 0, ECX, 6), + bank0!(with_xop_support, X1, 0, ECX, 11), + bank0!(with_pop_cnt_support, 1, 0, ECX, 23), + bank0!(with_cmpxchg16b_support, 1, 0, ECX, 13), + bank0!(with_altmovcr8_support, X1, 0, ECX, 4), + bank0!(with_lzcnt_support, X1, 0, ECX, 5), + bank0!(with_mis_align_sse_support, X1, 0, ECX, 7), + bank0!(with_mmx_ext_support, X1, 0, EDX, 22), + bank0!(with_amd3d_now_support, X1, 0, EDX, 31), + bank0!(with_extended_amd3d_now_support, X1, 0, EDX, 30), + bank0!(with_page_1gb_support, X1, 0, EDX, 26), + bank0!(with_aes_support, 1, 0, ECX, 25), + bank0!(with_pclmulqdq_support, 1, 0, ECX, 1), + bank0!(with_pcid_support, 1, 0, ECX, 17), + bank0!(with_fma4_support, X1, 0, ECX, 16), + bank0!(with_f16c_support, 1, 0, ECX, 29), + bank0!(with_rd_rand_support, 1, 0, ECX, 30), + bank0!(with_rd_wr_fs_gs_support, 7, 0, EBX, 0), + bank0!(with_smep_support, 7, 0, EBX, 7), + bank0!(with_enhanced_fast_string_support, 7, 0, EBX, 9), + bank0!(with_bmi1_support, 7, 0, EBX, 3), + bank0!(with_bmi2_support, 7, 0, EBX, 8), + bank0!(with_movbe_support, 1, 0, ECX, 22), + bank0!(with_dep_x87_fpu_save_support, 7, 0, EBX, 13), + bank0!(with_rd_seed_support, 7, 0, EBX, 18), + bank0!(with_adx_support, 7, 0, EBX, 19), + bank0!(with_intel_prefetch_support, X1, 0, ECX, 8; amd uncontrolled), + bank0!(with_smap_support, 7, 0, EBX, 20), + bank0!(with_hle_support, 7, 0, EBX, 4), + bank0!(with_rtm_support, 7, 0, EBX, 11), + bank0!(with_rdtscp_support, X1, 0, EDX, 27), + bank0!(with_clflushopt_support, 7, 0, EBX, 23), + bank0!(with_clwb_support, 7, 0, EBX, 24), + bank0!(with_sha_support, 7, 0, EBX, 29), + bank0!(with_x87_pointers_saved_support, X8, 0, EBX, 2), + bank0!(with_invpcid_support, 7, 0, EBX, 10), + bank0!(with_ibrs_support, 7, 0, EDX, 26; amd X8, 0, EBX, 14), + bank0!(with_stibp_support, 7, 0, EDX, 27; amd X8, 0, EBX, 15), + // CPUID enumerates IBPB with IBRS on Intel; WHP derives the bit from + // IBRS. + bank0!(with_ibpb_support, 7, 0, EDX, 26; amd X8, 0, EBX, 12), + bank0!(with_mdd_support, 7, 0, EDX, 31; amd X8, 0, EBX, 24), + bank0!(with_fast_short_rep_mov_support, 7, 0, EDX, 4), + bank0!(with_l1d_cache_flush_support, 7, 0, EDX, 28), + // AMD's VIRT_SSBD: SSBD through VIRT_SPEC_CTRL. + bank0!(with_virt_spec_ctrl_support, X8, 0, EBX, 25), + bank0!(with_rd_pid_support, 7, 0, ECX, 22), + bank0!(with_umip_support, 7, 0, ECX, 2), + bank0!(with_mb_clear_support, 7, 0, EDX, 10), + // Bank 1. + bank1!(with_a_count_m_count_support, 6, 0, ECX, 0), + bank1!(with_tsc_invariant_support, X7, 0, EDX, 8), + bank1!(with_cl_zero_support, X8, 0, EBX, 0), + bank1!(with_rdpru_support, X8, 0, EBX, 4), + // Also sets the linear address width, 0x80000008 EAX[15:8]. + bank1!(with_la57_support, 7, 0, ECX, 16), + // MSHV does not expose VMX through this bit alone, nor WHP SVM. + bank1!(with_nested_virt_support, 1, 0, ECX, 5; amd X1, 0, ECX, 2), + bank1!(with_psfd_support, 7, 2, EDX, 0; amd X8, 0, EBX, 28), + bank1!(with_cet_ss_support, 7, 0, ECX, 7), + bank1!(with_cet_ibt_support, 7, 0, EDX, 20), + bank1!(with_enqcmd_support, 7, 0, ECX, 29), + bank1!(with_umwait_tpause_support, 7, 0, ECX, 5), + bank1!(with_movdiri_support, 7, 0, ECX, 27), + bank1!(with_movdir64b_support, 7, 0, ECX, 28), + bank1!(with_cldemote_support, 7, 0, ECX, 25), + bank1!(with_serialize_support, 7, 0, EDX, 14), + bank1!(with_tsc_deadline_tmr_support, 1, 0, ECX, 24), + bank1!(with_tsc_adjust_support, 7, 0, EBX, 1), + bank1!(with_fz_l_rep_movsb, 7, 1, EAX, 10), + bank1!(with_fs_rep_stosb, 7, 1, EAX, 11), + bank1!(with_fs_rep_cmpsb, 7, 1, EAX, 12), + bank1!(with_tsx_ld_trk_support, 7, 0, EDX, 16), + // AMD's immunity to branch type confusion, IBPB_RET, and always-on STIBP + // preference. + bank1!(with_btc_no_support, X8, 0, EBX, 29), + bank1!(with_ibpb_rsb_flush_support, X8, 0, EBX, 30), + bank1!(with_stibp_always_on_support, X8, 0, EBX, 17), + bank1!(with_cmpccxadd_support, 7, 1, EAX, 7), + bank1!(with_bhi_dis_support, 7, 2, EDX, 4), + bank1!(with_prefetch_i_support, 7, 1, EDX, 14), + bank1!(with_sha512_support, 7, 1, EAX, 0), + bank1!(with_sm3_support, 7, 1, EAX, 1), + bank1!(with_sm4_support, 7, 1, EAX, 2), + // AMD's SBPB, IBPB_BRTYPE, SRSO_NO, SRSO_USER_KERNEL_NO, VERW_CLEAR, and + // TSA immunities. + bank1!(with_sbpb_support, X21, 0, EAX, 27), + bank1!(with_ibpb_br_type_support, X21, 0, EAX, 28), + bank1!(with_srso_no_support, X21, 0, EAX, 29), + bank1!(with_srso_user_kernel_no_support, X21, 0, EAX, 30), + bank1!(with_vrew_clear_support, X21, 0, EAX, 5), + bank1!(with_tsa_l1_no_support, X21, 0, ECX, 2), + bank1!(with_tsa_sq_no_support, X21, 0, ECX, 1), + bank1!(with_lass_support, 7, 1, EAX, 6), + // XSAVE features. + xsave!(with_xsave_support, 1, 0, ECX, 26), + xsave!(with_xsaveopt_support, 0xd, 1, EAX, 0), + xsave!(with_avx_support, 1, 0, ECX, 28), + xsave!(with_avx2_support, 7, 0, EBX, 5), + xsave!(with_fma_support, 1, 0, ECX, 12), + xsave!(with_mpx_support, 7, 0, EBX, 14), + xsave!(with_avx512_support, 7, 0, EBX, 16), + xsave!(with_avx512_dq_support, 7, 0, EBX, 17), + xsave!(with_avx512_cd_support, 7, 0, EBX, 28), + xsave!(with_avx512_bw_support, 7, 0, EBX, 30), + xsave!(with_avx512_vl_support, 7, 0, EBX, 31), + xsave!(with_xsave_comp_support, 0xd, 1, EAX, 1), + xsave!(with_xsave_supervisor_support, 0xd, 1, EAX, 3), + xsave!(with_xcr1_support, 0xd, 1, EAX, 2), + xsave!(with_avx512_bitalg_support, 7, 0, ECX, 12), + xsave!(with_avx512_ifma_support, 7, 0, EBX, 21), + xsave!(with_avx512_vbmi_support, 7, 0, ECX, 1), + xsave!(with_avx512_vbmi2_support, 7, 0, ECX, 6), + xsave!(with_avx512_vnni_support, 7, 0, ECX, 11), + xsave!(with_gfni_support, 7, 0, ECX, 8), + xsave!(with_vaes_support, 7, 0, ECX, 9), + xsave!(with_avx512_vpopcntdq_support, 7, 0, ECX, 14), + xsave!(with_vpclmulqdq_support, 7, 0, ECX, 10), + xsave!(with_avx512_bf16_support, 7, 1, EAX, 5), + xsave!(with_avx512_vp2_intersect_support, 7, 0, EDX, 8), + xsave!(with_avx512_fp16_support, 7, 0, EDX, 23), + xsave!(with_xfd_support, 0xd, 1, EAX, 4), + xsave!(with_amx_tile_support, 7, 0, EDX, 24), + xsave!(with_amx_bf16_support, 7, 0, EDX, 22), + xsave!(with_amx_int8_support, 7, 0, EDX, 25), + xsave!(with_avx_vnni_support, 7, 1, EAX, 4), + xsave!(with_avx_ifma_support, 7, 1, EAX, 23), + xsave!(with_avx_ne_convert_support, 7, 1, EDX, 5), + xsave!(with_avx_vnni_int8_support, 7, 1, EDX, 4), + xsave!(with_avx_vnni_int16_support, 7, 1, EDX, 10), + // CPUID enumerates AVX10 once; leaf 0x24 reports its vector lengths. + xsave!(with_avx10_1_256_support, 7, 1, EDX, 19), + xsave!(with_avx10_1_512_support, 7, 1, EDX, 19), + xsave!(with_amx_fp16_support, 7, 1, EAX, 21), +]; + +/// Returns the bits of `word` that a profile decides: those of +/// [`HV_FEATURES`], and for the banks those of +/// [`ARCH_CAPABILITIES_BANK_BITS`]. The backend decides the other bits. +pub fn mapped_mask(word: HvFeatureWord) -> u64 { + let features = HV_FEATURES + .iter() + .filter(|feature| feature.word == word) + .fold(0, |mask, feature| mask | feature.mask); + let bank = match word { + HvFeatureWord::Bank0 => 0, + HvFeatureWord::Bank1 => 1, + HvFeatureWord::Xsave => return features, + }; + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, b, _)| b == bank) + .fold(features, |mask, &(.., bit)| mask | 1 << bit) +} + +/// How a profile pins a CPUID bit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pinned { + /// The profile pins the bit set. + Set, + /// The profile pins the bit clear. + Clear, + /// The bit is VM-owned or runtime-owned: the profile leaves it unpinned. + Unpinned, +} + +/// Returns how `profile` pins `bit`. A leaf or subleaf the profile does not +/// list counts as zero, pinned. +pub fn pinned(profile: &CpuProfile, bit: CpuidBit) -> Pinned { + let (value, mask) = profile + .cpuid() + .iter() + .find(|entry| { + let (leaf, subleaf) = entry.key(); + leaf == bit.leaf && subleaf.unwrap_or(0) == bit.subleaf + }) + .map_or((0, !0), |entry| { + (entry.values()[bit.register], entry.masks()[bit.register]) + }); + let flag = 1 << bit.bit; + if mask & flag == 0 { + Pinned::Unpinned + } else if value & flag != 0 { + Pinned::Set + } else { + Pinned::Clear + } +} + +/// Returns the processor features of a partition that presents `profile`, +/// given the features the hypervisor offers. +/// +/// Every feature bit whose CPUID feature the profile clears is cleared, and +/// the `IA32_ARCH_CAPABILITIES` bits that the banks derive are restricted to +/// the profile's pinned value ([`restrict_banks_to_profile`]). Bits that +/// [`mapped_mask`] excludes are left as offered. +/// +/// Fails with `E_PROFILE_UNSUPPORTED`, naming every shortfall, when the +/// profile sets a CPUID feature whose bit the hypervisor does not offer +/// (except the time policy bits, which the time ABI's CPUID supplies), or +/// when the banks cannot present the profile's `IA32_ARCH_CAPABILITIES`. +pub fn profile_features( + profile: &CpuProfile, + available: HvFeatures, +) -> Result { + let mut features = available; + let mut missing = Vec::new(); + for feature in HV_FEATURES { + let word = features.word_mut(feature.word); + match feature.pinned_by(profile) { + Pinned::Clear => *word &= !feature.mask, + Pinned::Set if *word & feature.mask == 0 && !feature.is_time_policy() => { + missing.push(feature.name().to_owned()); + } + Pinned::Set | Pinned::Unpinned => {} + } + } + restrict_banks_to_profile(profile, &mut features.banks); + if let Some((value, mask)) = profile.msr(IA32_ARCH_CAPABILITIES) { + let presented = arch_capabilities_from_banks(features.banks); + if presented & mask != value & mask { + missing.push(format!( + "IA32_ARCH_CAPABILITIES {:#x} (the banks present {:#x} under mask {mask:#x})", + value & mask, + presented & mask + )); + } + } + if !missing.is_empty() { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "the hypervisor's processor features cannot present CPU profile {}: they lack {}", + profile.id(), + missing.join(", ") + ), + )); + } + Ok(features) +} + +/// Clears from `cpuid` every CPUID bit that [`HV_FEATURES`] maps to a feature +/// `available` lacks, so a host's CPUID and its features describe what a +/// partition can present, without a probe partition. The bits are those of +/// the vendor that `cpuid` reports ([`HvFeature::cpuid_for`]), Intel's for a +/// vendor that profiles do not serve; a feature that controls no bit on that +/// vendor's CPUs clears none. +/// +/// A CPUID bit that several features control (IBRS and IBPB both control +/// `SPEC_CTRL` on Intel) is cleared when any of them is missing. Bits the +/// table does not map are left alone. +/// +/// The function only clears, so the host's own view bounds the result. +/// MSHV's root CPUID hides `TSC_ADJUST` (`7.0:EBX[1]`), which a partition with +/// `tsc_adjust_support` presents, so that bit is under-reported; every profile +/// pins it clear. The root views of MSHV and WHP also show what neither gives +/// a guest. Those over-claims stay set: MONITOR, VMX, EST, TM and TM2, PDCM, +/// DS, ACPI, HTT, PT, TME, the hybrid bit, the topology fields, and leaves 5, +/// 6, `0xA`, `0x14`, and `0x15`. They are harmless to +/// [`verify_support`](crate::verify_support): no profile sets them, except +/// ARAT in leaf 6, a time policy bit that support checks exempt. The entries +/// outside a profile's tables over-claim too: on Skylake-SP, the root of both +/// MSHV and WHP reads Intel PT's subleaf `0x14.1` as non-zero, though neither +/// gives guests PT. So a surface built here is a +/// [`CpuidPresentation::PassThroughHostView`](crate::CpuidPresentation::PassThroughHostView). +/// The surface's guest physical address width must come from the hypervisor, +/// not from this CPUID (see [`HostCpuSurface`](crate::HostCpuSurface)). +pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { + let vendor = crate::cpuid::lookup(cpuid, 0, 0) + .and_then(|[_, ebx, ecx, edx]| { + CpuVendor::from_cpuid_vendor(&crate::signature::vendor_bytes(ebx, edx, ecx)) + }) + .unwrap_or(CpuVendor::Intel); + for feature in HV_FEATURES { + if available.word(feature.word) & feature.mask != 0 { + continue; + } + let Some(bit) = feature.cpuid_for(vendor) else { + continue; + }; + for entry in cpuid.iter_mut().filter(|entry| { + entry.leaf.0 == bit.leaf && entry.subleaf.map_or(0, |subleaf| subleaf.0) == bit.subleaf + }) { + let mut registers = entry.registers(); + registers[bit.register] &= !(1 << bit.bit); + *entry = CpuidEntry::new(bit.leaf, entry.subleaf.map(|subleaf| subleaf.0), registers); + } + } +} + +/// Returns the processor feature banks 0 and 1 a Hyper-V fingerprint +/// recorded, if any. +pub(crate) fn fingerprint_banks(backend: &BackendFingerprint) -> Option<[u64; 2]> { + let bank = |name: &str| backend.feature_banks.get(name).map(|value| value.0); + match backend.name.as_str() { + "mshv" => Some([ + bank("mshv.host.ProcessorFeatures0")?, + bank("mshv.host.ProcessorFeatures1").unwrap_or(0), + ]), + "whp" => Some( + match ( + bank("whp.capability.ProcessorFeaturesBanks.bank0"), + bank("whp.capability.ProcessorFeaturesBanks.bank1"), + ) { + (Some(bank0), bank1) => [bank0, bank1.unwrap_or(0)], + (None, _) => [bank("whp.capability.ProcessorFeatures")?, 0], + }, + ), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + #[test] + fn derives_arch_capabilities_from_azure_banks() { + // Ice Lake on Azure WHP: RdclNo, MdsNo, TaaNo; RfdsNo in bank 1. + let icelake_whp = [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1]; + assert_eq!(arch_capabilities_from_banks(icelake_whp), 0x0800_0121); + // Emerald Rapids on Azure MSHV: RdclNo, SkipL1df, and MdsNo. + let emeraldrapids_mshv = [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051]; + assert_eq!(arch_capabilities_from_banks(emeraldrapids_mshv), 0x29); + // WHP reserves SkipL1df. + let emeraldrapids_whp = [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051]; + assert_eq!(arch_capabilities_from_banks(emeraldrapids_whp), 0x21); + } + + #[test] + fn the_mask_covers_every_mapped_bit_once() { + assert_eq!( + ARCH_CAPABILITIES_BANK_MASK.count_ones() as usize, + ARCH_CAPABILITIES_BANK_BITS.len() + ); + assert_eq!( + arch_capabilities_from_banks([!0, !0]), + ARCH_CAPABILITIES_BANK_MASK + ); + let msr = arch_capabilities_msr([0, 0]); + assert_eq!( + (msr.supported, msr.controllable), + (0, ARCH_CAPABILITIES_BANK_MASK) + ); + } + + #[test] + fn restricts_the_banks_to_the_profile() { + let icelake = profile("intel.icelake-sp.v1"); + let mut banks = [!0, !0]; + restrict_banks_to_profile(icelake, &mut banks); + assert_eq!(arch_capabilities_from_banks(banks), 0x0800_0121); + // Bits that derive no IA32_ARCH_CAPABILITIES bit are untouched. + let mapped = |bank| { + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, b, _)| b == bank) + .fold(0u64, |mask, &(.., bit)| mask | 1 << bit) + }; + assert_eq!(banks[0] | mapped(0), !0); + assert_eq!(banks[1] | mapped(1), !0); + + let skylake = profile("intel.skylake-sp.v1"); + let mut banks = [!0, !0]; + restrict_banks_to_profile(skylake, &mut banks); + assert_eq!(arch_capabilities_from_banks(banks), 0); + } + + /// The processor features the hypervisors offered on the fleet's hosts + /// (CPU fingerprints): WHP's capability and MSHV's host partition + /// properties on the bare-metal Skylake-SP hosts and on the Ice Lake-SP + /// (8370C) and Emerald Rapids (8573C) Azure runners. + const HOSTS: [(&str, &str, HvFeatures); 6] = [ + ( + "intel.skylake-sp.v1", + "whp 4114", + HvFeatures { + banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], + xsave: 0x3fff, + }, + ), + ( + "intel.icelake-sp.v1", + "whp 8370C", + HvFeatures { + banks: [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.emeraldrapids.v1", + "whp 8573C", + HvFeatures { + banks: [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.skylake-sp.v1", + "mshv 4114", + HvFeatures { + banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], + xsave: 0x3fff, + }, + ), + ( + "intel.icelake-sp.v1", + "mshv 8370C", + HvFeatures { + banks: [0x2e2a_8bff_fff7_859f, 0x0001_00ae_0000_00f1], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.emeraldrapids.v1", + "mshv 8573C", + HvFeatures { + banks: [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }, + ), + ]; + + /// The features WHP offers on the bare-metal Skylake-SP host. + const SKYLAKE_SP_WHP_HOST: HvFeatures = HOSTS[0].2; + + fn mask(word: HvFeatureWord, name: &str) -> u64 { + HV_FEATURES + .iter() + .find(|feature| feature.word == word && feature.name() == name) + .unwrap_or_else(|| panic!("no feature {name} in {}", word.name())) + .mask + } + + fn has(features: &HvFeatures, word: HvFeatureWord, name: &str) -> bool { + features.word(word) & mask(word, name) != 0 + } + + #[test] + fn every_feature_is_one_distinct_bit() { + let mut seen = Vec::new(); + for feature in HV_FEATURES { + assert_eq!(feature.mask.count_ones(), 1, "{}", feature.name()); + assert!( + !seen.contains(&(feature.word, feature.mask)), + "{} repeats a bit", + feature.name() + ); + seen.push((feature.word, feature.mask)); + } + // No feature bit is also an IA32_ARCH_CAPABILITIES bit. + for &(_, bank, bit) in &ARCH_CAPABILITIES_BANK_BITS { + assert!( + !seen.contains(&(HvFeatureWord::ALL[bank], 1 << bit)), + "bank{bank} bit {bit}" + ); + } + } + + #[test] + fn the_mapped_mask_covers_the_features_and_arch_capabilities() { + for word in HvFeatureWord::ALL { + let features = HV_FEATURES + .iter() + .filter(|feature| feature.word == word) + .fold(0, |mask, feature| mask | feature.mask); + let arch_capabilities = ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, bank, _)| HvFeatureWord::ALL[bank] == word) + .fold(0u64, |mask, &(.., bit)| mask | 1 << bit); + assert_eq!( + mapped_mask(word), + features | arch_capabilities, + "{}", + word.name() + ); + } + } + + #[test] + fn pinned_profiles_derive_on_their_hosts() { + for (id, host, available) in HOSTS { + let profile = profile(id); + let features = + profile_features(profile, available).unwrap_or_else(|err| panic!("{host}: {err}")); + for word in HvFeatureWord::ALL { + assert_eq!( + features.word(word) & !available.word(word), + 0, + "{host}: {} gained bits", + word.name() + ); + assert_eq!( + features.word(word) & !mapped_mask(word), + available.word(word) & !mapped_mask(word), + "{host}: {} changed unmapped bits", + word.name() + ); + } + // Every mapped bit follows the profile's CPUID, on its vendor's + // bits. + for feature in HV_FEATURES { + let offered = available.word(feature.word) & feature.mask != 0; + let kept = features.word(feature.word) & feature.mask != 0; + match feature.pinned_by(profile) { + Pinned::Clear => assert!(!kept, "{host}: {}", feature.name()), + Pinned::Set | Pinned::Unpinned => { + assert_eq!(kept, offered, "{host}: {}", feature.name()) + } + } + } + // The time policy: no TSC-deadline, TSC_ADJUST, or APERF/MPERF. + for name in [ + "tsc_deadline_tmr_support", + "tsc_adjust_support", + "a_count_m_count_support", + ] { + assert!( + !has(&features, HvFeatureWord::Bank1, name), + "{host}: {name}" + ); + } + let (value, msr_mask) = profile.msr(IA32_ARCH_CAPABILITIES).unwrap(); + assert_eq!( + arch_capabilities_from_banks(features.banks) & msr_mask, + value & msr_mask, + "{host}" + ); + } + } + + #[test] + fn skylake_keeps_the_speculation_controls() { + // The Skylake-SP profile pins SPEC_CTRL, STIBP, SSBD, and MD_CLEAR + // (CPUID 7.0 EDX 0xac000400), which WHP's default features omit. + for (id, host, available) in HOSTS { + if id != "intel.skylake-sp.v1" { + continue; + } + let features = profile_features(profile(id), available).unwrap(); + for name in [ + "ibrs_support", + "ibpb_support", + "stibp_support", + "mdd_support", + "mb_clear_support", + ] { + assert!(has(&features, HvFeatureWord::Bank0, name), "{host}: {name}"); + } + } + } + + #[test] + fn invariant_tsc_follows_the_host() { + let skylake = + profile_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_WHP_HOST).unwrap(); + assert!(has(&skylake, HvFeatureWord::Bank1, "tsc_invariant_support")); + // Azure's WHP offers no invariant TSC. The profile still pins it, and + // the time ABI's CPUID supplies it. + let icelake = profile_features(profile("intel.icelake-sp.v1"), HOSTS[1].2).unwrap(); + assert!(!has( + &icelake, + HvFeatureWord::Bank1, + "tsc_invariant_support" + )); + } + + #[test] + fn pinned_profiles_keep_what_the_hypervisor_requires() { + // MSHV refuses a partition with mb_clear or rdtscp cleared on its own, + // or with an XSAVE prerequisite cleared while a feature that depends + // on it stays set (see the module documentation). + for (id, host, available) in HOSTS { + let features = profile_features(profile(id), available).unwrap(); + for name in ["mb_clear_support", "rdtscp_support"] { + if has(&available, HvFeatureWord::Bank0, name) { + assert!( + has(&features, HvFeatureWord::Bank0, name), + "{host}: {id} clears {name}" + ); + } + } + let kept = |name: &str| has(&features, HvFeatureWord::Xsave, name); + for feature in HV_FEATURES { + let name = feature.name(); + if feature.word != HvFeatureWord::Xsave || features.xsave & feature.mask == 0 { + continue; + } + assert!(kept("xsave_support"), "{host}: {name} without xsave"); + if name.starts_with("avx") && name != "avx_support" { + assert!(kept("avx_support"), "{host}: {name} without avx"); + } + if name.starts_with("avx512_") { + assert!(kept("avx512_support"), "{host}: {name} without avx512"); + } + } + } + } + + #[test] + fn a_missing_feature_is_unsupported() { + let mut available = SKYLAKE_SP_WHP_HOST; + available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); + available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibrs_support"); + let error = profile_features(profile("intel.skylake-sp.v1"), available).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!(error.message.contains("avx512_support"), "{error}"); + assert!(error.message.contains("ibrs_support"), "{error}"); + + // A newer generation's profile on an older host. + let error = + profile_features(profile("intel.emeraldrapids.v1"), SKYLAKE_SP_WHP_HOST).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + } + + #[test] + fn unlisted_leaves_read_zero_and_unmasked_bits_are_unpinned() { + let skylake = profile("intel.skylake-sp.v1"); + // Skylake-SP lists no leaf 7.1, so its features are pinned clear. + assert_eq!(pinned(skylake, CpuidBit::new(7, 1, EAX, 5)), Pinned::Clear); + // OSXSAVE is runtime state: the profile leaves it unpinned. + assert_eq!( + pinned(skylake, CpuidBit::new(1, 0, ECX, 27)), + Pinned::Unpinned + ); + assert_eq!(pinned(skylake, CpuidBit::new(1, 0, ECX, 26)), Pinned::Set); + assert_eq!(CpuidBit::new(7, 0, EDX, 26).to_string(), "0x7.0:edx[26]"); + } + + #[test] + fn restricts_cpuid_to_the_features() { + // Each host's features present its profile, except time policy bits + // that the time ABI's CPUID supplies. + for (id, host, available) in HOSTS { + let original = profile_entries(profile(id)); + let mut cpuid = original.clone(); + restrict_cpuid_to_features(&mut cpuid, available); + for (entry, before) in cpuid.iter().zip(&original) { + let (leaf, subleaf) = (entry.leaf.0, entry.subleaf.map_or(0, |s| s.0)); + for register in 0..4 { + let lost = before.registers()[register] & !entry.registers()[register]; + for bit in (0..32).filter(|bit| lost & (1 << bit) != 0) { + assert!( + TIME_POLICY_BITS.contains(&(leaf, subleaf, register, 1 << bit)), + "{host}: lost {}", + CpuidBit::new(leaf, subleaf, register, bit) + ); + } + } + } + } + + // A missing feature clears its CPUID bit, and nothing else. + let skylake = profile_entries(profile("intel.skylake-sp.v1")); + let mut available = SKYLAKE_SP_WHP_HOST; + available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); + available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibpb_support"); + let mut cpuid = skylake.clone(); + restrict_cpuid_to_features(&mut cpuid, available); + let leaf7 = |cpuid: &[CpuidEntry]| { + cpuid + .iter() + .find(|entry| entry.leaf.0 == 7 && entry.subleaf.map_or(0, |s| s.0) == 0) + .unwrap() + .registers() + }; + let (before, after) = (leaf7(&skylake), leaf7(&cpuid)); + // AVX512F goes, AVX512DQ stays. + assert_eq!(after[EBX], before[EBX] & !(1 << 16)); + // IBPB missing clears SPEC_CTRL, which IBRS and IBPB share. + assert_eq!(after[EDX], before[EDX] & !(1 << 26)); + assert_eq!( + (after[EAX], after[ECX]), + (before[EAX], before[ECX]), + "other registers" + ); + for (entry, before) in cpuid.iter().zip(&skylake) { + if entry.leaf.0 != 7 { + assert_eq!(entry, before); + } + } + } + + /// The processor features that WHP offered on the AMD EPYC 7763 (Milan) + /// Azure host that `test_support::MILAN_WHP_CPUID` comes from: no + /// speculation controls, but PSFD, `BTC_NO`, and nested virtualization. + const MILAN_WHP_HOST: HvFeatures = HvFeatures { + banks: [0x0602_0fcb_67f7_9fbf, 0x0000_0000_2000_01ed], + xsave: 0x50_381f, + }; + + /// The CPUID of an AMD host whose banks offer the speculation controls, + /// the immunities, and nested virtualization, as KVM's can on bare + /// metal, with SVM masked by the profile's policy. + fn amd_entries_with_speculation_controls() -> Vec { + let mut entries = crate::test_support::milan_whp_entries(); + for entry in &mut entries { + let mut registers = entry.registers(); + match entry.key() { + // SVM. + (X1, None) => registers[ECX] |= 1 << 2, + // IBPB, IBRS, STIBP, SSBD, VIRT_SSBD, PSFD, and BTC_NO. + (X8, None) => { + registers[EBX] |= + 1 << 12 | 1 << 14 | 1 << 15 | 1 << 24 | 1 << 25 | 1 << 28 | 1 << 29 + } + // SBPB, IBPB_BRTYPE, SRSO_NO, and the TSA immunities. + (X21, None) => { + registers[EAX] |= 1 << 27 | 1 << 28 | 1 << 29; + registers[ECX] |= 1 << 1 | 1 << 2; + } + _ => continue, + } + *entry = CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), registers); + } + entries + } + + /// Returns the host profile of an AMD host whose CPUID is `entries`. + fn amd_profile(entries: Vec) -> CpuProfile { + crate::derive_host_profile(&crate::test_support::host_fingerprint("whp", entries)).unwrap() + } + + #[test] + fn an_amd_profile_derives_on_its_host() { + let profile = amd_profile(crate::test_support::milan_whp_entries()); + let features = profile_features(&profile, MILAN_WHP_HOST).unwrap(); + // CLZERO, which the profile sets in 0x80000008 EBX, stays. + assert!(has(&features, HvFeatureWord::Bank1, "cl_zero_support")); + // SVM, RDPRU, and APERF/MPERF, which the profile clears, go, and so + // do PSFD, which this host offers without a SPEC_CTRL control, and + // BTC_NO: the policy clears both. + for name in [ + "nested_virt_support", + "rdpru_support", + "a_count_m_count_support", + "psfd_support", + "btc_no_support", + ] { + assert!(!has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + for word in HvFeatureWord::ALL { + assert_eq!(features.word(word) & !MILAN_WHP_HOST.word(word), 0); + } + } + + #[test] + fn an_amd_profile_takes_the_speculation_controls_from_amd_bits() { + let profile = amd_profile(amd_entries_with_speculation_controls()); + let bank0 = [ + "ibrs_support", + "stibp_support", + "ibpb_support", + "mdd_support", + "virt_spec_ctrl_support", + ]; + // PSFD stays beside the SPEC_CTRL controls, from AMD's bit: Intel's, + // 7.2 EDX[0], is not the profile's. + let bank1 = [ + "psfd_support", + "sbpb_support", + "ibpb_br_type_support", + "srso_no_support", + "tsa_l1_no_support", + "tsa_sq_no_support", + ]; + let mut available = MILAN_WHP_HOST; + for name in bank0 { + available.banks[0] |= mask(HvFeatureWord::Bank0, name); + } + for name in bank1 { + available.banks[1] |= mask(HvFeatureWord::Bank1, name); + } + // Offered features the profile does not set go, BTC_NO, which the + // policy clears, included. + available.banks[1] |= mask(HvFeatureWord::Bank1, "srso_user_kernel_no_support") + | mask(HvFeatureWord::Bank1, "stibp_always_on_support") + | mask(HvFeatureWord::Bank1, "btc_no_support"); + let features = profile_features(&profile, available).unwrap(); + for name in bank0 { + assert!(has(&features, HvFeatureWord::Bank0, name), "{name}"); + } + for name in bank1 { + assert!(has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + for name in [ + "srso_user_kernel_no_support", + "stibp_always_on_support", + "btc_no_support", + "nested_virt_support", + ] { + assert!(!has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + + // A host that lacks one fails the profile. + let mut lacking = available; + lacking.banks[0] &= !mask(HvFeatureWord::Bank0, "ibrs_support"); + lacking.banks[1] &= !mask(HvFeatureWord::Bank1, "srso_no_support"); + let error = profile_features(&profile, lacking).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error + .message + .ends_with("they lack ibrs_support, srso_no_support"), + "{error}" + ); + } + + #[test] + fn restricts_an_amd_hosts_cpuid_to_amd_bits() { + let original = amd_entries_with_speculation_controls(); + let mut cpuid = original.clone(); + let mut available = MILAN_WHP_HOST; + available.banks[0] |= mask(HvFeatureWord::Bank0, "stibp_support"); + restrict_cpuid_to_features(&mut cpuid, available); + let x8 = |cpuid: &[CpuidEntry]| crate::cpuid::lookup(cpuid, X8, 0).unwrap()[EBX]; + // IBPB, IBRS, SSBD, and VIRT_SSBD go, STIBP stays, and PSFD and + // BTC_NO, which the host offers, stay. + assert_eq!( + x8(&cpuid), + x8(&original) & !(1 << 12 | 1 << 14 | 1 << 24 | 1 << 25) + ); + // Intel's speculation bits are not AMD's. + assert_eq!( + crate::cpuid::lookup(&cpuid, 7, 0), + crate::cpuid::lookup(&original, 7, 0) + ); + } + + /// Intel's `PREFETCHW` feature controls `0x80000001` ECX bit 8 on Intel + /// CPUs only. WHP presents that bit, 3DNowPrefetch, to AMD guests + /// whatever the feature, which the Milan host does not even offer: an + /// AMD host's CPUID keeps it, and an AMD profile, which clears it, leaves + /// the feature as offered. + #[test] + fn intel_prefetch_controls_no_amd_bit() { + let feature = HV_FEATURES + .iter() + .find(|feature| feature.name() == "intel_prefetch_support") + .unwrap(); + assert_eq!( + feature.cpuid_for(CpuVendor::Intel), + Some(CpuidBit::new(X1, 0, ECX, 8)) + ); + assert_eq!(feature.cpuid_for(CpuVendor::Amd), None); + + let mut cpuid = crate::test_support::milan_whp_entries(); + assert!(!has( + &MILAN_WHP_HOST, + HvFeatureWord::Bank0, + "intel_prefetch_support" + )); + restrict_cpuid_to_features(&mut cpuid, MILAN_WHP_HOST); + assert_ne!( + crate::cpuid::lookup(&cpuid, X1, 0).unwrap()[ECX] & 1 << 8, + 0 + ); + + let profile = amd_profile(crate::test_support::milan_whp_entries()); + assert_eq!(profile.lookup(X1, 0)[ECX] & 1 << 8, 0); + assert!(matches!(feature.pinned_by(&profile), Pinned::Unpinned)); + let mut available = MILAN_WHP_HOST; + available.banks[0] |= mask(HvFeatureWord::Bank0, "intel_prefetch_support"); + let features = profile_features(&profile, available).unwrap(); + assert!(has( + &features, + HvFeatureWord::Bank0, + "intel_prefetch_support" + )); + } +} diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs new file mode 100644 index 00000000000..687096ff9ee --- /dev/null +++ b/vmm_core/cpu_profile/src/lib.rs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! x86 CPU profiles for the NVX time ABI, and the host CPU fingerprints they +//! are derived from. +//! +//! A [`CpuProfile`] is the complete guest-visible CPU surface of one CPU +//! generation, shared by every hypervisor backend: every CPUID leaf a guest +//! can observe, the XSAVE features and layout, the guest physical address +//! width, and pinned MSR values. Profiles are pinned in this crate +//! ([`pinned`]), immutable once released, and identified by ID and SHA-256 +//! digest. A VM's effective CPUID ([`CpuProfile::effective_cpuid`]) is a pure +//! function of its profile, its topology, and the time ABI's identity +//! leaves; OpenVMM builds it in `virt::time_abi::cpuid::effective_cpuid`. +//! +//! - **Selection:** [`select`] maps `--cpu-profile ` and the host +//! CPU ([`HostCpuSignature`]) to a pinned profile. A host profile serves a +//! development host instead: the VM worker derives one from the backend's +//! fingerprint of this host ([`derive_host_profile`]) for +//! `--cpu-profile host` ([`HOST`]), and falls back to one from `auto` on a +//! CPU that no pinned profile serves ([`pinned_profile_serves`]) and that +//! host profiles serve ([`supports_host_profiles`]), with a warning. +//! Either way, the partition holds its profile in a [`PartitionProfile`], +//! which core hands to the backend, so each VM of a process can have its +//! own host profile. +//! - **Verification:** each backend reports the CPU surface it supports +//! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the +//! profile. No CPUID entry outside the profile's tables carries host data: +//! a pass-through backend (MSHV, WHP) must present zero there +//! ([`check_unlisted_cpuid`]), and KVM answers those entries from the +//! effective CPUID itself ([`CpuidPresentation`]). +//! - **Restore:** [`pinned_for_restore`] checks a snapshot's recorded profile +//! digest against the pinned constant, core compares the recorded document +//! with [`pinned_record`]'s encoding, and [`check_generation`] checks the +//! destination host. Core compares the effective CPUID record with the +//! recomputed [`EffectiveCpuid`]. A snapshot of a host profile carries the +//! only copy of its document, which [`host_profile_for_restore`] decodes +//! and checks against the recorded digest, and from which the restoring +//! VM worker's [`PartitionProfile`] takes it. +//! - **Offline only:** the codecs and digests +//! ([`CpuProfile::encode`], [`CpuProfile::digest`], +//! [`CpuProfile::digest_string`], [`CpuProfile::decode`], +//! [`CpuProfile::from_pretty_json`], [`CpuProfile::to_pretty_json`], +//! [`EffectiveCpuid::encode`], [`EffectiveCpuid::digest`], +//! [`EffectiveCpuid::decode`], and [`EffectiveCpuid::decode_verified`]) +//! and [`pinned_profiles`] parse, encode, or hash. They serve tools, tests, +//! and `--cpu-fingerprint`, never a cold boot or restore of a pinned +//! profile, whose profile work uses only constants. Only a host profile, +//! which has no constants, encodes and hashes at cold boot and decodes at +//! restore. +//! +//! Failures carry the stable codes of the time ABI specification +//! ([`ProfileError`]). +//! +//! A [`CpuFingerprint`](fingerprint::CpuFingerprint) records the CPU surface +//! that one backend supports on one host, with the host's identity, as +//! deterministic canonical JSON with SHA-256 digests. The +//! [`derive`](mod@derive) module intersects the fingerprints of a +//! generation's hosts into its profile, following the dump, strip, and verify +//! workflow of Firecracker's `cpu-template-helper`. + +pub mod cpuid; +pub mod derive; +pub mod fingerprint; +pub mod host; +pub mod hv_banks; + +mod canonical; +mod catalog; +mod check; +mod effective; +mod error; +mod hex; +mod pinned; +mod pinned_data; +mod profile; +mod signature; +mod surface; +#[cfg(test)] +mod test_support; +mod unlisted; +mod vendor; + +pub use catalog::AUTO; +pub use catalog::HOST; +pub use catalog::PartitionProfile; +pub use catalog::check_generation; +pub use catalog::generation_of; +pub use catalog::host_profile_for_restore; +pub use catalog::is_host_profile_id; +pub use catalog::pinned; +pub use catalog::pinned_for_restore; +pub use catalog::pinned_profile_serves; +pub use catalog::pinned_profiles; +pub use catalog::pinned_record; +pub use catalog::select; +pub use catalog::select_auto; +pub use check::FingerprintCheck; +pub use check::SUMMARY_PREFIX; +pub use check::check_fingerprint; +pub use check::check_fingerprint_with; +pub use derive::derive_host_profile; +pub use derive::supports_host_profiles; +pub use effective::CpuidResult; +pub use effective::EFFECTIVE_CPUID_SCHEMA; +pub use effective::EffectiveCpuid; +pub use effective::x2apic_cpuid; +pub use error::ProfileError; +pub use error::ProfileErrorCode; +pub use hex::Hex32; +pub use hex::Hex64; +pub use hex::ParseHexError; +pub use pinned::PinnedRecord; +pub use profile::CpuProfile; +pub use profile::CpuidLeafValue; +pub use profile::Generation; +pub use profile::GenerationCpu; +pub use profile::PinnedMsr; +pub use profile::Provenance; +pub use profile::ProvenanceSource; +pub use profile::SCHEMA as PROFILE_SCHEMA; +pub use profile::VM_OWNED_LEAVES; +pub use profile::pinned_mask; +pub use profile::runtime_owned_bits; +pub use profile::vm_owned_bits; +pub use signature::HostCpuSignature; +pub use surface::CpuidPresentation; +pub use surface::HostCpuSurface; +pub use surface::SupportedMsr; +pub use surface::TIME_POLICY_BITS; +pub use surface::support_violations; +pub use surface::verify_support; +pub use surface::verify_support_with_unlisted; +pub use unlisted::check_unlisted_cpuid; +pub use unlisted::unlisted_cpuid_candidates; +pub use unlisted::unlisted_cpuid_violations; +pub use vendor::CpuVendor; diff --git a/vmm_core/cpu_profile/src/pinned.rs b/vmm_core/cpu_profile/src/pinned.rs new file mode 100644 index 00000000000..94965c55498 --- /dev/null +++ b/vmm_core/cpu_profile/src/pinned.rs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as compile-time static data. +//! +//! `pinned_data.rs` holds every pinned profile as Rust constants: its values, +//! its canonical encoding, and its golden digest. The `generate_pinned` +//! example generates the file from `profiles/.json`. An OpenVMM start +//! therefore neither parses JSON nor hashes: the first use of a profile +//! copies its constants into a [`CpuProfile`], and snapshot records use the +//! precomputed encoding and digest. The tests check every constant against +//! the JSON files and the golden digests. + +use crate::Hex32; +use crate::Hex64; +use crate::cpuid::XsaveComponent; +use crate::derive::KnownGeneration; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::PinnedMsr; +use crate::profile::Provenance; +use crate::profile::ProvenanceSource; +use crate::profile::SCHEMA; + +/// A pinned profile as static data. +pub(crate) struct PinnedProfile { + /// The profile ID. + pub(crate) id: &'static str, + /// The golden digest: the SHA-256 of `encoding`. + pub(crate) digest: [u8; 32], + /// The canonical encoding, compact canonical JSON. + pub(crate) encoding: &'static str, + /// The human-readable description. + pub(crate) description: &'static str, + /// The generation, which also gives the vendor. + pub(crate) generation: &'static KnownGeneration, + /// The CPUID table, sorted by leaf and subleaf. + pub(crate) cpuid: &'static [CpuidLeafValue], + /// The XCR0 bits the guest may enable. + pub(crate) xcr0: u64, + /// The IA32_XSS bits the guest may enable. + pub(crate) xss: u64, + /// The layout of every enabled XSAVE state component beyond x87 and SSE. + pub(crate) xsave_components: &'static [XsaveComponent], + /// The guest physical address width in bits. + pub(crate) physical_address_width: u32, + /// The pinned MSR values, sorted by index. + pub(crate) msrs: &'static [PinnedMsr], + /// How the profile was derived. + pub(crate) provenance_method: &'static str, + /// The host fingerprints it was derived from, by backend. + pub(crate) provenance_sources: &'static [PinnedSource], +} + +/// The fingerprints of one backend that a pinned profile was derived from. +pub(crate) struct PinnedSource { + backend: &'static str, + hosts: u32, + surface_digests: &'static [&'static str], +} + +impl PinnedProfile { + /// Returns the profile, copied from the constants. + pub(crate) fn to_profile(&self) -> CpuProfile { + CpuProfile { + cpuid: self.cpuid.to_vec(), + description: self.description.to_owned(), + generation: self.generation.generation(), + id: self.id.to_owned(), + msrs: self.msrs.to_vec(), + physical_address_width: self.physical_address_width, + provenance: Provenance { + method: self.provenance_method.to_owned(), + sources: self + .provenance_sources + .iter() + .map(|source| ProvenanceSource { + backend: source.backend.to_owned(), + hosts: source.hosts, + surface_digests: source + .surface_digests + .iter() + .map(|&digest| digest.to_owned()) + .collect(), + }) + .collect(), + }, + schema: SCHEMA.to_owned(), + vendor: self.generation.vendor.cpuid_vendor().to_owned(), + xcr0: Hex64(self.xcr0), + xsave_components: self.xsave_components.to_vec(), + xss: Hex64(self.xss), + } + } + + /// Returns the profile's record. + pub(crate) fn record(&self) -> PinnedRecord<'static> { + PinnedRecord { + id: self.id, + encoding: self.encoding.as_bytes(), + digest: self.digest, + } + } +} + +/// The CPU profile record that a snapshot copies: the profile ID, the +/// canonical encoding that the snapshot embeds, and its SHA-256, the profile +/// digest. A pinned profile's record is precomputed at build time +/// ([`pinned_record`](crate::pinned_record)), so capture can record it, and +/// restore can compare a recorded profile with it, without encoding or +/// hashing. A host profile's is computed once, when the VM worker selects the +/// profile ([`PartitionProfile::record`](crate::PartitionProfile::record)). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PinnedRecord<'a> { + /// The profile ID. + pub id: &'a str, + /// The canonical encoding, [`CpuProfile::encode`] of the profile. + pub encoding: &'a [u8], + /// The profile digest, the SHA-256 of `encoding`. + pub digest: [u8; 32], +} + +/// Returns a CPUID entry of a pinned profile. +pub(crate) const fn leaf( + leaf: u32, + subleaf: Option, + value: [u32; 4], + mask: [u32; 4], +) -> CpuidLeafValue { + CpuidLeafValue { + leaf: Hex32(leaf), + mask: [ + Hex32(mask[0]), + Hex32(mask[1]), + Hex32(mask[2]), + Hex32(mask[3]), + ], + subleaf: match subleaf { + Some(subleaf) => Some(Hex32(subleaf)), + None => None, + }, + value: [ + Hex32(value[0]), + Hex32(value[1]), + Hex32(value[2]), + Hex32(value[3]), + ], + } +} + +/// Returns an XSAVE state component of a pinned profile. +pub(crate) const fn xsave( + index: u32, + size: u32, + offset: u32, + supervisor: bool, + align64: bool, + xfd: bool, +) -> XsaveComponent { + XsaveComponent { + align64, + index, + offset, + size, + supervisor, + xfd, + } +} + +/// Returns a pinned MSR value of a pinned profile. +pub(crate) const fn msr(index: u32, value: u64, mask: u64) -> PinnedMsr { + PinnedMsr { + index: Hex32(index), + mask: Hex64(mask), + value: Hex64(value), + } +} + +/// Returns a provenance source of a pinned profile. +pub(crate) const fn source( + backend: &'static str, + hosts: u32, + surface_digests: &'static [&'static str], +) -> PinnedSource { + PinnedSource { + backend, + hosts, + surface_digests, + } +} + +/// Returns the digest that `hex`, 64 lowercase hex digits, spells. Malformed +/// input fails the build. +pub(crate) const fn digest(hex: &str) -> [u8; 32] { + const fn nibble(digit: u8) -> u8 { + match digit { + b'0'..=b'9' => digit - b'0', + b'a'..=b'f' => digit - b'a' + 10, + _ => panic!("a digest is lowercase hex"), + } + } + let hex = hex.as_bytes(); + assert!(hex.len() == 64, "a digest has 64 hex digits"); + let mut bytes = [0; 32]; + let mut i = 0; + while i < 32 { + bytes[i] = nibble(hex[2 * i]) << 4 | nibble(hex[2 * i + 1]); + i += 1; + } + bytes +} diff --git a/vmm_core/cpu_profile/src/pinned_data.rs b/vmm_core/cpu_profile/src/pinned_data.rs new file mode 100644 index 00000000000..d2687e175e6 --- /dev/null +++ b/vmm_core/cpu_profile/src/pinned_data.rs @@ -0,0 +1,2450 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as static data, generated from `profiles/.json` by +//! the `generate_pinned` example. Do not edit: the tests check every constant +//! against the JSON files and the golden digests. + +use crate::derive::KNOWN_GENERATIONS; +use crate::pinned::PinnedProfile; +use crate::pinned::digest; +use crate::pinned::leaf; +use crate::pinned::msr; +use crate::pinned::source; +use crate::pinned::xsave; + +/// The pinned profiles, in catalog order. Released profiles are immutable: a +/// change is a new revision with a new ID. +pub(crate) const PINNED: [PinnedProfile; 7] = [ + PinnedProfile { + id: "intel.skylake-sp.v1", + digest: digest("b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000016","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00050654","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x76036301","0x00f0b5ff","0x00000000","0x00c30000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000003ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x0280003f","0x00004fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0xd19f6fb9","0x00000000","0xac000400"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000ff","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000040","0x000003c0","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000040","0x00000400","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x6c796b53"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2d656b61","0x00295053","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x01006040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000302e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":85,"steppings":[0,4]}],"name":"skylake-sp"},"id":"intel.skylake-sp.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000000000000"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c"]},{"backend":"mshv","hosts":1,"surface_digests":["sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82"]},{"backend":"whp","hosts":1,"surface_digests":["sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000ff","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":3,"offset":960,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":4,"offset":1024,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[0], + cpuid: &[ + leaf( + 0x0, + None, + [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x5_0654, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x7603_6301, 0xf0_b5ff, 0x0, 0xc3_0000], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x3c0_003f, 0x3ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x280_003f, 0x4fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0xd19f_6fb9, 0x0, 0xac00_0400], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xff, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x3), + [0x40, 0x3c0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x4), + [0x40, 0x400, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x6c79_6b53], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x2d65_6b61, 0x29_5053, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x100_6040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x302e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xff, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(3, 64, 960, false, false, false), + xsave(4, 64, 1024, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x0, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "kvm", + 1, + &["sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c"], + ), + source( + "mshv", + 1, + &["sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82"], + ), + source( + "whp", + 1, + &["sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b"], + ), + ], + }, + PinnedProfile { + id: "intel.icelake-sp.v1", + digest: digest("a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001b","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000606a6","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x04c0003f","0x000003ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x02c0003f","0x0000ffff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000002","0xf1bf2fb9","0x00415f46","0x20000010"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000000","0x00000000","0x00000000","0x00000001"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x20656349"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x656b614c","0x2950532d","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x01006040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000392e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":106,"steppings":[0,15]}],"name":"icelake-sp"},"id":"intel.icelake-sp.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000008000121"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":5,"surface_digests":["sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f"]},{"backend":"mshv","hosts":1,"surface_digests":["sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379"]},{"backend":"whp","hosts":3,"surface_digests":["sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[1], + cpuid: &[ + leaf( + 0x0, + None, + [0x1b, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x6_06a6, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x4c0_003f, 0x3ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x2c0_003f, 0xffff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x2, 0xf1bf_2fb9, 0x41_5f46, 0x2000_0010], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x2), + [0x0, 0x0, 0x0, 0x1], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x2065_6349], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x656b_614c, 0x2950_532d, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x100_6040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x392e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x800_0121, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "kvm", + 5, + &["sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f"], + ), + source( + "mshv", + 1, + &["sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379"], + ), + source( + "whp", + 3, + &["sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e"], + ), + ], + }, + PinnedProfile { + id: "intel.emeraldrapids.v1", + digest: digest("73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000c06f2","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000007ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x04c0003f","0x00033fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0xf1bf2fb9","0x00415f46","0x20000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x72656d45"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20646c61","0x69706152","0x00297364","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x08007040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00003930","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":207,"steppings":[0,15]}],"name":"emeraldrapids"},"id":"intel.emeraldrapids.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000000000021"}],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"mshv","hosts":4,"surface_digests":["sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1"]},{"backend":"whp","hosts":2,"surface_digests":["sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[2], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xc_06f2, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x3c0_003f, 0x7ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x4c0_003f, 0x3_3fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0xf1bf_2fb9, 0x41_5f46, 0x2000_0010], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x7265_6d45], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x2064_6c61, 0x6970_6152, 0x29_7364, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x800_7040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3930, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 48, + msrs: &[msr(0x10a, 0x21, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "mshv", + 4, + &["sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1"], + ), + source( + "whp", + 2, + &["sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510"], + ), + ], + }, + PinnedProfile { + id: "intel.alderlake.v1", + digest: digest("2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000906a3","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x0240003f","0x000007ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x02c0003f","0x00007fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000002","0x219c27a9","0x18400724","0xac004410"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000810","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000000","0x00000000","0x00000000","0x00000001"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x726f4320","0x4d542865"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x72502029","0x7365636f","0x20726f73","0x646c4128"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x4c207265","0x29656b61","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x05007040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000302e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":151,"steppings":[0,15]},{"family":6,"model":154,"steppings":[0,15]}],"name":"alderlake"},"id":"intel.alderlake.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x000000000002e123"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"whp","hosts":1,"surface_digests":["sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[3], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x9_06a3, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x240_003f, 0x7ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x2c0_003f, 0x7fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x2, 0x219c_27a9, 0x1840_0724, 0xac00_4410], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x810, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x2), + [0x0, 0x0, 0x0, 0x1], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x726f_4320, 0x4d54_2865], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x7250_2029, 0x7365_636f, 0x2072_6f73, 0x646c_4128], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x4c20_7265, 0x2965_6b61, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x500_7040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x302e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x2_e123, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "whp", + 1, + &["sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53"], + )], + }, + PinnedProfile { + id: "amd.milan.v1", + digest: digest("463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000000d","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00a00f11","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x219c07a9","0x00400604","0x00000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00a00f11","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x616c694d","0x0000296e","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff40ff40","0xff40ff40","0x20080140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x48002200","0x68004200","0x02006140","0x08009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x00000005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x01c0003f","0x000003ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]}],"description":"AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":25,"model":1,"steppings":[0,15]}],"name":"milan"},"id":"amd.milan.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"whp","hosts":1,"surface_digests":["sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[4], + cpuid: &[ + leaf( + 0x0, + None, + [0xd, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xa0_0f11, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0x219c_07a9, 0x40_0604, 0x10], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xa0_0f11, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x616c_694d, 0x296e, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x4800_2200, 0x6800_4200, 0x200_6140, 0x800_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x5, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x1c0_003f, 0x3ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "whp", + 1, + &["sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307"], + )], + }, + PinnedProfile { + id: "amd.genoa.v1", + digest: digest("894ac647399039242eb0f0e6582e91691234446ebc9362576d7c5613f34b966b"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00a10f11","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x219c07a9","0x00400604","0x00000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00a10f11","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x6f6e6547","0x00002961","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff48ff40","0xff48ff40","0x20080140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x5c002200","0x6c004200","0x04006140","0x0a009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x00000005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x01c0003f","0x000007ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000204","0x00000000","0x00000006","0x00000000"]}],"description":"AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":25,"model":17,"steppings":[0,15]}],"name":"genoa"},"id":"amd.genoa.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[5], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xa1_0f11, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0x219c_07a9, 0x40_0604, 0x10], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xa1_0f11, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x6f6e_6547, 0x2961, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff48_ff40, 0xff48_ff40, 0x2008_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x5c00_2200, 0x6c00_4200, 0x400_6140, 0xa00_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x5, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x1c0_003f, 0x7ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x204, 0x0, 0x6, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "kvm", + 1, + &["sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa"], + )], + }, + PinnedProfile { + id: "amd.turin.v1", + digest: digest("0d9d044922eae3f27442178249f0b60feb0db07efe446e6641a1fcbfbb3a04ff"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00b00f21","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000001","0xf1bf07a9","0x00405f46","0x00000110"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000030","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000980","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000340","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000380","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000580","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00b00f21","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x69727554","0x0000296e","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff60ff40","0xff60ff40","0x300c0140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x40802040","0x60804040","0x04008140","0x0c009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x40020005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000003ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000204","0x00000000","0x00000006","0x00000000"]}],"description":"AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":26,"model":2,"steppings":[0,15]}],"name":"turin"},"id":"amd.turin.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":832,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":896,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1408,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[6], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xb0_0f21, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x1, 0xf1bf_07a9, 0x40_5f46, 0x110], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x30, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0x980, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x340, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x380, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x580, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xb0_0f21, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x6972_7554, 0x296e, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff60_ff40, 0xff60_ff40, 0x300c_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x4080_2040, 0x6080_4040, 0x400_8140, 0xc00_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x4002_0005, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x3c0_003f, 0x3ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x204, 0x0, 0x6, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 832, false, false, false), + xsave(6, 512, 896, false, false, false), + xsave(7, 1024, 1408, false, false, false), + ], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "kvm", + 1, + &["sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501"], + )], + }, +]; diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs new file mode 100644 index 00000000000..b79b0dfb5fb --- /dev/null +++ b/vmm_core/cpu_profile/src/profile.rs @@ -0,0 +1,1020 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pinned CPU profiles: the complete guest-visible CPU surface of one CPU +//! generation. +//! +//! A [`CpuProfile`] lists every CPUID leaf and subleaf a guest can observe in +//! the basic and extended ranges, with exact values and a mask of the bits it +//! pins; the XSAVE features and layout; the guest physical address width; and +//! pinned MSR values. The bits it does not pin are the ones OpenVMM sets per +//! VM, which [`vm_owned_bits`] and [`runtime_owned_bits`] define, so a VM's +//! effective CPUID is a pure function of the profile, its topology, and the +//! time ABI's identity leaves. +//! +//! The canonical encoding is compact canonical JSON (object keys sorted by +//! their UTF-8 bytes, no whitespace), and the profile digest is its SHA-256. +//! Pinned profiles are stored as pretty canonical JSON. Decoding accepts only +//! the canonical bytes, so a profile has exactly one encoding and one digest. +//! +//! Every serialized struct declares its fields in the byte order of their +//! keys, so serializing it directly gives the canonical form (tests check +//! this against the sorting encoder). Keep new fields in that order. + +use crate::Hex32; +use crate::Hex64; +use crate::canonical; +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::cpuid::XsaveComponent; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; +use crate::vendor::CpuVendor; +use serde::Deserialize; +use serde::Serialize; + +/// The schema of the profile documents this crate reads and writes. +pub const SCHEMA: &str = "openvmm-cpu-profile/v1"; + +/// The leaves that OpenVMM synthesizes entirely from the VM's topology, which +/// profiles therefore omit. +pub const VM_OWNED_LEAVES: [u32; 2] = [0xb, 0x1f]; + +/// The leaves whose subleaf 0 reports the highest subleaf in EAX. +const MAX_SUBLEAF_IN_EAX_LEAVES: [u32; 6] = [0x7, 0x14, 0x17, 0x18, 0x1d, 0x24]; + +/// `CPUID.(7,0):EDX[29]`, the presence of `IA32_ARCH_CAPABILITIES`. +const LEAF7_EDX_ARCH_CAPABILITIES: u32 = 1 << 29; + +/// The CPU generation that a profile serves. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Generation { + /// The CPU models of the generation. + pub cpus: Vec, + /// The generation name used in logs and reports, such as `icelake-sp`. + pub name: String, +} + +impl Generation { + /// Returns whether the host CPU, of the profile's `vendor`, belongs to + /// the generation. + pub fn contains(&self, vendor: &str, host: &HostCpuSignature) -> bool { + host.vendor().as_slice() == vendor.as_bytes() + && self.cpus.iter().any(|cpu| { + let (family, model, stepping) = + decode_signature(vendor.as_bytes(), host.signature()); + cpu.family == family + && cpu.model == model + && (cpu.steppings[0]..=cpu.steppings[1]).contains(&stepping) + }) + } +} + +/// A CPU model of a generation: a display family and model, and the range of +/// steppings it covers. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationCpu { + /// The display family. + pub family: u32, + /// The display model. + pub model: u32, + /// The first and last stepping, inclusive. Some model numbers span + /// generations: family 6 model 85 is Skylake-SP up to stepping 4, Cascade + /// Lake at steppings 5 to 7, and Cooper Lake at steppings 10 and 11. + pub steppings: [u32; 2], +} + +/// One CPUID leaf, or one subleaf of an indexed leaf, with its values and the +/// mask of the bits they define. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuidLeafValue { + /// The leaf, the input value of `EAX`. + pub leaf: Hex32, + /// The bits of each output register that the values define. + pub mask: [Hex32; 4], + /// The subleaf, the input value of `ECX`, or `None` for a leaf whose + /// output does not depend on `ECX`. + pub subleaf: Option, + /// The output values of `EAX`, `EBX`, `ECX`, and `EDX`. Bits outside the + /// mask are zero. + pub value: [Hex32; 4], +} + +impl CpuidLeafValue { + pub(crate) fn new(leaf: u32, subleaf: Option, value: [u32; 4], mask: [u32; 4]) -> Self { + Self { + leaf: Hex32(leaf), + subleaf: subleaf.map(Hex32), + value: value.map(Hex32), + mask: mask.map(Hex32), + } + } + + /// Returns the sort key: the leaf, then the subleaf, with a + /// subleaf-independent entry first. + pub fn key(&self) -> (u32, Option) { + (self.leaf.0, self.subleaf.map(|subleaf| subleaf.0)) + } + + /// Returns the output values. + pub fn values(&self) -> [u32; 4] { + self.value.map(|value| value.0) + } + + /// Returns the masks. + pub fn masks(&self) -> [u32; 4] { + self.mask.map(|mask| mask.0) + } +} + +/// An MSR value that a profile pins: the guest reads `value` in the bits of +/// `mask`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PinnedMsr { + /// The MSR index. + pub index: Hex32, + /// The bits the profile pins. The backend defines the others. + pub mask: Hex64, + /// The pinned value. Bits outside the mask are zero. + pub value: Hex64, +} + +/// Where a profile came from. Informative; part of the digest like every +/// other field. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Provenance { + /// How the profile was derived. + pub method: String, + /// The host fingerprints it was derived from, by backend. + pub sources: Vec, +} + +/// The fingerprints of one backend that a profile was derived from. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProvenanceSource { + /// The backend: `kvm`, `mshv`, or `whp`. + pub backend: String, + /// The number of host fingerprints. + pub hosts: u32, + /// The distinct surface digests of those fingerprints, sorted. + pub surface_digests: Vec, +} + +/// A CPU profile: the complete guest-visible CPU surface of one CPU +/// generation, shared by every backend. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuProfile { + pub(crate) cpuid: Vec, + pub(crate) description: String, + pub(crate) generation: Generation, + pub(crate) id: String, + pub(crate) msrs: Vec, + pub(crate) physical_address_width: u32, + pub(crate) provenance: Provenance, + pub(crate) schema: String, + pub(crate) vendor: String, + pub(crate) xcr0: Hex64, + pub(crate) xsave_components: Vec, + pub(crate) xss: Hex64, +} + +impl CpuProfile { + /// Returns the profile ID, `..v`, such as + /// `intel.icelake-sp.v1`. + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the human-readable description. + pub fn description(&self) -> &str { + &self.description + } + + /// Returns the 12-character CPUID vendor string, such as `GenuineIntel`. + pub fn vendor(&self) -> &str { + &self.vendor + } + + /// Returns the vendor of the profile's CPUs, which decides the layout of + /// the fields that OpenVMM sets per VM ([`vm_owned_bits`]). + pub fn cpu_vendor(&self) -> CpuVendor { + // Validation accepts only the vendors that profiles serve, and a + // pinned profile's vendor is its known generation's, which the + // catalog's tests check. + CpuVendor::from_cpuid_vendor(self.vendor.as_bytes()) + .expect("a valid profile has a vendor that profiles serve") + } + + /// Returns the CPU generation the profile serves. + pub fn generation(&self) -> &Generation { + &self.generation + } + + /// Returns the CPUID leaves, sorted by leaf and subleaf. + pub fn cpuid(&self) -> &[CpuidLeafValue] { + &self.cpuid + } + + /// Returns the output of CPUID for `leaf` and `subleaf`: the profile + /// values, which are zero in the bits OpenVMM sets per VM, or zeros for a + /// leaf or subleaf the profile does not list. + pub fn lookup(&self, leaf: u32, subleaf: u32) -> [u32; 4] { + find(&self.cpuid, leaf, subleaf).map_or([0; 4], CpuidLeafValue::values) + } + + /// Returns the XCR0 bits the guest may enable. + pub fn xcr0(&self) -> u64 { + self.xcr0.0 + } + + /// Returns the IA32_XSS bits the guest may enable. + pub fn xss(&self) -> u64 { + self.xss.0 + } + + /// Returns the layout of every enabled XSAVE state component beyond x87 + /// and SSE. + pub fn xsave_components(&self) -> &[XsaveComponent] { + &self.xsave_components + } + + /// Returns the guest physical address width in bits. + pub fn physical_address_width(&self) -> u8 { + self.physical_address_width as u8 + } + + /// Returns the pinned MSR values, sorted by index. + pub fn msrs(&self) -> &[PinnedMsr] { + &self.msrs + } + + /// Returns the pinned value and mask of MSR `index`, if the profile pins + /// it. + pub fn msr(&self, index: u32) -> Option<(u64, u64)> { + self.msrs + .iter() + .find(|msr| msr.index.0 == index) + .map(|msr| (msr.value.0, msr.mask.0)) + } + + /// Returns where the profile came from. + pub fn provenance(&self) -> &Provenance { + &self.provenance + } + + /// Returns the canonical encoding: compact canonical JSON. + /// + /// Offline only, for tools and tests: a pinned profile's encoding is the + /// constant [`pinned_record`](crate::pinned_record) returns. + pub fn encode(&self) -> Vec { + canonical::to_compact_ordered(self) + } + + /// Returns the profile as pretty canonical JSON, with a final newline: + /// the form in which profiles are pinned. Offline only. + pub fn to_pretty_json(&self) -> String { + canonical::to_pretty_ordered(self) + } + + /// Returns the profile digest: the SHA-256 of [`Self::encode`]. + /// + /// Offline only, for tools and tests: a pinned profile's digest is the + /// constant [`pinned_record`](crate::pinned_record) returns. + pub fn digest(&self) -> [u8; 32] { + canonical::sha256(&self.encode()) + } + + /// Returns the profile digest as `sha256:`. Offline only, for + /// `--cpu-fingerprint` and tools. + pub fn digest_string(&self) -> String { + canonical::format_digest(&self.digest()) + } + + /// Decodes a profile from its canonical encoding and validates it. + /// + /// Bytes that are not exactly the canonical encoding of a valid profile + /// fail with `E_PROFILE_DIGEST`: they cannot carry a verifiable digest. + /// Offline only: a restore compares the recorded document with the + /// pinned constant instead of decoding it. + pub fn decode(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid("the encoding is not UTF-8".to_owned()))?; + let this = Self::parse(text)?; + if this.encode() != bytes { + return Err(invalid(format!( + "profile {} is not in its canonical encoding", + this.id + ))); + } + Ok(this) + } + + /// Parses a profile from pretty canonical JSON, the pinned form, and + /// validates it. Offline only, for the profile generator and tests. + pub fn from_pretty_json(text: &str) -> Result { + let this = Self::parse(text)?; + if this.to_pretty_json() != text { + return Err(invalid(format!( + "profile {} is not in pretty canonical JSON", + this.id + ))); + } + Ok(this) + } + + fn parse(text: &str) -> Result { + let schema_error = |schema: Option<&str>| { + invalid(format!( + "unsupported profile schema {:?}, expected {SCHEMA:?}", + schema.unwrap_or_default() + )) + }; + let this: Self = match serde_json::from_str(text) { + Ok(this) => this, + Err(error) => { + // Name an unsupported schema rather than the field that a + // document of another schema lacks. + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|error| invalid(format!("malformed profile: {error}")))?; + let schema = value.get("schema").and_then(|schema| schema.as_str()); + if schema != Some(SCHEMA) { + return Err(schema_error(schema)); + } + return Err(invalid(format!("malformed profile: {error}"))); + } + }; + if this.schema != SCHEMA { + return Err(schema_error(Some(&this.schema))); + } + this.validate() + .map_err(|message| invalid(format!("profile {:?} is invalid: {message}", this.id)))?; + Ok(this) + } + + /// Assembles a profile from its parts and validates it. + #[expect(clippy::too_many_arguments)] + pub(crate) fn from_parts( + id: String, + description: String, + vendor: String, + generation: Generation, + mut leaves: Vec, + xcr0: u64, + xss: u64, + physical_address_width: u8, + msrs: Vec, + provenance: Provenance, + ) -> Result { + leaves.sort_by_key(CpuidLeafValue::key); + let entries = leaves + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect::>(); + let this = Self { + schema: SCHEMA.to_owned(), + id, + description, + vendor, + generation, + xsave_components: cpuid::xsave_components(&entries), + cpuid: leaves, + xcr0: Hex64(xcr0), + xss: Hex64(xss), + physical_address_width: physical_address_width.into(), + msrs, + provenance, + }; + this.validate()?; + Ok(this) + } + + /// Checks every structural invariant of a profile; see the module + /// documentation and [`SCHEMA`]. + fn validate(&self) -> Result<(), String> { + if self.schema != SCHEMA { + return Err(format!("schema {:?} is not {SCHEMA:?}", self.schema)); + } + let vendor = self.validate_identity()?; + self.validate_cpuid(vendor)?; + self.validate_xsave()?; + self.validate_msrs()?; + let width = self.lookup(EXTENDED_LEAF_BASE + 8, 0)[0] & 0xff; + if self.physical_address_width != width { + return Err(format!( + "physical address width {} differs from CPUID 0x80000008 EAX[7:0] {width}", + self.physical_address_width + )); + } + Ok(()) + } + + /// Checks the profile's identity and returns its vendor. + fn validate_identity(&self) -> Result { + let Some(cpu_vendor) = CpuVendor::from_cpuid_vendor(self.vendor.as_bytes()) else { + return Err(format!("unsupported vendor {:?}", self.vendor)); + }; + let vendor_short = cpu_vendor.id(); + let name = &self.generation.name; + if !is_id_component(name) { + return Err(format!("invalid generation name {name:?}")); + } + let revision = self + .id + .strip_prefix(vendor_short) + .and_then(|rest| rest.strip_prefix('.')) + .and_then(|rest| rest.strip_prefix(name.as_str())) + .and_then(|rest| rest.strip_prefix(".v")) + .ok_or_else(|| format!("ID {:?} is not {vendor_short}.{name}.v", self.id))?; + if !is_revision(revision) { + return Err(format!("ID {:?} has an invalid revision", self.id)); + } + if self.generation.cpus.is_empty() { + return Err("the generation lists no CPU".to_owned()); + } + for cpu in &self.generation.cpus { + let [first, last] = cpu.steppings; + if first > last || last > 0xf { + return Err(format!( + "invalid stepping range {first}..={last} for family {} model {}", + cpu.family, cpu.model + )); + } + } + let [_, ebx, ecx, edx] = self.lookup(0, 0); + let vendor = crate::signature::vendor_bytes(ebx, edx, ecx); + if vendor.as_slice() != self.vendor.as_bytes() { + return Err(format!( + "CPUID leaf 0 reports vendor {:?}, not {:?}", + String::from_utf8_lossy(&vendor), + self.vendor + )); + } + let signature = HostCpuSignature::new(vendor, self.lookup(1, 0)[0]); + if !self.generation.contains(&self.vendor, &signature) { + return Err(format!( + "CPUID.1:EAX {:#x} is not in generation {name}", + signature.signature() + )); + } + Ok(cpu_vendor) + } + + fn validate_cpuid(&self, vendor: CpuVendor) -> Result<(), String> { + for pair in self.cpuid.windows(2) { + if pair[0].key() >= pair[1].key() { + return Err(format!( + "CPUID entries are not strictly sorted at {}", + describe(&pair[1]) + )); + } + } + let max_basic = self.lookup(0, 0)[0]; + let max_extended = self.lookup(EXTENDED_LEAF_BASE, 0)[0]; + if !(1..cpuid::HYPERVISOR_LEAF_BASE).contains(&max_basic) { + return Err(format!("invalid maximum basic leaf {max_basic:#x}")); + } + if !(EXTENDED_LEAF_BASE..EXTENDED_LEAF_BASE + 0x100).contains(&max_extended) { + return Err(format!("invalid maximum extended leaf {max_extended:#x}")); + } + for entry in &self.cpuid { + let leaf = entry.leaf.0; + let in_range = leaf <= max_basic || (EXTENDED_LEAF_BASE..=max_extended).contains(&leaf); + if !in_range || VM_OWNED_LEAVES.contains(&leaf) { + return Err(format!( + "{} is outside the profile's leaves", + describe(entry) + )); + } + if entry.subleaf.is_some() != cpuid::is_indexed_leaf(leaf) { + return Err(format!( + "{} has the wrong subleaf indexing", + describe(entry) + )); + } + let value = entry.values(); + let expected_mask = pinned_mask(vendor, leaf, entry.subleaf.map(|s| s.0), value); + if entry.masks() != expected_mask { + return Err(format!( + "{} has mask {:#x?}, expected {expected_mask:#x?}", + describe(entry), + entry.masks() + )); + } + if value + .iter() + .zip(expected_mask) + .any(|(value, mask)| value & !mask != 0) + { + return Err(format!("{} sets bits outside its mask", describe(entry))); + } + } + let leaves = (0..=max_basic).chain(EXTENDED_LEAF_BASE..=max_extended); + for leaf in leaves.filter(|leaf| !VM_OWNED_LEAVES.contains(leaf)) { + let subleaves = self + .cpuid + .iter() + .filter(|entry| entry.leaf.0 == leaf) + .map(|entry| entry.subleaf.map(|s| s.0)) + .collect::>(); + let expected = self.expected_subleaves(leaf); + if subleaves != expected { + return Err(format!( + "CPUID {leaf:#x} lists subleaves {subleaves:?}, expected {expected:?}" + )); + } + } + Ok(()) + } + + /// Returns the subleaves a dense profile lists for `leaf`: `[None]` for a + /// leaf that is not indexed, and otherwise the subleaves the architecture + /// enumerates from the profile's own values. + fn expected_subleaves(&self, leaf: u32) -> Vec> { + if !cpuid::is_indexed_leaf(leaf) { + return vec![None]; + } + let last = match leaf { + // Up to and including the first subleaf with a null cache type: + // Intel's deterministic cache parameters, and AMD's cache + // topology. + 0x4 | 0x8000_001d => (0..0x40) + .find(|&subleaf| self.lookup(leaf, subleaf)[0] & 0x1f == 0) + .unwrap_or(0x3f), + 0xd => { + let components = (self.xcr0.0 | self.xss.0) & !3; + return [0, 1] + .into_iter() + .chain((2..63).filter(|index| components & (1 << index) != 0)) + .map(Some) + .collect(); + } + leaf if MAX_SUBLEAF_IN_EAX_LEAVES.contains(&leaf) => self.lookup(leaf, 0)[0].min(0x3f), + _ => 0, + }; + (0..=last).map(Some).collect() + } + + fn validate_xsave(&self) -> Result<(), String> { + let xcr0 = self.xcr0.0; + let xss = self.xss.0; + if xcr0 & 3 != 3 { + return Err(format!("XCR0 {xcr0:#x} lacks x87 or SSE")); + } + if xcr0 & xss != 0 { + return Err(format!("XCR0 {xcr0:#x} and XSS {xss:#x} overlap")); + } + let entries = self + .cpuid + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect::>(); + if cpuid::xsave_supported(&entries) != (xcr0, xss) { + return Err("XCR0 and XSS differ from CPUID leaf 0xd".to_owned()); + } + let components = cpuid::xsave_components(&entries); + if components != self.xsave_components { + return Err("the XSAVE components differ from CPUID leaf 0xd".to_owned()); + } + if components.len() != ((xcr0 | xss) & !3).count_ones() as usize { + return Err("an enabled XSAVE component has no CPUID leaf 0xd subleaf".to_owned()); + } + if components + .iter() + .any(|component| component.supervisor != (xss & (1 << component.index) != 0)) + { + return Err("an XSAVE component's supervisor flag disagrees with XSS".to_owned()); + } + let standard_size = cpuid::xsave_standard_size(&components, xcr0); + if self.lookup(0xd, 0)[2] != standard_size { + return Err(format!( + "CPUID.(0xd,0):ECX is not the standard XSAVE size {standard_size}" + )); + } + Ok(()) + } + + fn validate_msrs(&self) -> Result<(), String> { + for pair in self.msrs.windows(2) { + if pair[0].index.0 >= pair[1].index.0 { + return Err("pinned MSRs are not strictly sorted".to_owned()); + } + } + for msr in &self.msrs { + if msr.mask.0 == 0 || msr.value.0 & !msr.mask.0 != 0 { + return Err(format!( + "MSR {:#x} pins {:#x} under mask {:#x}", + msr.index.0, msr.value.0, msr.mask.0 + )); + } + if msr.index.0 == IA32_ARCH_CAPABILITIES + && self.lookup(7, 0)[3] & LEAF7_EDX_ARCH_CAPABILITIES == 0 + { + return Err("IA32_ARCH_CAPABILITIES is pinned but not enumerated".to_owned()); + } + } + Ok(()) + } +} + +/// Returns whether `component` is a valid vendor or generation component of a +/// profile ID: one or more lowercase ASCII letters, digits, and hyphens. +pub(crate) fn is_id_component(component: &str) -> bool { + !component.is_empty() + && component + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +/// Returns whether `revision` is a valid profile revision, the decimal number +/// after the `.v` that ends a profile ID: one or more digits without a leading +/// zero. +pub(crate) fn is_revision(revision: &str) -> bool { + !revision.is_empty() + && !revision.starts_with('0') + && revision.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Returns the entry of `entries` that CPUID `leaf` and `subleaf` read, if any. +pub(crate) fn find(entries: &[CpuidLeafValue], leaf: u32, subleaf: u32) -> Option<&CpuidLeafValue> { + entries.iter().find(|entry| { + entry.leaf.0 == leaf + && entry + .subleaf + .is_none_or(|entry_subleaf| entry_subleaf.0 == subleaf) + }) +} + +/// Returns the bits of CPUID `leaf` that OpenVMM sets from the VM +/// configuration, given the leaf's output `value`, in a profile of `vendor`'s +/// CPUs: +/// +/// - `CPUID.1:EBX[31:16]`, the logical processor count and initial APIC ID, +/// and `CPUID.1:ECX[21]`, x2APIC, which the APIC mode decides; +/// - every bit of the topology leaves in [`VM_OWNED_LEAVES`]; +/// - on Intel, `CPUID.4:EAX[31:14]`, the core and cache-sharing counts, in +/// every subleaf that describes a cache; and +/// - on AMD, the core count and APIC ID size, `CPUID.0x80000008:ECX[7:0]` and +/// `ECX[15:12]`; the cache-sharing count, `CPUID.0x8000001D:EAX[25:14]`, in +/// every subleaf that describes a cache; and the processor topology of +/// `CPUID.0x8000001E`: the extended APIC ID in `EAX`, the compute unit ID +/// and threads per compute unit in `EBX[15:0]`, and the node ID and nodes +/// per processor in `ECX[10:0]`. +/// +/// Each vendor's fields are VM-owned only in its own profiles: the Intel +/// profiles pin AMD's topology fields, which Intel CPUs leave zero. +pub fn vm_owned_bits(vendor: CpuVendor, leaf: u32, value: [u32; 4]) -> [u32; 4] { + let describes_cache = value[0] & 0x1f != 0; + match (vendor, leaf) { + (_, 0x1) => [0, 0xffff_0000, 1 << 21, 0], + (_, leaf) if VM_OWNED_LEAVES.contains(&leaf) => [!0; 4], + (CpuVendor::Intel, 0x4) if describes_cache => [0xffff_c000, 0, 0, 0], + (CpuVendor::Amd, 0x8000_0008) => [0, 0, 0xf0ff, 0], + (CpuVendor::Amd, 0x8000_001d) if describes_cache => [0x03ff_c000, 0, 0, 0], + (CpuVendor::Amd, 0x8000_001e) => [!0, 0xffff, 0x7ff, 0], + _ => [0; 4], + } +} + +/// Returns the bits of CPUID `leaf` and `subleaf` that reflect runtime state +/// rather than the CPU surface: OSXSAVE and OSPKE, which mirror control +/// register bits, and the XSAVE area sizes for the currently enabled XCR0 and +/// IA32_XSS. +pub fn runtime_owned_bits(leaf: u32, subleaf: Option) -> [u32; 4] { + match (leaf, subleaf) { + (0x1, _) => [0, 0, 1 << 27, 0], + (0x7, Some(0)) => [0, 0, 1 << 4, 0], + (0xd, Some(0 | 1)) => [0, !0, 0, 0], + _ => [0; 4], + } +} + +/// Returns the bits of CPUID `leaf` and `subleaf`, with output `value`, that +/// a profile of `vendor`'s CPUs pins: every bit that is neither VM-owned nor +/// runtime-owned. +pub fn pinned_mask( + vendor: CpuVendor, + leaf: u32, + subleaf: Option, + value: [u32; 4], +) -> [u32; 4] { + let vm = vm_owned_bits(vendor, leaf, value); + let runtime = runtime_owned_bits(leaf, subleaf); + [0, 1, 2, 3].map(|register| !(vm[register] | runtime[register])) +} + +pub(crate) fn describe(entry: &CpuidLeafValue) -> String { + describe_leaf(entry.leaf.0, entry.subleaf.map(|s| s.0)) +} + +pub(crate) fn describe_leaf(leaf: u32, subleaf: Option) -> String { + match subleaf { + Some(subleaf) => format!("CPUID {leaf:#x}.{subleaf}"), + None => format!("CPUID {leaf:#x}"), + } +} + +fn invalid(message: String) -> ProfileError { + ProfileError::new(ProfileErrorCode::ProfileDigest, message) +} + +#[cfg(test)] +fn to_value(value: &T) -> serde_json::Value { + // Every field serializes infallibly with string map keys. + serde_json::to_value(value).expect("profile serialization is infallible") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use serde_json::Value; + use serde_json::json; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Decodes `profile` after `edit` changes its JSON form. + fn decode_edited(edit: impl FnOnce(&mut Value)) -> Result { + let mut value = to_value(profile(ICELAKE)); + edit(&mut value); + CpuProfile::decode(canonical::to_compact(&value).as_bytes()) + } + + /// Returns the CPUID entry of `leaf` and `subleaf` in a profile's JSON. + fn entry(value: &mut Value, leaf: u32, subleaf: Option) -> &mut Value { + value["cpuid"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|entry| { + entry["leaf"] == json!(format!("{leaf:#010x}")) + && entry["subleaf"] + == subleaf.map_or(Value::Null, |s| json!(format!("{s:#010x}"))) + }) + .unwrap() + } + + fn assert_invalid(result: Result, expected: &str) { + let error = result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileDigest, "{error}"); + assert!(error.message.contains(expected), "{error}"); + } + + #[test] + fn pinned_profiles_round_trip_through_their_canonical_encoding() { + for profile in crate::pinned_profiles() { + let encoded = profile.encode(); + let decoded = CpuProfile::decode(&encoded).unwrap(); + assert_eq!(&decoded, profile); + assert_eq!(decoded.digest(), canonical::sha256(&encoded)); + assert_eq!( + CpuProfile::from_pretty_json(&profile.to_pretty_json()).unwrap(), + decoded + ); + } + } + + /// The direct serialization that `encode` and `to_pretty_json` use is + /// canonical only while every struct declares its fields in key order. + #[test] + fn direct_serialization_matches_the_sorting_encoder() { + for profile in crate::pinned_profiles() { + let value = to_value(profile); + assert_eq!( + String::from_utf8(profile.encode()).unwrap(), + canonical::to_compact(&value), + "{}", + profile.id() + ); + assert_eq!( + profile.to_pretty_json(), + canonical::to_pretty(&value), + "{}", + profile.id() + ); + } + // Fields that the pinned profiles leave at one value. + let component = XsaveComponent { + align64: true, + index: 12, + offset: 0, + size: 24, + supervisor: true, + xfd: true, + }; + assert_eq!( + canonical::to_compact_ordered(&component), + canonical::to_compact(&to_value(&component)).into_bytes() + ); + } + + #[test] + fn decoding_rejects_every_non_canonical_form() { + let profile = profile(ICELAKE); + let mut spaced = profile.encode(); + spaced.insert(1, b' '); + assert_invalid(CpuProfile::decode(&spaced), "canonical"); + assert_invalid( + CpuProfile::decode(profile.to_pretty_json().as_bytes()), + "canonical", + ); + assert_invalid( + CpuProfile::from_pretty_json(&String::from_utf8(profile.encode()).unwrap()), + "pretty canonical", + ); + assert_invalid(CpuProfile::decode(b"\xff"), "UTF-8"); + assert_invalid(CpuProfile::decode(b"{}"), "schema"); + assert_invalid( + decode_edited(|value| value["extra"] = json!(1)), + "unknown field", + ); + } + + #[test] + fn validation_rejects_malformed_identities() { + assert_invalid( + decode_edited(|value| value["id"] = json!("intel.icelake-sp.v01")), + "invalid revision", + ); + assert_invalid( + decode_edited(|value| value["id"] = json!("intel.skylake-sp.v1")), + "is not intel.icelake-sp.v", + ); + assert_invalid( + decode_edited(|value| value["vendor"] = json!("HygonGenuine")), + "unsupported vendor", + ); + // An AMD vendor on an Intel profile's ID and CPUID. + assert_invalid( + decode_edited(|value| value["vendor"] = json!("AuthenticAMD")), + "is not amd.icelake-sp.v", + ); + assert_invalid( + decode_edited(|value| { + value["vendor"] = json!("AuthenticAMD"); + value["id"] = json!("amd.icelake-sp.v1"); + }), + "CPUID leaf 0 reports vendor \"GenuineIntel\", not \"AuthenticAMD\"", + ); + assert_invalid( + decode_edited(|value| value["generation"]["cpus"][0]["model"] = json!(85)), + "is not in generation icelake-sp", + ); + assert_invalid( + decode_edited(|value| value["generation"]["cpus"][0]["steppings"] = json!([4, 3])), + "invalid stepping range", + ); + } + + #[test] + fn validation_rejects_malformed_cpuid_tables() { + // A VM-owned field that the profile pins. + assert_invalid( + decode_edited(|value| entry(value, 1, None)["mask"][1] = json!("0xffffffff")), + "has mask", + ); + // A value outside the mask. + assert_invalid( + decode_edited(|value| entry(value, 1, None)["value"][1] = json!("0x00010800")), + "outside its mask", + ); + // A missing leaf. + assert_invalid( + decode_edited(|value| { + value["cpuid"] + .as_array_mut() + .unwrap() + .retain(|entry| entry["leaf"] != json!("0x00000003")); + }), + "CPUID 0x3 lists subleaves []", + ); + // A hypervisor leaf, which belongs to the time ABI's identity. + assert_invalid( + decode_edited(|value| { + let cpuid = value["cpuid"].as_array_mut().unwrap(); + let position = cpuid + .iter() + .position(|entry| entry["leaf"].as_str().unwrap() >= "0x80000000") + .unwrap(); + cpuid.insert( + position, + json!({ + "leaf": "0x40000000", + "subleaf": null, + "value": ["0x00000000", "0x00000000", "0x00000000", "0x00000000"], + "mask": ["0xffffffff", "0xffffffff", "0xffffffff", "0xffffffff"], + }), + ); + }), + "outside the profile's leaves", + ); + // A subleaf beyond leaf 7's maximum. + assert_invalid( + decode_edited(|value| entry(value, 7, Some(0))["value"][0] = json!("0x00000001")), + "CPUID 0x7 lists subleaves", + ); + } + + #[test] + fn validation_rejects_inconsistent_xsave_and_msrs() { + // XCR0 enables components that leaf 0xd does not describe. + assert_invalid( + decode_edited(|value| value["xcr0"] = json!("0x00000000000000ff")), + "CPUID 0xd lists subleaves", + ); + // Leaf 0xd disagrees with XCR0. + assert_invalid( + decode_edited(|value| entry(value, 0xd, Some(0))["value"][0] = json!("0x00000067")), + "differ from CPUID leaf 0xd", + ); + assert_invalid( + decode_edited(|value| value["xsave_components"][0]["offset"] = json!(512)), + "XSAVE components differ", + ); + assert_invalid( + decode_edited(|value| value["physical_address_width"] = json!(52)), + "physical address width", + ); + assert_invalid( + decode_edited(|value| value["msrs"][0]["value"] = json!("0x8000000000000000")), + "under mask", + ); + assert_invalid( + decode_edited(|value| entry(value, 7, Some(0))["value"][3] = json!("0x00000010")), + "not enumerated", + ); + } + + #[test] + fn masks_leave_the_vm_and_runtime_fields_to_openvmm() { + for vendor in CpuVendor::ALL { + assert_eq!( + pinned_mask(vendor, 1, None, [0; 4]), + [!0, 0x0000_ffff, !(1 << 21 | 1 << 27), !0] + ); + assert_eq!( + pinned_mask(vendor, 7, Some(0), [0; 4]), + [!0, !0, !(1 << 4), !0] + ); + assert_eq!(pinned_mask(vendor, 0xd, Some(1), [0; 4]), [!0, 0, !0, !0]); + assert_eq!(pinned_mask(vendor, 0xd, Some(2), [0; 4]), [!0; 4]); + assert_eq!(pinned_mask(vendor, 0xb, Some(0), [0; 4]), [0; 4]); + } + let intel = CpuVendor::Intel; + assert_eq!( + pinned_mask(intel, 4, Some(0), [0x121, 0, 0, 0]), + [0x3fff, !0, !0, !0] + ); + // The null cache type that ends leaf 4 has no VM fields. + assert_eq!(pinned_mask(intel, 4, Some(4), [0; 4]), [!0; 4]); + // Intel profiles pin AMD's topology fields. + assert_eq!(pinned_mask(intel, 0x8000_0008, None, [0; 4]), [!0; 4]); + assert_eq!( + pinned_mask(intel, 0x8000_001d, Some(0), [0x121, 0, 0, 0]), + [!0; 4] + ); + assert_eq!(pinned_mask(intel, 0x8000_001e, None, [0; 4]), [!0; 4]); + + // AMD profiles leave AMD's to the VM, and pin leaf 4, which AMD + // CPUs leave zero. + let amd = CpuVendor::Amd; + assert_eq!(pinned_mask(amd, 4, Some(0), [0x121, 0, 0, 0]), [!0; 4]); + assert_eq!( + pinned_mask(amd, 0x8000_0008, None, [0; 4]), + [!0, !0, !0xf0ff, !0] + ); + assert_eq!( + pinned_mask(amd, 0x8000_001d, Some(3), [0x163, 0, 0, 0]), + [0xfc00_3fff, !0, !0, !0] + ); + assert_eq!(pinned_mask(amd, 0x8000_001d, Some(4), [0; 4]), [!0; 4]); + assert_eq!( + pinned_mask(amd, 0x8000_001e, None, [0; 4]), + [0, 0xffff_0000, 0xffff_f800, !0] + ); + } + + #[test] + fn generations_match_by_vendor_family_model_and_stepping() { + let skylake = profile("intel.skylake-sp.v1").generation(); + let intel = |signature| HostCpuSignature::new(*b"GenuineIntel", signature); + assert!(skylake.contains("GenuineIntel", &intel(0x0005_0654))); + // Cascade Lake shares family 6 model 85. + assert!(!skylake.contains("GenuineIntel", &intel(0x0005_0657))); + assert!(!skylake.contains( + "GenuineIntel", + &HostCpuSignature::new(*b"AuthenticAMD", 0x0005_0654) + )); + } +} diff --git a/vmm_core/cpu_profile/src/signature.rs b/vmm_core/cpu_profile/src/signature.rs new file mode 100644 index 00000000000..8ac1bf0bb13 --- /dev/null +++ b/vmm_core/cpu_profile/src/signature.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity of a host CPU that profile selection and generation checks +//! use. + +use std::fmt; + +/// The vendor and signature of a host CPU: the vendor string of CPUID leaf 0 +/// and `CPUID.1:EAX`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HostCpuSignature { + vendor: [u8; 12], + signature: u32, +} + +impl HostCpuSignature { + /// Returns the signature of a CPU with the 12-byte `vendor` string and + /// `CPUID.1:EAX` `signature`. + pub const fn new(vendor: [u8; 12], signature: u32) -> Self { + Self { vendor, signature } + } + + /// Returns the signature from the outputs of CPUID leaves 0 and 1, as + /// `[eax, ebx, ecx, edx]`. + pub fn from_cpuid(leaf0: [u32; 4], leaf1: [u32; 4]) -> Self { + Self::new(vendor_bytes(leaf0[1], leaf0[3], leaf0[2]), leaf1[0]) + } + + /// Returns the signature of the CPU this process runs on. + // The host CPU is identified with the CPUID instruction of the host, + // which exists only on x86-64 hosts. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + pub fn current() -> Self { + let cpuid = |leaf| { + let result = safe_intrinsics::cpuid(leaf, 0); + [result.eax, result.ebx, result.ecx, result.edx] + }; + Self::from_cpuid(cpuid(0), cpuid(1)) + } + + /// Returns the 12-byte vendor string, such as `GenuineIntel`. + pub fn vendor(&self) -> [u8; 12] { + self.vendor + } + + /// Returns the vendor string, with any non-UTF-8 bytes replaced. + pub fn vendor_str(&self) -> String { + String::from_utf8_lossy(&self.vendor).into_owned() + } + + /// Returns `CPUID.1:EAX`. + pub fn signature(&self) -> u32 { + self.signature + } + + /// Returns the display family. + pub fn family(&self) -> u32 { + decode_signature(&self.vendor, self.signature).0 + } + + /// Returns the display model. + pub fn model(&self) -> u32 { + decode_signature(&self.vendor, self.signature).1 + } + + /// Returns the stepping. + pub fn stepping(&self) -> u32 { + decode_signature(&self.vendor, self.signature).2 + } +} + +impl fmt::Display for HostCpuSignature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (family, model, stepping) = decode_signature(&self.vendor, self.signature); + write!( + f, + "{} family {family} model {model} stepping {stepping} (CPUID.1:EAX {:#010x})", + self.vendor_str(), + self.signature + ) + } +} + +/// Returns the 12-byte vendor string from CPUID leaf 0's EBX, EDX, and ECX. +pub(crate) fn vendor_bytes(ebx: u32, edx: u32, ecx: u32) -> [u8; 12] { + let mut vendor = [0; 12]; + for (chunk, register) in vendor + .as_chunks_mut::<4>() + .0 + .iter_mut() + .zip([ebx, edx, ecx]) + { + *chunk = register.to_le_bytes(); + } + vendor +} + +/// Returns the display family, display model, and stepping of the CPUID.1:EAX +/// `signature` of a CPU from `vendor`. +pub(crate) fn decode_signature(vendor: &[u8], signature: u32) -> (u32, u32, u32) { + let stepping = signature & 0xf; + let base_model = (signature >> 4) & 0xf; + let base_family = (signature >> 8) & 0xf; + let extended_model = (signature >> 16) & 0xf; + let extended_family = (signature >> 20) & 0xff; + let family = if base_family == 0xf { + base_family + extended_family + } else { + base_family + }; + // Intel extends the model for families 6 and 15; AMD only for family 15. + let extends_model = base_family == 0xf || (base_family == 0x6 && vendor == b"GenuineIntel"); + let model = if extends_model { + (extended_model << 4) | base_model + } else { + base_model + }; + (family, model, stepping) +} + +#[cfg(test)] +mod tests { + use super::HostCpuSignature; + use test_with_tracing::test; + + const INTEL_LEAF0: [u32; 4] = [0x1b, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]; + + #[test] + fn decodes_the_vendor_and_signature() { + let host = HostCpuSignature::from_cpuid(INTEL_LEAF0, [0x0006_06a6, 0, 0, 0]); + assert_eq!(&host.vendor(), b"GenuineIntel"); + assert_eq!(host.vendor_str(), "GenuineIntel"); + assert_eq!((host.family(), host.model(), host.stepping()), (6, 106, 6)); + assert_eq!( + host.to_string(), + "GenuineIntel family 6 model 106 stepping 6 (CPUID.1:EAX 0x000606a6)" + ); + } + + #[test] + fn decodes_extended_families_and_amd_models() { + let intel = HostCpuSignature::new(*b"GenuineIntel", 0x000c_06f2); + assert_eq!( + (intel.family(), intel.model(), intel.stepping()), + (6, 207, 2) + ); + let amd = HostCpuSignature::new(*b"AuthenticAMD", 0x00a1_0f11); + assert_eq!((amd.family(), amd.model(), amd.stepping()), (0x19, 0x11, 1)); + // AMD does not extend the model of family 6. + let old_amd = HostCpuSignature::new(*b"AuthenticAMD", 0x0001_0661); + assert_eq!((old_amd.family(), old_amd.model()), (6, 6)); + } + + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + #[test] + fn identifies_this_host() { + let host = HostCpuSignature::current(); + assert!(host.family() != 0, "{host}"); + } +} diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs new file mode 100644 index 00000000000..fe8e186d011 --- /dev/null +++ b/vmm_core/cpu_profile/src/surface.rs @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! What a backend supports, and the check that it supports a profile. + +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::hv_banks; +use crate::profile::CpuProfile; +use crate::profile::describe; +use crate::unlisted::unlisted_cpuid_error; +use crate::unlisted::unlisted_cpuid_violations; + +/// The register names, for messages. +const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + +/// The CPU surface a backend supports on this host, which it reports through +/// `supported_cpu_surface()` and which the CPU fingerprint records. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostCpuSurface { + /// Every CPUID leaf and subleaf the backend can expose, with every + /// feature it can expose set, normalized with [`cpuid::normalize`]. KVM + /// reports `KVM_GET_SUPPORTED_CPUID`. A CPU fingerprint of MSHV or WHP + /// records a probe partition with every available processor and XSAVE + /// feature enabled, and their `supported_cpu_surface()` approximates it + /// from the host's CPUID and processor features. + pub cpuid: Vec, + /// How the backend presents the CPUID entries outside a profile's + /// tables, and what `cpuid` holds there. + pub presentation: CpuidPresentation, + /// The widest guest physical address the backend supports, in bits. + /// + /// Support checks use this rather than the CPUID's `0x80000008:EAX[7:0]`: + /// a surface built from the host's own CPUID carries the host's width, + /// and a hypervisor may give guests another one. WHP, for one, reports + /// its partition property, 46 bits on a host whose CPUID reports 42. + pub physical_address_width: u8, + /// The MSR values the backend can present to a guest. + pub msrs: Vec, +} + +/// How a backend presents the CPUID entries that a VM's effective CPUID does +/// not list, and what a [`HostCpuSurface`] holds there, which decides whether +/// [`verify_support`] checks them (`E_CPU_UNLISTED`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CpuidPresentation { + /// A table backend, KVM: `KVM_SET_CPUID2` installs the effective CPUID as + /// the guest's whole CPUID table, and KVM answers every entry it does not + /// list from the table itself: zero, or the architecture's out-of-range + /// and topology results, never host data. The surface lists what the + /// backend can support, not what a guest reads, so there is nothing to + /// check. + Table, + /// A pass-through backend, MSHV or WHP, which programs the effective + /// CPUID over the hypervisor's own guest view: every entry the effective + /// CPUID does not list reads that view unless the backend registers a + /// result there. The surface is what the hypervisor presents to a guest, + /// as a probe partition enumerates it for `--cpu-fingerprint`, so every + /// non-zero entry outside the profile's tables fails the check. + PassThroughGuestView, + /// A pass-through backend whose surface comes from the host's own CPUID, + /// as the cheap `supported_cpu_surface()` of MSHV and WHP builds it. + /// Outside the profile's tables, the host's view is not the guest's: the + /// root partition sees entries that guests never do, such as Intel PT's + /// subleaf `0x14.1` on Skylake-SP. [`verify_support`] skips the check, + /// which [`check_unlisted_cpuid`](crate::check_unlisted_cpuid) makes on VP + /// 0's view at the host's + /// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates) + /// instead. + PassThroughHostView, +} + +/// The values of one MSR that a backend can present to a guest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SupportedMsr { + /// The MSR index. + pub index: u32, + /// The bits the backend can present set. + pub supported: u64, + /// The bits the backend can present clear, even where `supported` has + /// them. KVM controls every bit; MSHV and WHP control the bits that their + /// processor feature banks derive. + pub controllable: u64, +} + +impl HostCpuSurface { + /// Returns the surface that a CPU fingerprint recorded. + /// + /// `IA32_ARCH_CAPABILITIES` comes from the KVM feature MSR, or, for the + /// Hyper-V backends, from the processor feature banks. A KVM fingerprint + /// is a [`CpuidPresentation::Table`] surface; any other records a probe + /// partition, a [`CpuidPresentation::PassThroughGuestView`]. + pub fn from_fingerprint(backend: &BackendFingerprint) -> Self { + let width = cpuid::lookup(&backend.cpuid, EXTENDED_LEAF_BASE + 8, 0).unwrap_or_default()[0]; + let mut msrs = Vec::new(); + if let Some(value) = backend.msrs.arch_capabilities { + msrs.push(SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: value.0, + controllable: !0, + }); + } else if let Some(banks) = hv_banks::fingerprint_banks(backend) { + msrs.push(hv_banks::arch_capabilities_msr(banks)); + } + Self { + cpuid: backend.cpuid.clone(), + presentation: match backend.name.as_str() { + "kvm" => CpuidPresentation::Table, + _ => CpuidPresentation::PassThroughGuestView, + }, + physical_address_width: (width & 0xff) as u8, + msrs, + } + } + + /// Returns the supported value of `IA32_ARCH_CAPABILITIES` or another + /// MSR. + pub fn msr(&self, index: u32) -> Option<&SupportedMsr> { + self.msrs.iter().find(|msr| msr.index == index) + } +} + +/// The CPUID time bits that the time ABI sets by policy on every backend, +/// whatever the backend reports: the hypervisor bit, ARAT (a virtual LAPIC +/// timer never stops), and the invariant TSC (backed by host qualification +/// instead). Support checks skip them. +pub const TIME_POLICY_BITS: [(u32, u32, usize, u32); 3] = [ + (0x1, 0, 2, 1 << 31), + (0x6, 0, 0, 1 << 2), + (EXTENDED_LEAF_BASE + 7, 0, 3, 1 << 8), +]; + +/// How a support check compares one CPUID register, and how derivation +/// combines it across hosts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RegisterClass { + /// Feature flags: every pinned set bit must be supported. + Features, + /// Numeric limits in the given bit fields (shift, width): the profile's + /// may not exceed the backend's. + Limits(&'static [(u32, u32)]), + /// Values that do not constrain the host, or that other checks cover: + /// the vendor and signature (generation check), AMD's copy of the + /// signature in `0x80000001` EAX, which Intel CPUs leave zero, + /// descriptive cache, TLB, and brand information (AMD's cache topology + /// leaf `0x8000001D` included), and the XSAVE leaf (layout check). + Informational, +} + +pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> RegisterClass { + const ALL: &[(u32, u32)] = &[(0, 32)]; + const ADDRESS_WIDTHS: &[(u32, u32)] = &[(0, 8), (8, 8)]; + match (leaf, subleaf, register) { + (0x0, _, 0) | (0x8000_0000, _, 0) | (0x7, 0, 0) => RegisterClass::Limits(ALL), + (0x8000_0008, _, 0) => RegisterClass::Limits(ADDRESS_WIDTHS), + (0x0 | 0x2 | 0x3 | 0x4 | 0xd | 0x18 | 0x8000_001d, _, _) + | (0x1 | 0x8000_0001, _, 0) + | (0x1, _, 1) + | (0x8000_0002..=0x8000_0006, _, _) + | (0x8000_0008, _, 2 | 3) => RegisterClass::Informational, + _ => RegisterClass::Features, + } +} + +/// Checks that the backend described by `surface` supports `profile`: every +/// feature bit the profile sets except the [`TIME_POLICY_BITS`], every +/// numeric limit, the exact XSAVE layout of every enabled component, the +/// guest physical address width, and every pinned MSR value. For a +/// [`CpuidPresentation::PassThroughGuestView`] surface, it also checks that +/// every entry outside the profile's tables reads zero, as +/// [`check_unlisted_cpuid`](crate::check_unlisted_cpuid) does. +/// +/// Fails with `E_PROFILE_UNSUPPORTED`, naming every violation at once: each +/// missing leaf, subleaf, register, and bit, as [`support_violations`] lists +/// them, then each non-zero entry outside the profile's tables. A surface +/// whose only violations are such entries fails with +/// `E_CPU_UNLISTED`. +pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result<(), ProfileError> { + let unlisted = match surface.presentation { + CpuidPresentation::PassThroughGuestView => { + unlisted_cpuid_violations(profile, &surface.cpuid) + } + CpuidPresentation::Table | CpuidPresentation::PassThroughHostView => Vec::new(), + }; + support_result(profile, support_violations(profile, surface), &unlisted) +} + +/// Checks, as [`verify_support`] does, that the backend described by +/// `surface` supports `profile`, but checks the entries outside the +/// profile's tables in `presented` instead of `surface`: what VP 0 of a +/// partition configured from `profile`, as a cold boot configures it, reads +/// at the host's +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates). +/// +/// `--cpu-fingerprint` uses it for a pass-through backend whose fingerprint +/// records a probe partition with every available feature: such a partition +/// also presents the XSAVE components of features that no profile enables, +/// such as CET's user and supervisor states, which a partition configured +/// from the profile does not. +pub fn verify_support_with_unlisted( + profile: &CpuProfile, + surface: &HostCpuSurface, + presented: &[CpuidEntry], +) -> Result<(), ProfileError> { + support_result( + profile, + support_violations(profile, surface), + &unlisted_cpuid_violations(profile, presented), + ) +} + +/// Returns the outcome of a support check with `violations` and the +/// `unlisted` entries: `E_PROFILE_UNSUPPORTED` naming both, or +/// `E_CPU_UNLISTED` when only entries outside the profile fail. +fn support_result( + profile: &CpuProfile, + violations: Vec, + unlisted: &[String], +) -> Result<(), ProfileError> { + if violations.is_empty() { + if unlisted.is_empty() { + return Ok(()); + } + return Err(unlisted_cpuid_error(profile, unlisted)); + } + Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "the backend does not support CPU profile {}: {}", + profile.id(), + violations + .iter() + .chain(unlisted) + .map(String::as_str) + .collect::>() + .join("; ") + ), + )) +} + +/// Returns every way in which `surface` falls short of `profile`, in the +/// order [`verify_support`] reports them. +pub fn support_violations(profile: &CpuProfile, surface: &HostCpuSurface) -> Vec { + let mut violations = Vec::new(); + for entry in profile.cpuid() { + let leaf = entry.leaf.0; + let subleaf = entry.subleaf.map_or(0, |subleaf| subleaf.0); + let host = cpuid::lookup(&surface.cpuid, leaf, subleaf).unwrap_or_default(); + for (register, (value, mask)) in entry.values().iter().zip(entry.masks()).enumerate() { + let pinned = value & mask; + match register_class(leaf, subleaf, register) { + RegisterClass::Informational => {} + RegisterClass::Features => { + let exempt = TIME_POLICY_BITS + .iter() + .filter(|&&(l, s, r, _)| (l, s, r) == (leaf, subleaf, register)) + .fold(0, |bits, &(.., bit)| bits | bit); + let missing = pinned & !exempt & !host[register]; + if missing != 0 { + violations.push(format!( + "{} {} {} not supported", + describe(entry), + REGISTERS[register], + bits_phrase(missing.into()) + )); + } + } + RegisterClass::Limits(fields) => { + for &(shift, width) in fields { + // The guest physical address width is checked against + // `surface.physical_address_width` below. The surface's + // CPUID may hold the host's width instead. + if (leaf, register, shift) == (EXTENDED_LEAF_BASE + 8, 0, 0) { + continue; + } + let field = |value: u32| field_value(value, shift, width); + if field(pinned) > field(host[register]) { + violations.push(format!( + "{} {}[{}:{shift}] is {:#x}, above the supported {:#x}", + describe(entry), + REGISTERS[register], + shift + width - 1, + field(pinned), + field(host[register]) + )); + } + } + } + } + } + } + + let (host_xcr0, host_xss) = cpuid::xsave_supported(&surface.cpuid); + let missing_xcr0 = profile.xcr0() & !host_xcr0; + if missing_xcr0 != 0 { + violations.push(format!("XCR0 bits {missing_xcr0:#x} are not supported")); + } + let missing_xss = profile.xss() & !host_xss; + if missing_xss != 0 { + violations.push(format!("IA32_XSS bits {missing_xss:#x} are not supported")); + } + let host_components = cpuid::xsave_components(&surface.cpuid); + for component in profile.xsave_components() { + match host_components + .iter() + .find(|host| host.index == component.index) + { + Some(host) if host == component => {} + Some(host) => violations.push(format!( + "XSAVE component {} is {} bytes at offset {} (flags supervisor={} align64={} xfd={}), but the backend's is {} bytes at offset {} (flags supervisor={} align64={} xfd={})", + component.index, + component.size, + component.offset, + component.supervisor, + component.align64, + component.xfd, + host.size, + host.offset, + host.supervisor, + host.align64, + host.xfd + )), + None => violations.push(format!( + "XSAVE component {} has no layout", + component.index + )), + } + } + + if profile.physical_address_width() > surface.physical_address_width { + violations.push(format!( + "the guest physical address width {} exceeds the supported {}", + profile.physical_address_width(), + surface.physical_address_width + )); + } + + for msr in profile.msrs() { + let index = msr.index.0; + let host = surface.msr(index).copied().unwrap_or(SupportedMsr { + index, + supported: 0, + controllable: 0, + }); + let unsupported = msr.value.0 & !host.supported; + if unsupported != 0 { + violations.push(format!( + "MSR {index:#x} {} not supported", + bits_phrase(unsupported) + )); + } + let uncontrollable = !msr.value.0 & msr.mask.0 & host.supported & !host.controllable; + if uncontrollable != 0 { + violations.push(format!( + "MSR {index:#x} {} pinned clear but cannot be cleared", + bits_phrase(uncontrollable) + )); + } + } + violations +} + +/// Returns the `width`-bit field at `shift` of `value`. +pub(crate) fn field_value(value: u32, shift: u32, width: u32) -> u32 { + (value >> shift) & u32::MAX.checked_shr(32 - width).unwrap_or(0) +} + +/// Returns `bit 16 is` or `bits 3, 7 are`. +fn bits_phrase(bits: u64) -> String { + let list = (0..64) + .filter(|bit| bits & (1 << bit) != 0) + .map(|bit| bit.to_string()) + .collect::>(); + if list.len() == 1 { + format!("bit {} is", list[0]) + } else { + format!("bits {} are", list.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Returns the surface of a KVM host that supports exactly `profile`. + fn surface(profile: &CpuProfile) -> HostCpuSurface { + HostCpuSurface::from_fingerprint(&fingerprint(profile, "kvm").backend) + } + + /// Changes the register of `leaf` and `subleaf` in `surface`. + fn edit( + surface: &mut HostCpuSurface, + leaf: u32, + subleaf: u32, + register: usize, + f: impl FnOnce(u32) -> u32, + ) { + let entry = surface + .cpuid + .iter_mut() + .find(|entry| entry.leaf.0 == leaf && entry.subleaf.is_none_or(|s| s.0 == subleaf)) + .unwrap(); + let mut registers = entry.registers(); + registers[register] = f(registers[register]); + *entry = CpuidEntry::new(leaf, entry.subleaf.map(|s| s.0), registers); + } + + fn unsupported(profile: &CpuProfile, surface: &HostCpuSurface) -> String { + let error = verify_support(profile, surface).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported, "{error}"); + error.message + } + + #[test] + fn every_pinned_profile_supports_itself() { + for profile in crate::pinned_profiles() { + verify_support(profile, &surface(profile)).unwrap(); + for backend in ["mshv", "whp"] { + let surface = + HostCpuSurface::from_fingerprint(&fingerprint(profile, backend).backend); + assert_eq!( + surface.presentation, + CpuidPresentation::PassThroughGuestView + ); + verify_support(profile, &surface).unwrap(); + } + } + } + + #[test] + fn checks_unlisted_entries_of_a_pass_through_guest_view_only() { + let profile = profile(ICELAKE); + // Intel PT's subleaf 1, which the profile does not list, as the root + // partition of a Skylake-SP host reads it. + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new( + 0x14, + Some(1), + [0x0249_0002, 0x003f_3fff, 0, 0], + )); + entries.sort_by_key(CpuidEntry::key); + const PT: &str = "CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, \ + EBX 0x3f3fff, ECX 0x0, EDX 0x0"; + + let whp = HostCpuSurface::from_fingerprint( + &fingerprint_with(profile, "whp", entries.clone()).backend, + ); + let error = verify_support(profile, &whp).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert_eq!( + error.message, + format!( + "the backend presents CPUID entries outside CPU profile intel.icelake-sp.v1: {PT}" + ) + ); + assert!(support_violations(profile, &whp).is_empty()); + + // KVM installs the effective CPUID as the guest's whole table. + let kvm = HostCpuSurface::from_fingerprint( + &fingerprint_with(profile, "kvm", entries.clone()).backend, + ); + assert_eq!(kvm.presentation, CpuidPresentation::Table); + verify_support(profile, &kvm).unwrap(); + + // The host's own view does not show what a guest reads there. + let host_view = HostCpuSurface { + presentation: CpuidPresentation::PassThroughHostView, + ..whp.clone() + }; + verify_support(profile, &host_view).unwrap(); + + // An unlisted entry that reads zero is what the profile requires. + let mut zero = whp.clone(); + edit(&mut zero, 0x14, 1, 0, |_| 0); + edit(&mut zero, 0x14, 1, 1, |_| 0); + verify_support(profile, &zero).unwrap(); + + // Every violation is named at once, the missing features first. + let mut both = whp; + edit(&mut both, 7, 0, 1, |ebx| ebx & !(1 << 16)); + assert_eq!( + unsupported(profile, &both), + format!( + "the backend does not support CPU profile intel.icelake-sp.v1: \ + CPUID 0x7.0 EBX bit 16 is not supported; {PT}" + ) + ); + } + + #[test] + fn the_time_policy_bits_are_exempt() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + for (leaf, subleaf, register, bit) in TIME_POLICY_BITS { + edit(&mut surface, leaf, subleaf, register, |value| value & !bit); + } + verify_support(profile, &surface).unwrap(); + } + + #[test] + fn names_missing_features_and_limits() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + // AVX512F and PSFD. + edit(&mut surface, 7, 0, 1, |ebx| ebx & !(1 << 16)); + edit(&mut surface, 7, 2, 3, |edx| edx & !1); + let message = unsupported(profile, &surface); + assert_eq!( + message, + "the backend does not support CPU profile intel.icelake-sp.v1: \ + CPUID 0x7.0 EBX bit 16 is not supported; CPUID 0x7.2 EDX bit 0 is not supported" + ); + + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| (eax & !0xff) | 39); + surface.physical_address_width = 39; + let violations = support_violations(profile, &surface); + assert_eq!( + violations, + ["the guest physical address width 46 exceeds the supported 39"] + ); + + // The surface's own field decides the physical address width, not its + // CPUID: a cheap surface from the host's CPUID may report the host's + // narrower width. + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| (eax & !0xff) | 42); + verify_support(profile, &surface).unwrap(); + + // The linear address width is still a CPUID limit. + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| { + (eax & !0xff00) | 48 << 8 + }); + assert_eq!( + support_violations(profile, &surface), + ["CPUID 0x80000008 EAX[15:8] is 0x39, above the supported 0x30"] + ); + + let mut surface = self::surface(profile); + edit(&mut surface, 7, 0, 0, |_| 0); + assert!( + unsupported(profile, &surface) + .contains("CPUID 0x7.0 EAX[31:0] is 0x2, above the supported 0x0") + ); + } + + #[test] + fn requires_the_exact_xsave_layout() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + edit(&mut surface, 0xd, 7, 1, |offset| offset + 64); + assert!( + unsupported(profile, &surface) + .contains("XSAVE component 7 is 1024 bytes at offset 1664") + ); + let mut surface = self::surface(profile); + edit(&mut surface, 0xd, 0, 0, |eax| eax & !(1 << 7)); + let violations = support_violations(profile, &surface); + assert!( + violations.contains(&"XCR0 bits 0x80 are not supported".to_owned()), + "{violations:?}" + ); + } + + #[test] + fn checks_that_pinned_msr_values_can_be_presented() { + let profile = profile(ICELAKE); + let (value, mask) = profile.msr(IA32_ARCH_CAPABILITIES).unwrap(); + assert_eq!((value, mask), (0x0800_0121, 0x4000_0000_0c12_e1bb)); + + // KVM controls every bit, so it can present the value while + // supporting more. + let mut surface = surface(profile); + surface.msrs[0].supported |= 1 << 62 | 1 << 26; + verify_support(profile, &surface).unwrap(); + + // A Hyper-V backend cannot clear what its banks do not derive. + surface.msrs[0].controllable = hv_banks::ARCH_CAPABILITIES_BANK_MASK; + assert!( + unsupported(profile, &surface) + .contains("MSR 0x10a bit 62 is pinned clear but cannot be cleared") + ); + + // A missing immunity bit. + let mut surface = self::surface(profile); + surface.msrs[0].supported = value & !(1 << 27); + assert!(unsupported(profile, &surface).contains("MSR 0x10a bit 27 is not supported")); + surface.msrs.clear(); + assert!( + unsupported(profile, &surface).contains("MSR 0x10a bits 0, 5, 8, 27 are not supported") + ); + } + + #[test] + fn reads_the_hyper_v_arch_capabilities_from_the_banks() { + let profile = profile(ICELAKE); + let mut fingerprint = fingerprint(profile, "whp"); + fingerprint.backend.msrs.arch_capabilities = None; + fingerprint.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank0", + 0x2e0a_8bff_e7f7_859f, + ); + fingerprint.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank1", + 0x0001_000e_0000_00f1, + ); + let surface = HostCpuSurface::from_fingerprint(&fingerprint.backend); + assert_eq!( + surface.msr(IA32_ARCH_CAPABILITIES).copied(), + Some(SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: 0x0800_0121, + controllable: hv_banks::ARCH_CAPABILITIES_BANK_MASK, + }) + ); + verify_support(profile, &surface).unwrap(); + } +} diff --git a/vmm_core/cpu_profile/src/test_support.rs b/vmm_core/cpu_profile/src/test_support.rs new file mode 100644 index 00000000000..217593e5332 --- /dev/null +++ b/vmm_core/cpu_profile/src/test_support.rs @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixtures shared by the unit tests. + +use crate::CpuProfile; +use crate::Hex32; +use crate::Hex64; +use crate::cpuid::CpuidEntry; +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::CpuFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::fingerprint::ToolIdentity; +use crate::host::HostCpu; +use crate::host::HostIdentity; +use crate::host::HostOs; +use crate::signature::decode_signature; + +/// Returns the pinned profile `id`. +pub(crate) fn profile(id: &str) -> &'static CpuProfile { + crate::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) +} + +/// Returns a profile's CPUID values as fingerprint entries. +pub(crate) fn profile_entries(profile: &CpuProfile) -> Vec { + profile + .cpuid() + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect() +} + +/// Returns the fingerprint of a host whose `backend` supports exactly +/// `profile`: its CPUID values and, as a KVM host would report it, its pinned +/// `IA32_ARCH_CAPABILITIES` value. +pub(crate) fn fingerprint(profile: &CpuProfile, backend: &str) -> CpuFingerprint { + fingerprint_with(profile, backend, profile_entries(profile)) +} + +/// Returns the fingerprint of a host whose `backend` reports `cpuid`, of +/// `profile`'s CPU. +pub(crate) fn fingerprint_with( + profile: &CpuProfile, + backend: &str, + cpuid: Vec, +) -> CpuFingerprint { + let host = host_identity(profile.vendor().as_bytes(), &|leaf| profile.lookup(leaf, 0)); + let mut backend = BackendFingerprint::new(backend, "test", cpuid); + backend.msrs.arch_capabilities = profile + .msr(IA32_ARCH_CAPABILITIES) + .map(|(value, _)| Hex64(value)); + CpuFingerprint::new(tool(), host, backend) +} + +/// Returns the fingerprint of a host whose `backend` reports `cpuid`, of the +/// CPU whose vendor, signature, and brand `cpuid` reports. +pub(crate) fn host_fingerprint(backend: &str, cpuid: Vec) -> CpuFingerprint { + let leaf = |leaf| crate::cpuid::lookup(&cpuid, leaf, 0).unwrap_or_default(); + let [_, ebx, ecx, edx] = leaf(0); + let host = host_identity(&crate::signature::vendor_bytes(ebx, edx, ecx), &leaf); + CpuFingerprint::new( + tool(), + host, + BackendFingerprint::new(backend, "test", cpuid), + ) +} + +fn tool() -> ToolIdentity { + ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + } +} + +/// Returns the identity of a host of `vendor`'s CPU whose signature and +/// brand string `cpuid(leaf)` reports. +fn host_identity(vendor: &[u8], cpuid: &dyn Fn(u32) -> [u32; 4]) -> HostIdentity { + let signature = cpuid(1)[0]; + let (family, model, stepping) = decode_signature(vendor, signature); + let brand = (0x8000_0002..=0x8000_0004) + .flat_map(cpuid) + .flat_map(u32::to_le_bytes) + .collect::>(); + HostIdentity { + cpu: HostCpu { + vendor: String::from_utf8_lossy(vendor).into_owned(), + signature: Hex32(signature), + family, + model, + stepping, + brand: String::from_utf8_lossy(&brand) + .trim_end_matches('\0') + .to_owned(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: HostOs { + kind: "linux".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + } +} + +/// The CPUID of an AMD EPYC 7763 (Milan: family 0x19, model 1, stepping 1) +/// as a WHP probe partition presents it on an Azure host, which +/// `--cpu-fingerprint` recorded: each leaf, subleaf, and the registers. WHP +/// offers that host neither the speculation controls of `0x80000008` EBX nor +/// anything in `0x80000021`, and reports no cache sharing in `0x8000001D`. +pub(crate) const MILAN_WHP_CPUID: [(u32, Option, [u32; 4]); 57] = [ + (0x0, None, [0xd, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + (0x1, None, [0x00a0_0f11, 0x800, 0x76fa_3203, 0x078b_fbff]), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0, 0, 1, 0]), + (0x7, Some(0), [0, 0x219c_07a9, 0x0040_0684, 0x10]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0x7, 0x340, 0x340, 0]), + (0xd, Some(1), [0xf, 0x368, 0x1800, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xd, Some(0xb), [0x10, 0, 1, 0]), + (0xd, Some(0xc), [0x18, 0, 1, 0]), + (0x4000_0000, None, [0; 4]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00a0_0f11, 0x4000_0000, 0x0040_03f3, 0x2fd3_fbff], + ), + ( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x3637_3720, 0x3436_2033], + ), + ( + 0x8000_0003, + None, + [0x726f_432d, 0x7250_2065, 0x7365_636f, 0x2072_6f73], + ), + ( + 0x8000_0004, + None, + [0x2020_2020, 0x2020_2020, 0x2020_2020, 0x0020_2020], + ), + ( + 0x8000_0005, + None, + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x4800_2200, 0x6800_4200, 0x0200_6140, 0x0800_9140], + ), + (0x8000_0007, None, [0; 4]), + (0x8000_0008, None, [0x3030, 0x3000_0015, 0, 0x0001_0000]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0; 4]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x121, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x143, 0x01c0_003f, 0x3ff, 0x2]), + (0x8000_001d, Some(3), [0x163, 0x03c0_003f, 0x7fff, 0x1]), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0; 4]), +]; + +/// Returns [`MILAN_WHP_CPUID`] as fingerprint entries. +pub(crate) fn milan_whp_entries() -> Vec { + entries(&MILAN_WHP_CPUID) +} + +/// The CPUID of an AMD EPYC 9V74 (Genoa: family 0x19, model 0x11, +/// stepping 1) as KVM reports it (`KVM_GET_SUPPORTED_CPUID`) in an Azure VM +/// with nested virtualization, a GitHub-hosted Actions runner, which +/// `--cpu-fingerprint` recorded: each leaf, subleaf, and the registers. +/// That KVM offers only the x87, SSE, and AVX XSAVE states, PSFD without a +/// `SPEC_CTRL` control, and, as KVM does on every AMD host, Intel's +/// `IA32_ARCH_CAPABILITIES` enumeration. +pub(crate) const GENOA_KVM_CPUID: [(u32, Option, [u32; 4]); 71] = [ + (0x0, None, [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + ( + 0x1, + None, + [0x00a1_0f11, 0x0010_0800, 0xf7fa_3203, 0x078b_fbff], + ), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0x4, 0, 0, 0]), + (0x7, Some(0), [0, 0x219c_07ab, 0x0040_0604, 0x2000_0010]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0x7, 0x340, 0x340, 0]), + (0xd, Some(1), [0xf, 0x340, 0, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xe, None, [0; 4]), + (0xf, Some(0), [0; 4]), + (0x10, Some(0), [0; 4]), + (0x11, None, [0; 4]), + (0x12, Some(0), [0; 4]), + (0x13, None, [0; 4]), + (0x14, Some(0), [0; 4]), + (0x15, None, [0; 4]), + (0x16, None, [0; 4]), + (0x17, Some(0), [0; 4]), + (0x18, Some(0), [0; 4]), + (0x19, None, [0; 4]), + (0x1a, None, [0; 4]), + (0x1b, None, [0; 4]), + (0x1c, None, [0; 4]), + ( + 0x4000_0000, + None, + [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + ), + (0x4000_0001, None, [0x0100_7efb, 0, 0, 0]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00a1_0f11, 0x4000_0000, 0x0040_03f7, 0x2fd3_fbff], + ), + (0x8000_0002, None, [0; 4]), + (0x8000_0003, None, [0; 4]), + (0x8000_0004, None, [0; 4]), + ( + 0x8000_0005, + None, + [0xff48_ff40, 0xff48_ff40, 0x2008_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x5c00_2200, 0x6c00_4200, 0x0400_6140, 0x0a00_9140], + ), + (0x8000_0007, None, [0, 0, 0, 0x100]), + (0x8000_0008, None, [0x0030_3030, 0x1000_0005, 0x400f, 0]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0x1, 0x8, 0, 0x1000_9479]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x4121, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x4122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x4143, 0x01c0_003f, 0x7ff, 0x2]), + ( + 0x8000_001d, + Some(3), + [0x0003_c163, 0x03c0_003f, 0x7fff, 0x1], + ), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0x204, 0, 0x6, 0]), +]; + +/// Returns [`GENOA_KVM_CPUID`] as fingerprint entries. +pub(crate) fn genoa_kvm_entries() -> Vec { + entries(&GENOA_KVM_CPUID) +} + +/// The CPUID of an AMD EPYC 9V45 (Turin: family 0x1a, model 2, stepping 1) +/// as KVM reports it in an Azure VM with nested virtualization, a +/// GitHub-hosted Actions runner, which `--cpu-fingerprint` recorded. That +/// KVM offers the AVX-512 XSAVE states too, and, as on the Genoa runner, +/// PSFD without a `SPEC_CTRL` control and Intel's +/// `IA32_ARCH_CAPABILITIES` enumeration. +pub(crate) const TURIN_KVM_CPUID: [(u32, Option, [u32; 4]); 75] = [ + (0x0, None, [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + ( + 0x1, + None, + [0x00b0_0f21, 0x0010_0800, 0xf7fa_3203, 0x078b_fbff], + ), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0x4, 0, 0, 0]), + (0x7, Some(0), [0x1, 0xf1bf_07ab, 0x0040_5f46, 0x2000_0110]), + (0x7, Some(1), [0x30, 0, 0, 0]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0xe7, 0x980, 0x980, 0]), + (0xd, Some(1), [0xf, 0x980, 0, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xd, Some(5), [0x40, 0x340, 0, 0]), + (0xd, Some(6), [0x200, 0x380, 0, 0]), + (0xd, Some(7), [0x400, 0x580, 0, 0]), + (0xe, None, [0; 4]), + (0xf, Some(0), [0; 4]), + (0x10, Some(0), [0; 4]), + (0x11, None, [0; 4]), + (0x12, Some(0), [0; 4]), + (0x13, None, [0; 4]), + (0x14, Some(0), [0; 4]), + (0x15, None, [0; 4]), + (0x16, None, [0; 4]), + (0x17, Some(0), [0; 4]), + (0x18, Some(0), [0; 4]), + (0x19, None, [0; 4]), + (0x1a, None, [0; 4]), + (0x1b, None, [0; 4]), + (0x1c, None, [0; 4]), + ( + 0x4000_0000, + None, + [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + ), + (0x4000_0001, None, [0x0100_7efb, 0, 0, 0]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00b0_0f21, 0x4000_0000, 0x0040_03f7, 0x2fd3_fbff], + ), + (0x8000_0002, None, [0; 4]), + (0x8000_0003, None, [0; 4]), + (0x8000_0004, None, [0; 4]), + ( + 0x8000_0005, + None, + [0xff60_ff40, 0xff60_ff40, 0x300c_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x4080_2040, 0x6080_4040, 0x0400_8140, 0x0c00_9140], + ), + (0x8000_0007, None, [0, 0, 0, 0x100]), + (0x8000_0008, None, [0x0030_3030, 0x5002_0005, 0x400f, 0]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0x1, 0x8, 0, 0x1000_9479]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x4121, 0x02c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x4122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x4143, 0x03c0_003f, 0x3ff, 0x2]), + ( + 0x8000_001d, + Some(3), + [0x0003_c163, 0x03c0_003f, 0x7fff, 0x1], + ), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0x204, 0, 0x6, 0]), +]; + +/// Returns [`TURIN_KVM_CPUID`] as fingerprint entries. +pub(crate) fn turin_kvm_entries() -> Vec { + entries(&TURIN_KVM_CPUID) +} + +/// Returns a recorded CPUID `table` as fingerprint entries. +fn entries(table: &[(u32, Option, [u32; 4])]) -> Vec { + table + .iter() + .map(|&(leaf, subleaf, registers)| CpuidEntry::new(leaf, subleaf, registers)) + .collect() +} diff --git a/vmm_core/cpu_profile/src/unlisted.rs b/vmm_core/cpu_profile/src/unlisted.rs new file mode 100644 index 00000000000..36cef79b532 --- /dev/null +++ b/vmm_core/cpu_profile/src/unlisted.rs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID entries outside a profile's tables. +//! +//! No CPUID entry outside a VM's effective CPUID carries host data. KVM +//! installs the effective CPUID as the guest's whole CPUID table and answers +//! every other entry from it: zero, or a value the architecture's rules derive +//! from the table (Intel's out-of-range result, the highest basic leaf's, +//! above the maximum basic leaf, and an invalid level with the x2APIC ID past +//! the last topology level). MSHV and WHP program the effective CPUID over the +//! hypervisor's own guest view instead, so an entry it does not list reads +//! that view unless the backend registers a result there. The time ABI +//! reserves the entries past a profile's advertised maxima, and verification +//! requires that a pass-through backend presents zero at every entry outside +//! the profile's tables (`E_CPU_UNLISTED`), so no host data reaches the guest +//! there. + +use crate::cpuid::CpuidEntry; +use crate::cpuid::HYPERVISOR_LEAF_BASE; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::profile::CpuProfile; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use std::ops::RangeInclusive; + +/// The leaves reserved for hypervisors, where the time ABI's identity leaves +/// are. +const IDENTITY_RANGE: RangeInclusive = HYPERVISOR_LEAF_BASE..=0x4fff_ffff; + +/// Returns whether the entry for `leaf` and `subleaf` is outside `profile`'s +/// tables and outside the leaves that the time ABI owns: the identity range +/// `0x40000000` to `0x4fffffff` and the VM-owned topology leaves. A VM's +/// effective CPUID adds those, and other checks cover them. +/// +/// A subleaf-independent entry, in the profile or presented, covers every +/// subleaf of its leaf. +fn is_unlisted(profile: &CpuProfile, leaf: u32, subleaf: Option) -> bool { + let owned = IDENTITY_RANGE.contains(&leaf) || VM_OWNED_LEAVES.contains(&leaf); + // The table is sorted by leaf, and core checks every boot's report. + let table = profile.cpuid(); + let start = table.partition_point(|entry| entry.leaf.0 < leaf); + !owned + && !table[start..] + .iter() + .take_while(|entry| entry.leaf.0 == leaf) + .any(|entry| match (entry.subleaf, subleaf) { + (Some(listed), Some(subleaf)) => listed.0 == subleaf, + (None, _) | (_, None) => true, + }) +} + +/// Returns every non-zero entry of `presented`, in order, that is outside +/// `profile`'s tables, other than the identity range and the VM-owned +/// topology leaves, as [`check_unlisted_cpuid`] reports it. +pub fn unlisted_cpuid_violations(profile: &CpuProfile, presented: &[CpuidEntry]) -> Vec { + presented + .iter() + .filter(|entry| { + let (leaf, subleaf) = entry.key(); + entry.registers() != [0; 4] && is_unlisted(profile, leaf, subleaf) + }) + .map(|entry| { + let (leaf, subleaf) = entry.key(); + let [eax, ebx, ecx, edx] = entry.registers(); + format!( + "{} is outside the profile and reads EAX {eax:#x}, EBX {ebx:#x}, ECX {ecx:#x}, EDX {edx:#x}", + describe_leaf(leaf, subleaf) + ) + }) + .collect() +} + +/// Checks that a guest of a pass-through backend reads zero at every CPUID +/// entry of `presented` that is outside `profile`'s tables, other than the +/// identity range and the VM-owned topology leaves. +/// +/// `presented` is what a guest reads: a probe partition's architectural +/// enumeration, as `--cpu-fingerprint` records it, or VP 0's view at the +/// host's [`unlisted_cpuid_candidates`]. A table backend, KVM, passes by +/// construction. +/// +/// Fails with `E_CPU_UNLISTED`, naming every such entry and its +/// values at once. +pub fn check_unlisted_cpuid( + profile: &CpuProfile, + presented: &[CpuidEntry], +) -> Result<(), ProfileError> { + let violations = unlisted_cpuid_violations(profile, presented); + if violations.is_empty() { + return Ok(()); + } + Err(unlisted_cpuid_error(profile, &violations)) +} + +/// Returns the `E_CPU_UNLISTED` failure for `violations`. +pub(crate) fn unlisted_cpuid_error(profile: &CpuProfile, violations: &[String]) -> ProfileError { + ProfileError::new( + ProfileErrorCode::CpuUnlisted, + format!( + "the backend presents CPUID entries outside CPU profile {}: {}", + profile.id(), + violations.join("; ") + ), + ) +} + +/// Returns the keys of the entries of `host`, in order, that are outside +/// `profile`'s tables, other than the identity range and the VM-owned +/// topology leaves. +/// +/// `host` is the host's own CPUID, as [`cpuid::enumerate`](crate::cpuid::enumerate) +/// walks it. It reaches every entry the processor implements, including those +/// past the profile's maxima and those that only the root partition sees, so +/// these are the entries where a pass-through backend's guest may read the +/// hypervisor's own guest view. Because the identity range is exempt, +/// [`cpuid::enumerate_basic_and_extended`](crate::cpuid::enumerate_basic_and_extended) +/// gives the same candidates with fewer queries. A backend reads VP 0's view +/// there, at subleaf 0 for a subleaf-independent key, for +/// [`check_unlisted_cpuid`]. +pub fn unlisted_cpuid_candidates( + profile: &CpuProfile, + host: &[CpuidEntry], +) -> Vec<(u32, Option)> { + host.iter() + .map(CpuidEntry::key) + .filter(|&(leaf, subleaf)| is_unlisted(profile, leaf, subleaf)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + const SKYLAKE: &str = "intel.skylake-sp.v1"; + + /// Returns `profile`'s entries with `extra` added, sorted. + fn with(profile: &CpuProfile, extra: &[CpuidEntry]) -> Vec { + let mut entries = profile_entries(profile); + entries.extend_from_slice(extra); + entries.sort_by_key(CpuidEntry::key); + entries + } + + #[test] + fn every_pinned_profile_lists_its_own_entries() { + for profile in crate::pinned_profiles() { + let entries = profile_entries(profile); + assert!(unlisted_cpuid_candidates(profile, &entries).is_empty()); + check_unlisted_cpuid(profile, &entries).unwrap(); + } + } + + #[test] + fn names_every_non_zero_entry_outside_the_tables() { + let profile = profile(SKYLAKE); + let presented = with( + profile, + &[ + // RDT monitoring and allocation, Intel PT (as the root + // partition of a Skylake-SP host reads it), and the PKRU XSAVE + // component, which the profile does not enable. + CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7]), + CpuidEntry::new(0x10, Some(1), [0xa, 0x600, 0x4, 0xf]), + CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_3fff, 0, 0]), + CpuidEntry::new(0xd, Some(9), [8, 0xa80, 0, 0]), + // A leaf past the profile's maximum basic leaf, 0x16. + CpuidEntry::new(0x17, Some(0), [0, 0, 0, 1]), + ], + ); + let error = check_unlisted_cpuid(profile, &presented).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert_eq!( + error.to_string(), + "[E_CPU_UNLISTED] the backend presents CPUID entries outside CPU profile \ + intel.skylake-sp.v1: \ + CPUID 0xd.9 is outside the profile and reads EAX 0x8, EBX 0xa80, ECX 0x0, EDX 0x0; \ + CPUID 0xf.1 is outside the profile and reads EAX 0x0, EBX 0xb, ECX 0x6f, EDX 0x7; \ + CPUID 0x10.1 is outside the profile and reads EAX 0xa, EBX 0x600, ECX 0x4, EDX 0xf; \ + CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, EBX 0x3f3fff, ECX 0x0, EDX 0x0; \ + CPUID 0x17.0 is outside the profile and reads EAX 0x0, EBX 0x0, ECX 0x0, EDX 0x1" + ); + } + + #[test] + fn accepts_zeros_and_the_leaves_the_time_abi_owns() { + let profile = profile(SKYLAKE); + let presented = with( + profile, + &[ + // Subleaves that read zero, as a guest enumerates them. + CpuidEntry::new(0xf, Some(1), [0; 4]), + CpuidEntry::new(0x10, Some(3), [0; 4]), + CpuidEntry::new(0x12, Some(2), [0; 4]), + // The hypervisor range, where the identity leaves are. + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 0x7263_694d, 0, 0]), + CpuidEntry::new(0x4000_0003, None, [0x3fff, 0x2bfe, 0x2, 0xbed7b2]), + // The topology leaves, which the VM's effective CPUID adds. + CpuidEntry::new(0xb, Some(0), [1, 2, 0x100, 0]), + CpuidEntry::new(0xb, Some(1), [5, 20, 0x201, 0]), + CpuidEntry::new(0x1f, Some(1), [5, 20, 0x201, 0]), + ], + ); + check_unlisted_cpuid(profile, &presented).unwrap(); + assert_eq!( + unlisted_cpuid_candidates(profile, &presented), + [(0xf, Some(1)), (0x10, Some(3)), (0x12, Some(2))] + ); + } + + #[test] + fn a_subleaf_independent_entry_covers_every_subleaf() { + let profile = profile(SKYLAKE); + // Leaf 6 is subleaf-independent in the profile, and a backend may + // report an indexed leaf without its subleaf. + let presented = [ + CpuidEntry::new(0x6, Some(3), [0x4, 0, 0, 0]), + CpuidEntry::new(0x7, None, [0, 0xd19f_4fbb, 0x8, 0x8400_0000]), + ]; + check_unlisted_cpuid(profile, &presented).unwrap(); + assert!(unlisted_cpuid_candidates(profile, &presented).is_empty()); + } + + #[test] + fn the_identity_range_ends_at_0x4fffffff() { + let profile = profile(SKYLAKE); + let presented = [ + CpuidEntry::new(0x4fff_ffff, None, [1, 0, 0, 0]), + CpuidEntry::new(0x5000_0000, None, [1, 0, 0, 0]), + ]; + assert_eq!( + unlisted_cpuid_violations(profile, &presented), + [ + "CPUID 0x50000000 is outside the profile and reads EAX 0x1, EBX 0x0, ECX 0x0, EDX 0x0" + ] + ); + } + + #[test] + fn candidates_are_the_host_entries_outside_the_tables() { + let profile = profile(SKYLAKE); + let host = with( + profile, + &[ + CpuidEntry::new(0xb, Some(0), [1, 2, 0x100, 0]), + CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7]), + CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_3fff, 0, 0]), + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 0, 0, 0]), + CpuidEntry::new(0x8000_0009, None, [0; 4]), + ], + ); + assert_eq!( + unlisted_cpuid_candidates(profile, &host), + [(0xf, Some(1)), (0x14, Some(1)), (0x8000_0009, None)] + ); + } +} diff --git a/vmm_core/cpu_profile/src/vendor.rs b/vmm_core/cpu_profile/src/vendor.rs new file mode 100644 index 00000000000..20c65164d52 --- /dev/null +++ b/vmm_core/cpu_profile/src/vendor.rs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU vendors that profiles serve. + +use std::fmt; + +/// A CPU vendor that profiles serve. +/// +/// Intel and AMD enumerate their topology, caches, and speculation controls +/// in different CPUID leaves, so the bits that OpenVMM sets per VM +/// ([`vm_owned_bits`](crate::vm_owned_bits)), the derivation policy +/// ([`derive`](mod@crate::derive)), and the CPUID bits of the Hyper-V +/// processor features ([`hv_banks`](crate::hv_banks)) depend on the vendor. +/// Hygon's CPUs, which follow AMD's layout, have no profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CpuVendor { + /// Intel, `GenuineIntel`. + Intel, + /// AMD, `AuthenticAMD`. + Amd, +} + +impl CpuVendor { + /// Every vendor that profiles serve. + pub const ALL: [Self; 2] = [Self::Intel, Self::Amd]; + + /// Returns the vendor whose 12-byte CPUID vendor string is `vendor`, if + /// profiles serve it. + pub fn from_cpuid_vendor(vendor: &[u8]) -> Option { + Self::ALL + .into_iter() + .find(|known| known.cpuid_vendor().as_bytes() == vendor) + } + + /// Returns the 12-character CPUID vendor string, such as `GenuineIntel`. + pub const fn cpuid_vendor(self) -> &'static str { + match self { + Self::Intel => "GenuineIntel", + Self::Amd => "AuthenticAMD", + } + } + + /// Returns the vendor as profile IDs spell it: `intel` or `amd`. + pub const fn id(self) -> &'static str { + match self { + Self::Intel => "intel", + Self::Amd => "amd", + } + } +} + +impl fmt::Display for CpuVendor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.cpuid_vendor()) + } +} + +#[cfg(test)] +mod tests { + use super::CpuVendor; + use test_with_tracing::test; + + #[test] + fn maps_the_cpuid_vendor_strings_that_profiles_serve() { + assert_eq!( + CpuVendor::from_cpuid_vendor(b"GenuineIntel"), + Some(CpuVendor::Intel) + ); + assert_eq!( + CpuVendor::from_cpuid_vendor(b"AuthenticAMD"), + Some(CpuVendor::Amd) + ); + for vendor in [&b"HygonGenuine"[..], b"CentaurHauls", b"GenuineInte", b""] { + assert_eq!(CpuVendor::from_cpuid_vendor(vendor), None); + } + assert_eq!( + CpuVendor::ALL.map(|vendor| (vendor.id(), vendor.to_string())), + [ + ("intel", "GenuineIntel".to_owned()), + ("amd", "AuthenticAMD".to_owned()) + ] + ); + } +} diff --git a/vmm_core/src/acpi_builder.rs b/vmm_core/src/acpi_builder.rs index 316ba0fbd3e..c31d0bb64fd 100644 --- a/vmm_core/src/acpi_builder.rs +++ b/vmm_core/src/acpi_builder.rs @@ -3,6 +3,8 @@ //! Construct ACPI tables for a concrete VM topology +mod microvm; + // TODO: continue to remove these hardcoded deps use acpi::cedt::Cedt; use acpi::dsdt; @@ -227,6 +229,8 @@ pub enum AcpiArchConfig { pm_base: u16, /// ACPI IRQ number. acpi_irq: u32, + /// Legacy IRQs that must be described as active-high, level-triggered. + level_triggered_irqs: &'static [u32], /// x86 IOMMU ACPI table configuration. Generates an IVRS (AMD) or /// DMAR (Intel VT-d) table when set. At most one x86 IOMMU type /// is active per VM. @@ -585,6 +589,7 @@ impl AcpiTablesBuilder<'_, T> { } } + microvm::extend_madt_level_triggered_irqs(&self.arch, &mut madt_extra); T::extend_madt(self.processor_topology, &mut madt_extra); let (apic_addr, flags) = match self.arch { @@ -1473,6 +1478,8 @@ impl AcpiTablesBuilder<'_, T> { #[cfg(test)] mod test { + mod microvm; + use super::*; use acpi_spec::madt::MadtParser; use acpi_spec::mcfg::parse_mcfg; @@ -1514,6 +1521,7 @@ mod test { with_psp: false, pm_base: 1234, acpi_irq: 2, + level_triggered_irqs: &[], iommu: None, }, } diff --git a/vmm_core/src/acpi_builder/microvm.rs b/vmm_core/src/acpi_builder/microvm.rs new file mode 100644 index 00000000000..a7e1eb274ec --- /dev/null +++ b/vmm_core/src/acpi_builder/microvm.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM MADT additions: interrupt source overrides that describe legacy +//! IRQs as active-high and level-triggered. + +use super::AcpiArchConfig; +use acpi_spec::madt::InterruptPolarity; +use acpi_spec::madt::InterruptTriggerMode; +use zerocopy::IntoBytes; + +/// Appends an active-high, level-triggered interrupt source override for each +/// of the x86 `level_triggered_irqs` to the MADT entries in `madt_extra`. +pub(super) fn extend_madt_level_triggered_irqs(arch: &AcpiArchConfig, madt_extra: &mut Vec) { + if let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = *arch + { + for &irq in level_triggered_irqs { + madt_extra.extend_from_slice( + acpi_spec::madt::MadtInterruptSourceOverride::new( + irq.try_into().expect("legacy IRQ should be in range"), + irq, + Some(InterruptPolarity::ActiveHigh), + Some(InterruptTriggerMode::Level), + ) + .as_bytes(), + ); + } + } +} diff --git a/vmm_core/src/acpi_builder/test/microvm.rs b/vmm_core/src/acpi_builder/test/microvm.rs new file mode 100644 index 00000000000..f8f6e901d5b --- /dev/null +++ b/vmm_core/src/acpi_builder/test/microvm.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM MADT tests: SMP APIC IDs match the shared MP table, and +//! level-triggered legacy IRQs get interrupt source overrides. + +use super::*; +use vm_topology::processor::x86::X2ApicState; + +#[test] +fn test_microvm_smp_madt_matches_shared_mp_table() { + for processor_count in [1, 2, 4, 8] { + let mut topology_builder = TopologyBuilder::new_x86(); + topology_builder + .vps_per_socket(processor_count) + .smt_enabled(false) + .x2apic(X2ApicState::Unsupported); + let topology = topology_builder.build(processor_count).unwrap(); + let apic_ids = topology.vps_arch().map(|vp| vp.apic_id).collect::>(); + + let mem = new_mem(); + let pcie = vec![]; + let madt = new_builder(&mem, &topology, &pcie).build_madt(); + let madt_ids = MadtParser::new(&madt) + .unwrap() + .parse_apic_ids() + .unwrap() + .into_iter() + .flatten() + .collect::>(); + + let mp_table = loader::mptable::build_config_table(&loader::mptable::MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[], + }) + .unwrap(); + let mp_ids = mp_table[44..44 + apic_ids.len() * 20] + .chunks_exact(20) + .map(|entry| { + assert_eq!(entry[0], 0); + u32::from(entry[1]) + }) + .collect::>(); + + assert_eq!(mp_ids, apic_ids); + assert_eq!(madt_ids, apic_ids); + } +} + +#[test] +fn test_madt_level_triggered_irq_override() { + let mem = new_mem(); + let topology = TopologyBuilder::new_x86().build(1).unwrap(); + let pcie = vec![]; + let mut builder = new_builder(&mem, &topology, &pcie); + let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = &mut builder.arch + else { + unreachable!() + }; + *level_triggered_irqs = &[5]; + + let madt = builder.build_madt(); + let expected = acpi_spec::madt::MadtInterruptSourceOverride::new( + 5, + 5, + Some(InterruptPolarity::ActiveHigh), + Some(InterruptTriggerMode::Level), + ); + assert!( + madt.windows(expected.as_bytes().len()) + .any(|bytes| bytes == expected.as_bytes()) + ); +} diff --git a/vmm_core/src/device_builder.rs b/vmm_core/src/device_builder.rs index 089ac1444c9..440d98a9dc4 100644 --- a/vmm_core/src/device_builder.rs +++ b/vmm_core/src/device_builder.rs @@ -144,7 +144,11 @@ pub async fn build_dynamic_vpci_device( }; let pending_offer = pending_offer.context("missing deferred VPCI channel offer")?; - state_units.start_stopped_units().await; + if let Err(error) = state_units.start_stopped_units().await { + vpci_unit.remove().await; + pci_unit.remove().await; + return Err(error); + } if let Err(error) = pending_offer .offer_registered(&vpci_bus, driver_source, vmbus, state_units.is_running()) .await diff --git a/vmm_core/src/input_distributor.rs b/vmm_core/src/input_distributor.rs index 6aafd50d85d..7d6aef61e1c 100644 --- a/vmm_core/src/input_distributor.rs +++ b/vmm_core/src/input_distributor.rs @@ -195,8 +195,9 @@ struct Inner { } impl StateUnit for Inner { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.running = true; + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index 59e7e4d4814..6368cd39e32 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -4,6 +4,7 @@ //! State unit for managing the VM partition and associated virtual processors. mod debug; +mod snapshot; mod vp_set; pub use vp_set::Halt; @@ -90,6 +91,9 @@ struct PartitionUnitRunner { req_recv: Receiver, topology: ProcessorTopology, initial_regs: Option>, + /// Whether restores drop the VPs' saved TSC values (NVX time ABI). + #[cfg(guest_arch = "x86_64")] + omit_saved_tsc: bool, #[cfg(feature = "gdb")] debugger_state: debug::DebuggerState, @@ -130,6 +134,7 @@ enum PartitionRequest { SetInitialRegs(Rpc<(Vtl, Arc), Result<(), InitialRegError>>), AcceptInitialPages(Rpc, Result<(), AcceptInitialPagesError>>), StopVps(Rpc<(), ()>), + Snapshot(snapshot::SnapshotRequest), StartVps, /// Build the partition state blob for a dump file. #[cfg(feature = "dump")] @@ -139,6 +144,8 @@ enum PartitionRequest { pub struct PartitionUnitParams<'a> { pub vtl_guest_memory: [Option<&'a GuestMemory>; 3], pub processor_topology: &'a ProcessorTopology, + /// VP prefix to instantiate. The full topology remains guest-visible. + pub active_vp_count: Option, /// Tracks the halt state of VPs. pub halt_vps: Arc, /// The receiver returned from `Halt::new()`. @@ -166,6 +173,8 @@ pub enum Error { NameInUse(NameInUse), #[error("missing guest memory required for gdb support")] MissingGuestMemory, + #[error("active VP count {active} is outside topology capacity 1..={capacity}")] + InvalidActiveVpCount { active: u32, capacity: u32 }, } /// Error returned by [`PartitionUnit::set_initial_regs()`]. @@ -200,10 +209,16 @@ impl PartitionUnit { return Err(Error::DebuggingNotSupported); } - let mut vp_set = VpSet::new(params.vtl_guest_memory.map(|m| m.cloned()), params.halt_vps); + let active_vp_count = snapshot::active_vp_count(¶ms)?; + let mut vp_set = VpSet::new( + params.vtl_guest_memory.map(|m| m.cloned()), + params.halt_vps, + params.processor_topology.vp_count() as usize, + ); let vps = params .processor_topology .vps_arch() + .take(active_vp_count as usize) .map(|vp| vp_set.add(vp)) .collect(); @@ -221,6 +236,8 @@ impl PartitionUnit { req_recv, topology: params.processor_topology.clone(), initial_regs: None, + #[cfg(guest_arch = "x86_64")] + omit_saved_tsc: false, #[cfg(feature = "gdb")] debugger_state: debug::DebuggerState::new( params.vtl_guest_memory[0] @@ -379,6 +396,7 @@ impl PartitionUnitRunner { PartitionRequest::StopVps(rpc) => { rpc.handle(async |()| self.stop_vps().await).await } + PartitionRequest::Snapshot(req) => self.handle_snapshot(req).await, PartitionRequest::StartVps => { self.resume_vps(); } @@ -582,9 +600,10 @@ impl Drop for StopGuard { } impl StateUnit for PartitionUnitRunner { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.unit_started = true; self.try_start(); + Ok(()) } async fn stop(&mut self) { @@ -668,12 +687,19 @@ mod save_restore { pub async fn restore(&mut self, state: state::Partition) -> Result<(), RestoreError> { let state::Partition { partition, vps } = state; self.partition.restore(partition)?; - self.vp_set - .restore( - vps.into_iter() - .map(|state::Vp { vp_index, data }| (VpIndex::new(vp_index), data)), - ) - .await?; + let vps = vps + .into_iter() + .map(|state::Vp { vp_index, data }| { + #[cfg(guest_arch = "x86_64")] + let data = if self.omit_saved_tsc { + super::snapshot::without_saved_tsc(data)? + } else { + data + }; + Ok((VpIndex::new(vp_index), data)) + }) + .collect::, RestoreError>>()?; + self.vp_set.restore(vps).await?; Ok(()) } } diff --git a/vmm_core/src/partition_unit/debug.rs b/vmm_core/src/partition_unit/debug.rs index 69dcb937213..19e0aa89c0a 100644 --- a/vmm_core/src/partition_unit/debug.rs +++ b/vmm_core/src/partition_unit/debug.rs @@ -53,7 +53,9 @@ impl DebuggerState { tracing::debug!("halt reported to debugger"); self.halt_reported = true; notify.send(match reason { - HaltReason::PowerOff | HaltReason::Hibernate => DebugStopReason::PowerOff, + HaltReason::PowerOff + | HaltReason::PowerOffWithStatus { .. } + | HaltReason::Hibernate => DebugStopReason::PowerOff, HaltReason::Reset => DebugStopReason::Reset, HaltReason::Watchdog => DebugStopReason::Watchdog, HaltReason::TripleFault { vp, .. } => DebugStopReason::TripleFault { vp: *vp }, diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs new file mode 100644 index 00000000000..9a5a0658758 --- /dev/null +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Partition unit support for snapshots: validating the instantiated VP prefix, +//! stopping VPs at a deferred I/O boundary for capture, and the NVX time ABI's +//! LAPIC timer checks and restore advance. + +use super::Error; +use super::PartitionRequest; +use super::PartitionUnit; +use super::PartitionUnitParams; +use super::PartitionUnitRunner; +use super::StopGuard; +use mesh::rpc::FailableRpc; +use mesh::rpc::RpcSend; + +/// Snapshot requests handled by the partition unit runner. +pub(super) enum SnapshotRequest { + StopVpsAtIoBoundary(FailableRpc<(mesh::OneshotSender<()>, mesh::OneshotReceiver<()>), ()>), + #[cfg(guest_arch = "x86_64")] + CheckTimers(FailableRpc<(), ()>), + #[cfg(guest_arch = "x86_64")] + AdvanceLapic(FailableRpc<(u64, u64), ()>), + #[cfg(guest_arch = "x86_64")] + OmitSavedTsc(mesh::rpc::Rpc<(), ()>), +} + +/// Returns `data`, a VP's saved state, without its saved TSC. +#[cfg(guest_arch = "x86_64")] +pub(super) fn without_saved_tsc( + data: vmcore::save_restore::SavedStateBlob, +) -> Result { + let mut state: virt::vp::VpSavedState = data.parse()?; + state.clear_tsc(); + Ok(vmcore::save_restore::SavedStateBlob::new(state)) +} + +/// Returns the number of VPs to instantiate, validated against the topology's +/// VP count. +pub(super) fn active_vp_count(params: &PartitionUnitParams<'_>) -> Result { + let vp_capacity = params.processor_topology.vp_count(); + let active_vp_count = params.active_vp_count.unwrap_or(vp_capacity); + if !(1..=vp_capacity).contains(&active_vp_count) { + return Err(Error::InvalidActiveVpCount { + active: active_vp_count, + capacity: vp_capacity, + }); + } + Ok(active_vp_count) +} + +impl PartitionUnit { + /// Stops VPs after queuing stop events but before completing deferred I/O. + /// + /// Failure is terminal: the caller must tear down the partition instead of + /// attempting to resume it. An I/O-completion error retains the stop reference. + pub async fn temporarily_stop_vps_at_io_boundary( + &mut self, + release_io: mesh::OneshotSender<()>, + io_completed: mesh::OneshotReceiver<()>, + ) -> anyhow::Result { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::StopVpsAtIoBoundary(rpc)), + (release_io, io_completed), + ) + .await?; + Ok(StopGuard(self.req_send.clone())) + } + + /// Checks the LAPIC timer of every stopped vCPU for the NVX time ABI + /// (`E_LAPIC_PERIODIC`, `E_LAPIC_TSC_DEADLINE`). + #[cfg(guest_arch = "x86_64")] + pub async fn check_one_shot_timers(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::CheckTimers(rpc)), + (), + ) + .await?; + Ok(()) + } + + /// Advances the one-shot LAPIC timer of every stopped vCPU by + /// `downtime_ns` at `apic_hz` and sets every vCPU's LAPIC state again, + /// for the NVX time ABI. + #[cfg(guest_arch = "x86_64")] + pub async fn advance_lapic_timers( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> anyhow::Result<()> { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::AdvanceLapic(rpc)), + (downtime_ns, apic_hz), + ) + .await?; + Ok(()) + } + + /// Makes every later restore drop the VPs' saved TSC values, which the + /// NVX time ABI's synchronized TSC set supersedes, so no backend applies + /// a per-VP TSC write before it. + #[cfg(guest_arch = "x86_64")] + pub async fn omit_saved_tsc(&mut self) { + self.req_send + .call( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::OmitSavedTsc(rpc)), + (), + ) + .await + .unwrap(); + } +} + +impl PartitionUnitRunner { + /// Handles a snapshot request from [`PartitionUnit`]. + pub(super) async fn handle_snapshot(&mut self, request: SnapshotRequest) { + match request { + SnapshotRequest::StopVpsAtIoBoundary(rpc) => { + rpc.handle_failable(async |(release_io, io_completed)| { + // Keep the stop reference on failure too: a failed + // boundary must not be restarted before teardown. + self.vp_stop_count += 1; + self.vp_set + .stop_at_io_boundary(release_io, io_completed) + .await + }) + .await + } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::CheckTimers(rpc) => { + rpc.handle_failable(async |()| self.vp_set.check_one_shot_timers().await) + .await + } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::AdvanceLapic(rpc) => { + rpc.handle_failable(async |(downtime_ns, apic_hz)| { + self.vp_set.advance_lapic_timers(downtime_ns, apic_hz).await + }) + .await + } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::OmitSavedTsc(rpc) => rpc.handle_sync(|()| { + self.omit_saved_tsc = true; + }), + } + } +} diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index 535ce869d6c..67935fe3ea5 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -3,6 +3,11 @@ //! Virtual processor state management. +mod boundary; +mod prefix; +#[cfg(guest_arch = "x86_64")] +mod time_abi; + use super::HaltReason; use super::HaltReasonReceiver; use super::InternalHaltReason; @@ -78,6 +83,14 @@ trait ControlVp: ProtobufSaveRestore { /// Scrub per-VP state for a VTL. fn scrub(&mut self, vtl: Vtl) -> anyhow::Result<()>; + /// Checks the stopped vCPU's LAPIC timer for the time ABI. + #[cfg(guest_arch = "x86_64")] + fn check_one_shot_timer(&mut self) -> anyhow::Result<()>; + + /// Advances the stopped vCPU's one-shot LAPIC timer for the time ABI. + #[cfg(guest_arch = "x86_64")] + fn advance_lapic_timer(&mut self, advance: time_abi::LapicAdvance) -> anyhow::Result<()>; + #[cfg(feature = "gdb")] fn debug(&mut self) -> &mut dyn DebugVp; @@ -175,6 +188,16 @@ where } } + #[cfg(guest_arch = "x86_64")] + fn check_one_shot_timer(&mut self) -> anyhow::Result<()> { + time_abi::check_one_shot_timer(self.vp) + } + + #[cfg(guest_arch = "x86_64")] + fn advance_lapic_timer(&mut self, advance: time_abi::LapicAdvance) -> anyhow::Result<()> { + advance.apply(self.vp) + } + fn inspect_vp( &mut self, vtl_guest_memory: &[Option; NUM_VTLS], @@ -753,6 +776,7 @@ pub struct VpSet { inner: Arc, #[inspect(rename = "vp", iter_by_index, safe)] vps: Vec, + vp_capacity: usize, #[inspect(skip)] started: bool, } @@ -768,7 +792,11 @@ struct Vp { } impl VpSet { - pub fn new(vtl_guest_memory: [Option; NUM_VTLS], halt: Arc) -> Self { + pub fn new( + vtl_guest_memory: [Option; NUM_VTLS], + halt: Arc, + vp_capacity: usize, + ) -> Self { let inner = Inner { vtl_guest_memory, halt, @@ -776,6 +804,7 @@ impl VpSet { Self { inner: Arc::new(inner), vps: Vec::new(), + vp_capacity, started: false, } } @@ -831,7 +860,8 @@ impl VpSet { /// Stops all VPs. pub async fn stop(&mut self) { if self.started { - self.vps + let stops = self + .vps .iter() .map(|vp| { let (send, recv) = mesh::oneshot(); @@ -839,9 +869,9 @@ impl VpSet { // Ignore VPs whose runners have been dropped. async { recv.await.ok() } }) - .collect::>() - .await; + .collect::>(); self.started = false; + stops.await; } } @@ -881,6 +911,7 @@ impl VpSet { pub async fn save(&mut self) -> Result, SaveError> { assert!(!self.started); + self.validate_save_vp_count()?; self.vps .iter() .enumerate() @@ -903,6 +934,7 @@ impl VpSet { states: impl IntoIterator, ) -> Result<(), RestoreError> { assert!(!self.started); + let states = self.select_instantiated_vp_states(states)?; states .into_iter() .map(|(vp_index, data)| { @@ -987,7 +1019,7 @@ impl VpSet { vp: VpIndex, vtl: Vtl, ) -> anyhow::Result { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call(|x| VpEvent::State(StateEvent::GetDumpVpState(x)), vtl) .await @@ -1003,7 +1035,7 @@ impl VpSet { vp: VpIndex, state: virt::x86::DebugState, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::SetDebugState(x))), @@ -1032,7 +1064,7 @@ impl VpSet { vp: VpIndex, state: Box, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::SetVpState(x))), @@ -1043,7 +1075,7 @@ impl VpSet { } pub async fn get_vp_state(&self, vp: VpIndex) -> anyhow::Result> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::GetVpState(x))), @@ -1059,7 +1091,7 @@ impl VpSet { gva: u64, len: usize, ) -> anyhow::Result> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::ReadVirtualMemory(x))), @@ -1075,7 +1107,7 @@ impl VpSet { gva: u64, data: Vec, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::WriteVirtualMemory(x))), @@ -1101,6 +1133,10 @@ enum StateEvent { Restore(Rpc>), Reset(mesh::rpc::FailableRpc<(), ()>), Scrub(mesh::rpc::FailableRpc), + #[cfg(guest_arch = "x86_64")] + CheckTimers(mesh::rpc::FailableRpc<(), ()>), + #[cfg(guest_arch = "x86_64")] + AdvanceLapic(mesh::rpc::FailableRpc), #[cfg(feature = "dump")] GetDumpVpState(Rpc>), #[cfg(feature = "gdb")] @@ -1347,6 +1383,14 @@ impl RunnerInner { StateEvent::Restore(rpc) => rpc.handle_sync(|data| vp.restore(data)), StateEvent::Reset(rpc) => rpc.handle_failable_sync(|()| vp.reset()), StateEvent::Scrub(rpc) => rpc.handle_failable_sync(|vtl| vp.scrub(vtl)), + #[cfg(guest_arch = "x86_64")] + StateEvent::CheckTimers(rpc) => { + rpc.handle_failable_sync(|()| vp.check_one_shot_timer()) + } + #[cfg(guest_arch = "x86_64")] + StateEvent::AdvanceLapic(rpc) => { + rpc.handle_failable_sync(|advance| vp.advance_lapic_timer(advance)) + } #[cfg(feature = "dump")] StateEvent::GetDumpVpState(rpc) => rpc.handle_sync(|vtl| vp.get_dump_vp_state(vtl)), #[cfg(feature = "gdb")] diff --git a/vmm_core/src/partition_unit/vp_set/boundary.rs b/vmm_core/src/partition_unit/vp_set/boundary.rs new file mode 100644 index 00000000000..aecba8b75ae --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/boundary.rs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The snapshot I/O boundary: queuing a stop for every VP before releasing a +//! deferred I/O completion, so the VP that issued the I/O cannot re-enter the +//! guest. + +use super::VpEvent; +use super::VpSet; +use anyhow::Context as _; +use futures::future::JoinAll; + +impl VpSet { + /// Stops all VPs at a deferred I/O completion boundary. + pub async fn stop_at_io_boundary( + &mut self, + release_io: mesh::OneshotSender<()>, + io_completed: mesh::OneshotReceiver<()>, + ) -> anyhow::Result<()> { + let stops = self.started.then(|| { + let stops = self + .vps + .iter() + .map(|vp| { + let (send, recv) = mesh::oneshot(); + vp.send.send(VpEvent::Stop(send)); + async { recv.await.ok() } + }) + .collect::>(); + self.started = false; + stops + }); + + // Every VP has observed a queued stop event before the deferred I/O is + // completed, so the originating VP cannot re-enter guest execution. + release_io.send(()); + let io_result = io_completed + .await + .context("deferred I/O completion channel closed"); + // `started` is already false, so a later stop cannot join these requests. + // Drain them even if the I/O peer failed before acknowledging completion. + if let Some(stops) = stops { + stops.await; + } + io_result + } +} + +#[cfg(test)] +mod tests { + use crate::partition_unit::vp_set::*; + use test_with_tracing::test; + use vm_topology::processor::VpInfo; + + fn vp_set(count: u32) -> (VpSet, Vec) { + let (halt, _halt_recv) = Halt::new(); + let mut vps = VpSet::new([None, None, None], Arc::new(halt), count as usize); + let runners = (0..count) + .map(|index| { + vps.add(TargetVpInfo { + base: VpInfo { + vp_index: VpIndex::new(index), + vnode: 0, + }, + #[cfg(guest_arch = "x86_64")] + apic_id: index, + #[cfg(guest_arch = "aarch64")] + mpidr: u64::from(index).into(), + #[cfg(guest_arch = "aarch64")] + gicr: None, + #[cfg(guest_arch = "aarch64")] + pmu_gsiv: None, + }) + }) + .collect(); + (vps, runners) + } + + fn boundary_waits_for_all_stops(io_succeeds: bool) { + let (mut vps, mut runners) = vp_set(2); + vps.start(); + for runner in &mut runners { + assert!(matches!(runner.recv.try_recv(), Ok(VpEvent::Start))); + } + + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + let mut stops = runners.iter_mut().map(|runner| { + let Ok(VpEvent::Stop(stop)) = runner.recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop + }); + let first_stop = stops.next().unwrap(); + let second_stop = stops.next().unwrap(); + if io_succeeds { + complete.send(()); + } else { + drop(complete); + } + assert!(boundary.as_mut().now_or_never().is_none()); + first_stop.send(()); + assert!(boundary.as_mut().now_or_never().is_none()); + second_stop.send(()); + let result = boundary.as_mut().now_or_never().unwrap(); + if io_succeeds { + result.unwrap(); + } else { + assert_eq!( + result.unwrap_err().to_string(), + "deferred I/O completion channel closed" + ); + } + drop(boundary); + assert!(!vps.started); + assert!(vps.stop().now_or_never().is_some()); + for runner in &mut runners { + assert!(runner.recv.try_recv().is_err()); + } + } + + #[test] + fn successful_boundary_waits_for_all_stops() { + boundary_waits_for_all_stops(true); + } + + #[test] + fn failed_boundary_waits_for_all_stops() { + boundary_waits_for_all_stops(false); + } + + #[test] + fn boundary_waits_for_io_completion_after_all_stops() { + for io_succeeds in [true, false] { + let (mut vps, mut runners) = vp_set(1); + vps.start(); + assert!(matches!(runners[0].recv.try_recv(), Ok(VpEvent::Start))); + + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + let Ok(VpEvent::Stop(stop)) = runners[0].recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop.send(()); + assert!(boundary.as_mut().now_or_never().is_none()); + + if io_succeeds { + complete.send(()); + } else { + drop(complete); + } + let result = boundary.as_mut().now_or_never().unwrap(); + assert_eq!(result.is_ok(), io_succeeds); + drop(boundary); + assert!(!vps.started); + } + } + + #[test] + fn failed_boundary_tolerates_dropped_runner() { + let (mut vps, mut runners) = vp_set(2); + vps.start(); + for runner in &mut runners { + assert!(matches!(runner.recv.try_recv(), Ok(VpEvent::Start))); + } + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + drop(complete); + assert!(boundary.as_mut().now_or_never().is_none()); + drop(runners.pop().unwrap()); + assert!(boundary.as_mut().now_or_never().is_none()); + let Ok(VpEvent::Stop(stop)) = runners[0].recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop.send(()); + assert!(boundary.as_mut().now_or_never().unwrap().is_err()); + drop(boundary); + drop(runners); + assert!(vps.teardown().now_or_never().is_some()); + } + + #[test] + fn stopped_boundary_still_checks_io_completion() { + let (mut vps, mut runners) = vp_set(1); + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + assert!(runners[0].recv.try_recv().is_err()); + drop(complete); + assert!(boundary.as_mut().now_or_never().unwrap().is_err()); + } +} diff --git a/vmm_core/src/partition_unit/vp_set/prefix.rs b/vmm_core/src/partition_unit/vp_set/prefix.rs new file mode 100644 index 00000000000..55cb08079b3 --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/prefix.rs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The instantiated VP prefix. +//! +//! A VP set may instantiate only a prefix of the VPs in its capacity (the +//! topology's VP count). Save requires every VP to be instantiated. Restore +//! validates the complete saved VP inventory before it selects the states of +//! the instantiated prefix. Dump and debug access to an uninstantiated VP +//! fails. + +#[cfg(any(feature = "dump", feature = "gdb"))] +use super::Vp; +use super::VpSet; +#[cfg(any(test, feature = "dump", feature = "gdb"))] +use anyhow::Context as _; +use virt::VpIndex; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; + +fn validate_restore_vp_indices( + vp_count: usize, + indices: impl IntoIterator, +) -> Result<(), RestoreError> { + let mut present = vec![false; vp_count]; + for vp_index in indices { + let index = vp_index.index() as usize; + let slot = present + .get_mut(index) + .ok_or_else(|| RestoreError::UnknownEntryId(format!("vp{}", vp_index.index())))?; + if std::mem::replace(slot, true) { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "snapshot contains duplicate state for vp{}", + vp_index.index() + ))); + } + } + if let Some(index) = present.iter().position(|present| !present) { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "snapshot is missing state for vp{index}" + ))); + } + Ok(()) +} + +fn select_instantiated_vp_states( + vp_capacity: usize, + instantiated_vp_count: usize, + states: Vec<(VpIndex, T)>, +) -> Result, RestoreError> { + validate_restore_vp_indices(vp_capacity, states.iter().map(|(vp_index, _)| *vp_index))?; + Ok(states + .into_iter() + .filter(|(vp_index, _)| vp_index.index() < instantiated_vp_count as u32) + .collect()) +} + +fn validate_save_vp_count( + instantiated_vp_count: usize, + vp_capacity: usize, +) -> Result<(), SaveError> { + if instantiated_vp_count != vp_capacity { + return Err(SaveError::NotSupported); + } + Ok(()) +} + +#[cfg(any(test, feature = "dump", feature = "gdb"))] +fn instantiated_vp_index(vp_count: usize, vp: VpIndex) -> anyhow::Result { + let index = vp.index() as usize; + (index < vp_count) + .then_some(index) + .with_context(|| format!("vp{} is not instantiated", vp.index())) +} + +impl VpSet { + /// Fails unless every VP is instantiated, because a prefix cannot produce + /// a complete saved VP inventory. + pub(super) fn validate_save_vp_count(&self) -> Result<(), SaveError> { + validate_save_vp_count(self.vps.len(), self.vp_capacity) + } + + /// Validates the complete saved VP inventory and returns the states of the + /// instantiated prefix. + pub(super) fn select_instantiated_vp_states( + &self, + states: impl IntoIterator, + ) -> Result, RestoreError> { + select_instantiated_vp_states( + self.vp_capacity, + self.vps.len(), + states.into_iter().collect(), + ) + } + + /// Returns VP `vp`, or an error if it is not instantiated. + #[cfg(any(feature = "dump", feature = "gdb"))] + pub(super) fn instantiated_vp(&self, vp: VpIndex) -> anyhow::Result<&Vp> { + Ok(&self.vps[instantiated_vp_index(self.vps.len(), vp)?]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn requires_exactly_one_restore_entry_per_vp() { + validate_restore_vp_indices(4, (0..4).map(VpIndex::new)).unwrap(); + + let missing = validate_restore_vp_indices(4, [0, 1, 3].map(VpIndex::new)).unwrap_err(); + let RestoreError::InvalidSavedState(missing) = missing else { + panic!("expected invalid saved state"); + }; + assert!(missing.to_string().contains("missing state for vp2")); + + let duplicate = validate_restore_vp_indices(4, [0, 1, 1, 3].map(VpIndex::new)).unwrap_err(); + let RestoreError::InvalidSavedState(duplicate) = duplicate else { + panic!("expected invalid saved state"); + }; + assert!(duplicate.to_string().contains("duplicate state for vp1")); + + let unknown = validate_restore_vp_indices(4, [0, 1, 2, 4].map(VpIndex::new)).unwrap_err(); + assert!(unknown.to_string().contains("unknown entry id: vp4")); + } + + #[test] + fn validates_full_inventory_before_selecting_instantiated_prefix() { + let states = (0..4).map(|index| (VpIndex::new(index), index)).collect(); + let selected = select_instantiated_vp_states(4, 2, states).unwrap(); + assert_eq!( + selected + .into_iter() + .map(|(vp_index, state)| (vp_index.index(), state)) + .collect::>(), + [(0, 0), (1, 1)] + ); + + let missing_dormant_vp = [0, 1, 2] + .map(|index| (VpIndex::new(index), index)) + .into_iter() + .collect(); + let error = select_instantiated_vp_states(4, 2, missing_dormant_vp).unwrap_err(); + let RestoreError::InvalidSavedState(error) = error else { + panic!("expected invalid saved state"); + }; + assert!(error.to_string().contains("missing state for vp3")); + } + + #[test] + fn rejects_saving_an_instantiated_prefix() { + assert!(validate_save_vp_count(4, 4).is_ok()); + assert!(matches!( + validate_save_vp_count(2, 4), + Err(SaveError::NotSupported) + )); + } + + #[test] + fn rejects_access_to_an_uninstantiated_vp() { + assert_eq!(instantiated_vp_index(2, VpIndex::new(1)).unwrap(), 1); + assert!( + instantiated_vp_index(2, VpIndex::new(2)) + .unwrap_err() + .to_string() + .contains("vp2 is not instantiated") + ); + } +} diff --git a/vmm_core/src/partition_unit/vp_set/time_abi.rs b/vmm_core/src/partition_unit/vp_set/time_abi.rs new file mode 100644 index 00000000000..19989453748 --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/time_abi.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! LAPIC timer checks and advancement for an NVX time ABI snapshot. + +use super::StateEvent; +use super::VpEvent; +use super::VpSet; +use anyhow::Context as _; +use futures::future::TryJoinAll; +use hvdef::Vtl; +use mesh::rpc::RpcSend; +use virt::Processor; +use virt::vp::AccessVpState; + +/// A request to advance the LAPIC timer of a stopped VP by the restore +/// downtime. +#[derive(Debug, Copy, Clone)] +pub(super) struct LapicAdvance { + downtime_ns: u64, + apic_hz: u64, +} + +/// Checks the LAPIC timer of the stopped VP `vp` (`E_LAPIC_PERIODIC`, +/// `E_LAPIC_TSC_DEADLINE`). +pub(super) fn check_one_shot_timer(vp: &mut impl Processor) -> anyhow::Result<()> { + let apic = vp + .access_state(Vtl::Vtl0) + .apic() + .context("failed to read stopped LAPIC")?; + apic.check_one_shot_timer()?; + Ok(()) +} + +impl LapicAdvance { + /// Advances the one-shot LAPIC timer of the stopped VP `vp` and sets its + /// LAPIC state again, even when no timer is armed: KVM derives its timer + /// deadline from the guest TSC when the LAPIC state is set. + pub(super) fn apply(self, vp: &mut impl Processor) -> anyhow::Result<()> { + let mut access = vp.access_state(Vtl::Vtl0); + let mut apic = access.apic().context("failed to read stopped LAPIC")?; + apic.advance_one_shot_timer(self.downtime_ns, self.apic_hz)?; + access + .set_apic(&apic) + .context("failed to set the restored LAPIC")?; + access + .commit() + .context("failed to commit the restored LAPIC")?; + Ok(()) + } +} + +impl VpSet { + /// Checks the LAPIC timer of every stopped VP: capture step 1 of the time + /// ABI. Restore needs no separate check, because the LAPIC advance + /// rejects the same timers. + pub async fn check_one_shot_timers(&mut self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.started, + "vCPUs must be stopped before checking LAPIC timers" + ); + self.vps + .iter() + .enumerate() + .map(|(index, vp)| async move { + vp.send + .call_failable(|rpc| VpEvent::State(StateEvent::CheckTimers(rpc)), ()) + .await + .with_context(|| format!("vp{index} LAPIC timer")) + }) + .collect::>() + .await?; + Ok(()) + } + + /// Advances the one-shot LAPIC timer of every stopped VP by `downtime_ns` + /// at `apic_hz` and sets every VP's LAPIC state again: restore step 14 of + /// the time ABI, after the synchronized TSC set. A periodic or + /// TSC-deadline timer fails it (`E_LAPIC_PERIODIC`, + /// `E_LAPIC_TSC_DEADLINE`) before any VP runs. + pub async fn advance_lapic_timers( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> anyhow::Result<()> { + anyhow::ensure!( + !self.started, + "vCPUs must be stopped before advancing LAPIC timers" + ); + self.vps + .iter() + .enumerate() + .map(|(index, vp)| async move { + vp.send + .call_failable( + |rpc| VpEvent::State(StateEvent::AdvanceLapic(rpc)), + LapicAdvance { + downtime_ns, + apic_hz, + }, + ) + .await + .with_context(|| format!("vp{index} LAPIC timer advance")) + }) + .collect::>() + .await?; + Ok(()) + } +} diff --git a/vmm_core/src/platform_resolvers.rs b/vmm_core/src/platform_resolvers.rs index 0bab41f1f1c..8554543de6f 100644 --- a/vmm_core/src/platform_resolvers.rs +++ b/vmm_core/src/platform_resolvers.rs @@ -27,6 +27,9 @@ impl ResolveResource for HaltResolver let halt = self.0.clone(); Ok((move |request: PowerRequest| match request { PowerRequest::PowerOff => halt.halt(HaltReason::PowerOff), + PowerRequest::PowerOffWithStatus { code } => { + halt.halt(HaltReason::PowerOffWithStatus { code }) + } PowerRequest::Reset => halt.halt(HaltReason::Reset), PowerRequest::Hibernate => halt.halt(HaltReason::Hibernate), PowerRequest::TripleFault { vp } => halt.halt(HaltReason::TripleFault { diff --git a/vmm_core/src/vmbus_unit.rs b/vmm_core/src/vmbus_unit.rs index 5d6d9b0f398..76024683b0d 100644 --- a/vmm_core/src/vmbus_unit.rs +++ b/vmm_core/src/vmbus_unit.rs @@ -78,8 +78,9 @@ impl VmbusServerHandle { struct VmbusServerUnit(VmbusServer); impl StateUnit for &'_ VmbusServerUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { @@ -134,8 +135,9 @@ impl ChannelUnit { } impl StateUnit for &'_ ChannelUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { @@ -205,8 +207,9 @@ impl Inspect for SimpleChannelUnit { } impl StateUnit for &'_ SimpleChannelUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/src/vmtime_unit.rs b/vmm_core/src/vmtime_unit.rs index a53688cb527..e044524e437 100644 --- a/vmm_core/src/vmtime_unit.rs +++ b/vmm_core/src/vmtime_unit.rs @@ -17,8 +17,9 @@ use vmcore::vmtime::VmTimeKeeper; struct KeeperUnit<'a>(#[inspect(mut)] &'a mut VmTimeKeeper); impl StateUnit for KeeperUnit<'_> { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start().await; + Ok(()) } async fn stop(&mut self) { @@ -38,6 +39,11 @@ impl StateUnit for KeeperUnit<'_> { self.0.restore(state.parse()?).await; Ok(()) } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.0.advance(duration).await; + Ok(()) + } } /// Runs the VM time keeper, responding to state changes from `recv`, until diff --git a/vmm_core/state_unit/src/advance_time.rs b/vmm_core/state_unit/src/advance_time.rs new file mode 100644 index 00000000000..68e33602749 --- /dev/null +++ b/vmm_core/state_unit/src/advance_time.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Advancing guest-visible time by the snapshot downtime after restore. + +use super::State; +use super::StateRequest; +use super::StateTransitionError; +use super::StateUnits; +use super::check; +use std::time::Duration; + +impl StateUnits { + /// Advances guest-visible time on all stopped units in dependency order. + /// Only units added with [`UnitBuilder::advances_time`](crate::UnitBuilder::advances_time) + /// receive the request; the others pass through the operation without a + /// round trip. + pub async fn advance_time(&mut self, duration: Duration) -> Result<(), StateTransitionError> { + assert!(!self.running); + let results = self + .run_op( + "advance_time", + None, + State::Stopped, + State::AdvancingTime, + State::Stopped, + StateRequest::AdvanceTime, + |_, unit| unit.advances_time.then_some(duration), + |unit| &unit.dependencies, + ) + .await; + check( + "advance_time", + results + .into_iter() + .map(|(name, result)| (name, result.map_err(anyhow::Error::from))), + )?; + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/inventory.rs b/vmm_core/state_unit/src/inventory.rs new file mode 100644 index 00000000000..ff3bbb650e2 --- /dev/null +++ b/vmm_core/state_unit/src/inventory.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The state-unit inventory: the registered unit names, which a snapshot +//! records and restore requires to match exactly. + +use super::StateUnits; + +impl StateUnits { + /// Returns every registered state-unit name in stable registration order. + pub fn inventory(&self) -> Vec { + self.inner + .lock() + .units + .values() + .map(|unit| unit.name.to_string()) + .collect() + } + + /// Verifies that a saved inventory exactly matches the registered units. + pub fn validate_inventory(&self, saved_inventory: &[String]) -> anyhow::Result<()> { + let current_inventory = self.inventory(); + anyhow::ensure!( + saved_inventory == current_inventory, + "saved state-unit inventory does not match the current machine: saved={saved_inventory:?}, current={current_inventory:?}" + ); + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index 1cfd09a304e..e06ab65ce9a 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -30,6 +30,11 @@ #![forbid(unsafe_code)] +mod advance_time; +mod inventory; +pub mod quiesce; +mod start; + use futures::FutureExt; use futures::StreamExt; use futures::future::join_all; @@ -69,11 +74,17 @@ use vmcore::save_restore::SavedStateBlob; #[derive(Debug, MeshPayload)] pub enum StateRequest { /// Start asynchronous operations. - Start(Rpc<(), ()>), + Start(FailableRpc<(), ()>), /// Stop asynchronous operations. Stop(Rpc<(), ()>), + /// Stop accepting new host input before a snapshot vCPU boundary. + QuiesceInput(FailableRpc<(), ()>), + + /// Resume host input after a failed snapshot transaction. + ResumeInput(FailableRpc<(), ()>), + /// Reset a stopped unit to initial state. Reset(FailableRpc<(), ()>), @@ -83,6 +94,9 @@ pub enum StateRequest { /// Restore state of a stopped unit. Restore(FailableRpc), + /// Advance guest-visible time while stopped after snapshot restore. + AdvanceTime(FailableRpc), + /// Inspect state. Inspect(inspect::Deferred), } @@ -95,11 +109,21 @@ pub enum StateRequest { #[expect(async_fn_in_trait)] // Don't need Send bounds pub trait StateUnit: InspectMut { /// Start asynchronous processing. - async fn start(&mut self); + async fn start(&mut self) -> anyhow::Result<()>; /// Stop asynchronous processing. async fn stop(&mut self); + /// Stops accepting new host input while preserving runtime state. + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + /// Resumes host input after a failed snapshot transaction. + async fn resume_input(&mut self) -> anyhow::Result<()> { + Ok(()) + } + /// Reset to initial state. /// /// Must only be called while stopped. @@ -114,6 +138,13 @@ pub trait StateUnit: InspectMut { /// /// Must only be called while stopped. async fn restore(&mut self, buffer: SavedStateBlob) -> Result<(), RestoreError>; + + /// Advance guest-visible time after restore. Most units derive their + /// deadlines from VM time and need no direct adjustment; only units added + /// with [`UnitBuilder::advances_time`] receive this request. + async fn advance_time(&mut self, _duration: std::time::Duration) -> anyhow::Result<()> { + Ok(()) + } } /// Runs a simple unit that only needs to respond to state requests. @@ -163,6 +194,9 @@ impl StateRequest { StateRequest::Start(_) | StateRequest::Stop(_) + | StateRequest::QuiesceInput(_) + | StateRequest::ResumeInput(_) + | StateRequest::AdvanceTime(_) | StateRequest::Reset(_) | StateRequest::Save(_) | StateRequest::Restore(_) => { @@ -189,14 +223,26 @@ impl StateRequest { /// Runs this state request against `unit`. pub async fn apply(self, unit: &mut impl StateUnit) { match self { - StateRequest::Start(rpc) => rpc.handle(async |()| unit.start().await).await, + StateRequest::Start(rpc) => rpc.handle_failable(async |()| unit.start().await).await, StateRequest::Stop(rpc) => rpc.handle(async |()| unit.stop().await).await, + StateRequest::QuiesceInput(rpc) => { + rpc.handle_failable(async |()| unit.quiesce_input().await) + .await + } + StateRequest::ResumeInput(rpc) => { + rpc.handle_failable(async |()| unit.resume_input().await) + .await + } StateRequest::Reset(rpc) => rpc.handle_failable(async |()| unit.reset().await).await, StateRequest::Save(rpc) => rpc.handle_failable(async |()| unit.save().await).await, StateRequest::Restore(rpc) => { rpc.handle_failable(async |buffer| unit.restore(buffer).await) .await } + StateRequest::AdvanceTime(rpc) => { + rpc.handle_failable(async |duration| unit.advance_time(duration).await) + .await + } StateRequest::Inspect(req) => req.inspect(unit), } } @@ -218,6 +264,8 @@ enum State { Resetting, Saving, Restoring, + AdvancingTime, + QuiesceUncertain, } #[derive(Debug)] @@ -235,6 +283,7 @@ struct Unit { dependents: Vec, state: State, inspect_sensitivity: inspect::SensitivityLevel, + advances_time: bool, } /// An error returned when a state unit name is already in use. @@ -450,6 +499,7 @@ impl StateUnits { dependencies: Vec::new(), dependents: Vec::new(), inspect_sensitivity: inspect::SensitivityLevel::Unspecified, + advances_time: false, } } @@ -462,26 +512,16 @@ impl StateUnits { /// via [`StateUnits::add`] while the VM was running. /// /// Does nothing if all units are stopped, via [`StateUnits::stop`]. - pub async fn start_stopped_units(&mut self) { + pub async fn start_stopped_units(&mut self) -> anyhow::Result<()> { if self.is_running() { - self.start().await; + self.start().await?; } + Ok(()) } /// Starts all the state units. - pub async fn start(&mut self) { - self.run_op( - "start", - None, - State::Stopped, - State::Starting, - State::Running, - StateRequest::Start, - |_, _| Some(()), - |unit| &unit.dependencies, - ) - .await; - self.running = true; + pub async fn start(&mut self) -> anyhow::Result<()> { + self.start_with_rollback().await } /// Stops all the state units. @@ -834,9 +874,18 @@ pub struct UnitBuilder<'a> { dependencies: Vec, dependents: Vec, inspect_sensitivity: inspect::SensitivityLevel, + advances_time: bool, } impl UnitBuilder<'_> { + /// Marks this new unit as one whose [`StateUnit::advance_time`] does + /// work, so that [`StateUnits::advance_time`] sends it the request. + /// Other units derive their deadlines from VM time and are not contacted. + pub fn advances_time(mut self) -> Self { + self.advances_time = true; + self + } + /// Adds `handle` as a dependency of this new unit. /// /// Operations will be ordered to ensure that a dependency will stop after @@ -905,6 +954,7 @@ impl UnitBuilder<'_> { dependents: self.dependents, state: State::Stopped, inspect_sensitivity: self.inspect_sensitivity, + advances_time: self.advances_time, }, ); let unit_id = UnitId { @@ -1007,6 +1057,10 @@ impl Ready { #[cfg(test)] mod tests { + mod inventory; + mod quiesce; + mod start; + use super::StateUnit; use super::StateUnits; use crate::run_unit; @@ -1037,7 +1091,9 @@ mod tests { struct SavedState(bool); impl StateUnit for TestUnit { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} @@ -1079,7 +1135,9 @@ mod tests { } impl StateUnit for TestUnitSetDep { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} @@ -1108,6 +1166,65 @@ mod tests { } } + /// A unit that records whether it was asked to advance time. + struct TimeUnit(Arc); + + impl StateUnit for TimeUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Ok(()) + } + + async fn advance_time(&mut self, _duration: Duration) -> anyhow::Result<()> { + self.0.store(true, Ordering::Relaxed); + Ok(()) + } + } + + impl InspectMut for TimeUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } + } + + #[async_test] + async fn advance_time_reaches_only_units_that_advance_time(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let flagged = Arc::new(AtomicBool::new(false)); + let unflagged = Arc::new(AtomicBool::new(false)); + let a = units + .add("flagged") + .advances_time() + .spawn(&driver, |recv| run_unit(TimeUnit(flagged.clone()), recv)) + .unwrap(); + let _b = units + .add("unflagged") + .depends_on(a.handle()) + .spawn(&driver, |recv| run_unit(TimeUnit(unflagged.clone()), recv)) + .unwrap(); + + units.advance_time(Duration::from_secs(1)).await.unwrap(); + assert!(flagged.load(Ordering::Relaxed)); + assert!(!unflagged.load(Ordering::Relaxed)); + + // Both units are stopped again and can start. + units.start().await.unwrap(); + units.stop().await; + } + #[async_test] async fn test_state_change(driver: DefaultDriver) { let mut units = StateUnits::new(); @@ -1131,13 +1248,13 @@ mod tests { .add("b") .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) .unwrap(); - units.start().await; + units.start().await.unwrap(); let _c = units .add("c") .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)); units.stop().await; - units.start().await; + units.start().await.unwrap(); units.stop().await; @@ -1184,7 +1301,7 @@ mod tests { ) }) .unwrap(); - units.start().await; + units.start().await.unwrap(); units.stop().await; let state = units.save().await.unwrap(); diff --git a/vmm_core/state_unit/src/quiesce.rs b/vmm_core/state_unit/src/quiesce.rs new file mode 100644 index 00000000000..70e8a455ac3 --- /dev/null +++ b/vmm_core/state_unit/src/quiesce.rs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot quiesce transactions: gating host input around the snapshot +//! vCPU boundary, bounded quiesce of every unit for save, and restart after a +//! failed save. + +use super::State; +use super::StateRequest; +use super::StateUnits; +use super::state_change; +use anyhow::Context as _; +use futures::future::join_all; +use mesh::CancelContext; +use mesh::rpc::FailableRpc; +use std::time::Duration; +use std::time::Instant; +use thiserror::Error; + +/// A bounded quiesce failed and the VM must not be saved. +#[derive(Debug, Error)] +#[error("failed to quiesce state units for save: {failures:?}")] +pub struct QuiesceForSaveError { + failures: Vec<(String, String)>, + uncertain: bool, +} + +impl QuiesceForSaveError { + /// Returns whether any unit may have partially changed ownership or state. + /// + /// When true, the VM cannot be resumed safely and must be terminated. + pub fn has_uncertain_state(&self) -> bool { + self.uncertain + } +} + +impl StateUnits { + /// Stops host-input producers before establishing a snapshot vCPU boundary. + pub async fn quiesce_input_for_save(&self, timeout: Duration) -> anyhow::Result<()> { + self.run_input_op("quiesce_input", timeout, StateRequest::QuiesceInput) + .await + } + + /// Resumes host-input producers before releasing a failed snapshot boundary. + pub async fn resume_input_after_save(&self, timeout: Duration) -> anyhow::Result<()> { + self.run_input_op("resume_input", timeout, StateRequest::ResumeInput) + .await + } + + async fn run_input_op( + &self, + operation: &'static str, + timeout: Duration, + request: impl Copy + FnOnce(FailableRpc<(), ()>) -> StateRequest, + ) -> anyhow::Result<()> { + let operations = { + let inner = self.inner.lock(); + inner + .units + .values() + .map(|unit| { + let name = unit.name.clone(); + let operation = state_change(name.clone(), unit, request, Some(())); + async move { (name, operation.await) } + }) + .collect::>() + }; + let mut context = CancelContext::new().with_timeout(timeout); + let results = context + .until_cancelled(join_all(operations)) + .await + .with_context(|| format!("{operation} timed out"))?; + let mut failures = Vec::new(); + for (name, result) in results { + match result { + Ok(Some(Ok(()))) => {} + Ok(Some(Err(error))) => failures.push(format!("{name}: {error}")), + Ok(None) => failures.push(format!("{name}: request was not sent")), + Err(error) => failures.push(format!("{name}: {error}")), + } + } + anyhow::ensure!( + failures.is_empty(), + "{operation} failed: {}", + failures.join("; ") + ); + Ok(()) + } + + /// Stops all units in reverse dependency order within `timeout`. + /// + /// A timeout or communication failure after a stop request is sent leaves + /// ownership uncertain. The returned error reports this via + /// [`QuiesceForSaveError::has_uncertain_state`]; callers must terminate the + /// VM rather than attempt rollback in that case. + pub async fn quiesce_for_save(&mut self, timeout: Duration) -> Result<(), QuiesceForSaveError> { + assert!(self.running); + + enum UnitResult { + Stopped, + DependencyFailed, + Uncertain(anyhow::Error), + } + + let context = CancelContext::new().with_timeout(timeout); + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + assert_eq!( + unit.state, + State::Running, + "unit {} is not running before save quiesce", + unit.name, + ); + let name = unit.name.clone(); + let dependencies = unit.dependents.clone(); + let ready_set = ready_set.clone(); + let mut context = context.clone(); + let stop = state_change(name.clone(), unit, StateRequest::Stop, Some(())); + operations.push(async move { + if !ready_set.wait("quiesce_for_save", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, UnitResult::DependencyFailed); + } + + let result = match context.until_cancelled(stop).await { + Ok(Ok(Some(()))) => UnitResult::Stopped, + Ok(Ok(None)) => { + UnitResult::Uncertain(anyhow::anyhow!("stop request was not sent")) + } + Ok(Err(error)) => UnitResult::Uncertain(error.into()), + Err(reason) => UnitResult::Uncertain(reason.into()), + }; + ready_set.done(id, matches!(result, UnitResult::Stopped)); + (name, id, result) + }); + unit.state = State::Stopping; + } + } + + let start = Instant::now(); + let results = join_all(operations).await; + tracing::info!(duration = ?Instant::now() - start, "save quiesce complete"); + + let mut failures = Vec::new(); + let mut uncertain = false; + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + UnitResult::Stopped => unit.state = State::Stopped, + UnitResult::DependencyFailed => { + unit.state = State::Running; + failures.push(( + name.to_string(), + "a dependent unit did not quiesce".to_owned(), + )); + } + UnitResult::Uncertain(error) => { + unit.state = State::QuiesceUncertain; + uncertain = true; + failures.push((name.to_string(), format!("{error:#}"))); + } + } + } + drop(inner); + + if failures.is_empty() { + self.running = false; + Ok(()) + } else { + Err(QuiesceForSaveError { + failures, + uncertain, + }) + } + } + + /// Restarts every unit after a save failure that occurred after a fully + /// successful quiesce but before publication committed. + pub async fn resume_after_failed_save(&mut self, timeout: Duration) -> anyhow::Result<()> { + anyhow::ensure!(!self.running, "VM was not successfully quiesced for save"); + anyhow::ensure!( + self.inner + .lock() + .units + .values() + .all(|unit| unit.state == State::Stopped), + "state-unit rollback is unsafe because not every unit is cleanly stopped" + ); + + enum StartResult { + Started, + DependencyFailed, + Failed(anyhow::Error), + Uncertain(anyhow::Error), + } + + let context = CancelContext::new().with_timeout(timeout); + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + let name = unit.name.clone(); + let dependencies = unit.dependencies.clone(); + let ready_set = ready_set.clone(); + let mut context = context.clone(); + let start = state_change(name.clone(), unit, StateRequest::Start, Some(())); + operations.push(async move { + if !ready_set.wait("snapshot_rollback", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, StartResult::DependencyFailed); + } + + let result = match context.until_cancelled(start).await { + Ok(Ok(Some(Ok(())))) => StartResult::Started, + Ok(Ok(Some(Err(error)))) => StartResult::Failed(error.into()), + Ok(Ok(None)) => { + StartResult::Uncertain(anyhow::anyhow!("start request was not sent")) + } + Ok(Err(error)) => StartResult::Uncertain(error.into()), + Err(reason) => StartResult::Uncertain(reason.into()), + }; + ready_set.done(id, matches!(result, StartResult::Started)); + (name, id, result) + }); + unit.state = State::Starting; + } + } + + let results = join_all(operations).await; + let mut failures = Vec::new(); + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + StartResult::Started => unit.state = State::Running, + StartResult::DependencyFailed => { + unit.state = State::Stopped; + failures.push(format!("{name}: a dependency did not restart")); + } + StartResult::Failed(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + StartResult::Uncertain(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + } + } + drop(inner); + + anyhow::ensure!( + failures.is_empty(), + "snapshot rollback could not safely restart all state units: {}", + failures.join("; ") + ); + self.running = true; + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/start.rs b/vmm_core/state_unit/src/start.rs new file mode 100644 index 00000000000..a2e098c071a --- /dev/null +++ b/vmm_core/state_unit/src/start.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fallible start with rollback. If a unit fails to start, its dependents do +//! not start, and the units that did start are stopped again. + +use super::State; +use super::StateRequest; +use super::StateUnits; +use super::state_change; +use futures::future::join_all; +use std::time::Instant; + +impl StateUnits { + /// Starts all stopped units in dependency order. + /// + /// A unit does not start if one of its dependencies fails to start. On any + /// failure, the units that did start are stopped again, and a unit whose + /// start failed or is uncertain is left in a terminal state that fails + /// later starts. + pub(super) async fn start_with_rollback(&mut self) -> anyhow::Result<()> { + enum StartResult { + Started, + DependencyFailed, + Failed(anyhow::Error), + Uncertain(anyhow::Error), + } + + let was_running = self.running; + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + if let Some(unit) = inner + .units + .values() + .find(|unit| !matches!(unit.state, State::Stopped | State::Running)) + { + anyhow::bail!( + "state unit '{}' cannot be started from terminal state {:?}", + unit.name, + unit.state + ); + } + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + if unit.state == State::Running { + ready_set.done(id, true); + continue; + } + assert_eq!( + unit.state, + State::Stopped, + "unit {} is not stopped before start", + unit.name, + ); + let name = unit.name.clone(); + let dependencies = unit.dependencies.clone(); + let ready_set = ready_set.clone(); + let start = state_change(name.clone(), unit, StateRequest::Start, Some(())); + operations.push(async move { + if !ready_set.wait("start", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, StartResult::DependencyFailed); + } + + let result = match start.await { + Ok(Some(Ok(()))) => StartResult::Started, + Ok(Some(Err(error))) => StartResult::Failed(error.into()), + Ok(None) => { + StartResult::Uncertain(anyhow::anyhow!("start request was not sent")) + } + Err(error) => StartResult::Uncertain(error.into()), + }; + ready_set.done(id, matches!(result, StartResult::Started)); + (name, id, result) + }); + unit.state = State::Starting; + } + } + + let start = Instant::now(); + let results = join_all(operations).await; + tracing::info!(duration = ?Instant::now() - start, "state-unit start complete"); + + let mut failures = Vec::new(); + let mut started_ids = Vec::new(); + { + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + StartResult::Started => { + unit.state = State::Running; + started_ids.push(id); + } + StartResult::DependencyFailed => { + unit.state = State::Stopped; + failures.push(format!("{name}: a dependency did not start")); + } + StartResult::Failed(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + StartResult::Uncertain(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + } + } + } + + if failures.is_empty() { + self.running = true; + Ok(()) + } else { + if !started_ids.is_empty() { + self.run_op( + "failed_start_rollback", + Some(&started_ids), + State::Running, + State::Stopping, + State::Stopped, + StateRequest::Stop, + |_, _| Some(()), + |unit| &unit.dependents, + ) + .await; + } + self.running = was_running; + anyhow::bail!("state units could not start: {}", failures.join("; ")) + } + } +} diff --git a/vmm_core/state_unit/src/tests/inventory.rs b/vmm_core/state_unit/src/tests/inventory.rs new file mode 100644 index 00000000000..a700ab9a832 --- /dev/null +++ b/vmm_core/state_unit/src/tests/inventory.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the state-unit inventory. + +use super::TestUnit; +use crate::StateUnits; +use crate::run_unit; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; + +#[async_test] +async fn test_exact_inventory_validation(driver: DefaultDriver) { + let units = StateUnits::new(); + let _first = units + .add("first") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + let _second = units + .add("second") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + + assert_eq!(units.inventory(), ["first", "second"]); + units + .validate_inventory(&["first".to_owned(), "second".to_owned()]) + .unwrap(); + assert!(units.validate_inventory(&["first".to_owned()]).is_err()); + assert!( + units + .validate_inventory(&["second".to_owned(), "first".to_owned()]) + .is_err() + ); + assert!( + units + .validate_inventory(&["first".to_owned(), "second".to_owned(), "extra".to_owned(),]) + .is_err() + ); +} diff --git a/vmm_core/state_unit/src/tests/quiesce.rs b/vmm_core/state_unit/src/tests/quiesce.rs new file mode 100644 index 00000000000..59fd25643c0 --- /dev/null +++ b/vmm_core/state_unit/src/tests/quiesce.rs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for snapshot quiesce and for restart after a failed save. + +use super::TestUnit; +use crate::StateUnit; +use crate::StateUnits; +use crate::run_unit; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use pal_async::async_test; +use std::time::Duration; +use test_with_tracing::test; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +struct SlowStopUnit { + driver: DefaultDriver, + delay: Duration, +} + +impl StateUnit for SlowStopUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) { + pal_async::timer::PolledTimer::new(&self.driver) + .sleep(self.delay) + .await; + } + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for SlowStopUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +struct SlowRollbackStartUnit { + driver: DefaultDriver, + starts: u32, +} + +impl StateUnit for SlowRollbackStartUnit { + async fn start(&mut self) -> anyhow::Result<()> { + if self.starts != 0 { + pal_async::timer::PolledTimer::new(&self.driver) + .sleep(Duration::from_secs(1)) + .await; + } + self.starts += 1; + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for SlowRollbackStartUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +#[async_test] +async fn test_quiesce_and_resume_after_failed_save(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("unit") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + units.start().await.unwrap(); + + units + .quiesce_for_save(Duration::from_secs(1)) + .await + .unwrap(); + assert!(!units.is_running()); + + units + .resume_after_failed_save(Duration::from_secs(1)) + .await + .unwrap(); + assert!(units.is_running()); +} + +#[async_test] +async fn test_quiesce_timeout_is_uncertain(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("slow") + .spawn(&driver, { + let driver = driver.clone(); + |recv| { + run_unit( + SlowStopUnit { + driver, + delay: Duration::from_secs(1), + }, + recv, + ) + } + }) + .unwrap(); + units.start().await.unwrap(); + + let started = std::time::Instant::now(); + let error = units + .quiesce_for_save(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(error.has_uncertain_state()); + assert!(started.elapsed() < Duration::from_millis(500)); + assert!( + units + .resume_after_failed_save(Duration::from_secs(1)) + .await + .is_err() + ); +} + +#[async_test] +async fn test_rollback_start_timeout_is_bounded(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("slow-rollback") + .spawn(&driver, { + let driver = driver.clone(); + |recv| run_unit(SlowRollbackStartUnit { driver, starts: 0 }, recv) + }) + .unwrap(); + units.start().await.unwrap(); + units + .quiesce_for_save(Duration::from_secs(1)) + .await + .unwrap(); + + let started = std::time::Instant::now(); + let error = units + .resume_after_failed_save(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(error.to_string().contains("snapshot rollback")); + assert!(started.elapsed() < Duration::from_millis(500)); + assert!(!units.is_running()); +} diff --git a/vmm_core/state_unit/src/tests/start.rs b/vmm_core/state_unit/src/tests/start.rs new file mode 100644 index 00000000000..f08063f4cae --- /dev/null +++ b/vmm_core/state_unit/src/tests/start.rs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for fallible start. + +use crate::StateUnit; +use crate::StateUnits; +use crate::run_unit; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use pal_async::async_test; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use test_with_tracing::test; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +struct StartResultUnit { + started: Arc, + stopped: Arc, + fail: bool, +} + +impl StateUnit for StartResultUnit { + async fn start(&mut self) -> anyhow::Result<()> { + anyhow::ensure!(!self.fail, "intentional start failure"); + self.started.store(true, Ordering::Relaxed); + Ok(()) + } + + async fn stop(&mut self) { + self.stopped.store(true, Ordering::Relaxed); + } + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for StartResultUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +#[async_test] +async fn test_start_failure_blocks_dependent(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let independent_started = Arc::new(AtomicBool::new(false)); + let independent_stopped = Arc::new(AtomicBool::new(false)); + let _independent = units + .add("independent") + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: independent_started.clone(), + stopped: independent_stopped.clone(), + fail: false, + }, + recv, + ) + }) + .unwrap(); + let failed = units + .add("failed") + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: Arc::new(AtomicBool::new(false)), + stopped: Arc::new(AtomicBool::new(false)), + fail: true, + }, + recv, + ) + }) + .unwrap(); + let dependent_started = Arc::new(AtomicBool::new(false)); + let _dependent = units + .add("dependent") + .depends_on(failed.handle()) + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: dependent_started.clone(), + stopped: Arc::new(AtomicBool::new(false)), + fail: false, + }, + recv, + ) + }) + .unwrap(); + + let error = units.start().await.unwrap_err(); + assert!(error.to_string().contains("intentional start failure")); + assert!(independent_started.load(Ordering::Relaxed)); + assert!(independent_stopped.load(Ordering::Relaxed)); + assert!(!dependent_started.load(Ordering::Relaxed)); + assert!(!units.is_running()); + let retry_error = units.start().await.unwrap_err(); + assert!(retry_error.to_string().contains("terminal state")); +} diff --git a/vmm_core/virt/Cargo.toml b/vmm_core/virt/Cargo.toml index d63a25a3096..0837e0cc279 100644 --- a/vmm_core/virt/Cargo.toml +++ b/vmm_core/virt/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] aarch64defs.workspace = true +cpu_profile.workspace = true hvdef.workspace = true pci_core.workspace = true memory_range = { workspace = true, features = ["inspect"] } @@ -27,7 +28,23 @@ slab.workspace = true thiserror.workspace = true tracing.workspace = true zerocopy.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true + +[target.'cfg(target_arch = "x86_64")'.dependencies] +safe_intrinsics.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Registry", + "Win32_System_SystemInformation", + "Win32_System_WindowsProgramming", +] } + [dev-dependencies] +test_with_tracing.workspace = true [build-dependencies] build_rs_guest_arch.workspace = true diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index 114b3f713ae..642385bcb74 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -356,6 +356,17 @@ pub struct ProtoPartitionConfig<'a> { /// Device-assignment MSI IOVA reservation selected for this partition. #[cfg(guest_arch = "aarch64")] pub device_assignment_msi_iova_range: Option, + /// Whether supported backends should return mapped guest-memory faults to + /// the VMM for user-mode resolution. + pub user_mode_memory_faults: bool, + /// Whether supported backends should register large guest-memory ranges + /// incrementally as the guest accesses them. + pub lazy_memory_registration: bool, + /// The NVX time ABI configuration. A backend that implements the time ABI + /// returns `Some` from [`Partition::time_abi`] for such a partition; the + /// worker rejects one that does not. + #[cfg(guest_arch = "x86_64")] + pub time_abi: Option, } /// Partition creation configuration. @@ -526,6 +537,18 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { /// Returns the source of the initial virtual processor state. fn initial_vp_state_source(&self) -> InitialVpStateSource; + /// Completes partition initialization after all guest memory is attached. + fn finalize_memory(&self) -> Result<(), Self::Error> { + Ok(()) + } + + /// Returns the NVX time ABI primitives, if the partition was built with + /// [`ProtoPartitionConfig::time_abi`]. + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn crate::time_abi::TimeAbiBackend> { + None + } + /// Returns a trait object for initial page imports during the initial start /// flow. fn supports_initial_page_acceptance( diff --git a/vmm_core/virt/src/lib.rs b/vmm_core/virt/src/lib.rs index 81d2d623d04..148fb6295c7 100644 --- a/vmm_core/virt/src/lib.rs +++ b/vmm_core/virt/src/lib.rs @@ -11,6 +11,7 @@ pub mod irqcon; pub use vmcore::irqfd; pub mod state; pub mod synic; +pub mod time_abi; pub mod x86; pub use arch::*; diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs new file mode 100644 index 00000000000..13e9f08887a --- /dev/null +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The time ABI primitives a backend provides. + +use super::DeclaredRates; +use super::HostTimeSample; +use super::RateCheck; +use super::TimeAbiError; +use super::TimeAbiTestHooks; +use super::rate; +use crate::CpuidLeaf; +use vm_topology::processor::VpIndex; + +/// Time ABI primitives of a backend partition, returned by +/// [`Partition::time_abi`](crate::Partition::time_abi) when the partition +/// was built with [`TimeAbiConfig`](super::TimeAbiConfig). +/// +/// Every method is called with all VPs stopped. No method falls back to +/// another mechanism: a backend that cannot meet an obligation returns the +/// failure code the specification names. +pub trait TimeAbiBackend: Send + Sync { + /// Returns the native guest TSC rate `F_d`, in Hz. The guest TSC is never + /// scaled. Fails with `E_TSC_RATE_UNAVAILABLE`. + fn native_tsc_hz(&self) -> Result; + + /// Returns the LAPIC timer rate, in Hz: the backend constant. Fails with + /// `E_LAPIC_RATE_UNAVAILABLE`. + fn lapic_hz(&self) -> Result; + + /// Verifies, once after the partition is built, that identity routing is + /// installed, that the synchronized TSC set is available, and that the + /// guest TSC is not scaled. Fails with `E_IDENTITY_ROUTING`, + /// `E_TSC_SYNC_UNSUPPORTED`, or `E_TSC_SCALING_ACTIVE`. + /// + /// Core calls it on cold boot and restore before any VP runs and before + /// any VP state is restored, so it may briefly stop partition time (MSHV + /// probes `TimeFreeze`) and read VP 0's reset state. + fn preflight(&self) -> Result; + + /// Returns the CPUID that VP 0 observes for the leaves of + /// [`TimeAbiConfig::cpuid`](super::TimeAbiConfig::cpuid), or a superset, + /// with VP 0's own APIC identity in the per-VP fields. Core compares it + /// with the CPU profile's effective CPUID under its masks + /// (`E_CPU_SURFACE`). Fails with `E_CPU_SURFACE`. + /// + /// A backend whose guests read the hypervisor's own values outside that + /// table (MSHV, WHP) also returns VP 0's view at every entry the host's + /// CPUID enumerates outside the profile's tables + /// (`cpu_profile::unlisted_cpuid_candidates`), at subleaf 0 for a + /// subleaf-independent entry. Core requires each to read zero + /// (`E_CPU_UNLISTED`), so that no reserved entry exposes a host feature. + /// + /// Core calls it with all VPs stopped, before any VP runs and before any + /// VP state is restored, on cold boot and restore. Core's first call to + /// this method or to [`Self::preflight`] comes before any VP runs, so a + /// backend that can only read VP 0's live view reads it then and keeps + /// it. + fn effective_cpuid(&self) -> Result, TimeAbiError>; + + /// Returns the CPU surface the backend supports on this host, for the CPU + /// profile's support check (`E_PROFILE_UNSUPPORTED`), which core runs at + /// every cold boot and restore. It must be cheap: capability queries and + /// the host's CPUID, never a probe partition. A backend that cannot report + /// its surface returns `None`, which fails every time ABI boot + /// (`E_PROFILE_UNSUPPORTED`). + fn supported_cpu_surface( + &self, + ) -> Result, TimeAbiError>; + + /// Takes the capture anchor: VP 0's TSC paired with + /// [`sample_host_time`](super::host::sample_host_time). Fails with + /// `E_TSC_ANCHOR` if the pair cannot be formed within the bound. + fn capture_anchor(&self) -> Result; + + /// Performs the synchronized TSC set. + /// + /// The backend takes the restore anchor, one host instant, and calls + /// `target` with the host sample of that instant. The orchestrator + /// selects the downtime there and returns the TSC target, or fails. The + /// backend then makes every instantiated VP hold that target as of the + /// anchor and verifies it by read-back before returning + /// (`E_TSC_SYNC_READBACK`). Creating a VP afterwards is an error + /// (`E_VP_LATE_CREATION`). + /// + /// Guest time runs from the anchor: a backend that stops partition time + /// for the set resumes it right after a successful read-back, before + /// returning, never at the first VP run. A failed set may leave it + /// stopped; the restore then fails. + /// + /// Core calls it once per restore, after every instantiated VP is bound + /// (which creates it on MSHV) and its saved state is restored, and before + /// any restored VP runs. A cold boot never calls it. + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result; +} + +/// How a backend delivers the identity MSR range to [`super::TimeAbiMsrs`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityMsrRoute { + /// Every access in the range exits to OpenVMM. + ExitToVmm, + /// The hypervisor serves the range natively with values verified equal + /// to the time ABI's (TBD(mshv)). + NativeVerified, +} + +/// How a backend performs the synchronized TSC set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TscSyncMethod { + /// One TSC offset for every VP, computed from one host TSC read (KVM). + CommonOffset, + /// Partition time is frozen, the target is written to every VP and read + /// back while frozen, and time resumes right after the read-back (MSHV, + /// WHP). + FrozenWrite, +} + +/// The result of [`TimeAbiBackend::preflight`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackendPreflight { + /// The identity MSR route. + pub msr_route: IdentityMsrRoute, + /// The synchronized TSC set method. + pub sync: TscSyncMethod, +} + +/// The largest accepted anchor pairing, in nanoseconds. A backend samples the +/// capture anchor, and a restore anchor that pairs a TSC read with host time, +/// a bounded number of times and keeps the tightest pair; if none is within +/// this bound, the capture or restore fails with `E_TSC_ANCHOR`. +pub const MAX_ANCHOR_PAIRING_NS: u64 = 100_000; + +/// The capture anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TscAnchor { + /// `T_c`, VP 0's TSC. + pub tsc: u64, + /// The host sample paired with `tsc`. + pub sample: HostTimeSample, + /// The pairing uncertainty: half the width of the host-time bracket + /// around the TSC read, in nanoseconds. + pub pairing_ns: u64, +} + +/// The result of [`TimeAbiBackend::set_synchronized_tsc`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TscSetReport { + /// The TSC target every VP holds as of the anchor. + pub target: u64, + /// The host sample of the restore anchor. + pub sample: HostTimeSample, + /// The read-back evidence, one entry per instantiated VP: the TSC value, + /// or the offset for [`TscSyncMethod::CommonOffset`]. + pub readback: Vec<(VpIndex, u64)>, + /// The method used. + pub method: TscSyncMethod, +} + +/// The outcome of [`negotiate_rates`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NegotiatedRates { + /// The rates to declare to the guest. + pub declared: DeclaredRates, + /// The backend's native TSC rate `F_d`, after the rate test hook. + pub native_tsc_hz: u64, + /// On restore, the accepted rate check against the snapshot's rate. + pub check: Option, +} + +/// Applies the rate policy to a backend: restore step 9 of the +/// specification, and its cold-boot equivalent when `saved` is `None`. +/// +/// A cold boot declares the native TSC rate. A restore declares the saved +/// rates after checking the native rate, perturbed by the +/// `dest-rate-offset-ppm` hook, against the tolerance, and the LAPIC rate +/// against the saved one. `hypervisor` is the backend ID (`kvm`, `mshv`, or +/// `whp`). +pub fn negotiate_rates( + backend: &dyn TimeAbiBackend, + hypervisor: &str, + saved: Option, + hooks: &TimeAbiTestHooks, +) -> Result { + let native_tsc_hz = backend.native_tsc_hz()?; + let lapic_hz = backend.lapic_hz()?; + match saved { + None => { + rate::check_lapic_hz(hypervisor, lapic_hz, None)?; + Ok(NegotiatedRates { + declared: DeclaredRates::new(native_tsc_hz, lapic_hz)?, + native_tsc_hz, + check: None, + }) + } + Some(saved) => { + let native_tsc_hz = rate::apply_rate_offset(native_tsc_hz, hooks.dest_rate_offset_ppm)?; + let check = rate::check_tsc_rate(saved.tsc_hz, native_tsc_hz)?; + rate::check_lapic_hz(hypervisor, lapic_hz, Some(saved.apic_hz))?; + Ok(NegotiatedRates { + declared: DeclaredRates::new(saved.tsc_hz, saved.apic_hz)?, + native_tsc_hz, + check: Some(check), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::TimeAbiCode; + use crate::time_abi::rate::LAPIC_HZ_HYPERV; + use crate::time_abi::rate::LAPIC_HZ_KVM; + + struct FakeBackend { + tsc_hz: Result, + lapic_hz: u64, + } + + impl TimeAbiBackend for FakeBackend { + fn native_tsc_hz(&self) -> Result { + self.tsc_hz.clone() + } + + fn lapic_hz(&self) -> Result { + Ok(self.lapic_hz) + } + + fn preflight(&self) -> Result { + unreachable!() + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + unreachable!() + } + + fn supported_cpu_surface( + &self, + ) -> Result, TimeAbiError> { + unreachable!() + } + + fn capture_anchor(&self) -> Result { + unreachable!() + } + + fn set_synchronized_tsc( + &self, + _target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + unreachable!() + } + } + + fn backend(tsc_hz: u64, lapic_hz: u64) -> FakeBackend { + FakeBackend { + tsc_hz: Ok(tsc_hz), + lapic_hz, + } + } + + const NO_HOOKS: TimeAbiTestHooks = TimeAbiTestHooks { + force_utc_downtime: false, + boot_id_mismatch: false, + dest_rate_offset_ppm: 0, + downtime_add_s: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + host_cpu_unknown: false, + }; + + #[test] + fn cold_boot_declares_the_native_rate() { + let rates = negotiate_rates( + &backend(2_194_804_000, LAPIC_HZ_KVM), + "kvm", + None, + &NO_HOOKS, + ) + .unwrap(); + assert_eq!( + rates, + NegotiatedRates { + declared: DeclaredRates { + tsc_hz: 2_194_804_000, + apic_hz: LAPIC_HZ_KVM, + }, + native_tsc_hz: 2_194_804_000, + check: None, + } + ); + assert_eq!( + negotiate_rates( + &backend(2_194_804_000, LAPIC_HZ_HYPERV), + "kvm", + None, + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + negotiate_rates(&backend(1_000, LAPIC_HZ_HYPERV), "whp", None, &NO_HOOKS) + .unwrap_err() + .code, + TimeAbiCode::TscRateImplausible + ); + let unavailable = FakeBackend { + tsc_hz: Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "no rate", + )), + lapic_hz: LAPIC_HZ_HYPERV, + }; + assert_eq!( + negotiate_rates(&unavailable, "mshv", None, &NO_HOOKS) + .unwrap_err() + .code, + TimeAbiCode::TscRateUnavailable + ); + } + + #[test] + fn restore_declares_the_saved_rates() { + let saved = DeclaredRates { + tsc_hz: 2_000_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + let rates = negotiate_rates( + &backend(2_000_400_000, LAPIC_HZ_HYPERV), + "mshv", + Some(saved), + &NO_HOOKS, + ) + .unwrap(); + assert_eq!(rates.declared, saved); + assert_eq!(rates.native_tsc_hz, 2_000_400_000); + assert_eq!(rates.check.unwrap().rate_deviation, 200 * 65_536); + + assert_eq!( + negotiate_rates( + &backend(2_000_600_000, LAPIC_HZ_HYPERV), + "mshv", + Some(saved), + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::TscRateTolerance + ); + assert_eq!( + negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_KVM), + "kvm", + Some(saved), + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + } + + #[test] + fn restore_applies_the_rate_hook() { + let saved = DeclaredRates { + tsc_hz: 2_000_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + let hooks = TimeAbiTestHooks { + dest_rate_offset_ppm: -200, + ..NO_HOOKS + }; + let rates = negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + Some(saved), + &hooks, + ) + .unwrap(); + assert_eq!(rates.native_tsc_hz, 1_999_600_000); + assert_eq!(rates.check.unwrap().rate_deviation, -200 * 65_536); + let hooks = TimeAbiTestHooks { + dest_rate_offset_ppm: 251, + ..NO_HOOKS + }; + assert_eq!( + negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + Some(saved), + &hooks + ) + .unwrap_err() + .code, + TimeAbiCode::TscRateTolerance + ); + // The hook never changes a cold boot's declared rate. + let rates = negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + None, + &hooks, + ) + .unwrap(); + assert_eq!(rates.declared.tsc_hz, 2_000_000_000); + } +} diff --git a/vmm_core/virt/src/time_abi/cpuid.rs b/vmm_core/virt/src/time_abi/cpuid.rs new file mode 100644 index 00000000000..b8950e678f7 --- /dev/null +++ b/vmm_core/virt/src/time_abi/cpuid.rs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective CPUID of a time ABI partition: its CPU profile completed with +//! OpenVMM's topology leaves, the APIC mode, and the time ABI's identity +//! leaves. Core, the backends, and their tests all build it here. + +use super::TimeAbiCode; +use super::TimeAbiError; +use crate::CpuidLeaf; +use crate::CpuidLeafSet; +use cpu_profile::CpuProfile; +use cpu_profile::CpuidResult; +use cpu_profile::EffectiveCpuid; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::ApicMode; +use vm_topology::processor::x86::X86Topology; + +impl From<&CpuidLeaf> for CpuidResult { + fn from(leaf: &CpuidLeaf) -> Self { + Self { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + } + } +} + +/// Builds the effective CPUID of a partition with `profile` and `topology`: +/// the profile completed with OpenVMM's topology leaves, each extended +/// topology leaf's terminating subleaf, the APIC mode, and the time ABI's +/// identity and explicit zero leaves. Fails with `E_CPU_SURFACE` if they do +/// not complete the profile exactly. +pub fn effective_cpuid( + profile: &CpuProfile, + topology: &ProcessorTopology, +) -> Result { + let mut topology_leaves = Vec::new(); + crate::x86::topology::topology_cpuid( + topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot build the topology CPUID of CPU profile {}: {err}", + profile.id() + ), + ) + })?; + crate::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); + let mut vm: Vec<_> = topology_leaves.iter().map(CpuidResult::from).collect(); + vm.push(cpu_profile::x2apic_cpuid(!matches!( + topology.apic_mode(), + ApicMode::XApic + ))); + let identity: Vec<_> = super::identity::identity_cpuid_leaves(topology.vp_count()) + .iter() + .map(CpuidResult::from) + .chain(super::identity::identity_zero_cpuid_leaves().map(|leaf| CpuidResult::from(&leaf))) + .collect(); + Ok(profile.effective_cpuid(&vm, &identity)?) +} + +/// Returns the CPUID results a backend programs for `effective` +/// ([`TimeAbiConfig::cpuid`](super::TimeAbiConfig::cpuid)): every result of +/// the effective CPUID, with the per-VP APIC identity bits unmasked, so that +/// the backend sets each VP's own. +pub fn backend_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { + CpuidLeafSet::new( + effective + .results() + .map(|result| { + let per_vp = crate::x86::topology::per_vp_cpuid_bits(result.function); + CpuidLeaf { + function: result.function, + index: result.index, + result: result.result, + mask: [0, 1, 2, 3].map(|register| result.mask[register] & !per_vp[register]), + } + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::identity; + use cpu_profile::CpuVendor; + use test_with_tracing::test; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use x86defs::cpuid::CacheParametersEax; + + fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// The contract between OpenVMM and the profiles: OpenVMM's topology + /// leaves and the time ABI's identity leaves complete every pinned + /// profile into an effective CPUID that satisfies the CPU time bits and + /// the identity, in both APIC modes. + #[test] + fn pinned_profiles_satisfy_the_time_abi_with_openvmm_topologies() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + identity::check_time_bits( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + true, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + identity::check_identity( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + vp_count, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + assert_eq!( + EffectiveCpuid::decode(&effective.encode()).unwrap(), + effective + ); + } + } + } + } + + /// Every vCPU of a time ABI partition has its own L1 and L2 caches, and + /// all of them share the L3 cache, as in one socket of the profile's + /// generation: in leaf 4 on Intel, and on AMD in leaf 8000001Dh, which + /// has leaf 4's layout without the core count. + #[test] + fn pinned_profiles_share_the_l3_cache_across_the_socket() { + for profile in cpu_profile::pinned_profiles() { + let (leaf, has_core_count) = match profile.cpu_vendor() { + CpuVendor::Intel => (4, true), + CpuVendor::Amd => (0x8000_001d, false), + }; + for vp_count in [1, 2, 8] { + let topology = topology(vp_count, X2ApicState::Supported); + let socket = topology.reserved_vps_per_socket(); + let effective = effective_cpuid(profile, &topology).unwrap(); + let mut levels = Vec::new(); + for subleaf in 0..16 { + let eax = CacheParametersEax::from(effective.lookup(leaf, subleaf)[0]); + if eax.cache_type() == 0 { + break; + } + let sharing = if eax.cache_level() == 3 { + socket - 1 + } else { + 0 + }; + assert_eq!( + eax.threads_sharing_cache_minus_one(), + sharing, + "{}, {vp_count} VPs, subleaf {subleaf}", + profile.id() + ); + assert_eq!( + eax.cores_per_socket_minus_one(), + if has_core_count { socket - 1 } else { 0 }, + "{}, {vp_count} VPs, subleaf {subleaf}", + profile.id() + ); + levels.push(eax.cache_level()); + } + assert_eq!(levels.last(), Some(&3), "{}", profile.id()); + } + } + } + + #[test] + fn backend_cpuid_unmasks_only_the_per_vp_apic_bits() { + for profile in cpu_profile::pinned_profiles() { + let effective = effective_cpuid(profile, &topology(2, X2ApicState::Supported)).unwrap(); + let table = backend_cpuid(&effective); + assert_eq!( + table.leaves().len(), + effective.results().count(), + "{}", + profile.id() + ); + for result in effective.results() { + let leaf = table + .leaves() + .iter() + .find(|leaf| leaf.function == result.function && leaf.index == result.index) + .unwrap_or_else(|| panic!("{}: {result:?} is missing", profile.id())); + let per_vp = crate::x86::topology::per_vp_cpuid_bits(result.function); + assert_eq!(leaf.result, result.result); + for ((mask, effective_mask), per_vp) in + leaf.mask.iter().zip(result.mask).zip(per_vp) + { + assert_eq!(*mask, effective_mask & !per_vp); + } + } + } + } +} diff --git a/vmm_core/virt/src/time_abi/downtime.rs b/vmm_core/virt/src/time_abi/downtime.rs new file mode 100644 index 00000000000..ba6e9ed5096 --- /dev/null +++ b/vmm_core/virt/src/time_abi/downtime.rs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Downtime selection and the restore TSC target. + +use super::HostIdentity; +use super::HostTimeSample; +use super::TimeAbiCode; +use super::TimeAbiError; +use super::TimeAbiTestHooks; + +/// The longest accepted downtime: 30 days. +pub const MAX_DOWNTIME_NS: u64 = 30 * 24 * 60 * 60 * 1_000_000_000; +/// The smallest disagreement between the UTC delta and a monotonic downtime +/// that is reported as a host wall-clock step. +pub const HOST_WALL_CLOCK_STEP_NS: u64 = 1_000_000_000; + +/// What capture recorded at the capture anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureTimeRecord { + /// `T_c`, the guest TSC of VP 0. + pub tsc: u64, + /// The host clocks. + pub sample: HostTimeSample, + /// The host and boot identities. + pub identity: HostIdentity, +} + +/// The source of a downtime measurement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DowntimeSource { + /// Host monotonic time on the same host boot. + HostMonotonic, + /// The host UTC delta. + Utc, +} + +/// A selected downtime `D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Downtime { + /// `D`, in nanoseconds. + pub nanos: u64, + /// The source `D` came from. + pub source: DowntimeSource, + /// On the monotonic path, the UTC delta minus the monotonic delta when + /// it exceeds [`HOST_WALL_CLOCK_STEP_NS`] in magnitude: the host + /// wall clock was stepped. It is logged and does not change `D`. + pub host_wall_clock_step_ns: Option, +} + +/// Selects the downtime source and measures `D` from the capture record to +/// the destination sample `now`. +/// +/// Host monotonic time is used iff the host identity, boot identity, and +/// clock kind all match and no test hook forces UTC; otherwise the UTC delta +/// is used. Test hooks apply as specified: `utc_offset_ms` to the destination +/// UTC reading and `downtime_add_s` to `D` before the bounds check. +pub fn select_downtime( + capture: &CaptureTimeRecord, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, +) -> Result { + let utc_now = i128::from(now.utc_ns) + i128::from(hooks.utc_offset_ms) * 1_000_000; + let utc_delta = utc_now - i128::from(capture.sample.utc_ns); + let same_boot = capture.identity.host_id == destination.host_id + && capture.identity.boot_id == destination.boot_id + && capture.identity.clock == destination.clock + && !hooks.boot_id_mismatch; + + let (source, measured, host_wall_clock_step_ns) = if same_boot && !hooks.force_utc_downtime { + let delta = i128::from(now.monotonic_ns) - i128::from(capture.sample.monotonic_ns); + let step = utc_delta - delta; + let step = (step.unsigned_abs() > u128::from(HOST_WALL_CLOCK_STEP_NS)) + .then(|| i64::try_from(step).unwrap_or(if step < 0 { i64::MIN } else { i64::MAX })); + (DowntimeSource::HostMonotonic, delta, step) + } else { + (DowntimeSource::Utc, utc_delta, None) + }; + + let downtime = measured + i128::from(hooks.downtime_add_s) * 1_000_000_000; + if downtime < 0 { + return Err(TimeAbiError::new( + TimeAbiCode::DowntimeNegative, + format!("downtime from the {source:?} source is negative: {downtime} ns"), + )); + } + if downtime > i128::from(MAX_DOWNTIME_NS) { + return Err(TimeAbiError::new( + TimeAbiCode::DowntimeExcessive, + format!( + "downtime from the {source:?} source is {downtime} ns, above {MAX_DOWNTIME_NS} ns" + ), + )); + } + Ok(Downtime { + nanos: downtime as u64, + source, + host_wall_clock_step_ns, + }) +} + +/// Returns the restore TSC target `T_c + floor(D * F_s / 1_000_000_000)`. +pub fn tsc_target(capture_tsc: u64, downtime_ns: u64, tsc_hz: u64) -> Result { + let cycles = u128::from(downtime_ns) * u128::from(tsc_hz) / 1_000_000_000; + u64::try_from(u128::from(capture_tsc) + cycles).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::TscTargetOverflow, + format!( + "TSC target from {capture_tsc} plus {downtime_ns} ns at {tsc_hz} Hz exceeds 64 bits" + ), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::HostClockKind; + + const SECOND: u64 = 1_000_000_000; + + fn identity(host: u8, boot: u8) -> HostIdentity { + HostIdentity { + host_id: [host; 16], + boot_id: [boot; 16], + clock: HostClockKind::LinuxBoottime, + } + } + + fn capture() -> CaptureTimeRecord { + CaptureTimeRecord { + tsc: 1_000, + sample: HostTimeSample { + utc_ns: 1_700_000_000 * SECOND, + monotonic_ns: 100 * SECOND, + }, + identity: identity(1, 1), + } + } + + fn after(seconds_utc: u64, seconds_monotonic: u64) -> HostTimeSample { + let capture = capture(); + HostTimeSample { + utc_ns: capture.sample.utc_ns + seconds_utc * SECOND, + monotonic_ns: capture.sample.monotonic_ns + seconds_monotonic * SECOND, + } + } + + fn select( + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, + ) -> Result { + select_downtime(&capture(), destination, now, hooks) + } + + #[test] + fn same_boot_uses_host_monotonic_time() { + let downtime = select(&identity(1, 1), &after(7, 5), &Default::default()).unwrap(); + assert_eq!( + downtime, + Downtime { + nanos: 5 * SECOND, + source: DowntimeSource::HostMonotonic, + host_wall_clock_step_ns: Some(2 * SECOND as i64), + } + ); + let downtime = select(&identity(1, 1), &after(6, 5), &Default::default()).unwrap(); + assert_eq!(downtime.host_wall_clock_step_ns, None); + } + + #[test] + fn any_identity_difference_uses_utc() { + let mut other_clock = identity(1, 1); + other_clock.clock = HostClockKind::WindowsInterruptTime; + for destination in [identity(2, 1), identity(1, 2), other_clock] { + let downtime = select(&destination, &after(7, 0), &Default::default()).unwrap(); + assert_eq!(downtime.source, DowntimeSource::Utc); + assert_eq!(downtime.nanos, 7 * SECOND); + assert_eq!(downtime.host_wall_clock_step_ns, None); + } + } + + #[test] + fn hooks_force_utc() { + for hooks in [ + TimeAbiTestHooks { + force_utc_downtime: true, + ..Default::default() + }, + TimeAbiTestHooks { + boot_id_mismatch: true, + ..Default::default() + }, + ] { + let downtime = select(&identity(1, 1), &after(7, 5), &hooks).unwrap(); + assert_eq!(downtime.source, DowntimeSource::Utc); + assert_eq!(downtime.nanos, 7 * SECOND); + } + } + + #[test] + fn bounds() { + let max_seconds = MAX_DOWNTIME_NS / SECOND; + let downtime = + select(&identity(2, 2), &after(max_seconds, 0), &Default::default()).unwrap(); + assert_eq!(downtime.nanos, MAX_DOWNTIME_NS); + assert_eq!( + select( + &identity(2, 2), + &after(max_seconds + 1, 0), + &Default::default() + ) + .unwrap_err() + .code, + TimeAbiCode::DowntimeExcessive + ); + let hooks = TimeAbiTestHooks { + downtime_add_s: 2_592_001, + ..Default::default() + }; + assert_eq!( + select(&identity(1, 1), &after(0, 0), &hooks) + .unwrap_err() + .code, + TimeAbiCode::DowntimeExcessive + ); + } + + #[test] + fn negative_downtime_is_rejected() { + let mut now = after(0, 0); + now.utc_ns -= 1; + assert_eq!( + select(&identity(2, 2), &now, &Default::default()) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + let mut now = after(0, 0); + now.monotonic_ns -= 1; + assert_eq!( + select(&identity(1, 1), &now, &Default::default()) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + let hooks = TimeAbiTestHooks { + force_utc_downtime: true, + utc_offset_ms: -10_000, + ..Default::default() + }; + assert_eq!( + select(&identity(1, 1), &after(5, 5), &hooks) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + } + + #[test] + fn utc_offset_hook_applies_to_utc() { + let hooks = TimeAbiTestHooks { + utc_offset_ms: 1_500, + ..Default::default() + }; + let downtime = select(&identity(2, 2), &after(5, 0), &hooks).unwrap(); + assert_eq!(downtime.nanos, 6_500_000_000); + } + + #[test] + fn target() { + assert_eq!( + tsc_target(1_000, SECOND, 2_000_000_000).unwrap(), + 2_000_001_000 + ); + // Floors partial cycles: 1 ns at 2.5 GHz is 2.5 cycles. + assert_eq!(tsc_target(0, 1, 2_500_000_000).unwrap(), 2); + assert_eq!( + tsc_target(u64::MAX - 1, 1, 2_000_000_000).unwrap_err().code, + TimeAbiCode::TscTargetOverflow + ); + assert_eq!( + tsc_target(u64::MAX - 2, 1, 2_000_000_000).unwrap(), + u64::MAX + ); + } +} diff --git a/vmm_core/virt/src/time_abi/host.rs b/vmm_core/virt/src/time_abi/host.rs new file mode 100644 index 00000000000..dc79cd10a49 --- /dev/null +++ b/vmm_core/virt/src/time_abi/host.rs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host time samples and host identities. + +// UNSAFETY: Calling host clock and Windows registry APIs. +#![cfg_attr(any(target_os = "linux", windows), expect(unsafe_code))] + +use super::TimeAbiCode; +use super::TimeAbiError; +use mesh_protobuf::Protobuf; + +/// The host monotonic clock a sample was taken from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub enum HostClockKind { + /// Linux `CLOCK_BOOTTIME`. + #[mesh(1)] + LinuxBoottime, + /// Windows `QueryInterruptTimePrecise`, in nanoseconds. + #[mesh(2)] + WindowsInterruptTime, +} + +impl HostClockKind { + /// Returns the manifest spelling of the clock kind. + pub const fn as_str(self) -> &'static str { + match self { + Self::LinuxBoottime => "linux-boottime", + Self::WindowsInterruptTime => "windows-interrupt-time", + } + } + + /// Parses the manifest spelling of a clock kind. + pub fn from_manifest(value: &str) -> Option { + match value { + "linux-boottime" => Some(Self::LinuxBoottime), + "windows-interrupt-time" => Some(Self::WindowsInterruptTime), + _ => None, + } + } +} + +/// The identity of a host and of its current boot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct HostIdentity { + /// The host identity: `/etc/machine-id` on Linux, `MachineGuid` on + /// Windows. + #[mesh(1)] + pub host_id: [u8; 16], + /// The boot identity: `/proc/sys/kernel/random/boot_id` on Linux, + /// `PrefetchParameters\BootId` (zero-extended) on Windows. + #[mesh(2)] + pub boot_id: [u8; 16], + /// The host monotonic clock. + #[mesh(3)] + pub clock: HostClockKind, +} + +/// A pair of host clock readings taken back to back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostTimeSample { + /// Host UTC, in nanoseconds since the Unix epoch. + pub utc_ns: u64, + /// Host monotonic time, in nanoseconds, from the clock of + /// [`HostIdentity::clock`]. + pub monotonic_ns: u64, +} + +/// Samples host UTC and host monotonic time back to back. +/// +/// The monotonic clock is read on both sides of the UTC read and the midpoint +/// is returned, so the pair is consistent to within half the read time. +pub fn sample_host_time() -> Result { + let monotonic_before = sys::monotonic_ns()?; + let utc_ns = sys::utc_ns()?; + let monotonic_after = sys::monotonic_ns()?; + if monotonic_after < monotonic_before { + return Err(host_error("the host monotonic clock went backwards")); + } + Ok(HostTimeSample { + utc_ns, + monotonic_ns: monotonic_before + (monotonic_after - monotonic_before) / 2, + }) +} + +/// Returns the identity of this host and of its current boot. +pub fn host_identity() -> Result { + sys::host_identity() +} + +fn host_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::HostIdentity, message) +} + +/// Parses 32 hexadecimal digits, ignoring dashes and a trailing newline, as +/// in `/etc/machine-id`, the Linux boot ID, and the Windows machine GUID. +fn parse_hex_id(text: &str) -> Option<[u8; 16]> { + let digits: Vec = text + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .bytes() + .filter(|&b| b != b'-') + .collect(); + if digits.len() != 32 { + return None; + } + let mut id = [0; 16]; + for (i, byte) in id.iter_mut().enumerate() { + let pair = std::str::from_utf8(&digits[2 * i..2 * i + 2]).ok()?; + *byte = u8::from_str_radix(pair, 16).ok()?; + } + Some(id) +} + +/// Returns a 16-byte identity holding `value` little-endian, zero-extended. +#[cfg_attr(not(any(windows, test)), expect(dead_code))] +fn zero_extended_id(value: u32) -> [u8; 16] { + let mut id = [0; 16]; + id[..4].copy_from_slice(&value.to_le_bytes()); + id +} + +#[cfg(target_os = "linux")] +mod sys { + use super::HostClockKind; + use super::HostIdentity; + use super::host_error; + use super::parse_hex_id; + use crate::time_abi::TimeAbiError; + + fn clock_ns(clock: libc::clockid_t, name: &str) -> Result { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the + // call. + let r = unsafe { libc::clock_gettime(clock, &mut ts) }; + if r != 0 { + return Err(host_error(format!( + "cannot read {name}: {}", + std::io::Error::last_os_error() + ))); + } + let secs = + u64::try_from(ts.tv_sec).map_err(|_| host_error(format!("{name} is negative")))?; + let nanos = + u64::try_from(ts.tv_nsec).map_err(|_| host_error(format!("{name} is malformed")))?; + secs.checked_mul(1_000_000_000) + .and_then(|ns| ns.checked_add(nanos)) + .ok_or_else(|| host_error(format!("{name} overflows"))) + } + + pub(super) fn utc_ns() -> Result { + clock_ns(libc::CLOCK_REALTIME, "CLOCK_REALTIME") + } + + pub(super) fn monotonic_ns() -> Result { + clock_ns(libc::CLOCK_BOOTTIME, "CLOCK_BOOTTIME") + } + + fn read_id(path: &str) -> Result<[u8; 16], TimeAbiError> { + use std::io::Read; + + // The identity files hold 33 to 37 bytes, which one read returns. Read + // them into a stack buffer with no metadata query: every system call + // is costly in a Hyper-V root partition. + let mut buf = [0; 64]; + let mut file = std::fs::File::open(path) + .map_err(|err| host_error(format!("cannot read {path}: {err}")))?; + let len = loop { + match file.read(&mut buf) { + Ok(len) => break len, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) => return Err(host_error(format!("cannot read {path}: {err}"))), + } + }; + std::str::from_utf8(&buf[..len]) + .ok() + .and_then(parse_hex_id) + .ok_or_else(|| host_error(format!("{path} is malformed"))) + } + + pub(super) fn host_identity() -> Result { + Ok(HostIdentity { + host_id: read_id("/etc/machine-id")?, + boot_id: read_id("/proc/sys/kernel/random/boot_id")?, + clock: HostClockKind::LinuxBoottime, + }) + } +} + +#[cfg(windows)] +mod sys { + use super::HostClockKind; + use super::HostIdentity; + use super::host_error; + use super::parse_hex_id; + use super::zero_extended_id; + use crate::time_abi::TimeAbiError; + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE; + use windows_sys::Win32::System::Registry::RRF_RT_REG_DWORD; + use windows_sys::Win32::System::Registry::RRF_RT_REG_SZ; + use windows_sys::Win32::System::Registry::RegGetValueW; + use windows_sys::Win32::System::SystemInformation::GetSystemTimePreciseAsFileTime; + use windows_sys::Win32::System::WindowsProgramming::QueryInterruptTimePrecise; + + /// 100 ns intervals from 1601-01-01 to 1970-01-01. + const FILETIME_UNIX_EPOCH: u64 = 116_444_736_000_000_000; + + pub(super) fn utc_ns() -> Result { + let mut time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + // SAFETY: `time` is a valid, writable FILETIME for the duration of + // the call. + unsafe { GetSystemTimePreciseAsFileTime(&mut time) }; + let intervals = (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + intervals + .checked_sub(FILETIME_UNIX_EPOCH) + .and_then(|intervals| intervals.checked_mul(100)) + .ok_or_else(|| host_error("host UTC is outside the supported range")) + } + + pub(super) fn monotonic_ns() -> Result { + let mut intervals = 0; + // SAFETY: `intervals` is a valid, writable u64 for the duration of + // the call. + unsafe { QueryInterruptTimePrecise(&mut intervals) }; + intervals + .checked_mul(100) + .ok_or_else(|| host_error("host interrupt time overflows")) + } + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() + } + + fn read_value( + key: &str, + value: &str, + flags: u32, + data: &mut [u8], + ) -> Result { + let key_wide = wide(key); + let value_wide = wide(value); + let mut len = data.len() as u32; + // SAFETY: the key and value names are NUL-terminated UTF-16 strings, + // and `data` is writable for `len` bytes, all for the duration of the + // call. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + key_wide.as_ptr(), + value_wide.as_ptr(), + flags, + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return Err(host_error(format!( + "cannot read registry value HKLM\\{key}\\{value}: {}", + std::io::Error::from_raw_os_error(status as i32) + ))); + } + Ok(len as usize) + } + + fn machine_guid() -> Result<[u8; 16], TimeAbiError> { + const KEY: &str = r"SOFTWARE\Microsoft\Cryptography"; + let mut data = [0_u8; 128]; + let len = read_value(KEY, "MachineGuid", RRF_RT_REG_SZ, &mut data)?; + let units: Vec = (0..len.min(data.len()) / 2) + .map(|i| u16::from_le_bytes([data[2 * i], data[2 * i + 1]])) + .take_while(|&unit| unit != 0) + .collect(); + let text = String::from_utf16_lossy(&units); + parse_hex_id(&text).ok_or_else(|| host_error("MachineGuid is malformed")) + } + + fn boot_id() -> Result<[u8; 16], TimeAbiError> { + const KEY: &str = r"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters"; + let mut data = [0_u8; 4]; + let len = read_value(KEY, "BootId", RRF_RT_REG_DWORD, &mut data)?; + if len != data.len() { + return Err(host_error("BootId is malformed")); + } + Ok(zero_extended_id(u32::from_le_bytes(data))) + } + + pub(super) fn host_identity() -> Result { + Ok(HostIdentity { + host_id: machine_guid()?, + boot_id: boot_id()?, + clock: HostClockKind::WindowsInterruptTime, + }) + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +mod sys { + use super::HostIdentity; + use super::host_error; + use crate::time_abi::TimeAbiError; + + pub(super) fn utc_ns() -> Result { + Err(host_error( + "host time sampling is not supported on this host", + )) + } + + pub(super) fn monotonic_ns() -> Result { + Err(host_error( + "host time sampling is not supported on this host", + )) + } + + pub(super) fn host_identity() -> Result { + Err(host_error("host identity is not supported on this host")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_ids() { + assert_eq!( + parse_hex_id("0123456789abcdef0123456789ABCDEF\n"), + Some([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef + ]) + ); + assert_eq!( + parse_hex_id("01234567-89ab-cdef-0123-456789abcdef"), + parse_hex_id("0123456789abcdef0123456789abcdef") + ); + assert_eq!( + parse_hex_id("{01234567-89ab-cdef-0123-456789abcdef}"), + parse_hex_id("0123456789abcdef0123456789abcdef") + ); + assert_eq!(parse_hex_id("0123"), None); + assert_eq!(parse_hex_id("0123456789abcdef0123456789abcdeg"), None); + assert_eq!(parse_hex_id("0123456789abcdef0123456789abcdef00"), None); + } + + #[test] + fn zero_extension() { + assert_eq!( + zero_extended_id(6), + [6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + ); + } + + #[test] + fn clock_kind_spelling() { + for kind in [ + HostClockKind::LinuxBoottime, + HostClockKind::WindowsInterruptTime, + ] { + assert_eq!(HostClockKind::from_manifest(kind.as_str()), Some(kind)); + } + assert_eq!(HostClockKind::from_manifest("utc"), None); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn host_time_samples_advance() { + let first = sample_host_time().unwrap(); + let second = sample_host_time().unwrap(); + // After 2020-01-01. + assert!(first.utc_ns > 1_577_836_800_000_000_000); + assert!(second.monotonic_ns >= first.monotonic_ns); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + #[ignore = "reads the host identity, which containers may lack"] + fn host_identity_is_available() { + let first = host_identity().unwrap(); + assert_ne!(first.host_id, [0; 16]); + assert_eq!(host_identity().unwrap(), first); + } +} diff --git a/vmm_core/virt/src/time_abi/identity.rs b/vmm_core/virt/src/time_abi/identity.rs new file mode 100644 index 00000000000..98179d76454 --- /dev/null +++ b/vmm_core/virt/src/time_abi/identity.rs @@ -0,0 +1,471 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The hypervisor identity CPUID leaves and the CPU time bits. + +use super::TimeAbiCode; +use super::TimeAbiError; +use crate::CpuidLeaf; +use crate::CpuidLeafSet; +use std::ops::RangeInclusive; + +/// The identity leaves the time ABI defines. Every other leaf in this range +/// reads as zero or as the vendor's out-of-range result. +pub const IDENTITY_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4000_00ff; +/// Every leaf a hypervisor may use, including the bases at `0x400xxx00` +/// that carry KVM, Xen, or VMware signatures. +pub const HYPERVISOR_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4fff_ffff; +/// The highest identity leaf. +pub const IDENTITY_MAX_LEAF: u32 = 0x4000_0005; +/// Leaves beyond the identity that every backend programs as zero, because +/// Linux and Hyper-V-aware software probe them and KVM cannot list the whole +/// range. +pub const IDENTITY_ZERO_LEAVES: [RangeInclusive; 2] = + [0x4000_0006..=0x4000_000f, 0x4000_0080..=0x4000_0082]; +/// `"Microsoft Hv"` in EBX, ECX, and EDX of leaf `0x40000000`. +pub const VENDOR_SIGNATURE: [u32; 3] = [0x7263_694d, 0x666f_736f, 0x7648_2074]; +/// `"Hv#1"` in EAX of leaf `0x40000001`. +pub const INTERFACE_SIGNATURE: u32 = 0x3123_7648; +/// `"NVX"` in EAX of leaf `0x40000002`. +pub const NVX_BUILD_SIGNATURE: u32 = 0x0058_564e; +/// Time ABI 1.0 in EBX of leaf `0x40000002`. +pub const TIME_ABI_VERSION_EBX: u32 = 0x0001_0000; +/// `AccessHypercallMsrs`, `AccessVpIndex`, `AccessFrequencyRegs`, and +/// `AccessTscInvariantControls` in EAX of leaf `0x40000003`. +pub const PRIVILEGES_EAX: u32 = 0x0000_8860; +/// `FrequencyRegsAvailable` in EDX of leaf `0x40000003`. +pub const FEATURES_EDX: u32 = 0x0000_0100; +/// "Never notify" spinlock retries in EBX of leaf `0x40000004`. +pub const SPINLOCK_RETRIES_EBX: u32 = 0xffff_ffff; +/// The invariant-TSC bit in EDX of leaf `0x80000007`. +pub const INVARIANT_TSC_EDX: u32 = 1 << 8; + +const LEAF_VERSION_AND_FEATURES: u32 = 0x1; +const LEAF_POWER_MANAGEMENT: u32 = 0x6; +const LEAF_EXTENDED_FEATURES: u32 = 0x7; +const LEAF_PERFORMANCE_MONITORING: u32 = 0xa; +const LEAF_CORE_CRYSTAL_CLOCK: u32 = 0x15; +const LEAF_PROCESSOR_FREQUENCY: u32 = 0x16; +const LEAF_EXTENDED_MAX: u32 = 0x8000_0000; +const LEAF_EXTENDED_FEATURES_1: u32 = 0x8000_0001; +const LEAF_ADVANCED_POWER_MANAGEMENT: u32 = 0x8000_0007; + +const ECX1_PDCM: u32 = 1 << 15; +const ECX1_TSC_DEADLINE: u32 = 1 << 24; +const ECX1_HYPERVISOR: u32 = 1 << 31; +const EDX1_TSC: u32 = 1 << 4; +const EBX7_TSC_ADJUST: u32 = 1 << 1; +const EAX6_ARAT: u32 = 1 << 2; +const EDX80000001_RDTSCP: u32 = 1 << 27; + +/// Returns the six identity leaves `0x40000000..=0x40000005` for a VM with +/// `vp_count` VPs. Every VP sees the same values. +pub fn identity_cpuid_leaves(vp_count: u32) -> [CpuidLeaf; 6] { + let [ebx, ecx, edx] = VENDOR_SIGNATURE; + [ + CpuidLeaf::new(0x4000_0000, [IDENTITY_MAX_LEAF, ebx, ecx, edx]), + CpuidLeaf::new(0x4000_0001, [INTERFACE_SIGNATURE, 0, 0, 0]), + CpuidLeaf::new( + 0x4000_0002, + [NVX_BUILD_SIGNATURE, TIME_ABI_VERSION_EBX, 0, 0], + ), + CpuidLeaf::new(0x4000_0003, [PRIVILEGES_EAX, 0, 0, FEATURES_EDX]), + CpuidLeaf::new(0x4000_0004, [0, SPINLOCK_RETRIES_EBX, 0, 0]), + CpuidLeaf::new(0x4000_0005, [vp_count, vp_count, 0, 0]), + ] +} + +/// Returns the explicit zero leaves of [`IDENTITY_ZERO_LEAVES`]. +pub fn identity_zero_cpuid_leaves() -> impl Iterator { + IDENTITY_ZERO_LEAVES + .into_iter() + .flatten() + .map(|leaf| CpuidLeaf::new(leaf, [0; 4])) +} + +/// Returns CPUID results that enforce the CPU time bits over another CPUID +/// source. Bits outside the time bits keep that source's values. +/// +/// `invariant_tsc` sets CPUID `0x80000007` EDX to the invariant-TSC bit, or to +/// zero when the backend must not expose it (TBD(whp)). +pub fn time_bits_cpuid_leaves(invariant_tsc: bool) -> Vec { + vec![ + CpuidLeaf::new(LEAF_VERSION_AND_FEATURES, [0, 0, ECX1_HYPERVISOR, EDX1_TSC]).masked([ + 0, + 0, + ECX1_HYPERVISOR | ECX1_TSC_DEADLINE | ECX1_PDCM, + EDX1_TSC, + ]), + CpuidLeaf::new(LEAF_POWER_MANAGEMENT, [EAX6_ARAT, 0, 0, 0]), + CpuidLeaf::new(LEAF_EXTENDED_FEATURES, [0; 4]) + .indexed(0) + .masked([0, EBX7_TSC_ADJUST, 0, 0]), + CpuidLeaf::new(LEAF_PERFORMANCE_MONITORING, [0; 4]), + CpuidLeaf::new(LEAF_CORE_CRYSTAL_CLOCK, [0; 4]), + CpuidLeaf::new(LEAF_PROCESSOR_FREQUENCY, [0; 4]), + CpuidLeaf::new(LEAF_EXTENDED_FEATURES_1, [0, 0, 0, EDX80000001_RDTSCP]).masked([ + 0, + 0, + 0, + EDX80000001_RDTSCP, + ]), + CpuidLeaf::new( + LEAF_ADVANCED_POWER_MANAGEMENT, + [0, 0, 0, if invariant_tsc { INVARIANT_TSC_EDX } else { 0 }], + ), + ] +} + +/// Returns the time ABI CPUID results a backend applies over its own CPUID +/// until CPU profiles land: the identity leaves, the explicit zero leaves, +/// and the CPU time bits. +/// +/// The caller must also ensure that no other leaf in +/// [`HYPERVISOR_CPUID_RANGE`] is exposed. +pub fn time_abi_cpuid(vp_count: u32, invariant_tsc: bool) -> CpuidLeafSet { + let mut leaves = time_bits_cpuid_leaves(invariant_tsc); + leaves.extend(identity_cpuid_leaves(vp_count)); + leaves.extend(identity_zero_cpuid_leaves()); + CpuidLeafSet::new(leaves) +} + +/// Wraps a CPUID lookup so that every leaf in [`HYPERVISOR_CPUID_RANGE`] +/// reads as zero. +/// +/// Backends pass this view to +/// [`X86PartitionCapabilities::from_cpuid`](crate::x86::X86PartitionCapabilities::from_cpuid), +/// so the identity leaves never make `hv1` true: the time ABI does not expose +/// the synthetic MSR, SynIC, and reference-time state that `hv1` implies. +pub fn capabilities_cpuid<'a>( + cpuid: &'a mut dyn FnMut(u32, u32) -> [u32; 4], +) -> impl FnMut(u32, u32) -> [u32; 4] + 'a { + move |leaf, subleaf| { + if HYPERVISOR_CPUID_RANGE.contains(&leaf) { + [0; 4] + } else { + cpuid(leaf, subleaf) + } + } +} + +/// Checks the identity leaves of an effective CPUID: the six leaves exactly, +/// the explicit zero leaves, and no hypervisor signature at any base +/// `0x40000100..=0x4000ff00`. `cpuid` returns zeros for leaves absent from +/// the CPUID table being checked. +pub fn check_identity( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + vp_count: u32, +) -> Result<(), TimeAbiError> { + let expected = identity_cpuid_leaves(vp_count) + .into_iter() + .chain(identity_zero_cpuid_leaves()); + for leaf in expected { + let actual = cpuid(leaf.function, 0); + if actual != leaf.result { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "CPUID {:#x} is {actual:#x?}, expected {:#x?}", + leaf.function, leaf.result + ), + )); + } + } + for base in (0x4000_0100..=0x4000_ff00).step_by(0x100) { + let [_, ebx, ecx, edx] = cpuid(base, 0); + if [ebx, ecx, edx] != [0; 3] { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!("CPUID {base:#x} carries a hypervisor signature"), + )); + } + } + Ok(()) +} + +/// Checks the CPU time bits of an effective CPUID. +/// +/// With `invariant_tsc`, CPUID `0x80000007` EDX must be exactly the +/// invariant-TSC bit; without it, EDX may also be zero (TBD(whp)). +pub fn check_time_bits( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + invariant_tsc: bool, +) -> Result<(), TimeAbiError> { + fn violation(what: &str) -> TimeAbiError { + TimeAbiError::new( + TimeAbiCode::ProfileTimeBits, + format!("CPU time bits violated: {what}"), + ) + } + + let max_basic = cpuid(0, 0)[0]; + if max_basic < LEAF_VERSION_AND_FEATURES { + return Err(violation("CPUID leaf 0x1 is missing")); + } + let [_, _, ecx, edx] = cpuid(LEAF_VERSION_AND_FEATURES, 0); + if ecx & ECX1_HYPERVISOR == 0 { + return Err(violation("CPUID.1:ECX[31] hypervisor present is clear")); + } + if ecx & ECX1_TSC_DEADLINE != 0 { + return Err(violation("CPUID.1:ECX[24] TSC-deadline timer is set")); + } + if ecx & ECX1_PDCM != 0 { + return Err(violation("CPUID.1:ECX[15] PDCM is set")); + } + if edx & EDX1_TSC == 0 { + return Err(violation("CPUID.1:EDX[4] TSC is clear")); + } + if max_basic >= LEAF_POWER_MANAGEMENT && cpuid(LEAF_POWER_MANAGEMENT, 0) != [EAX6_ARAT, 0, 0, 0] + { + return Err(violation("CPUID.6 is not ARAT only")); + } + if max_basic >= LEAF_EXTENDED_FEATURES + && cpuid(LEAF_EXTENDED_FEATURES, 0)[1] & EBX7_TSC_ADJUST != 0 + { + return Err(violation("CPUID.7.0:EBX[1] IA32_TSC_ADJUST is set")); + } + for leaf in [ + LEAF_PERFORMANCE_MONITORING, + LEAF_CORE_CRYSTAL_CLOCK, + LEAF_PROCESSOR_FREQUENCY, + ] { + if max_basic >= leaf && cpuid(leaf, 0) != [0; 4] { + return Err(violation(&format!("CPUID {leaf:#x} is not zero"))); + } + } + let max_extended = cpuid(LEAF_EXTENDED_MAX, 0)[0]; + if max_extended < LEAF_ADVANCED_POWER_MANAGEMENT { + return Err(violation("CPUID leaf 0x80000007 is missing")); + } + if cpuid(LEAF_EXTENDED_FEATURES_1, 0)[3] & EDX80000001_RDTSCP == 0 { + return Err(violation("CPUID.80000001:EDX[27] RDTSCP is clear")); + } + let apm = cpuid(LEAF_ADVANCED_POWER_MANAGEMENT, 0); + let apm_valid = apm == [0, 0, 0, INVARIANT_TSC_EDX] || (!invariant_tsc && apm == [0; 4]); + if !apm_valid { + return Err(violation("CPUID.80000007 is not invariant TSC only")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::x86::X86PartitionCapabilities; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::ApicMode; + + /// A host CPUID with every time bit wrong and a KVM signature. + fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [0x1f, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + (1, _) => [0x0005_0657, 0, 0x7ffa_fbff | ECX1_TSC_DEADLINE, 0xbfeb_fbff], + (6, _) => [0x77, 2, 9, 0], + (7, 0) => [0, 0xd19f_4fbb, 0, 0], + (0xa, _) => [0x0740_4f04, 0, 0, 0x603], + (0x15, _) => [2, 0xd4, 0x017d_7840, 0], + (0x16, _) => [0x0bb8, 0x0e74, 0x64, 0], + (0x4000_0000, _) => [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + (0x4000_0001, _) => [0x0100_8efb, 0, 0, 0], + (0x8000_0000, _) => [0x8000_0008, 0, 0, 0], + (0x8000_0001, _) => [0, 0, 0x121, 0x2c10_0800], + (0x8000_0007, _) => [0, 0, 0, 0x100], + _ => [0; 4], + } + } + + fn effective(leaf: u32, subleaf: u32, overlay: &CpuidLeafSet) -> [u32; 4] { + let base = if HYPERVISOR_CPUID_RANGE.contains(&leaf) { + [0; 4] + } else { + host_cpuid(leaf, subleaf) + }; + overlay.result(leaf, subleaf, &base) + } + + #[test] + fn identity_leaves_match_the_specification() { + let leaves = identity_cpuid_leaves(4); + let results: Vec<_> = leaves + .iter() + .map(|leaf| (leaf.function, leaf.result)) + .collect(); + assert_eq!( + results, + [ + ( + 0x4000_0000, + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074] + ), + (0x4000_0001, [0x3123_7648, 0, 0, 0]), + (0x4000_0002, [0x0058_564e, 0x0001_0000, 0, 0]), + (0x4000_0003, [0x0000_8860, 0, 0, 0x0000_0100]), + (0x4000_0004, [0, 0xffff_ffff, 0, 0]), + (0x4000_0005, [4, 4, 0, 0]), + ] + ); + let vendor: Vec = VENDOR_SIGNATURE + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect(); + assert_eq!(vendor, b"Microsoft Hv"); + assert_eq!(&INTERFACE_SIGNATURE.to_le_bytes(), b"Hv#1"); + assert_eq!(&NVX_BUILD_SIGNATURE.to_le_bytes(), b"NVX\0"); + } + + #[test] + fn overlay_enforces_identity_and_time_bits() { + let overlay = time_abi_cpuid(8, true); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + check_time_bits(&mut cpuid, true).unwrap(); + check_identity(&mut cpuid, 8).unwrap(); + // Bits outside the time bits keep the host's values. + assert_eq!( + cpuid(1, 0)[2], + (0x7ffa_fbff & !(ECX1_TSC_DEADLINE | ECX1_PDCM)) | ECX1_HYPERVISOR + ); + assert_eq!(cpuid(7, 0)[1], 0xd19f_4fbb & !EBX7_TSC_ADJUST); + assert_eq!(cpuid(0x8000_0001, 0)[3], 0x2c10_0800 | EDX80000001_RDTSCP); + assert_eq!(cpuid(0x15, 0), [0; 4]); + } + + #[test] + fn invariant_tsc_may_be_clear_only_when_allowed() { + let overlay = time_abi_cpuid(1, false); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + check_time_bits(&mut cpuid, false).unwrap(); + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits + ); + } + + #[test] + fn time_bit_violations_are_rejected() { + let mut host = host_cpuid; + assert_eq!( + check_time_bits(&mut host, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits + ); + let overlay = time_abi_cpuid(1, true); + for (leaf, subleaf, register, bit) in [ + (1, 0, 2, ECX1_TSC_DEADLINE), + (1, 0, 2, ECX1_PDCM), + (6, 0, 1, 1), + (7, 0, 1, EBX7_TSC_ADJUST), + (0xa, 0, 0, 1), + (0x15, 0, 2, 1), + (0x8000_0007, 0, 3, 1), + ] { + let mut cpuid = |l, s| { + let mut result = effective(l, s, &overlay); + if (l, s) == (leaf, subleaf) { + result[register] |= bit; + } + result + }; + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits, + "{leaf:#x}" + ); + } + for (leaf, register, bit) in [ + (1, 2, ECX1_HYPERVISOR), + (1, 3, EDX1_TSC), + (0x8000_0001, 3, EDX80000001_RDTSCP), + ] { + let mut cpuid = |l, s| { + let mut result = effective(l, s, &overlay); + if l == leaf { + result[register] &= !bit; + } + result + }; + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits, + "{leaf:#x}" + ); + } + } + + #[test] + fn identity_check_rejects_other_signatures() { + let overlay = time_abi_cpuid(2, true); + let mut cpuid = |leaf, subleaf| { + if leaf == 0x4000_0100 { + [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d] + } else { + effective(leaf, subleaf, &overlay) + } + }; + assert_eq!( + check_identity(&mut cpuid, 2).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + assert_eq!( + check_identity(&mut cpuid, 3).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + // A "VS#1" interface signature, which the explicit zero leaves rule + // out. + let mut cpuid = |leaf, subleaf| { + if leaf == 0x4000_0081 { + [0x3123_5356, 0, 0, 0] + } else { + effective(leaf, subleaf, &overlay) + } + }; + assert_eq!( + check_identity(&mut cpuid, 2).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn overlay_programs_explicit_zero_leaves() { + let overlay = time_abi_cpuid(1, true); + let listed: Vec = overlay + .leaves() + .iter() + .map(|leaf| leaf.function) + .filter(|function| HYPERVISOR_CPUID_RANGE.contains(function)) + .collect(); + let mut expected: Vec = (0x4000_0000..=0x4000_000f).collect(); + expected.extend(0x4000_0080..=0x4000_0082); + assert_eq!(listed, expected); + for leaf in overlay.leaves() { + if (0x4000_0006..=0x4000_0082).contains(&leaf.function) { + assert_eq!((leaf.result, leaf.mask), ([0; 4], [!0; 4])); + } + } + } + + #[test] + fn capabilities_ignore_the_identity() { + let topology = TopologyBuilder::new_x86().build(1).unwrap(); + let x2apic = topology.apic_mode() != ApicMode::XApic; + let overlay = time_abi_cpuid(1, true); + let mut cpuid = |leaf, subleaf| { + let mut result = effective(leaf, subleaf, &overlay); + if leaf == 1 { + // Match the topology's APIC mode and drop XSAVE, which the + // fake host does not describe. + result[2] &= !((1 << 21) | (1 << 26)); + if x2apic { + result[2] |= 1 << 21; + } + } + result + }; + let unmasked = X86PartitionCapabilities::from_cpuid(&topology, &mut cpuid).unwrap(); + assert!(unmasked.hv1); + let masked = + X86PartitionCapabilities::from_cpuid(&topology, &mut capabilities_cpuid(&mut cpuid)) + .unwrap(); + assert!(!masked.hv1); + assert!(!masked.tsc_deadline); + } +} diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs new file mode 100644 index 00000000000..ecab204d9c9 --- /dev/null +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI v1. +//! +//! The guest-visible time contract of the microVM: a minimal Hyper-V +//! frequency identity, the identity MSRs, the TSC rate policy, downtime +//! selection, and the backend primitives that snapshot restore needs. The +//! contract is specified in the NVX repository, `doc/design/time-abi.md`. + +#[cfg(guest_arch = "x86_64")] +pub mod backend; +#[cfg(guest_arch = "x86_64")] +pub mod cpuid; +pub mod downtime; +pub mod host; +#[cfg(guest_arch = "x86_64")] +pub mod identity; +#[cfg(guest_arch = "x86_64")] +pub mod msr; +pub mod rate; +pub mod surface; + +#[cfg(guest_arch = "x86_64")] +pub use backend::BackendPreflight; +#[cfg(guest_arch = "x86_64")] +pub use backend::IdentityMsrRoute; +#[cfg(guest_arch = "x86_64")] +pub use backend::MAX_ANCHOR_PAIRING_NS; +#[cfg(guest_arch = "x86_64")] +pub use backend::NegotiatedRates; +#[cfg(guest_arch = "x86_64")] +pub use backend::TimeAbiBackend; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscAnchor; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscSetReport; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscSyncMethod; +#[cfg(guest_arch = "x86_64")] +pub use backend::negotiate_rates; +pub use downtime::CaptureTimeRecord; +pub use downtime::Downtime; +pub use downtime::DowntimeSource; +pub use host::HostClockKind; +pub use host::HostIdentity; +pub use host::HostTimeSample; +#[cfg(guest_arch = "x86_64")] +pub use msr::TimeAbiMsrs; +pub use rate::RateCheck; + +use mesh_protobuf::Protobuf; +use std::fmt; + +/// The time ABI version this OpenVMM implements. +pub const TIME_ABI_VERSION: u32 = 1; + +macro_rules! time_abi_codes { + ($($(#[doc = $doc:literal])* $variant:ident => $name:literal,)*) => { + /// A stable failure code of the time ABI. + /// + /// The spelling returned by [`TimeAbiCode::as_str`] is part of the + /// ABI: tests and orchestrators match it. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum TimeAbiCode { + $($(#[doc = $doc])* $variant,)* + } + + impl TimeAbiCode { + /// Every code, in specification order. + pub const ALL: &[TimeAbiCode] = &[$(TimeAbiCode::$variant,)*]; + + /// Returns the stable name of the code, for example + /// `E_TSC_RATE_TOLERANCE`. + pub const fn as_str(self) -> &'static str { + match self { + $(TimeAbiCode::$variant => $name,)* + } + } + + /// Returns the code with the stable name `name`, if any. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|code| code.as_str() == name) + } + } + }; +} + +time_abi_codes! { + /// The manifest version is not the supported one. + SnapshotVersion => "E_SNAPSHOT_VERSION", + /// The time contract is missing or malformed. + ManifestTime => "E_MANIFEST_TIME", + /// The snapshot was taken on another backend. + BackendMismatch => "E_BACKEND_MISMATCH", + /// The CPU profile is not pinned in this OpenVMM. + ProfileUnknown => "E_PROFILE_UNKNOWN", + /// A CPU profile or effective-CPUID digest does not verify. + ProfileDigest => "E_PROFILE_DIGEST", + /// `--cpu-profile auto` maps the host to no profile. + ProfileHostUnknown => "E_PROFILE_HOST_UNKNOWN", + /// The CPU profile violates the CPU time bits. + ProfileTimeBits => "E_PROFILE_TIME_BITS", + /// The host CPU generation is not in the profile. + CpuGeneration => "E_CPU_GENERATION", + /// The backend lacks a feature, limit, or value of the profile. + ProfileUnsupported => "E_PROFILE_UNSUPPORTED", + /// The recomputed effective CPUID differs from the recorded one. + CpuSurface => "E_CPU_SURFACE", + /// The hypervisor presents a non-zero CPUID entry outside the profile's + /// tables, which would expose a host feature in a reserved entry. + CpuUnlisted => "E_CPU_UNLISTED", + /// The backend cannot deliver the identity CPUID or MSRs. + IdentityRouting => "E_IDENTITY_ROUTING", + /// The backend lacks the synchronized TSC set. + TscSyncUnsupported => "E_TSC_SYNC_UNSUPPORTED", + /// The guest TSC would be scaled. + TscScalingActive => "E_TSC_SCALING_ACTIVE", + /// The backend cannot report its native TSC rate. + TscRateUnavailable => "E_TSC_RATE_UNAVAILABLE", + /// A TSC rate is outside the plausible range. + TscRateImplausible => "E_TSC_RATE_IMPLAUSIBLE", + /// The destination TSC rate is outside the tolerance. + TscRateTolerance => "E_TSC_RATE_TOLERANCE", + /// The backend cannot report its LAPIC timer rate. + LapicRateUnavailable => "E_LAPIC_RATE_UNAVAILABLE", + /// The LAPIC timer rate is not the backend constant or the saved rate. + LapicRateMismatch => "E_LAPIC_RATE_MISMATCH", + /// The host identity, boot identity, or clocks are unavailable. + HostIdentity => "E_HOST_IDENTITY", + /// The downtime is negative. + DowntimeNegative => "E_DOWNTIME_NEGATIVE", + /// The downtime exceeds the maximum. + DowntimeExcessive => "E_DOWNTIME_EXCESSIVE", + /// The capture anchor is unavailable or not paired closely enough. + TscAnchor => "E_TSC_ANCHOR", + /// The restore TSC target exceeds 64 bits. + TscTargetOverflow => "E_TSC_TARGET_OVERFLOW", + /// A VP does not hold the synchronized TSC value. + TscSyncReadback => "E_TSC_SYNC_READBACK", + /// A VP was instantiated after the synchronized TSC set. + VpLateCreation => "E_VP_LATE_CREATION", + /// A periodic LAPIC timer is armed. + LapicPeriodic => "E_LAPIC_PERIODIC", + /// TSC-deadline LAPIC timer mode or state is present. + LapicTscDeadline => "E_LAPIC_TSC_DEADLINE", + /// PIT channel 0 is counting in a periodic mode. + PitActive => "E_PIT_ACTIVE", + /// The generation counter would overflow. + GenerationExhausted => "E_GENERATION_EXHAUSTED", + /// A clock token is present in the kernel command line. + CmdlineClockToken => "E_CMDLINE_CLOCK_TOKEN", + /// A test hook is malformed or unknown. + TestHook => "E_TEST_HOOK", +} + +impl fmt::Display for TimeAbiCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A time ABI failure. +/// +/// It displays as `[E_CODE] message`, so the code survives conversion to a +/// string across the worker boundary and error wrapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeAbiError { + /// The stable code. + pub code: TimeAbiCode, + /// A human-readable description with the relevant values. + pub message: String, +} + +impl TimeAbiError { + /// Returns a new error. + pub fn new(code: TimeAbiCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl fmt::Display for TimeAbiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +impl std::error::Error for TimeAbiError {} + +impl From for TimeAbiError { + /// Keeps the code: every CPU profile code is a time ABI code. + fn from(error: cpu_profile::ProfileError) -> Self { + let code = TimeAbiCode::from_name(error.code.as_str()) + .expect("every CPU profile code is a time ABI code"); + Self::new(code, error.message) + } +} + +/// The rates the guest reads from MSRs `0x40000022` and `0x40000023`. +/// +/// They are declared once per VM process: a cold boot declares the native TSC +/// rate and a restore declares the snapshot's rates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct DeclaredRates { + /// The declared TSC rate `F`, in Hz. + #[mesh(1)] + pub tsc_hz: u64, + /// The LAPIC timer rate `L`, in Hz. + #[mesh(2)] + pub apic_hz: u64, +} + +impl DeclaredRates { + /// Returns validated rates: a plausible TSC rate and a LAPIC rate that is + /// one of the backend constants. + pub fn new(tsc_hz: u64, apic_hz: u64) -> Result { + rate::check_plausible_tsc_hz(tsc_hz)?; + if apic_hz != rate::LAPIC_HZ_KVM && apic_hz != rate::LAPIC_HZ_HYPERV { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!("LAPIC timer rate {apic_hz} Hz is not a backend constant"), + )); + } + Ok(Self { tsc_hz, apic_hz }) + } +} + +/// Test hooks selected with the hidden `--x-time-abi-test-hook` option. +/// +/// Every active hook is logged and sets the `TEST_HOOKS` flag in the restore +/// packet and in every time sample. +#[derive(Debug, Clone, Default, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct TimeAbiTestHooks { + /// Select the UTC downtime source even on the same host boot. + #[mesh(1)] + pub force_utc_downtime: bool, + /// Treat the destination boot identity as different. + #[mesh(2)] + pub boot_id_mismatch: bool, + /// Perturb the measured destination TSC rate by this many ppm. + #[mesh(3)] + pub dest_rate_offset_ppm: i32, + /// Add this many seconds to the measured downtime. + #[mesh(4)] + pub downtime_add_s: u64, + /// Add this many milliseconds to every destination UTC reading. + #[mesh(5)] + pub utc_offset_ms: i64, + /// Delay the handling of time-sample selections by this many + /// microseconds. + #[mesh(6)] + pub sample_delay_us: u32, + /// On a cold boot with `--cpu-profile auto`, treat the host CPU as one + /// that no pinned CPU profile serves, so that `auto` falls back to a host + /// profile on a host that a pinned profile serves. + #[mesh(7)] + pub host_cpu_unknown: bool, +} + +/// The largest accepted `dest-rate-offset-ppm` magnitude. +pub const MAX_HOOK_RATE_OFFSET_PPM: i32 = 10_000; +/// The largest accepted `downtime-add-s` value: ten years. +pub const MAX_HOOK_DOWNTIME_ADD_S: u64 = 10 * 365 * 24 * 60 * 60; +/// The largest accepted `utc-offset-ms` magnitude: ten years. +pub const MAX_HOOK_UTC_OFFSET_MS: i64 = 10 * 365 * 24 * 60 * 60 * 1000; +/// The largest accepted `sample-delay-us` value. +pub const MAX_HOOK_SAMPLE_DELAY_US: u32 = 100_000; + +impl TimeAbiTestHooks { + /// Parses hook values such as `force-utc-downtime` or + /// `dest-rate-offset-ppm=-200`. A hook may appear at most once. + pub fn parse>(values: &[S]) -> Result { + fn invalid(value: &str, reason: &str) -> TimeAbiError { + TimeAbiError::new( + TimeAbiCode::TestHook, + format!("invalid time ABI test hook '{value}': {reason}"), + ) + } + + fn number( + value: &str, + argument: Option<&str>, + min: T, + max: T, + ) -> Result { + let argument = argument.ok_or_else(|| invalid(value, "missing value"))?; + let parsed: T = argument + .parse() + .map_err(|_| invalid(value, "malformed number"))?; + if parsed < min || parsed > max { + return Err(invalid(value, "value out of range")); + } + Ok(parsed) + } + + let mut hooks = Self::default(); + let mut seen = Vec::new(); + for value in values { + let value = value.as_ref(); + let (name, argument) = match value.split_once('=') { + Some((name, argument)) => (name, Some(argument)), + None => (value, None), + }; + if seen.contains(&name) { + return Err(invalid(value, "repeated hook")); + } + seen.push(name); + match name { + "force-utc-downtime" | "boot-id-mismatch" | "host-cpu-unknown" + if argument.is_some() => + { + return Err(invalid(value, "the hook takes no value")); + } + "force-utc-downtime" => hooks.force_utc_downtime = true, + "boot-id-mismatch" => hooks.boot_id_mismatch = true, + "host-cpu-unknown" => hooks.host_cpu_unknown = true, + "dest-rate-offset-ppm" => { + hooks.dest_rate_offset_ppm = number( + value, + argument, + -MAX_HOOK_RATE_OFFSET_PPM, + MAX_HOOK_RATE_OFFSET_PPM, + )?; + } + "downtime-add-s" => { + hooks.downtime_add_s = number(value, argument, 0, MAX_HOOK_DOWNTIME_ADD_S)?; + } + "utc-offset-ms" => { + hooks.utc_offset_ms = number( + value, + argument, + -MAX_HOOK_UTC_OFFSET_MS, + MAX_HOOK_UTC_OFFSET_MS, + )?; + } + "sample-delay-us" => { + hooks.sample_delay_us = number(value, argument, 0, MAX_HOOK_SAMPLE_DELAY_US)?; + } + _ => return Err(invalid(value, "unknown hook")), + } + } + Ok(hooks) + } + + /// Returns whether any hook alters time ABI behavior. + pub fn active(&self) -> bool { + *self != Self::default() + } +} + +/// Kernel parameters that configure guest clocks and that the time ABI +/// forbids. +pub const CLOCK_TOKENS: [&str; 2] = ["tsc_early_khz", "lapic_timer_hz"]; + +/// Rejects a kernel command line that sets a [`CLOCK_TOKENS`] parameter +/// (`E_CMDLINE_CLOCK_TOKEN`). +/// +/// It follows the kernel's parsing: parameters are separated by unquoted +/// whitespace, `-` and `_` are equivalent in names, a parameter may be +/// quoted as a whole, and parsing stops at the `--` delimiter, after which +/// arguments belong to init. +pub fn check_command_line_clock_tokens(cmdline: &str) -> Result<(), TimeAbiError> { + fn normalized(name: &str) -> impl Iterator + '_ { + name.bytes().map(|b| if b == b'-' { b'_' } else { b }) + } + + let mut tokens = Vec::new(); + let mut start = None; + let mut in_quote = false; + for (offset, c) in cmdline.char_indices() { + if c == '"' { + in_quote = !in_quote; + } + if c.is_ascii_whitespace() && !in_quote { + if let Some(start) = start.take() { + tokens.push(&cmdline[start..offset]); + } + } else if start.is_none() { + start = Some(offset); + } + } + if let Some(start) = start { + tokens.push(&cmdline[start..]); + } + + for token in tokens { + let unquoted = token + .strip_prefix('"') + .map_or(token, |rest| rest.strip_suffix('"').unwrap_or(rest)); + if unquoted == "--" { + break; + } + let name = unquoted.split_once('=').map_or(unquoted, |(name, _)| name); + if CLOCK_TOKENS + .iter() + .any(|clock| normalized(name).eq(normalized(clock))) + { + return Err(TimeAbiError::new( + TimeAbiCode::CmdlineClockToken, + format!("kernel command line sets the clock parameter '{token}'"), + )); + } + } + Ok(()) +} + +/// Time ABI configuration of a partition, set in +/// [`ProtoPartitionConfig::time_abi`](crate::ProtoPartitionConfig::time_abi). +/// +/// A backend that receives it must program `cpuid` after every other CPUID +/// source, remove every hypervisor-range leaf of its own, derive its +/// processor features from the CPU profile, route the identity MSR range to +/// `msrs`, derive its partition capabilities through +/// [`identity::capabilities_cpuid`], and implement [`TimeAbiBackend`]. +#[cfg(guest_arch = "x86_64")] +#[derive(Debug, Clone)] +pub struct TimeAbiConfig { + /// The complete CPUID of the partition, which the backend programs on + /// every VP: the effective CPUID of the CPU profile (every leaf and + /// subleaf of the profile, the topology leaves, and the identity and + /// explicit zero leaves). Bits it leaves unmasked are not the profile's: + /// the runtime-owned bits, and the per-VP fields (the initial APIC ID in + /// leaf 1, the x2APIC IDs in leaves 0xB and 0x1F, and the IDs in leaf + /// 0x8000001E), which the backend sets for each VP. + pub cpuid: std::sync::Arc, + /// The identity MSR handler, shared with the worker's `time-abi` state + /// unit. + pub msrs: std::sync::Arc, + /// The partition's CPU profile, selected or restored before the partition + /// is created: a pinned profile or a host profile, from which a backend + /// derives its processor features. Only the partitions that use a host + /// profile hold it. + pub cpu_profile: cpu_profile::PartitionProfile, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_errors_keep_their_codes() { + for code in cpu_profile::ProfileErrorCode::ALL { + let error = TimeAbiError::from(cpu_profile::ProfileError::new(code, "message")); + assert_eq!(error.code.as_str(), code.as_str()); + assert_eq!(error.message, "message"); + } + } + + #[test] + fn codes_match_the_specification() { + let names: Vec<_> = TimeAbiCode::ALL.iter().map(|code| code.as_str()).collect(); + assert_eq!( + names, + [ + "E_SNAPSHOT_VERSION", + "E_MANIFEST_TIME", + "E_BACKEND_MISMATCH", + "E_PROFILE_UNKNOWN", + "E_PROFILE_DIGEST", + "E_PROFILE_HOST_UNKNOWN", + "E_PROFILE_TIME_BITS", + "E_CPU_GENERATION", + "E_PROFILE_UNSUPPORTED", + "E_CPU_SURFACE", + "E_CPU_UNLISTED", + "E_IDENTITY_ROUTING", + "E_TSC_SYNC_UNSUPPORTED", + "E_TSC_SCALING_ACTIVE", + "E_TSC_RATE_UNAVAILABLE", + "E_TSC_RATE_IMPLAUSIBLE", + "E_TSC_RATE_TOLERANCE", + "E_LAPIC_RATE_UNAVAILABLE", + "E_LAPIC_RATE_MISMATCH", + "E_HOST_IDENTITY", + "E_DOWNTIME_NEGATIVE", + "E_DOWNTIME_EXCESSIVE", + "E_TSC_ANCHOR", + "E_TSC_TARGET_OVERFLOW", + "E_TSC_SYNC_READBACK", + "E_VP_LATE_CREATION", + "E_LAPIC_PERIODIC", + "E_LAPIC_TSC_DEADLINE", + "E_PIT_ACTIVE", + "E_GENERATION_EXHAUSTED", + "E_CMDLINE_CLOCK_TOKEN", + "E_TEST_HOOK", + ] + ); + } + + #[test] + fn codes_parse_from_their_names() { + for &code in TimeAbiCode::ALL { + assert_eq!(TimeAbiCode::from_name(code.as_str()), Some(code)); + } + assert_eq!(TimeAbiCode::from_name("E_NOT_A_CODE"), None); + assert_eq!(TimeAbiCode::from_name("e_test_hook"), None); + } + + #[test] + fn error_display_leads_with_the_code() { + let error = TimeAbiError::new(TimeAbiCode::TscRateTolerance, "rate too far"); + assert_eq!(error.to_string(), "[E_TSC_RATE_TOLERANCE] rate too far"); + let wrapped = anyhow::Error::new(error).context("restore failed"); + assert!(format!("{wrapped:#}").contains("[E_TSC_RATE_TOLERANCE] rate too far")); + } + + #[test] + fn declared_rates_are_validated() { + DeclaredRates::new(2_000_000_000, rate::LAPIC_HZ_KVM).unwrap(); + DeclaredRates::new(2_000_000_000, rate::LAPIC_HZ_HYPERV).unwrap(); + assert_eq!( + DeclaredRates::new(2_000_000_000, 100_000_000) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + DeclaredRates::new(100, rate::LAPIC_HZ_KVM) + .unwrap_err() + .code, + TimeAbiCode::TscRateImplausible + ); + } + + #[test] + fn clock_tokens_are_rejected() { + for cmdline in [ + "console=hvc0 tsc_early_khz=2500000", + "lapic_timer_hz=200000000 console=hvc0", + "console=hvc0 tsc-early-khz=1", + "console=hvc0 lapic-timer_hz", + r#"console=hvc0 "tsc_early_khz=1""#, + "console=hvc0\ttsc_early_khz=1\n", + ] { + assert_eq!( + check_command_line_clock_tokens(cmdline).unwrap_err().code, + TimeAbiCode::CmdlineClockToken, + "{cmdline}" + ); + } + } + + #[test] + fn clock_token_check_follows_kernel_parsing() { + for cmdline in [ + "", + "console=hvc0 clocksource=tsc", + "console=hvc0 -- tsc_early_khz=1", + r#"console=hvc0 "--" lapic_timer_hz=1"#, + r#"console=hvc0 note="tsc_early_khz=1 lapic_timer_hz=1""#, + "xtsc_early_khz=1 tsc_early_khzx=1", + ] { + check_command_line_clock_tokens(cmdline).unwrap(); + } + } + + #[test] + fn test_hooks_parse() { + let hooks = TimeAbiTestHooks::parse(&[ + "force-utc-downtime", + "boot-id-mismatch", + "dest-rate-offset-ppm=-200", + "downtime-add-s=2592001", + "utc-offset-ms=-5000", + "sample-delay-us=3000", + "host-cpu-unknown", + ]) + .unwrap(); + assert_eq!( + hooks, + TimeAbiTestHooks { + force_utc_downtime: true, + boot_id_mismatch: true, + dest_rate_offset_ppm: -200, + downtime_add_s: 2_592_001, + utc_offset_ms: -5000, + sample_delay_us: 3000, + host_cpu_unknown: true, + } + ); + assert!(hooks.active()); + assert!(!TimeAbiTestHooks::parse::<&str>(&[]).unwrap().active()); + // The CPU profile hook alone is a test hook too. + let host_cpu_unknown = TimeAbiTestHooks::parse(&["host-cpu-unknown"]).unwrap(); + assert!(host_cpu_unknown.host_cpu_unknown); + assert!(host_cpu_unknown.active()); + } + + #[test] + fn test_hooks_reject_malformed_values() { + for value in [ + "unknown", + "force-utc-downtime=1", + "host-cpu-unknown=1", + "host-cpu-unknown=", + "dest-rate-offset-ppm", + "dest-rate-offset-ppm=", + "dest-rate-offset-ppm=x", + "dest-rate-offset-ppm=10001", + "downtime-add-s=-1", + "downtime-add-s=315360001", + "utc-offset-ms=315360000001", + "sample-delay-us=100001", + ] { + assert_eq!( + TimeAbiTestHooks::parse(&[value]).unwrap_err().code, + TimeAbiCode::TestHook, + "{value}" + ); + } + assert_eq!( + TimeAbiTestHooks::parse(&["downtime-add-s=1", "downtime-add-s=2"]) + .unwrap_err() + .code, + TimeAbiCode::TestHook + ); + } +} diff --git a/vmm_core/virt/src/time_abi/msr.rs b/vmm_core/virt/src/time_abi/msr.rs new file mode 100644 index 00000000000..53a9a5c9c09 --- /dev/null +++ b/vmm_core/virt/src/time_abi/msr.rs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity MSR handler. +//! +//! OpenVMM serves the whole identity MSR range itself on every backend. Each +//! backend routes accesses in [`IDENTITY_MSR_RANGE`] to one shared +//! [`TimeAbiMsrs`] before any other MSR handling. On a partition with an AMD +//! CPU profile, each backend also serves AMD's configuration MSRs +//! ([`AMD_CONFIG_MSRS`]) with fixed values. + +use super::DeclaredRates; +use crate::x86::MsrError; +use inspect::Inspect; +use mesh_protobuf::Protobuf; +use std::ops::RangeInclusive; +use std::sync::OnceLock; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use thiserror::Error; +use vm_topology::processor::VpIndex; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SavedStateRoot; + +/// The identity MSR range. Every MSR in it that the table below does not +/// define raises #GP. +pub const IDENTITY_MSR_RANGE: RangeInclusive = 0x4000_0000..=0x4000_01ff; +/// `HV_X64_MSR_VP_INDEX`: the VP index, read only. +pub const MSR_VP_INDEX: u32 = 0x4000_0002; +/// `HV_X64_MSR_TSC_FREQUENCY`: the declared TSC rate, read only. +pub const MSR_TSC_FREQUENCY: u32 = 0x4000_0022; +/// `HV_X64_MSR_APIC_FREQUENCY`: the LAPIC timer rate, read only. +pub const MSR_APIC_FREQUENCY: u32 = 0x4000_0023; +/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`: 0 or 1, partition-wide guest state. +pub const MSR_TSC_INVARIANT_CONTROL: u32 = 0x4000_0118; + +/// `MSR_K7_HWCR`, AMD's hardware configuration register. +pub const MSR_AMD_HWCR: u32 = 0xc001_0015; +/// HWCR's `TscFreqSel`, bit 24: the TSC counts at the P0 frequency. +pub const HWCR_TSC_FREQ_SEL: u64 = 1 << 24; +/// `MSR_AMD64_DE_CFG`, AMD's decode configuration register. +pub const MSR_AMD_DE_CFG: u32 = 0xc001_1029; +/// DE_CFG's `LFENCE` serialization bit, bit 1: `LFENCE` is dispatch +/// serializing. +pub const DE_CFG_LFENCE_SERIALIZE: u64 = 1 << 1; + +/// The AMD configuration MSRs that a partition with an AMD CPU profile +/// presents on every backend, with the value that every read returns: +/// +/// - HWCR sets only `TscFreqSel`. Linux reads HWCR with an unchecked +/// `rdmsr` on every AMD CPU with an invariant TSC, which the time ABI's +/// CPUID always sets: a fault would fail the guest's `G_UNCHECKED_MSR` +/// check, and a clear `TscFreqSel` makes Linux warn that the TSC does not +/// count at the P0 frequency. +/// - DE_CFG sets only `LFENCE` serialization, which Linux otherwise tries to +/// set itself on a CPU whose CPUID does not enumerate it. +/// +/// A write of the value that a read returns succeeds and changes nothing; +/// any other write raises #GP. Linux writes neither MSR on a CPU whose CPUID +/// clears the instructions-retired counter and CPUID faulting, as the +/// profiles' derivation policy does. +pub const AMD_CONFIG_MSRS: [(u32, u64); 2] = [ + (MSR_AMD_HWCR, HWCR_TSC_FREQ_SEL), + (MSR_AMD_DE_CFG, DE_CFG_LFENCE_SERIALIZE), +]; + +/// Handles a guest read of `msr` on a partition with an AMD CPU profile: +/// the value of one of the [`AMD_CONFIG_MSRS`], or `None` for any other MSR, +/// which the backend handles as before. +pub fn amd_config_msr_read(msr: u32) -> Option { + AMD_CONFIG_MSRS + .iter() + .find(|&&(index, _)| index == msr) + .map(|&(_, value)| value) +} + +/// Handles a guest write of `value` to `msr` on a partition with an AMD CPU +/// profile, with the same contract as [`amd_config_msr_read`]: writing the +/// value that a read returns succeeds, and any other value raises #GP. +pub fn amd_config_msr_write(msr: u32, value: u64) -> Option> { + let fixed = amd_config_msr_read(msr)?; + Some(if value == fixed { + Ok(()) + } else { + Err(MsrError::InvalidAccess) + }) +} + +/// The rates were declared before, with different values. +#[derive(Debug, Error)] +#[error("time ABI rates are already declared as {0:?}")] +pub struct AlreadyDeclared(pub DeclaredRates); + +/// The identity MSR handler of one partition. +/// +/// It is shared between the backend's MSR exit path and the worker's +/// `time-abi` state unit. +#[derive(Debug, Default)] +pub struct TimeAbiMsrs { + rates: OnceLock, + tsc_invariant_control: AtomicU64, +} + +/// Saved state of the identity MSRs. +#[derive(Debug, Clone, PartialEq, Eq, Protobuf, SavedStateRoot)] +#[mesh(package = "virt.time_abi")] +pub struct TimeAbiSavedState { + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL`. + #[mesh(1)] + pub tsc_invariant_control: u64, +} + +impl TimeAbiMsrs { + /// Returns a handler with no declared rates. + pub fn new() -> Self { + Self::default() + } + + /// Declares the rates. The worker calls this once, before any VP runs; + /// declaring the same rates again is accepted. + pub fn declare(&self, rates: DeclaredRates) -> Result<(), AlreadyDeclared> { + let declared = *self.rates.get_or_init(|| rates); + if declared != rates { + return Err(AlreadyDeclared(declared)); + } + Ok(()) + } + + /// Returns the declared rates, if any. + pub fn declared(&self) -> Option { + self.rates.get().copied() + } + + /// Handles a read of `msr` by `vp`. + /// + /// Returns `None` if `msr` is outside [`IDENTITY_MSR_RANGE`], so the + /// backend handles it as before. `Some(Err(_))` means the backend must + /// inject #GP; in-range MSRs never fall through to other handlers. + pub fn read(&self, vp: VpIndex, msr: u32) -> Option> { + if !IDENTITY_MSR_RANGE.contains(&msr) { + return None; + } + let value = match msr { + MSR_VP_INDEX => Ok(vp.index().into()), + MSR_TSC_FREQUENCY | MSR_APIC_FREQUENCY => match self.declared() { + Some(rates) if msr == MSR_TSC_FREQUENCY => Ok(rates.tsc_hz), + Some(rates) => Ok(rates.apic_hz), + None => { + tracelimit::warn_ratelimited!( + vp = vp.index(), + msr, + "identity frequency MSR read before the rates were declared" + ); + Err(MsrError::InvalidAccess) + } + }, + MSR_TSC_INVARIANT_CONTROL => Ok(self.tsc_invariant_control()), + _ => Err(MsrError::InvalidAccess), + }; + Some(value) + } + + /// Handles a write of `value` to `msr` by `vp`, with the same contract as + /// [`Self::read`]. + pub fn write(&self, vp: VpIndex, msr: u32, value: u64) -> Option> { + let _ = vp; + if !IDENTITY_MSR_RANGE.contains(&msr) { + return None; + } + let result = match (msr, value) { + (MSR_TSC_INVARIANT_CONTROL, 0 | 1) => { + self.tsc_invariant_control.store(value, Ordering::Relaxed); + Ok(()) + } + _ => Err(MsrError::InvalidAccess), + }; + Some(result) + } + + /// Returns `HV_X64_MSR_TSC_INVARIANT_CONTROL`. + pub fn tsc_invariant_control(&self) -> u64 { + self.tsc_invariant_control.load(Ordering::Relaxed) + } + + /// Resets the guest state to its power-on value. The declared rates are + /// kept: they belong to the VM process. + pub fn reset(&self) { + self.tsc_invariant_control.store(0, Ordering::Relaxed); + } + + /// Saves the guest state. + pub fn save(&self) -> TimeAbiSavedState { + TimeAbiSavedState { + tsc_invariant_control: self.tsc_invariant_control(), + } + } + + /// Restores the guest state. + pub fn restore(&self, state: TimeAbiSavedState) -> Result<(), RestoreError> { + let TimeAbiSavedState { + tsc_invariant_control, + } = state; + if tsc_invariant_control > 1 { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "HV_X64_MSR_TSC_INVARIANT_CONTROL value {tsc_invariant_control} is not 0 or 1" + ))); + } + self.tsc_invariant_control + .store(tsc_invariant_control, Ordering::Relaxed); + Ok(()) + } +} + +impl Inspect for TimeAbiMsrs { + fn inspect(&self, req: inspect::Request<'_>) { + let mut resp = req.respond(); + if let Some(rates) = self.declared() { + resp.field("tsc_hz", rates.tsc_hz) + .field("apic_hz", rates.apic_hz); + } + resp.field("tsc_invariant_control", self.tsc_invariant_control()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::rate::LAPIC_HZ_HYPERV; + + const RATES: DeclaredRates = DeclaredRates { + tsc_hz: 2_100_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + + fn gp(result: Option>) -> bool { + matches!(result, Some(Err(MsrError::InvalidAccess))) + } + + #[test] + fn reads() { + let msrs = TimeAbiMsrs::new(); + let vp = VpIndex::new(3); + assert!(gp(msrs.read(vp, MSR_TSC_FREQUENCY))); + assert!(gp(msrs.read(vp, MSR_APIC_FREQUENCY))); + msrs.declare(RATES).unwrap(); + assert_eq!(msrs.read(vp, MSR_VP_INDEX).unwrap().unwrap(), 3); + assert_eq!( + msrs.read(vp, MSR_TSC_FREQUENCY).unwrap().unwrap(), + 2_100_000_000 + ); + assert_eq!( + msrs.read(vp, MSR_APIC_FREQUENCY).unwrap().unwrap(), + 200_000_000 + ); + assert_eq!( + msrs.read(vp, MSR_TSC_INVARIANT_CONTROL).unwrap().unwrap(), + 0 + ); + // Hyper-V MSRs the time ABI does not define raise #GP. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_0073, + 0x4000_01ff, + ] { + assert!(gp(msrs.read(vp, msr)), "{msr:#x}"); + } + for msr in [0x3fff_ffff, 0x4000_0200, 0x10] { + assert!(msrs.read(vp, msr).is_none(), "{msr:#x}"); + } + } + + #[test] + fn writes() { + let msrs = TimeAbiMsrs::new(); + let vp = VpIndex::BSP; + msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + assert_eq!(msrs.tsc_invariant_control(), 1); + msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 0) + .unwrap() + .unwrap(); + assert_eq!(msrs.tsc_invariant_control(), 0); + assert!(gp(msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 2))); + for msr in [ + MSR_VP_INDEX, + MSR_TSC_FREQUENCY, + MSR_APIC_FREQUENCY, + 0x4000_0001, + ] { + assert!(gp(msrs.write(vp, msr, 0)), "{msr:#x}"); + } + assert!(msrs.write(vp, 0x4000_0200, 0).is_none()); + } + + #[test] + fn amd_config_msrs_read_fixed_values_and_accept_only_them() { + assert_eq!(amd_config_msr_read(MSR_AMD_HWCR), Some(1 << 24)); + assert_eq!(amd_config_msr_read(MSR_AMD_DE_CFG), Some(1 << 1)); + for msr in [0xc001_0010, 0xc001_0114, 0x4000_0022, 0x10] { + assert_eq!(amd_config_msr_read(msr), None, "{msr:#x}"); + assert!(amd_config_msr_write(msr, 0).is_none(), "{msr:#x}"); + } + // Linux's msr_set_bit writes nothing when the bit is set already. + assert!(matches!( + amd_config_msr_write(MSR_AMD_HWCR, 1 << 24), + Some(Ok(())) + )); + assert!(matches!( + amd_config_msr_write(MSR_AMD_DE_CFG, 1 << 1), + Some(Ok(())) + )); + // Setting IRPERF_EN or McStatusWrEn, or clearing a fixed bit. + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 1 << 24 | 1 << 30))); + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 1 << 24 | 1 << 18))); + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 0))); + assert!(gp(amd_config_msr_write(MSR_AMD_DE_CFG, 0))); + } + + #[test] + fn declare_once() { + let msrs = TimeAbiMsrs::new(); + assert_eq!(msrs.declared(), None); + msrs.declare(RATES).unwrap(); + msrs.declare(RATES).unwrap(); + let other = DeclaredRates { + tsc_hz: 2_000_000_000, + ..RATES + }; + assert_eq!(msrs.declare(other).unwrap_err().0, RATES); + assert_eq!(msrs.declared(), Some(RATES)); + } + + #[test] + fn save_restore_reset() { + let msrs = TimeAbiMsrs::new(); + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + let saved = msrs.save(); + assert_eq!(saved.tsc_invariant_control, 1); + + let restored = TimeAbiMsrs::new(); + restored.restore(saved).unwrap(); + assert_eq!(restored.tsc_invariant_control(), 1); + assert!( + restored + .restore(TimeAbiSavedState { + tsc_invariant_control: 2, + }) + .is_err() + ); + assert_eq!(restored.tsc_invariant_control(), 1); + + restored.declare(RATES).unwrap(); + restored.reset(); + assert_eq!(restored.tsc_invariant_control(), 0); + assert_eq!(restored.declared(), Some(RATES)); + } +} diff --git a/vmm_core/virt/src/time_abi/rate.rs b/vmm_core/virt/src/time_abi/rate.rs new file mode 100644 index 00000000000..698246c7eeb --- /dev/null +++ b/vmm_core/virt/src/time_abi/rate.rs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The TSC rate policy and the LAPIC rate rule. + +use super::TimeAbiCode; +use super::TimeAbiError; + +/// The accepted deviation of the destination TSC rate from the declared one. +pub const TSC_TOLERANCE_PPM: u32 = 250; +/// The lowest plausible TSC rate. +pub const MIN_TSC_HZ: u64 = 500_000_000; +/// The highest plausible TSC rate. +pub const MAX_TSC_HZ: u64 = 10_000_000_000; +/// The LAPIC timer rate on KVM. +pub const LAPIC_HZ_KVM: u64 = 1_000_000_000; +/// The LAPIC timer rate on MSHV and WHP. +pub const LAPIC_HZ_HYPERV: u64 = 200_000_000; +/// The largest LAPIC divide-configuration value. +pub const MAX_LAPIC_DIVIDE: u32 = 128; + +/// Checks that `hz` is a plausible TSC rate. +pub fn check_plausible_tsc_hz(hz: u64) -> Result<(), TimeAbiError> { + if !(MIN_TSC_HZ..=MAX_TSC_HZ).contains(&hz) { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateImplausible, + format!("TSC rate {hz} Hz is outside {MIN_TSC_HZ} to {MAX_TSC_HZ} Hz"), + )); + } + Ok(()) +} + +/// Returns the LAPIC timer rate of the backend with hypervisor ID +/// `hypervisor` (`kvm`, `mshv`, or `whp`). +pub fn backend_lapic_hz(hypervisor: &str) -> Result { + match hypervisor { + "kvm" => Ok(LAPIC_HZ_KVM), + "mshv" | "whp" => Ok(LAPIC_HZ_HYPERV), + _ => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("backend '{hypervisor}' has no time ABI LAPIC rate"), + )), + } +} + +/// Checks a backend's reported LAPIC rate against its constant and, on +/// restore, against the saved rate. +pub fn check_lapic_hz( + hypervisor: &str, + actual: u64, + saved: Option, +) -> Result<(), TimeAbiError> { + let expected = backend_lapic_hz(hypervisor)?; + if actual != expected { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!( + "backend '{hypervisor}' reports LAPIC rate {actual} Hz, expected {expected} Hz" + ), + )); + } + if let Some(saved) = saved { + if saved != actual { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!( + "snapshot LAPIC rate {saved} Hz differs from the destination's {actual} Hz" + ), + )); + } + } + Ok(()) +} + +/// The outcome of an accepted TSC rate check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateCheck { + /// The declared rate `F_s`, in Hz. + pub declared_hz: u64, + /// The destination's native rate `F_d`, in Hz. + pub native_hz: u64, + /// `(F_d - F_s) / F_s` in parts per billion, rounded to nearest, for logs. + pub deviation_ppb: i64, + /// The restore packet's rate deviation: `(F_d - F_s) / F_s` in ppm + /// scaled by 2^16, rounded to nearest. + pub rate_deviation: i32, +} + +/// Applies the rate policy: accepts iff +/// `|F_d - F_s| * 1_000_000 <= TSC_TOLERANCE_PPM * F_s`, boundary included. +pub fn check_tsc_rate(declared_hz: u64, native_hz: u64) -> Result { + check_plausible_tsc_hz(declared_hz)?; + check_plausible_tsc_hz(native_hz)?; + let declared = i128::from(declared_hz); + let difference = i128::from(native_hz) - declared; + if difference.abs() * 1_000_000 > i128::from(TSC_TOLERANCE_PPM) * declared { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateTolerance, + format!( + "destination TSC rate {native_hz} Hz deviates from the declared {declared_hz} Hz by {} ppb, beyond {TSC_TOLERANCE_PPM} ppm", + div_round_nearest(difference * 1_000_000_000, declared) + ), + )); + } + // Both values are bounded by the tolerance check above. + let deviation_ppb = div_round_nearest(difference * 1_000_000_000, declared) as i64; + let rate_deviation = div_round_nearest(difference * 1_000_000 * 65_536, declared) as i32; + Ok(RateCheck { + declared_hz, + native_hz, + deviation_ppb, + rate_deviation, + }) +} + +/// Applies the `dest-rate-offset-ppm` test hook to a measured native rate. +pub fn apply_rate_offset(native_hz: u64, offset_ppm: i32) -> Result { + let native = i128::from(native_hz); + let offset = div_round_nearest(native * i128::from(offset_ppm), 1_000_000); + u64::try_from(native + offset).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::TestHook, + format!("rate offset {offset_ppm} ppm cannot apply to {native_hz} Hz"), + ) + }) +} + +/// Returns the LAPIC ticks of a counting-mode one-shot timer over +/// `downtime_ns`: `floor(D * L / 1_000_000_000 / divide)`. +/// +/// `divide` is the divide-configuration value, a power of two from 1 to +/// [`MAX_LAPIC_DIVIDE`]; other values return `None`. +pub fn lapic_ticks(downtime_ns: u64, apic_hz: u64, divide: u32) -> Option { + if !divide.is_power_of_two() || divide > MAX_LAPIC_DIVIDE { + return None; + } + let ticks = + u128::from(downtime_ns) * u128::from(apic_hz) / (1_000_000_000 * u128::from(divide)); + u64::try_from(ticks).ok() +} + +/// Divides with rounding to nearest, ties away from zero. `den` must be +/// positive. +fn div_round_nearest(num: i128, den: i128) -> i128 { + debug_assert!(den > 0); + let magnitude = (num.abs() + den / 2) / den; + if num < 0 { -magnitude } else { magnitude } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plausibility_bounds_are_inclusive() { + check_plausible_tsc_hz(MIN_TSC_HZ).unwrap(); + check_plausible_tsc_hz(MAX_TSC_HZ).unwrap(); + for hz in [0, MIN_TSC_HZ - 1, MAX_TSC_HZ + 1] { + assert_eq!( + check_plausible_tsc_hz(hz).unwrap_err().code, + TimeAbiCode::TscRateImplausible + ); + } + } + + #[test] + fn tolerance_boundary_is_accepted() { + let declared = 2_000_000_000; + // 250 ppm of 2 GHz is exactly 500 kHz. + let fast = check_tsc_rate(declared, declared + 500_000).unwrap(); + assert_eq!(fast.rate_deviation, 250 * 65_536); + assert_eq!(fast.deviation_ppb, 250_000); + let slow = check_tsc_rate(declared, declared - 500_000).unwrap(); + assert_eq!(slow.rate_deviation, -250 * 65_536); + for native in [declared + 500_001, declared - 500_001] { + assert_eq!( + check_tsc_rate(declared, native).unwrap_err().code, + TimeAbiCode::TscRateTolerance + ); + } + } + + #[test] + fn rate_deviation_rounds_to_nearest() { + let declared = 2_100_000_000; + let same = check_tsc_rate(declared, declared).unwrap(); + assert_eq!((same.rate_deviation, same.deviation_ppb), (0, 0)); + // 1 Hz of 2.1 GHz is 31.2076... in units of 2^-16 ppm. + assert_eq!( + check_tsc_rate(declared, declared + 1) + .unwrap() + .rate_deviation, + 31 + ); + assert_eq!( + check_tsc_rate(declared, declared - 1) + .unwrap() + .rate_deviation, + -31 + ); + // 200 ppm of 2.1 GHz. + let hooked = check_tsc_rate(declared, declared + 420_000).unwrap(); + assert_eq!(hooked.rate_deviation, 200 * 65_536); + } + + #[test] + fn rate_check_rejects_implausible_rates() { + assert_eq!( + check_tsc_rate(1_000, 1_000).unwrap_err().code, + TimeAbiCode::TscRateImplausible + ); + } + + #[test] + fn rate_offset_hook() { + assert_eq!( + apply_rate_offset(2_000_000_000, 251).unwrap(), + 2_000_502_000 + ); + assert_eq!( + apply_rate_offset(2_000_000_000, -200).unwrap(), + 1_999_600_000 + ); + assert_eq!(apply_rate_offset(2_000_000_000, 0).unwrap(), 2_000_000_000); + let rejected = apply_rate_offset(2_000_000_000, 251).unwrap(); + assert_eq!( + check_tsc_rate(2_000_000_000, rejected).unwrap_err().code, + TimeAbiCode::TscRateTolerance + ); + } + + #[test] + fn lapic_rates() { + assert_eq!(backend_lapic_hz("kvm").unwrap(), LAPIC_HZ_KVM); + assert_eq!(backend_lapic_hz("mshv").unwrap(), LAPIC_HZ_HYPERV); + assert_eq!(backend_lapic_hz("whp").unwrap(), LAPIC_HZ_HYPERV); + assert_eq!( + backend_lapic_hz("hvf").unwrap_err().code, + TimeAbiCode::LapicRateUnavailable + ); + check_lapic_hz("kvm", LAPIC_HZ_KVM, None).unwrap(); + check_lapic_hz("whp", LAPIC_HZ_HYPERV, Some(LAPIC_HZ_HYPERV)).unwrap(); + assert_eq!( + check_lapic_hz("whp", LAPIC_HZ_KVM, None).unwrap_err().code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + check_lapic_hz("mshv", LAPIC_HZ_HYPERV, Some(LAPIC_HZ_KVM)) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + } + + #[test] + fn lapic_tick_rule() { + // One second at 200 MHz with divide 1 and 16. + assert_eq!( + lapic_ticks(1_000_000_000, LAPIC_HZ_HYPERV, 1), + Some(200_000_000) + ); + assert_eq!( + lapic_ticks(1_000_000_000, LAPIC_HZ_HYPERV, 16), + Some(12_500_000) + ); + // Floors partial ticks. + assert_eq!(lapic_ticks(4, LAPIC_HZ_HYPERV, 1), Some(0)); + assert_eq!(lapic_ticks(5, LAPIC_HZ_HYPERV, 1), Some(1)); + assert_eq!(lapic_ticks(1_999, LAPIC_HZ_KVM, 2), Some(999)); + // Thirty days at 1 GHz. + let thirty_days = 30 * 24 * 60 * 60 * 1_000_000_000; + assert_eq!(lapic_ticks(thirty_days, LAPIC_HZ_KVM, 1), Some(thirty_days)); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 3), None); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 256), None); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 0), None); + } +} diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs new file mode 100644 index 00000000000..ced99848fc5 --- /dev/null +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host CPU facts for the CPU profile record of a snapshot, and the CPU +//! surface a backend supports. + +/// Returns the host's display signature, CPUID.1:EAX, as the VMM's host OS +/// sees it, or `None` on a host that is not x86-64. +pub fn host_cpu_signature() -> Option { + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Some(safe_intrinsics::cpuid(1, 0).eax) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + None + } +} + +/// The CPU surface a backend supports on this host, as +/// [`TimeAbiBackend::supported_cpu_surface`](super::TimeAbiBackend::supported_cpu_surface) +/// reports it: what `cpu_profile::HostCpuSurface` holds, in this crate's +/// types. +#[derive(Debug, Clone, Default)] +pub struct SupportedCpuSurface { + /// Every CPUID leaf and subleaf the backend can expose, with every + /// feature it can expose set. Masks are ignored. + pub cpuid: Vec, + /// The widest guest physical address the backend supports, in bits. + pub physical_address_width: u8, + /// The MSR values the backend can present to a guest. + pub msrs: Vec, +} + +/// The values of one MSR that a backend can present to a guest. +#[derive(Debug, Clone, Copy)] +pub struct SupportedMsrValue { + /// The MSR index. + pub index: u32, + /// The bits the backend can present set. + pub supported: u64, + /// The bits the backend can present clear, even where `supported` has + /// them. + pub controllable: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_cpu_signature_is_reported() { + assert_ne!(host_cpu_signature(), Some(0)); + } +} diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index e476b15d819..f7cdc9b99aa 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -6,6 +6,7 @@ pub mod apic_software_device; pub mod snp; pub mod topology; +pub mod tsc; pub mod vm; pub mod vp; @@ -74,6 +75,8 @@ pub struct X86PartitionCapabilities { pub sgx: bool, /// TSC_AUX is supported pub tsc_aux: bool, + /// IA32_TSC_DEADLINE is supported. + pub tsc_deadline: bool, /// The address of the virtual top of memory, for encrypted VMs. /// /// This is computed from the Hyper-V isolation leaf. It is guaranteed to be @@ -133,6 +136,7 @@ impl X86PartitionCapabilities { cet_ss: false, sgx: false, tsc_aux: false, + tsc_deadline: false, vtom: None, physical_address_width: max_physical_address_size_from_cpuid(&mut *f), snp_c_bit: snp_c_bit_from_cpuid(&mut *f), @@ -156,6 +160,7 @@ impl X86PartitionCapabilities { this.reset_rdx = result[0].into(); let features = result[2] as u64 | ((result[3] as u64) << 32); this.x2apic = features & (1 << 21) != 0; + this.tsc_deadline = features & (1 << 24) != 0; xsave = features & (1 << 26) != 0; hypervisor = features & (1 << 31) != 0; } diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index ebe60d16b0b..87686f0417c 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -3,16 +3,21 @@ //! Provides processor topology related cpuid leaves. +mod bsp_identity; + use crate::CpuidLeaf; use std::cmp::min; use thiserror::Error; use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::VpIndex; +use vm_topology::processor::x86::X86Topology; use x86defs::cpuid::CacheParametersEax; use x86defs::cpuid::CpuidFunction; use x86defs::cpuid::ExtendedAddressSpaceSizesEcx; use x86defs::cpuid::ExtendedTopologyEax; use x86defs::cpuid::ExtendedTopologyEbx; use x86defs::cpuid::ExtendedTopologyEcx; +use x86defs::cpuid::ProcessorTopologyDefinitionEax; use x86defs::cpuid::ProcessorTopologyDefinitionEbx; use x86defs::cpuid::ProcessorTopologyDefinitionEcx; use x86defs::cpuid::TopologyLevelType; @@ -28,12 +33,46 @@ pub type CpuidFn<'a> = &'a dyn Fn(u32, u32) -> [u32; 4]; #[error("unknown processor vendor {0}")] pub struct UnknownVendor(Vendor); +/// Returns the bits of a CPUID result that hold a VP's own APIC identity, +/// which backends set for each VP: the initial APIC ID in leaf 01h, the +/// x2APIC ID in leaves 0Bh and 1Fh, and the extended APIC, compute unit, and +/// node IDs in leaf 8000001Eh. The partition-wide results that +/// [`topology_cpuid`] builds carry the BSP's identity in these bits. +pub fn per_vp_cpuid_bits(function: u32) -> [u32; 4] { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => [ + 0, + VersionAndFeaturesEbx::new() + .with_initial_apic_id(0xff) + .into(), + 0, + 0, + ], + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => [0, 0, 0, !0], + CpuidFunction::ProcessorTopologyDefinition => [ + ProcessorTopologyDefinitionEax::new() + .with_extended_apic_id(!0) + .into(), + ProcessorTopologyDefinitionEbx::new() + .with_compute_unit_id(!0) + .into(), + ProcessorTopologyDefinitionEcx::new() + .with_node_id(!0) + .into(), + 0, + ], + _ => [0; 4], + } +} + /// Adds appropriately masked leaves for reporting processor topology. /// /// This includes some bits of leaves 01h and 04h, plus all of leaves 0Bh and -/// 1Fh +/// 1Fh, and on AMD processors the core count of leaf 80000008h, the +/// cache-sharing counts of leaf 8000001Dh, and leaf 8000001Eh. pub fn topology_cpuid<'a>( - topology: &'a ProcessorTopology, + topology: &'a ProcessorTopology, cpuid: CpuidFn<'a>, leaves: &mut Vec, ) -> Result<(), UnknownVendor> { @@ -44,6 +83,7 @@ pub fn topology_cpuid<'a>( return Err(UnknownVendor(vendor)); }; + let first_leaf = leaves.len(); // Set the number of VPs per socket in leaf 01h. leaves.push( CpuidLeaf::new( @@ -87,17 +127,53 @@ pub fn topology_cpuid<'a>( if vendor.is_amd_compatible() { // Add AMD-specific topology leaves here. amd_extended_address_space_sizes_cpuid(topology, leaves); + amd_cache_topology_cpuid(topology, cpuid, leaves); amd_processor_topology_definition_cpuid(topology, leaves); } + bsp_identity::apply(topology, &mut leaves[first_leaf..]); Ok(()) } +/// Adds the subleaf that ends each extended topology leaf in `leaves` (0Bh, +/// and 1Fh where present) after its levels: an invalid level with its own +/// number in `ECX[7:0]` and the BSP's x2APIC ID in `EDX`, as Intel defines +/// it. Linux reads it to end its topology enumeration. +/// +/// [`topology_cpuid`] does not add it, so partitions without the NVX time ABI +/// keep their backend's own answer there. The time ABI's effective CPUID +/// lists it; backend tests that mirror the effective CPUID call this function +/// instead of copying it. +pub fn terminate_extended_topology( + topology: &ProcessorTopology, + leaves: &mut Vec, +) { + let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; + for function in [ + CpuidFunction::ExtendedTopologyEnumeration, + CpuidFunction::V2ExtendedTopologyEnumeration, + ] { + let levels = leaves + .iter() + .filter(|leaf| leaf.function == function.0) + .count() as u32; + if levels == 0 { + continue; + } + let ecx = ExtendedTopologyEcx::new().with_level_number(levels as u8); + leaves.push( + CpuidLeaf::new(function.0, [0, 0, ecx.into(), bsp_apic_id]) + .indexed(levels) + .masked([!0; 4]), + ); + } +} + /// Adds subleaves for leaf 04h. /// /// Only valid for Intel processors. fn cache_parameters_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, cpuid: CpuidFn<'_>, leaves: &mut Vec, ) { @@ -125,7 +201,9 @@ fn cache_parameters_cpuid( } // The level 3 cache is not per-VP; indicate that it is per-socket. - if eax.cache_level() == 3 { + // The level comes from the cache's own descriptor: `eax` carries only + // the topology fields. + if CacheParametersEax::from(result[0]).cache_level() == 3 { eax.set_threads_sharing_cache_minus_one(topology.reserved_vps_per_socket() - 1); } @@ -133,13 +211,14 @@ fn cache_parameters_cpuid( .with_cores_per_socket_minus_one(0x3f) .with_threads_sharing_cache_minus_one(0xfff); + // Each subleaf describes one cache, so the result applies to subleaf + // `i` only. An unindexed result would apply to every subleaf, and a + // backend that looks up the first matching result, such as KVM, + // would then report it for every cache. leaves.push( - CpuidLeaf::new(CpuidFunction::CacheParameters.0, [eax.into(), 0, 0, 0]).masked([ - eax_mask.into(), - 0, - 0, - 0, - ]), + CpuidLeaf::new(CpuidFunction::CacheParameters.0, [eax.into(), 0, 0, 0]) + .indexed(i) + .masked([eax_mask.into(), 0, 0, 0]), ) } } @@ -149,7 +228,7 @@ fn cache_parameters_cpuid( /// The x2APIC values in edx will be zero. The caller will need to ensure /// these are set correctly for each VP. fn extended_topology_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, function: CpuidFunction, leaves: &mut Vec, ) { @@ -191,7 +270,7 @@ fn extended_topology_cpuid( /// /// This leaf contains core count and APIC ID size information. fn amd_extended_address_space_sizes_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, leaves: &mut Vec, ) { let nc = (topology.reserved_vps_per_socket() - 1) as u8; @@ -213,9 +292,55 @@ fn amd_extended_address_space_sizes_cpuid( ); } +/// Adds the cache-sharing counts of leaf 8000001Dh (Cache Topology +/// Information) for AMD processors, as [`cache_parameters_cpuid`] sets them +/// in leaf 04h for Intel processors: each VP has its own L1 and L2 caches, +/// shared only with its SMT sibling, and the socket shares the level 3 +/// cache. +/// +/// Each subleaf describes one cache, up to the first with a null cache type, +/// and only its `EAX[25:14]`, the number of logical processors sharing the +/// cache less one, changes. Linux derives each CPU's last-level cache from +/// the last cache's count, so without it a guest would see the host's L3 +/// grouping, such as one L3 per core complex. +fn amd_cache_topology_cpuid( + topology: &ProcessorTopology, + cpuid: CpuidFn<'_>, + leaves: &mut Vec, +) { + // AMD's EAX matches leaf 04h's below bit 26, where leaf 04h counts the + // cores per socket and AMD reserves the bits. + const MAX_SHARING_MINUS_ONE: u32 = 0xfff; + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + for i in 0..=255 { + let descriptor = + CacheParametersEax::from(cpuid(CpuidFunction::CacheTopologyDefinition.0, i)[0]); + if descriptor.cache_type() == 0 { + break; + } + let sharing = if descriptor.cache_level() == 3 { + topology.reserved_vps_per_socket() + } else { + threads_per_core + }; + let eax = CacheParametersEax::new() + .with_threads_sharing_cache_minus_one(min(MAX_SHARING_MINUS_ONE, sharing - 1)); + let eax_mask = + CacheParametersEax::new().with_threads_sharing_cache_minus_one(MAX_SHARING_MINUS_ONE); + leaves.push( + CpuidLeaf::new( + CpuidFunction::CacheTopologyDefinition.0, + [eax.into(), 0, 0, 0], + ) + .indexed(i) + .masked([eax_mask.into(), 0, 0, 0]), + ); + } +} + /// Adds leaf 8000001Eh (Processor Topology Definition) for AMD processors. fn amd_processor_topology_definition_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, leaves: &mut Vec, ) { // threads_per_compute_unit is (threads per core - 1). @@ -237,3 +362,249 @@ fn amd_processor_topology_definition_cpuid( .masked([0, ebx_mask.into(), ecx_mask.into(), 0]), ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CpuidLeafSet; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + + /// An Intel CPU with maximum basic leaf 0x1f and four caches in leaf 4: + /// L1 data, L1 instruction, L2, and L3. + fn intel_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [ + 0x1f, + u32::from_le_bytes(*b"Genu"), + u32::from_le_bytes(*b"ntel"), + u32::from_le_bytes(*b"ineI"), + ], + (4, 0) => [0x21, 0x1c0_003f, 0x3f, 0], + (4, 1) => [0x22, 0x1c0_003f, 0x3f, 0], + (4, 2) => [0x43, 0x3c0_003f, 0x3ff, 0], + (4, 3) => [0x63, 0x3c0_003f, 0xbfff, 4], + _ => [0; 4], + } + } + + #[test] + fn cache_results_apply_to_their_own_subleaf() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(4) + .x2apic(X2ApicState::Supported) + .build(4) + .unwrap(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + + let caches: Vec<_> = leaves + .iter() + .filter(|leaf| leaf.function == CpuidFunction::CacheParameters.0) + .map(|leaf| leaf.index) + .collect(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3)]); + + // Over each cache's own descriptor, only the topology fields change: + // 4 cores per socket. + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let native = intel_cpuid(4, subleaf); + let result = set.result(4, subleaf, &native); + assert_eq!(result[1..], native[1..], "subleaf {subleaf}"); + assert_eq!(result[0] & 0x3fff, native[0] & 0x3fff, "subleaf {subleaf}"); + assert_eq!( + CacheParametersEax::from(result[0]).cores_per_socket_minus_one(), + 3 + ); + } + // The null cache that ends the enumeration keeps its native value. + assert_eq!(set.result(4, 4, &[0; 4]), [0; 4]); + } + + /// The L1 and L2 caches belong to a core, and the L3 cache to the socket. + /// Leaf 4 counts addressable IDs, so the socket's VP count is rounded up + /// to a power of two. + #[test] + fn l3_cache_is_shared_by_the_socket() { + for vps in [1, 2, 6, 8] { + for smt in [false, true] { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(vps) + .smt_enabled(smt) + .x2apic(X2ApicState::Supported) + .build(vps) + .unwrap(); + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + let reserved = topology.reserved_vps_per_socket(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let eax = CacheParametersEax::from( + set.result(4, subleaf, &intel_cpuid(4, subleaf))[0], + ); + let sharing = if eax.cache_level() == 3 { + reserved + } else { + threads_per_core + }; + let context = format!("{vps} VPs, smt {smt}, subleaf {subleaf}"); + assert_eq!( + eax.threads_sharing_cache_minus_one() + 1, + sharing, + "{context}" + ); + assert_eq!( + eax.cores_per_socket_minus_one() + 1, + reserved / threads_per_core, + "{context}" + ); + } + } + } + } + + /// An AMD CPU with maximum basic leaf 0xd and four caches in leaf + /// 0x8000001D, as an AMD EPYC 7763 reports them through WHP: L1 data, L1 + /// instruction, L2, and L3, with no cache-sharing counts. + fn amd_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [ + 0xd, + u32::from_le_bytes(*b"Auth"), + u32::from_le_bytes(*b"cAMD"), + u32::from_le_bytes(*b"enti"), + ], + (0x8000_001d, 0) => [0x121, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 1) => [0x122, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 2) => [0x143, 0x01c0_003f, 0x3ff, 2], + (0x8000_001d, 3) => [0x163, 0x03c0_003f, 0x7fff, 1], + _ => [0; 4], + } + } + + /// On AMD, the L1 and L2 caches belong to a core, and the L3 cache to + /// the socket, as leaf 4 reports on Intel: in leaf 0x8000001D, whose + /// subleaves each describe one cache. + #[test] + fn amd_l3_cache_is_shared_by_the_socket() { + for vps in [1, 2, 6, 8] { + for smt in [false, true] { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(vps) + .smt_enabled(smt) + .x2apic(X2ApicState::Supported) + .build(vps) + .unwrap(); + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + let reserved = topology.reserved_vps_per_socket(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &amd_cpuid, &mut leaves).unwrap(); + let caches: Vec<_> = leaves + .iter() + .filter(|leaf| leaf.function == CpuidFunction::CacheTopologyDefinition.0) + .map(|leaf| leaf.index) + .collect(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3)]); + assert!( + !leaves + .iter() + .any(|leaf| leaf.function == CpuidFunction::CacheParameters.0) + ); + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let native = amd_cpuid(0x8000_001d, subleaf); + let result = set.result(0x8000_001d, subleaf, &native); + let context = format!("{vps} VPs, smt {smt}, subleaf {subleaf}"); + // Only the sharing count changes. + assert_eq!(result[1..], native[1..], "{context}"); + assert_eq!( + result[0] & !0x03ff_c000, + native[0] & !0x03ff_c000, + "{context}" + ); + let eax = CacheParametersEax::from(result[0]); + let sharing = if eax.cache_level() == 3 { + reserved + } else { + threads_per_core + }; + assert_eq!( + eax.threads_sharing_cache_minus_one() + 1, + sharing, + "{context}" + ); + } + // The null cache that ends the enumeration keeps its native + // value. + assert_eq!(set.result(0x8000_001d, 4, &[0; 4]), [0; 4]); + } + } + } + + #[test] + fn per_vp_bits_cover_the_bsp_identity() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(2) + .x2apic(X2ApicState::Supported) + .build(2) + .unwrap(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + for leaf in &leaves { + let per_vp = per_vp_cpuid_bits(leaf.function); + let expected = match CpuidFunction(leaf.function) { + CpuidFunction::VersionAndFeatures => [0, 0xff00_0000, 0, 0], + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => [0, 0, 0, !0], + _ => [0; 4], + }; + assert_eq!(per_vp, expected, "{leaf:?}"); + for (mask, bits) in leaf.mask.iter().zip(per_vp) { + assert_eq!(mask & bits, bits); + } + } + assert_ne!( + per_vp_cpuid_bits(CpuidFunction::ProcessorTopologyDefinition.0), + [0; 4] + ); + assert_eq!(per_vp_cpuid_bits(7), [0; 4]); + } + + #[test] + fn every_extended_topology_leaf_gets_a_terminator() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(4) + .x2apic(X2ApicState::Supported) + .build(4) + .unwrap(); + let mut leaves = Vec::new(); + for function in [0xb, 0x1f] { + for index in 0..2 { + leaves.push( + CpuidLeaf::new(function, [1, 2, 3, 0]) + .indexed(index) + .masked([!0; 4]), + ); + } + } + terminate_extended_topology(&topology, &mut leaves); + let terminators: Vec<_> = leaves[4..] + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect(); + assert_eq!( + terminators, + [ + (0xb, Some(2), [0, 0, 2, 0], [!0; 4]), + (0x1f, Some(2), [0, 0, 2, 0], [!0; 4]), + ] + ); + + // Without extended topology leaves, nothing is added. + let mut leaves = vec![CpuidLeaf::new(1, [0; 4])]; + terminate_extended_topology(&topology, &mut leaves); + assert_eq!(leaves.len(), 1); + } +} diff --git a/vmm_core/virt/src/x86/topology/bsp_identity.rs b/vmm_core/virt/src/x86/topology/bsp_identity.rs new file mode 100644 index 00000000000..868fbbd020e --- /dev/null +++ b/vmm_core/virt/src/x86/topology/bsp_identity.rs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reports the BSP's APIC identity in the partition-wide topology CPUID +//! leaves. Backends replace these values for each VP. + +use crate::CpuidLeaf; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::X86Topology; +use x86defs::cpuid::CpuidFunction; +use x86defs::cpuid::ProcessorTopologyDefinitionEax; +use x86defs::cpuid::ProcessorTopologyDefinitionEbx; +use x86defs::cpuid::ProcessorTopologyDefinitionEcx; +use x86defs::cpuid::VersionAndFeaturesEbx; + +/// Adds the BSP identity to the topology `leaves` built by +/// [`super::topology_cpuid`]: the initial APIC ID in leaf 01h, the x2APIC ID +/// in leaves 0Bh and 1Fh, and the extended APIC, compute unit, and node IDs +/// in leaf 8000001Eh, each included in the leaf's mask. +pub(super) fn apply(topology: &ProcessorTopology, leaves: &mut [CpuidLeaf]) { + let bsp_apic_id = topology.vp_arch(crate::VpIndex::BSP).apic_id; + for leaf in leaves { + match CpuidFunction(leaf.function) { + CpuidFunction::VersionAndFeatures => { + leaf.result[1] = VersionAndFeaturesEbx::from(leaf.result[1]) + .with_initial_apic_id(bsp_apic_id as u8) + .into(); + leaf.mask[1] = VersionAndFeaturesEbx::from(leaf.mask[1]) + .with_initial_apic_id(0xff) + .into(); + } + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => { + leaf.result[3] = bsp_apic_id; + leaf.mask[3] = !0; + } + CpuidFunction::ProcessorTopologyDefinition => { + let threads_per_compute_unit: u8 = if topology.smt_enabled() { 1 } else { 0 }; + leaf.result[0] = ProcessorTopologyDefinitionEax::from(leaf.result[0]) + .with_extended_apic_id(bsp_apic_id) + .into(); + leaf.mask[0] = ProcessorTopologyDefinitionEax::from(leaf.mask[0]) + .with_extended_apic_id(!0) + .into(); + leaf.result[1] = ProcessorTopologyDefinitionEbx::from(leaf.result[1]) + .with_compute_unit_id( + (bsp_apic_id % topology.reserved_vps_per_socket() + / (u32::from(threads_per_compute_unit) + 1)) + as u8, + ) + .into(); + leaf.mask[1] = ProcessorTopologyDefinitionEbx::from(leaf.mask[1]) + .with_compute_unit_id(!0) + .into(); + leaf.result[2] = ProcessorTopologyDefinitionEcx::from(leaf.result[2]) + .with_node_id((bsp_apic_id / topology.reserved_vps_per_socket()) as u8) + .into(); + leaf.mask[2] = ProcessorTopologyDefinitionEcx::from(leaf.mask[2]) + .with_node_id(!0) + .into(); + } + _ => {} + } + } +} diff --git a/vmm_core/virt/src/x86/tsc.rs b/vmm_core/virt/src/x86/tsc.rs new file mode 100644 index 00000000000..599e20060a4 --- /dev/null +++ b/vmm_core/virt/src/x86/tsc.rs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID leaves that expose an exact TSC frequency, for partitions without +//! the NVX time ABI. + +use thiserror::Error; +use x86defs::cpuid::CpuidFunction; + +/// The backend TSC frequency cannot be represented by CPUID leaf `0x15`. +#[derive(Debug, Error)] +pub enum TscFrequencyCpuidError { + #[error("TSC frequency must be nonzero")] + Zero, + #[error("TSC frequency {0} Hz cannot be represented by CPUID leaf 0x15")] + Unrepresentable(u64), +} + +/// Returns CPUID overrides that expose an exact virtual TSC frequency. +pub fn tsc_frequency_cpuid_leaves( + frequency_hz: u64, + current_max_basic_leaf: u32, +) -> Result<[crate::CpuidLeaf; 2], TscFrequencyCpuidError> { + fn gcd(mut left: u64, mut right: u64) -> u64 { + while right != 0 { + (left, right) = (right, left % right); + } + left + } + + if frequency_hz == 0 { + return Err(TscFrequencyCpuidError::Zero); + } + const FALLBACK_CRYSTAL_FREQUENCY_HZ: u64 = 1_000_000_000; + let (denominator, numerator, crystal_frequency_hz) = + if let Ok(frequency_hz) = u32::try_from(frequency_hz) { + (1, 1, frequency_hz) + } else { + let divisor = gcd(frequency_hz, FALLBACK_CRYSTAL_FREQUENCY_HZ); + let numerator = u32::try_from(frequency_hz / divisor) + .map_err(|_| TscFrequencyCpuidError::Unrepresentable(frequency_hz))?; + let denominator = u32::try_from(FALLBACK_CRYSTAL_FREQUENCY_HZ / divisor) + .map_err(|_| TscFrequencyCpuidError::Unrepresentable(frequency_hz))?; + (denominator, numerator, FALLBACK_CRYSTAL_FREQUENCY_HZ as u32) + }; + + Ok([ + crate::CpuidLeaf::new( + CpuidFunction::VendorAndMaxFunction.0, + [ + current_max_basic_leaf.max(CpuidFunction::CoreCrystalClockInformation.0), + 0, + 0, + 0, + ], + ) + .masked([u32::MAX, 0, 0, 0]), + crate::CpuidLeaf::new( + CpuidFunction::CoreCrystalClockInformation.0, + [denominator, numerator, crystal_frequency_hz, 0], + ), + ]) +} + +#[cfg(test)] +mod tests { + use super::tsc_frequency_cpuid_leaves; + use test_with_tracing::test; + + #[test] + fn tsc_frequency_cpuid_avoids_kernel_overflow() { + let frequency_hz = 2_194_843_733; + let leaves = tsc_frequency_cpuid_leaves(frequency_hz, 0).unwrap(); + let [denominator, numerator, crystal_frequency_hz, _] = leaves[1].result; + + assert_eq!( + u64::from(crystal_frequency_hz) * u64::from(numerator) / u64::from(denominator), + frequency_hz + ); + assert!( + (crystal_frequency_hz / 1000) + .checked_mul(numerator) + .is_some() + ); + } + + #[test] + fn tsc_frequency_cpuid_supports_large_round_frequency() { + let frequency_hz = 5_000_000_000; + let leaves = tsc_frequency_cpuid_leaves(frequency_hz, 0).unwrap(); + let [denominator, numerator, crystal_frequency_hz, _] = leaves[1].result; + + assert_eq!( + u64::from(crystal_frequency_hz) * u64::from(numerator) / u64::from(denominator), + frequency_hz + ); + } +} diff --git a/vmm_core/virt/src/x86/vp.rs b/vmm_core/virt/src/x86/vp.rs index be25442b921..cfb10b97861 100644 --- a/vmm_core/virt/src/x86/vp.rs +++ b/vmm_core/virt/src/x86/vp.rs @@ -3,6 +3,8 @@ //! Per-VP state. +mod snapshot; + use super::SegmentRegister; use super::TableRegister; use super::X86PartitionCapabilities; @@ -24,6 +26,7 @@ use hvdef::HvX64SegmentRegister; use hvdef::HvX64TableRegister; use inspect::Inspect; use mesh_protobuf::Protobuf; +pub use snapshot::TscDeadline; use std::fmt::Debug; use vm_topology::processor::x86::X86VpInfo; use x86defs::RFlags; @@ -1962,6 +1965,7 @@ state_trait! { (12, "cet", cet, set_cet, Cet), (13, "cet_ss", cet_ss, set_cet_ss, CetSs), (14, "tsc_aux", tsc_aux, set_tsc_aux, TscAux), + (15, "tsc_deadline", tsc_deadline, set_tsc_deadline, TscDeadline), // Synic state (100, "synic", synic_msrs, set_synic_msrs, SyntheticMsrs), @@ -1995,6 +1999,14 @@ state_trait! { (200, "nested_state", nested_state, set_nested_state, NestedState), } +impl VpSavedState { + /// Removes the saved TSC, so restoring this state leaves the VP's TSC to + /// the NVX time ABI's synchronized TSC set. + pub fn clear_tsc(&mut self) { + self.tsc = None; + } +} + /// Resets register state for an x86 INIT via the APIC. pub fn x86_init(access: &mut T, vp_info: &X86VpInfo) -> Result<(), T::Error> { // Reset core register and debug register state, but preserve a few bits of cr0. @@ -2025,3 +2037,25 @@ pub fn x86_init(access: &mut T, vp_info: &X86VpInfo) -> Result Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_tsc_keeps_the_other_elements() { + let mut state = VpSavedState { + tsc: Some(Tsc { value: 7 }), + tsc_aux: Some(TscAux { value: 3 }), + ..Default::default() + }; + state.clear_tsc(); + assert_eq!( + state, + VpSavedState { + tsc_aux: Some(TscAux { value: 3 }), + ..Default::default() + } + ); + } +} diff --git a/vmm_core/virt/src/x86/vp/snapshot.rs b/vmm_core/virt/src/x86/vp/snapshot.rs new file mode 100644 index 00000000000..532442c3e94 --- /dev/null +++ b/vmm_core/virt/src/x86/vp/snapshot.rs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! VP state for snapshot restore: the IA32_TSC_DEADLINE state element and +//! LAPIC timer advancement across snapshot downtime. + +use super::Apic; +use super::ApicRegisters; +use crate::state::HvRegisterState; +use crate::state::StateElement; +use crate::time_abi::TimeAbiCode; +use crate::time_abi::TimeAbiError; +use crate::time_abi::rate; +use crate::x86::X86PartitionCapabilities; +use hvdef::HvRegisterValue; +use hvdef::HvX64RegisterName; +use inspect::Inspect; +use mesh_protobuf::Protobuf; +use vm_topology::processor::x86::X86VpInfo; + +impl Apic { + fn queue_timer_interrupt(&mut self, registers: &mut ApicRegisters) { + if registers.lvt_timer & (1 << 16) != 0 { + return; + } + let vector = (registers.lvt_timer & 0xff) as usize; + if vector < 16 { + return; + } + let bank = vector / 32; + let mask = 1 << (vector % 32); + registers.irr[bank] |= mask; + registers.tmr[bank] &= !mask; + self.auto_eoi[bank] &= !mask; + } + + /// Checks the LAPIC timer against the NVX time ABI: an armed periodic + /// timer fails with `E_LAPIC_PERIODIC`, and TSC-deadline (or reserved) + /// timer mode with `E_LAPIC_TSC_DEADLINE`. + pub fn check_one_shot_timer(&self) -> Result<(), TimeAbiError> { + let registers = ApicRegisters::from_array(self.registers); + match (registers.lvt_timer >> 17) & 0x3 { + 1 if registers.timer_icr != 0 => Err(TimeAbiError::new( + TimeAbiCode::LapicPeriodic, + format!( + "the LAPIC timer is periodic with initial count {:#x}", + registers.timer_icr + ), + )), + mode @ (2 | 3) => Err(TimeAbiError::new( + TimeAbiCode::LapicTscDeadline, + format!("the LAPIC timer is in mode {mode}, not one-shot"), + )), + _ => Ok(()), + } + } + + /// Advances a counting-mode one-shot LAPIC timer over a downtime of + /// `downtime_ns` at `apic_hz`, by the time ABI's tick rule: if the ticks + /// reach the current count, the count becomes 0 and the timer interrupt + /// is queued unless the LVT is masked; otherwise the count decreases by + /// the ticks. Fails like [`Self::check_one_shot_timer`]. + pub fn advance_one_shot_timer( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> Result<(), TimeAbiError> { + self.check_one_shot_timer()?; + let mut registers = ApicRegisters::from_array(self.registers); + if registers.timer_ccr == 0 { + return Ok(()); + } + let divide = match registers.timer_dcr & 0xb { + 0xb => 1, + 0 => 2, + 1 => 4, + 2 => 8, + 3 => 16, + 8 => 32, + 9 => 64, + 0xa => 128, + _ => unreachable!(), + }; + let ticks = rate::lapic_ticks(downtime_ns, apic_hz, divide) + .expect("the divide configuration is a power of two up to 128"); + if ticks >= u64::from(registers.timer_ccr) { + registers.timer_ccr = 0; + self.queue_timer_interrupt(&mut registers); + } else { + registers.timer_ccr -= ticks as u32; + } + self.registers = *registers.as_array(); + Ok(()) + } +} + +/// IA32_TSC_DEADLINE state. +#[repr(C)] +#[derive(Default, Debug, PartialEq, Eq, Protobuf, Inspect)] +#[mesh(package = "virt.x86")] +pub struct TscDeadline { + #[mesh(1)] + #[inspect(hex)] + pub value: u64, +} + +impl HvRegisterState for TscDeadline { + fn names(&self) -> &'static [HvX64RegisterName; 1] { + &[HvX64RegisterName::TscDeadline] + } + + fn get_values<'a>(&self, it: impl Iterator) { + for (dest, src) in it.zip([self.value]) { + *dest = src.into(); + } + } + + fn set_values(&mut self, it: impl Iterator) { + for (src, dest) in it.zip([&mut self.value]) { + *dest = src.as_u64(); + } + } +} + +impl StateElement for TscDeadline { + fn is_present(caps: &X86PartitionCapabilities) -> bool { + caps.tsc_deadline + } + + fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { + Self { value: 0 } + } + + fn can_compare(_caps: &X86PartitionCapabilities) -> bool { + // A deadline can expire between restoring it and reading it back. + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + use zerocopy::FromZeros; + + const TIMER_VECTOR: u32 = 0x40; + const TIMER_MASKED: u32 = 1 << 16; + const TIMER_PERIODIC: u32 = 1 << 17; + const TIMER_TSC_DEADLINE: u32 = 2 << 17; + + fn apic(lvt_timer: u32, initial_count: u32, current_count: u32) -> Apic { + let registers = ApicRegisters { + lvt_timer, + timer_icr: initial_count, + timer_ccr: current_count, + timer_dcr: 0xb, + ..FromZeros::new_zeroed() + }; + Apic { + apic_base: 0, + registers: *registers.as_array(), + auto_eoi: [0; 8], + } + } + + fn timer_pending(apic: &Apic) -> bool { + let registers = apic.registers(); + registers.irr[TIMER_VECTOR as usize / 32] & (1 << (TIMER_VECTOR as usize % 32)) != 0 + } + + fn with_divide(apic: Apic, dcr: u32) -> Apic { + let mut registers = ApicRegisters::from_array(apic.registers); + registers.timer_dcr = dcr; + Apic { + registers: *registers.as_array(), + ..apic + } + } + + #[test] + fn time_abi_one_shot_timer_counts_down_by_the_tick_rule() { + // D = 1 ms at L = 200 MHz is 200,000 ticks before division. + for (dcr, divide) in [ + (0xb, 1), + (0, 2), + (1, 4), + (2, 8), + (3, 16), + (8, 32), + (9, 64), + (0xa, 128), + ] { + let mut timer = with_divide(apic(TIMER_VECTOR, 1_000_000, 1_000_000), dcr); + timer + .advance_one_shot_timer(1_000_000, 200_000_000) + .unwrap(); + assert_eq!(timer.registers().timer_ccr, 1_000_000 - 200_000 / divide); + assert!(!timer_pending(&timer)); + } + + // floor(999 ns * 1 GHz / 1e9) = 999 ticks. + let mut timer = apic(TIMER_VECTOR, 1_000, 1_000); + timer.advance_one_shot_timer(999, 1_000_000_000).unwrap(); + assert_eq!(timer.registers().timer_ccr, 1); + assert!(!timer_pending(&timer)); + } + + #[test] + fn time_abi_one_shot_timer_expires_once() { + let mut timer = apic(TIMER_VECTOR, 100, 50); + timer.advance_one_shot_timer(50, 1_000_000_000).unwrap(); + assert_eq!(timer.registers().timer_ccr, 0); + assert!(timer_pending(&timer)); + // An expired timer stays expired; nothing else is queued. + timer + .advance_one_shot_timer(1_000_000_000, 1_000_000_000) + .unwrap(); + assert_eq!(timer.registers().timer_ccr, 0); + + let mut masked = apic(TIMER_VECTOR | TIMER_MASKED, 100, 50); + masked + .advance_one_shot_timer(1_000_000_000, 200_000_000) + .unwrap(); + assert_eq!(masked.registers().timer_ccr, 0); + assert!(!timer_pending(&masked)); + + let mut idle = apic(TIMER_VECTOR, 0, 0); + idle.advance_one_shot_timer(1_000_000_000, 200_000_000) + .unwrap(); + assert!(!timer_pending(&idle)); + } + + #[test] + fn time_abi_rejects_periodic_and_deadline_timers() { + let periodic = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); + assert_eq!( + periodic.check_one_shot_timer().unwrap_err().code, + TimeAbiCode::LapicPeriodic + ); + let mut advanced = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); + assert_eq!( + advanced + .advance_one_shot_timer(1_000, 200_000_000) + .unwrap_err() + .code, + TimeAbiCode::LapicPeriodic + ); + // A periodic LVT with no initial count is not armed. + apic(TIMER_VECTOR | TIMER_PERIODIC, 0, 0) + .check_one_shot_timer() + .unwrap(); + + for lvt in [TIMER_TSC_DEADLINE, 3 << 17] { + assert_eq!( + apic(TIMER_VECTOR | lvt, 0, 0) + .check_one_shot_timer() + .unwrap_err() + .code, + TimeAbiCode::LapicTscDeadline + ); + } + } +} diff --git a/vmm_core/virt_kvm/Cargo.toml b/vmm_core/virt_kvm/Cargo.toml index 691447bcf80..28d2745f77c 100644 --- a/vmm_core/virt_kvm/Cargo.toml +++ b/vmm_core/virt_kvm/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [target.'cfg(target_os = "linux")'.dependencies] virt.workspace = true +cpu_profile.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true hvdef.workspace = true diff --git a/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs new file mode 100644 index 00000000000..f6bb03790d5 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID adjustments for snapshots: hiding CET-SS, whose state KVM cannot +//! save. + +use virt::CpuidLeaf; +use x86defs::cpuid::CpuidFunction; + +/// Returns the leaf that hides CET-SS from the guest. +/// +/// KVM does not expose an API to save or restore the shadow-stack +/// pointer. Do not advertise CET-SS, or a snapshot could silently lose +/// architecturally active state that the CPU contract claimed to cover. +pub(super) fn hide_cet_ss() -> CpuidLeaf { + let cet_ss_mask: u32 = x86defs::cpuid::ExtendedFeatureSubleaf0Ecx::new() + .with_cet_ss(true) + .into(); + CpuidLeaf::new(CpuidFunction::ExtendedFeatures.0, [0; 4]) + .indexed(0) + .masked([0, 0, cet_ss_mask, 0]) +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs b/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs new file mode 100644 index 00000000000..211187a22f7 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the KVM backend: the guest CPU surface and time +//! capabilities that KVM supports on this host. + +use super::Kvm; +use crate::KvmError; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; +use kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX; +use std::collections::BTreeMap; + +const METHOD: &str = "KVM_GET_SUPPORTED_CPUID, without permission for dynamically enabled \ + XSAVE features"; + +/// KVM's APIC bus cycle before `KVM_CAP_X86_APIC_BUS_CYCLES_NS` made it +/// configurable. +const DEFAULT_APIC_BUS_CYCLE_NS: u64 = 1; + +/// The KVM capabilities recorded in the fingerprint. +const CAPABILITIES: &[(&str, u32)] = &[ + ("KVM_CAP_ADJUST_CLOCK", kvm::KVM_CAP_ADJUST_CLOCK), + ("KVM_CAP_EXT_CPUID", kvm::KVM_CAP_EXT_CPUID), + ("KVM_CAP_GET_MSR_FEATURES", kvm::KVM_CAP_GET_MSR_FEATURES), + ("KVM_CAP_GET_TSC_KHZ", kvm::KVM_CAP_GET_TSC_KHZ), + ("KVM_CAP_HYPERV", kvm::KVM_CAP_HYPERV), + ( + "KVM_CAP_HYPERV_ENFORCE_CPUID", + kvm::KVM_CAP_HYPERV_ENFORCE_CPUID, + ), + ("KVM_CAP_HYPERV_SYNIC2", kvm::KVM_CAP_HYPERV_SYNIC2), + ("KVM_CAP_HYPERV_TIME", kvm::KVM_CAP_HYPERV_TIME), + ("KVM_CAP_KVMCLOCK_CTRL", kvm::KVM_CAP_KVMCLOCK_CTRL), + ("KVM_CAP_NESTED_STATE", kvm::KVM_CAP_NESTED_STATE), + ("KVM_CAP_PMU_CAPABILITY", kvm::KVM_CAP_PMU_CAPABILITY), + ("KVM_CAP_SPLIT_IRQCHIP", kvm::KVM_CAP_SPLIT_IRQCHIP), + ("KVM_CAP_SYS_ATTRIBUTES", kvm::KVM_CAP_SYS_ATTRIBUTES), + ("KVM_CAP_TSC_CONTROL", kvm::KVM_CAP_TSC_CONTROL), + ( + "KVM_CAP_TSC_DEADLINE_TIMER", + kvm::KVM_CAP_TSC_DEADLINE_TIMER, + ), + ("KVM_CAP_VCPU_ATTRIBUTES", kvm::KVM_CAP_VCPU_ATTRIBUTES), + ("KVM_CAP_VM_TSC_CONTROL", kvm::KVM_CAP_VM_TSC_CONTROL), + ("KVM_CAP_X2APIC_API", kvm::KVM_CAP_X2APIC_API), + ( + "KVM_CAP_X86_APIC_BUS_CYCLES_NS", + kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS, + ), + ("KVM_CAP_X86_DISABLE_EXITS", kvm::KVM_CAP_X86_DISABLE_EXITS), + ("KVM_CAP_X86_MSR_FILTER", kvm::KVM_CAP_X86_MSR_FILTER), + ("KVM_CAP_X86_NOTIFY_VMEXIT", kvm::KVM_CAP_X86_NOTIFY_VMEXIT), + ( + "KVM_CAP_X86_USER_SPACE_MSR", + kvm::KVM_CAP_X86_USER_SPACE_MSR, + ), + ("KVM_CAP_XCRS", kvm::KVM_CAP_XCRS), + ("KVM_CAP_XSAVE", kvm::KVM_CAP_XSAVE), + ("KVM_CAP_XSAVE2", kvm::KVM_CAP_XSAVE2), +]; + +/// The capabilities through which KVM routes guest MSR accesses to the VMM. +const MSR_INTERCEPT_CAPABILITIES: &[&str] = + &["KVM_CAP_X86_USER_SPACE_MSR", "KVM_CAP_X86_MSR_FILTER"]; + +fn cpuid_entry(entry: &kvm::kvm_cpuid_entry2) -> CpuidEntry { + CpuidEntry::new( + entry.function, + (entry.flags & KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0).then_some(entry.index), + [entry.eax, entry.ebx, entry.ecx, entry.edx], + ) +} + +impl Kvm { + /// Returns the guest CPU surface and time capabilities that KVM supports + /// on this host. + /// + /// The CPUID table is `KVM_GET_SUPPORTED_CPUID`. Like OpenVMM's own + /// partitions, the fingerprint does not request permission for + /// dynamically enabled XSAVE features such as AMX tile data, so KVM omits + /// them; `kvm.xcomp_guest_supp` records what KVM could support with + /// permission. The feature MSRs are KVM's MSR-based features. The TSC + /// rate and the APIC bus cycle come from a transient probe VM without + /// vCPUs. TSC offset control is `KVM_CAP_VCPU_ATTRIBUTES`, which x86 KVM + /// reports together with the `KVM_VCPU_TSC_OFFSET` attribute (Linux + /// 5.16 and later). + pub fn cpu_fingerprint(&self) -> Result { + let cpuid = self + .kvm + .supported_cpuid()? + .iter() + .map(cpuid_entry) + .collect(); + let mut fingerprint = BackendFingerprint::new("kvm", METHOD, cpuid); + + fingerprint.record( + "kvm.api_version", + self.kvm.api_version().map(|version| version as u64), + ); + let mut capabilities = BTreeMap::new(); + for &(name, capability) in CAPABILITIES { + if let Some(value) = fingerprint.record( + &format!("kvm.cap.{name}"), + self.kvm + .check_extension(capability) + .map(|value| value as u64), + ) { + capabilities.insert(name, value); + } + } + let has_capability = |name| capabilities.get(name).is_some_and(|&value| value != 0); + + match self.kvm.msr_feature_index_list() { + Ok(indices) => { + let mut msrs = Vec::with_capacity(indices.len()); + for index in indices { + match self.kvm.feature_msr(index) { + Ok(value) => msrs.push((index, value)), + Err(error) => fingerprint + .set_unavailable(format!("kvm.feature_msr.{index:#010x}"), &error), + } + } + fingerprint.set_feature_msrs(msrs); + } + Err(error) => fingerprint.set_unavailable("kvm.msr_feature_index_list", &error), + } + match self.kvm.msr_index_list() { + Ok(indices) => fingerprint.set_supported_msrs(indices), + Err(error) => fingerprint.set_unavailable("kvm.msr_index_list", &error), + } + fingerprint.record("kvm.xcomp_guest_supp", self.kvm.xsave_guest_supported()); + fingerprint.record("kvm.mce_cap_supported", self.kvm.supported_mce_cap()); + match self.kvm.supported_hv_cpuid() { + Ok(entries) => fingerprint.set_extra_cpuid( + "kvm.supported_hv_cpuid", + entries.iter().map(cpuid_entry).collect(), + ), + Err(error) => fingerprint.set_unavailable("kvm.supported_hv_cpuid", &error), + } + + let vm = self.kvm.new_vm(kvm::VmType::Default)?; + let tsc_frequency_hz = fingerprint.record("kvm.vm.tsc_frequency_hz", vm.tsc_frequency_hz()); + let apic_bus_cycle_ns = fingerprint.record( + "kvm.vm.cap.KVM_CAP_X86_APIC_BUS_CYCLES_NS", + vm.check_extension(kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS) + .map(|value| value as u64), + ); + drop(vm); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + // Zero means that the APIC bus cycle is not configurable and has its + // default length. + time.lapic_timer_frequency_hz = Some( + 1_000_000_000 + / apic_bus_cycle_ns + .filter(|&ns| ns != 0) + .unwrap_or(DEFAULT_APIC_BUS_CYCLE_NS), + ); + // KVM emulates the TSC-deadline timer, but kernels before 6.14 do not + // report it in the supported CPUID. + time.tsc_deadline |= has_capability("KVM_CAP_TSC_DEADLINE_TIMER"); + time.tsc_offset_control = Some(has_capability("KVM_CAP_VCPU_ATTRIBUTES")); + time.tsc_scaling = Some(has_capability("KVM_CAP_TSC_CONTROL")); + for &name in MSR_INTERCEPT_CAPABILITIES { + time.msr_intercepts + .insert(name.to_owned(), has_capability(name)); + } + Ok(fingerprint) + } +} + +#[cfg(test)] +mod tests { + use super::Kvm; + use cpu_profile::Hex32; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires /dev/kvm"] + fn fingerprint_reports_the_supported_surface() { + let kvm = Kvm::new().unwrap(); + let fingerprint = kvm.cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "KVM fingerprint"); + assert_eq!(fingerprint, kvm.cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert_eq!( + fingerprint.time.lapic_timer_frequency_hz, + Some(1_000_000_000) + ); + assert!( + fingerprint + .msrs + .supported + .contains(&Hex32(x86defs::X86X_MSR_TSC)) + ); + } +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index d53399b15af..d0411d96e64 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -5,11 +5,15 @@ #![cfg(all(target_os = "linux", guest_arch = "x86_64"))] +mod cpu_contract; +mod fingerprint; mod regs; pub(crate) mod snp; +mod time_abi; mod vm_state; mod vp_state; +pub(crate) use time_abi::KvmTimeAbi; pub(crate) use vp_state::seg_reg; pub(crate) use vp_state::table_reg; @@ -78,6 +82,7 @@ use virt::irqcon::DeliveryMode; use virt::irqcon::IoApicRouting; use virt::irqcon::MsiRequest; use virt::state::StateElement; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::vm::AccessVmState; use virt::x86::HardwareBreakpoint; use virt::x86::max_physical_address_size_from_cpuid; @@ -166,7 +171,37 @@ impl virt::Hypervisor for Kvm { } let nested_virt = config.nested_virt; + if config.time_abi.is_some() { + if config.hv_config.is_some() { + return Err(KvmError::TimeAbiUnsupported("Hyper-V enlightenments")); + } + if !matches!(config.isolation.isolation_type(), virt::IsolationType::None) { + return Err(KvmError::TimeAbiUnsupported("isolation")); + } + } let supported_cpuid = self.kvm.supported_cpuid()?; + // The CPU surface for the profile's support check: this supported + // CPUID and KVM's IA32_ARCH_CAPABILITIES. + let cpu_surface = match &config.time_abi { + Some(_) => { + let arch_capabilities = self + .kvm + .feature_msr(time_abi::MSR_IA32_ARCH_CAPABILITIES) + .map_err(|err| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::ProfileUnsupported, + format!( + "cannot read KVM's IA32_ARCH_CAPABILITIES feature MSR: {err:#}" + ), + ) + })?; + Some(time_abi::supported_surface( + &supported_cpuid, + arch_capabilities, + )) + } + None => None, + }; // KVM's in-kernel LAPIC only exposes the CMCI LVT register (APIC // offset 0x2F0) when the guest's IA32_MCG_CAP advertises MCG_CMCI_P. @@ -259,6 +294,8 @@ impl virt::Hypervisor for Kvm { CpuidLeaf::new(CpuidFunction::SgxEnumeration.0, [0; 4]).indexed(2), // SGX enumeration is subleaf 2 ); + cpuid_entries.push(cpu_contract::hide_cet_ss()); + if let Some(hv_config) = &config.hv_config { if hv_config.vtl2.is_some() { return Err(KvmError::Vtl2NotSupported); @@ -428,6 +465,9 @@ impl virt::Hypervisor for Kvm { vm.enable_split_irqchip(virt::irqcon::IRQ_LINES as u32)?; vm.enable_x2apic_api()?; vm.enable_unknown_msr_exits()?; + if let Some(time_abi) = &config.time_abi { + time_abi::install_identity_msr_filter(&vm, time_abi.cpu_profile.cpu_vendor())?; + } if let Some(sev) = &sev { vm.sev_snp_init( @@ -444,6 +484,7 @@ impl virt::Hypervisor for Kvm { cpuid: cpuid_entries, nested_virt, supported_mce_cap, + cpu_surface, }) } } @@ -459,6 +500,8 @@ pub struct KvmProtoPartition<'a> { /// MCE capability bits (`IA32_MCG_CAP`) the host allows setting, from /// `KVM_X86_GET_MCE_CAP_SUPPORTED`. supported_mce_cap: u64, + /// The CPU surface KVM supports, for the time ABI's CPU profile. + cpu_surface: Option, } impl ProtoPartition for KvmProtoPartition<'_> { @@ -467,7 +510,15 @@ impl ProtoPartition for KvmProtoPartition<'_> { type ProcessorBinder = KvmProcessorBinder; fn max_physical_address_size(&self) -> u8 { - max_physical_address_size_from_cpuid(&|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4])) + match &self.config.time_abi { + // The guest sees the CPU profile's width. + Some(time_abi) => max_physical_address_size_from_cpuid(&|eax, ecx| { + time_abi.cpuid.result(eax, ecx, &[0; 4]) + }), + None => max_physical_address_size_from_cpuid(&|eax, ecx| { + self.cpuid.result(eax, ecx, &[0; 4]) + }), + } } fn build( @@ -480,41 +531,38 @@ impl ProtoPartition for KvmProtoPartition<'_> { return Err(SnpError::InvalidVmsaGpa(config.bsp.gpa).into()); } - // Build topology leaves using the base cpuid before consuming it. - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - self.config.processor_topology, - &|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4]), - &mut topology_leaves, - ) - .map_err(KvmError::TopologyCpuid)?; - - // Work around a KVM bug where PSFD is advertised in guest CPUID - // but the SPEC_CTRL MSR is not accessible. Check the KVM-reported - // CPUID (before user overrides) since that determines what KVM - // will allow. - let psfd_fixup = strip_psfd_leaf(&self.cpuid); - - let mut cpuid = self.cpuid.into_leaves(); - cpuid.extend(config.cpuid); - cpuid.extend(topology_leaves); - cpuid.extend(psfd_fixup); - let cpuid = CpuidLeafSet::new(cpuid); + let cpuid = match &self.config.time_abi { + Some(time_abi) => time_abi::partition_cpuid(&time_abi.cpuid), + None => { + // Build topology leaves using the base cpuid before consuming + // it. + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + self.config.processor_topology, + &|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4]), + &mut topology_leaves, + ) + .map_err(KvmError::TopologyCpuid)?; + + // Work around a KVM bug where PSFD is advertised in guest + // CPUID but the SPEC_CTRL MSR is not accessible. Check the + // KVM-reported CPUID (before user overrides) since that + // determines what KVM will allow. + let psfd_fixup = strip_psfd_leaf(&self.cpuid); + + let mut cpuid = self.cpuid.into_leaves(); + cpuid.extend(config.cpuid); + cpuid.extend(topology_leaves); + cpuid.extend(psfd_fixup); + CpuidLeafSet::new(cpuid) + } + }; let bsp_apic_id = self.config.processor_topology.vp_arch(VpIndex::BSP).apic_id; if bsp_apic_id != 0 { self.vm.set_bsp(bsp_apic_id)?; } - let mut caps = virt::PartitionCapabilities::from_cpuid( - self.config.processor_topology, - &mut |function, index| cpuid.result(function, index, &[0; 4]), - ) - .map_err(KvmError::Capabilities)?; - - caps.can_freeze_time = false; - caps.nested_virt = self.nested_virt; - // Create all VCPUs now so that they are assigned dense, sequential // vcpu_idx values (KVM assigns vcpu_idx in creation order). KVM's // Hyper-V enlightenment code has a fast O(1) VP-index-to-vcpu lookup @@ -527,6 +575,55 @@ impl ProtoPartition for KvmProtoPartition<'_> { self.vm.add_vp(vp_info.apic_id)?; } + let mut caps = if self.config.time_abi.is_some() { + // The identity leaves must not make the partition model Hyper-V + // or the KVM clock. + virt::PartitionCapabilities::from_cpuid( + self.config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut |function, index| { + cpuid.result(function, index, &[0; 4]) + }), + ) + } else { + virt::PartitionCapabilities::from_cpuid( + self.config.processor_topology, + &mut |function, index| cpuid.result(function, index, &[0; 4]), + ) + } + .map_err(KvmError::Capabilities)?; + let time_abi = match &self.config.time_abi { + Some(config) => { + time_abi::check_capabilities(&caps)?; + let vcpus: Vec<_> = self + .config + .processor_topology + .vps_arch() + .map(|vp_info| vp_info.apic_id) + .collect(); + let surface = self.cpu_surface.take().unwrap_or_default(); + let profile = &config.cpu_profile; + let supported = surface + .msrs + .iter() + .find(|msr| msr.index == time_abi::MSR_IA32_ARCH_CAPABILITIES) + .map_or(0, |msr| msr.supported); + let arch_capabilities = + time_abi::profile_arch_capabilities(profile, supported, &cpuid); + Some(KvmTimeAbi::new( + &self.vm, + &vcpus, + config.msrs.clone(), + surface, + arch_capabilities, + profile.cpu_vendor(), + )?) + } + None => None, + }; + + caps.can_freeze_time = false; + caps.nested_virt = self.nested_virt; + let mut gsi_routing = GsiRouting::new(); // Claim the IOAPIC routes. @@ -600,6 +697,7 @@ impl ProtoPartition for KvmProtoPartition<'_> { reserved_vps_per_socket: self.config.processor_topology.reserved_vps_per_socket(), mce_cmci_supported: x86defs::McgCap::from(self.supported_mce_cap).cmci_p(), synic_ports: Default::default(), + time_abi, }); let partition = KvmPartition { @@ -698,21 +796,20 @@ impl ProtoPartition for KvmProtoPartition<'_> { /// However, KVM also passes through AMD PSFD without including it in that /// check. PSFD is architecturally controlled via the SPEC_CTRL MSR, so a /// guest that sees PSFD and infers SPEC_CTRL MSR support (as Hyper-V -/// does) will #GP when writing the MSR. +/// does) will #GP when writing the MSR. AMD CPU profiles keep PSFD under +/// the same condition (`cpu_profile::derive::has_spec_ctrl_control`). /// /// Returns a leaf that strips PSFD when it should not be advertised. fn strip_psfd_leaf(cpuid: &CpuidLeafSet) -> Option { use x86defs::cpuid::ExtendedAddressSpaceSizesEbx; - use x86defs::cpuid::ExtendedFeatureSubleaf0Edx; let leaf7 = cpuid.result(CpuidFunction::ExtendedFeatures.0, 0, &[0; 4]); let leaf80000008 = cpuid.result(CpuidFunction::ExtendedAddressSpaceSizes.0, 0, &[0; 4]); - let edx = ExtendedFeatureSubleaf0Edx::from(leaf7[3]); let ebx = ExtendedAddressSpaceSizesEbx::from(leaf80000008[1]); // Mirror KVM's guest_has_spec_ctrl_msr() check. - let has_spec_ctrl_msr = edx.ibrs() || ebx.ibrs() || ebx.stibp() || ebx.ssbd(); + let has_spec_ctrl_msr = cpu_profile::derive::has_spec_ctrl_control(leaf7[3], leaf80000008[1]); if !has_spec_ctrl_msr && ebx.psfd() { let psfd_mask = ExtendedAddressSpaceSizesEbx::new().with_psfd(true); Some( @@ -768,6 +865,13 @@ impl Partition for KvmPartition { virt::InitialVpStateSource::Registers } + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner + .time_abi + .is_some() + .then_some(&*self.inner as &dyn virt::time_abi::TimeAbiBackend) + } + fn supports_reset(&self) -> Option<&dyn ResetPartition> { // TODO: Support resetting SNP launch state and rebuilding the protected // guest before advertising reset support. @@ -1009,6 +1113,9 @@ impl virt::BindProcessor for KvmProcessorBinder { .collect::>(); kvm.set_cpuid(&cpuid_entries)?; + if let Some(time_abi) = &self.partition.time_abi { + time_abi.set_arch_capabilities(&kvm)?; + } let mut vp = KvmProcessor { partition: &self.partition, @@ -1581,6 +1688,12 @@ impl<'p> Processor for KvmProcessor<'p> { stop: StopVp<'_>, dev: &impl CpuIo, ) -> Result { + if let Some(time_abi) = &self.partition.time_abi { + // A restore or a reset may have changed the guest-visible value. + time_abi + .sync_invariant_control(&self.kvm) + .map_err(|err| dev.fatal_error(KvmRunVpError::TimeAbi(err).into()))?; + } loop { self.inner.needs_yield.maybe_yield().await; stop.check()?; @@ -1664,7 +1777,17 @@ impl<'p> Processor for KvmProcessor<'p> { dev.read_mmio(self.vpindex, address, data).await } kvm::Exit::MsrRead { index, data, error } => { - if MYSTERY_MSRS.contains(&index) { + if let Some(result) = self + .partition + .time_abi + .as_ref() + .and_then(|time_abi| time_abi.read_msr(self.vpindex, index)) + { + match result { + Ok(value) => *data = value, + Err(_) => *error = 1, + } + } else if MYSTERY_MSRS.contains(&index) { tracelimit::warn_ratelimited!(index, "stubbed out mystery MSR read"); *data = 0; } else { @@ -1673,7 +1796,22 @@ impl<'p> Processor for KvmProcessor<'p> { } } kvm::Exit::MsrWrite { index, data, error } => { - if MYSTERY_MSRS.contains(&index) { + if let Some(time_abi) = &self.partition.time_abi + && let Some(result) = time_abi.write_msr(self.vpindex, index, data) + { + match result { + Ok(()) if index == MSR_TSC_INVARIANT_CONTROL => { + // KVM hides invariant TSC until its own + // copy is set; mirror it before the guest + // runs on. + time_abi.sync_invariant_control(&self.kvm).map_err(|err| { + dev.fatal_error(KvmRunVpError::TimeAbi(err).into()) + })?; + } + Ok(()) => {} + Err(_) => *error = 1, + } + } else if MYSTERY_MSRS.contains(&index) { tracelimit::warn_ratelimited!(index, "stubbed out mystery MSR write"); } else { tracelimit::error_ratelimited!(index, data, "unrecognized msr write"); diff --git a/vmm_core/virt_kvm/src/arch/x86_64/regs.rs b/vmm_core/virt_kvm/src/arch/x86_64/regs.rs index e4138dd0248..b5bbfa48fec 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/regs.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/regs.rs @@ -52,6 +52,7 @@ pub const fn register_to_msr(name: HvX64RegisterName) -> Result x86defs::X86X_MSR_MTRR_PHYSBASE0 + 15, HvX64RegisterName::Tsc => x86defs::X86X_MSR_TSC, + HvX64RegisterName::TscDeadline => x86defs::X86X_MSR_TSC_DEADLINE, HvX64RegisterName::TscAux => x86defs::X86X_MSR_TSC_AUX, HvX64RegisterName::Xss => x86defs::X86X_MSR_XSS, diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs new file mode 100644 index 00000000000..0c4f815cbb5 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -0,0 +1,1480 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI v1 on KVM. +//! +//! Active only for partitions built with +//! [`ProtoPartitionConfig::time_abi`](virt::ProtoPartitionConfig::time_abi). +//! It implements the KVM column of "Backend obligations" in NVX +//! `doc/design/time-abi.md`: +//! +//! - **CPUID and the CPU profile.** The partition's CPUID is the CPU +//! profile's complete effective CPUID, programmed verbatim with +//! `KVM_SET_CPUID2` (each vCPU gets its own APIC identity), so no KVM leaf +//! remains and the guest sees the Hyper-V frequency identity instead of +//! KVM's signature. Without a KVM signature the guest has no KVM +//! paravirtual feature, and `KVM_CAP_ENFORCE_PV_FEATURE_CPUID` makes their +//! MSRs (kvmclock and the others) raise #GP. Every vCPU presents the +//! profile's `IA32_ARCH_CAPABILITIES` in the bits the profile pins, and +//! KVM's own value in the others. KVM's supported CPUID and +//! `IA32_ARCH_CAPABILITIES` feature MSR form the surface for the profile's +//! support check. The partition capabilities come from the CPUID with the +//! hypervisor range masked, so the partition does not model Hyper-V +//! (`hv1`). +//! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff`, +//! `IA32_TSC_ADJUST`, `IA32_TSC_DEADLINE`, and the legacy P6 L2-cache MSRs, +//! and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: +//! [`TimeAbiMsrs`] serves the identity range, and the other MSRs raise #GP +//! (with their CPUID bits clear, KVM would still serve `IA32_TSC_ADJUST`, +//! and would read `IA32_TSC_DEADLINE` as 0 and ignore writes; KVM serves +//! reads of `MSR_IA32_BBL_CR_CTL3`, and OpenVMM stubs the other L2-cache +//! MSRs for the PCAT BIOS). KVM checks the filter before its in-kernel +//! Hyper-V MSRs, so those never see a guest access. Under an AMD CPU +//! profile, the filter also denies HWCR and DE_CFG, which OpenVMM serves +//! with the time ABI's fixed values +//! ([`AMD_CONFIG_MSRS`](virt::time_abi::msr::AMD_CONFIG_MSRS)) instead of +//! KVM's own (HWCR reading 0). +//! - **Invariant TSC.** With `"Hv#1"` and `AccessTscInvariantControls` in +//! CPUID, KVM (Linux 6.3 and later) hides the invariant-TSC bit of CPUID +//! `0x80000007` until its own copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` is +//! set. The filter keeps the guest's writes away from KVM, so the backend +//! mirrors the guest-visible value into KVM with host-initiated writes, +//! which bypass the filter: after every guest write, and before a VP runs +//! after the value changed otherwise (restore and reset). +//! - **TSC.** The guest TSC is the host TSC plus a per-vCPU offset and is +//! never scaled: the backend never calls `KVM_SET_TSC_KHZ`, and `F_d` is +//! VP 0's `KVM_GET_TSC_KHZ`. The synchronized set writes one +//! `KVM_VCPU_TSC_OFFSET` (Linux 5.16 and later) to every vCPU, computed from +//! one host TSC read, and reads every offset back. +//! - **LAPIC.** The in-kernel LAPIC timer counts KVM's fixed 1 GHz bus clock; +//! `KVM_CAP_X86_APIC_BUS_CYCLES_NS` is never set. +//! +//! **Restore and the saved per-VP TSC.** A time ABI restore omits every VP's +//! saved TSC (core strips the `tsc` element before VP state restore), so no +//! host-initiated `IA32_TSC` write reaches KVM's TSC synchronization +//! heuristic. That heuristic treats a write within about a second of the TSC +//! KVM expects as a synchronization attempt and keeps its own offset; Linux +//! 6.6 applies it to the first write after vCPU creation too, which would +//! discard the saved TSC of a snapshot taken within about a second of the +//! guest's boot. Each vCPU instead keeps its creation-time offset until the +//! synchronized set writes the common offset, before any VP runs, on every +//! kernel. KVM computes the LAPIC timer's TSC deadline when the LAPIC state is +//! set, so the restore orchestrator re-applies the LAPIC state after the +//! synchronized set. + +use super::KvmPartitionInner; +use crate::KvmError; +use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; +use parking_lot::Mutex; +use std::ops::RangeInclusive; +use std::sync::Arc; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::msr::AMD_CONFIG_MSRS; +use virt::time_abi::msr::IDENTITY_MSR_RANGE; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; +use virt::time_abi::rate::LAPIC_HZ_KVM; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; +use virt::x86::MsrError; +use x86defs::cpuid::CpuidFunction; + +/// `IA32_ARCH_CAPABILITIES`, which the CPU profile pins. +pub(crate) const MSR_IA32_ARCH_CAPABILITIES: u32 = 0x10a; + +/// `IA32_TSC_ADJUST`, which the time ABI hides. +const MSR_IA32_TSC_ADJUST: u32 = 0x3b; + +/// `IA32_TSC_DEADLINE`, which the time ABI hides. +const MSR_IA32_TSC_DEADLINE: u32 = 0x6e0; + +/// The TSC MSRs that the time ABI hides; they raise #GP. +const HIDDEN_TSC_MSRS: [u32; 2] = [MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_DEADLINE]; + +/// The legacy P6 L2-cache MSRs that the backend stubs for Windows booting +/// from the PCAT BIOS (`MYSTERY_MSRS` in the parent module). No CPU profile +/// pins them and a microVM never probes them, so they raise #GP on a time +/// ABI partition, as on MSHV. KVM itself serves reads of `0x11e` +/// (`MSR_IA32_BBL_CR_CTL3`), so the filter denies them all. +const LEGACY_L2_CACHE_MSRS: [RangeInclusive; 4] = + [0x88..=0x8a, 0x116..=0x116, 0x118..=0x11b, 0x11e..=0x11e]; + +/// Whether a guest access to `msr` raises #GP on a time ABI partition, +/// outside the identity range. +fn is_hidden_msr(msr: u32) -> bool { + HIDDEN_TSC_MSRS.contains(&msr) + || LEGACY_L2_CACHE_MSRS + .iter() + .any(|range| range.contains(&msr)) +} + +/// Pairing attempts before an anchor fails: an attempt only misses +/// [`MAX_ANCHOR_PAIRING_NS`] when the thread is interrupted between the two +/// TSC reads around the host sample. +const ANCHOR_ATTEMPTS: usize = 16; + +/// Returns the MSR filter ranges of the time ABI: deny every guest access to +/// the identity range, the hidden TSC MSRs, and the legacy L2-cache MSRs, so +/// they exit to OpenVMM. +pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 7] { + let [l2_a, l2_b, l2_c, l2_d] = LEGACY_L2_CACHE_MSRS; + [ + kvm::MsrFilterRange::deny(IDENTITY_MSR_RANGE), + kvm::MsrFilterRange::deny(MSR_IA32_TSC_ADJUST..=MSR_IA32_TSC_ADJUST), + kvm::MsrFilterRange::deny(MSR_IA32_TSC_DEADLINE..=MSR_IA32_TSC_DEADLINE), + kvm::MsrFilterRange::deny(l2_a), + kvm::MsrFilterRange::deny(l2_b), + kvm::MsrFilterRange::deny(l2_c), + kvm::MsrFilterRange::deny(l2_d), + ] +} + +/// Returns the MSR filter ranges of a time ABI partition whose CPU profile is +/// of `vendor`: [`identity_msr_filter`], and for AMD also AMD's +/// configuration MSRs ([`AMD_CONFIG_MSRS`]), which KVM would otherwise +/// serve itself (HWCR reading 0, and DE_CFG reading what the VMM last set). +pub(crate) fn time_abi_msr_filter(vendor: CpuVendor) -> Vec { + let mut ranges = identity_msr_filter().to_vec(); + if vendor == CpuVendor::Amd { + ranges.extend( + AMD_CONFIG_MSRS + .iter() + .map(|&(msr, _)| kvm::MsrFilterRange::deny(msr..=msr)), + ); + } + ranges +} + +/// Routes the time ABI's MSRs to user space: user-space exits for unknown +/// and filtered MSRs, and the [`time_abi_msr_filter`] of a CPU profile of +/// `vendor` (every other MSR keeps its in-kernel handling). +pub(crate) fn install_identity_msr_filter( + vm: &kvm::Partition, + vendor: CpuVendor, +) -> Result<(), TimeAbiError> { + for (name, cap) in [ + ( + "KVM_CAP_X86_USER_SPACE_MSR", + kvm::KVM_CAP_X86_USER_SPACE_MSR, + ), + ("KVM_CAP_X86_MSR_FILTER", kvm::KVM_CAP_X86_MSR_FILTER), + ] { + if !vm.check_extension(cap).is_ok_and(|value| value > 0) { + return Err(routing_error(format!( + "KVM lacks {name} (Linux 5.10 or later)" + ))); + } + } + vm.enable_msr_exits(kvm::KVM_MSR_EXIT_REASON_UNKNOWN | kvm::KVM_MSR_EXIT_REASON_FILTER) + .map_err(|err| routing_error(format!("cannot enable user-space MSR exits: {err:#}")))?; + vm.set_msr_filter(true, &time_abi_msr_filter(vendor)) + .map_err(|err| routing_error(format!("cannot install the MSR filter: {err:#}")))?; + Ok(()) +} + +/// Returns the partition CPUID: the time ABI CPUID verbatim. It is the CPU +/// profile's complete effective CPUID (the profile's leaves, OpenVMM's +/// topology leaves and APIC mode, and the identity and explicit zero leaves), +/// so neither KVM's supported CPUID nor any other source adds a leaf or a +/// bit. `bind` sets each vCPU's own APIC identity in the per-VP fields. +pub(crate) fn partition_cpuid(time_abi: &CpuidLeafSet) -> CpuidLeafSet { + CpuidLeafSet::new(time_abi.leaves().to_vec()) +} + +/// Returns the CPU surface KVM supports on this host, for the CPU profile's +/// support check (`E_PROFILE_UNSUPPORTED`): `supported` is +/// `KVM_GET_SUPPORTED_CPUID` as the partition queries it (without permission +/// for dynamically enabled XSAVE features), the physical address width is +/// the one it reports, and `arch_capabilities` is KVM's +/// `IA32_ARCH_CAPABILITIES` feature MSR, any bit of which the VMM may present +/// clear. AMD's PSFD is cleared where KVM advertises it without the +/// SPEC_CTRL access that a guest needs to use it (see `strip_psfd_leaf`), so +/// a profile that sets it fails the check instead of losing it silently. +pub(crate) fn supported_surface( + supported: &[kvm::kvm_cpuid_entry2], + arch_capabilities: u64, +) -> SupportedCpuSurface { + let mut cpuid = cpuid_leaves(supported); + if let Some(psfd_fixup) = super::strip_psfd_leaf(&CpuidLeafSet::new(cpuid.clone())) { + for leaf in cpuid + .iter_mut() + .filter(|leaf| leaf.function == psfd_fixup.function) + { + psfd_fixup.apply(&mut leaf.result); + } + } + let physical_address_width = cpuid + .iter() + .find(|leaf| leaf.function == CpuidFunction::ExtendedAddressSpaceSizes.0) + .map_or(0, |leaf| leaf.result[0] as u8); + SupportedCpuSurface { + cpuid, + physical_address_width, + msrs: vec![SupportedMsrValue { + index: MSR_IA32_ARCH_CAPABILITIES, + supported: arch_capabilities, + controllable: !0, + }], + } +} + +/// Returns the `IA32_ARCH_CAPABILITIES` value that every vCPU presents under +/// `profile`: the profile's pinned bits, and KVM's own value (`supported`) in +/// the bits it leaves to the backend. `None` when the profile pins no value +/// or its CPUID (`cpuid`) does not enumerate the MSR, which then raises #GP +/// and keeps KVM's default. +pub(crate) fn profile_arch_capabilities( + profile: &CpuProfile, + supported: u64, + cpuid: &CpuidLeafSet, +) -> Option { + let (value, mask) = profile.msr(MSR_IA32_ARCH_CAPABILITIES)?; + let leaf7_edx = cpuid.result(CpuidFunction::ExtendedFeatures.0, 0, &[0; 4])[3]; + let enumerated = + x86defs::cpuid::ExtendedFeatureSubleaf0Edx::from(leaf7_edx).arch_capabilities(); + enumerated.then_some((value & mask) | (supported & !mask)) +} + +/// Converts the KVM CPUID entries programmed for a VP to CPUID leaves. +pub(crate) fn cpuid_leaves(entries: &[kvm::kvm_cpuid_entry2]) -> Vec { + entries + .iter() + .map(|entry| { + let leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); + if entry.flags & kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0 { + leaf.indexed(entry.index) + } else { + leaf + } + }) + .collect() +} + +fn routing_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::IdentityRouting, message) +} + +/// Two host TSC reads around another host operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HostTscBracket { + pub start: u64, + pub end: u64, +} + +/// Waits until every earlier instruction has completed locally, so that a +/// TSC read is not reordered with its neighbors. +fn lfence() { + // SAFETY: LFENCE only orders instruction execution. + unsafe { core::arch::asm!("lfence", options(nostack, preserves_flags)) }; +} + +impl HostTscBracket { + /// Runs `f` between two host TSC reads. + pub(crate) fn around(f: impl FnOnce() -> T) -> (Self, T) { + lfence(); + let start = safe_intrinsics::rdtsc(); + lfence(); + let value = f(); + lfence(); + let end = safe_intrinsics::rdtsc(); + (Self { start, end }, value) + } + + /// The host TSC at the middle of the bracket, and the most the instant of + /// the bracketed operation can be from it, in cycles. `None` if the TSC + /// went backwards. + pub(crate) fn midpoint(&self) -> Option<(u64, u64)> { + let width = self.end.checked_sub(self.start)?; + Some((self.start + width / 2, width.div_ceil(2))) + } +} + +/// Converts `cycles` at `tsc_hz` to nanoseconds, rounding up. +fn cycles_to_ns_ceil(cycles: u64, tsc_hz: u64) -> u64 { + let ns = (u128::from(cycles) * 1_000_000_000).div_ceil(u128::from(tsc_hz.max(1))); + u64::try_from(ns).unwrap_or(u64::MAX) +} + +/// Pairs a host time sample with the bracket of host TSC reads around it. +/// +/// Returns the host TSC at the middle of the bracket and the pairing +/// uncertainty, half the bracket, in nanoseconds at `tsc_hz`. `None` if the +/// TSC went backwards. +pub(crate) fn pair_host_sample(bracket: HostTscBracket, tsc_hz: u64) -> Option<(u64, u64)> { + let (host_tsc, half_width) = bracket.midpoint()?; + Some((host_tsc, cycles_to_ns_ceil(half_width, tsc_hz))) +} + +/// A host time sample paired with the host TSC. +#[derive(Debug, Clone, Copy)] +struct PairedSample { + host_tsc: u64, + sample: HostTimeSample, + pairing_ns: u64, +} + +/// Takes the `anchor` ("capture anchor" or "restore anchor"): a host time +/// sample paired with the host TSC within [`MAX_ANCHOR_PAIRING_NS`]. +fn paired_host_sample(tsc_hz: u64, anchor: &str) -> Result { + pair_within_bound(tsc_hz, anchor, || HostTscBracket::around(sample_host_time)) +} + +/// Samples with `sample` until a pair is within [`MAX_ANCHOR_PAIRING_NS`], +/// at most [`ANCHOR_ATTEMPTS`] times, then fails with `E_TSC_ANCHOR`. +fn pair_within_bound( + tsc_hz: u64, + anchor: &str, + mut sample: impl FnMut() -> (HostTscBracket, Result), +) -> Result { + let mut tightest_ns: Option = None; + for _ in 0..ANCHOR_ATTEMPTS { + let (bracket, host) = sample(); + let host = host?; + let Some((host_tsc, pairing_ns)) = pair_host_sample(bracket, tsc_hz) else { + continue; + }; + if pairing_ns <= MAX_ANCHOR_PAIRING_NS { + return Ok(PairedSample { + host_tsc, + sample: host, + pairing_ns, + }); + } + tightest_ns = Some(tightest_ns.map_or(pairing_ns, |ns| ns.min(pairing_ns))); + } + Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "cannot pair the {anchor} with a host time sample within {MAX_ANCHOR_PAIRING_NS} ns \ + in {ANCHOR_ATTEMPTS} attempts (tightest pairing: {})", + tightest_ns.map_or("none, the host TSC went backwards".into(), |ns| format!( + "{ns} ns" + )) + ), + )) +} + +/// Returns whether the guest TSC read from a vCPU between the host TSC reads +/// of `bracket` is the host TSC plus `offset`, as it is without TSC scaling. +pub(crate) fn tsc_is_unscaled(guest_tsc: u64, offset: u64, bracket: HostTscBracket) -> bool { + let host_tsc = guest_tsc.wrapping_sub(offset); + (bracket.start..=bracket.end).contains(&host_tsc) +} + +/// Checks the synchronized set's read-back: every vCPU's offset must be the +/// common offset. +pub(crate) fn check_offset_readback( + offset: u64, + readback: &[(VpIndex, u64)], +) -> Result<(), TimeAbiError> { + let mismatched: Vec<_> = readback + .iter() + .filter(|(_, value)| *value != offset) + .map(|(vp, value)| format!("VP {}: {value:#x}", vp.index())) + .collect(); + if !mismatched.is_empty() { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "KVM_VCPU_TSC_OFFSET read back differs from the common offset {offset:#x}: {}", + mismatched.join(", ") + ), + )); + } + Ok(()) +} + +/// The time ABI state of a KVM partition. +#[derive(Debug)] +pub(crate) struct KvmTimeAbi { + msrs: Arc, + /// The value of KVM's own `HV_X64_MSR_TSC_INVARIANT_CONTROL`, or `None` + /// if this KVM does not implement it (Linux before 6.3, or built without + /// Hyper-V emulation), in which case KVM does not hide invariant TSC + /// either. + kvm_invariant_control: Option>, + /// The CPU surface KVM supports, taken when the partition was created. + surface: SupportedCpuSurface, + /// The `IA32_ARCH_CAPABILITIES` value of every vCPU, from the CPU + /// profile ([`profile_arch_capabilities`]). + arch_capabilities: Option, + /// Whether the CPU profile is AMD's, so that OpenVMM serves AMD's + /// configuration MSRs, which [`time_abi_msr_filter`] routes to it. + amd_config_msrs: bool, +} + +impl KvmTimeAbi { + /// Sets up the vCPUs of a new partition for the time ABI: KVM's + /// paravirtual MSRs raise #GP, and KVM's copy of + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. `surface`, + /// `arch_capabilities`, and `vendor` come from the partition's CPU + /// profile. + pub(crate) fn new( + vm: &kvm::Partition, + vcpus: &[u32], + msrs: Arc, + surface: SupportedCpuSurface, + arch_capabilities: Option, + vendor: CpuVendor, + ) -> Result { + for &vcpu in vcpus { + vm.vp(vcpu) + .enable_enforce_pv_feature_cpuid() + .map_err(|err| { + routing_error(format!( + "cannot hide KVM's paravirtual MSRs on vCPU {vcpu}: {err:#}" + )) + })?; + } + // Writing KVM's power-on value tells whether KVM implements the MSR. + // `vcpus[0]` is VP 0. + let kvm_invariant_control = + match vm.vp(vcpus[0]).set_msrs(&[(MSR_TSC_INVARIANT_CONTROL, 0)]) { + Ok(()) => Some(Mutex::new(0)), + Err(kvm::Error::IncompleteMsrs { .. }) => { + tracing::info!( + "KVM lacks HV_X64_MSR_TSC_INVARIANT_CONTROL, so it does not hide \ + invariant TSC" + ); + None + } + Err(err) => { + return Err(routing_error(format!( + "cannot write KVM's HV_X64_MSR_TSC_INVARIANT_CONTROL: {err:#}" + ))); + } + }; + Ok(Self { + msrs, + kvm_invariant_control, + surface, + arch_capabilities, + amd_config_msrs: vendor == CpuVendor::Amd, + }) + } + + /// Presents the CPU profile's `IA32_ARCH_CAPABILITIES` on `vp`, with a + /// host-initiated write after its CPUID is set: KVM accepts the MSR only + /// for a vCPU whose CPUID enumerates it, and keeps the value across + /// resets. + pub(crate) fn set_arch_capabilities( + &self, + vp: &kvm::Processor<'_>, + ) -> Result<(), TimeAbiError> { + let Some(value) = self.arch_capabilities else { + return Ok(()); + }; + vp.set_msrs(&[(MSR_IA32_ARCH_CAPABILITIES, value)]) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "KVM cannot present the CPU profile's IA32_ARCH_CAPABILITIES {value:#x}: \ + {err:#}" + ), + ) + }) + } + + /// Handles a guest read of `msr` that exited to user space. `None` means + /// the MSR is not the time ABI's. + pub(crate) fn read_msr(&self, vp: VpIndex, msr: u32) -> Option> { + if is_hidden_msr(msr) { + tracelimit::info_ratelimited!(vp = vp.index(), msr, "hidden MSR read raises #GP"); + return Some(Err(MsrError::InvalidAccess)); + } + if self.amd_config_msrs + && let Some(value) = amd_config_msr_read(msr) + { + return Some(Ok(value)); + } + self.msrs.read(vp, msr) + } + + /// Handles a guest write of `value` to `msr` that exited to user space. + /// `None` means the MSR is not the time ABI's. + /// + /// After an accepted `HV_X64_MSR_TSC_INVARIANT_CONTROL` write, the caller + /// mirrors it with [`Self::sync_invariant_control`] before resuming the + /// guest. + pub(crate) fn write_msr( + &self, + vp: VpIndex, + msr: u32, + value: u64, + ) -> Option> { + if is_hidden_msr(msr) { + tracelimit::info_ratelimited!( + vp = vp.index(), + msr, + value, + "hidden MSR write raises #GP" + ); + return Some(Err(MsrError::InvalidAccess)); + } + if self.amd_config_msrs + && let Some(result) = amd_config_msr_write(msr, value) + { + return Some(result); + } + self.msrs.write(vp, msr, value) + } + + /// Makes KVM's copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` equal the + /// guest-visible value, with a host-initiated write through `vp` when it + /// differs. + pub(crate) fn sync_invariant_control( + &self, + vp: &kvm::Processor<'_>, + ) -> Result<(), TimeAbiError> { + let Some(kvm_value) = &self.kvm_invariant_control else { + return Ok(()); + }; + // Hold the lock while writing so that concurrent callers apply the + // latest guest-visible value last. + let mut kvm_value = kvm_value.lock(); + let value = self.msrs.tsc_invariant_control(); + if *kvm_value != value { + vp.set_msrs(&[(MSR_TSC_INVARIANT_CONTROL, value)]) + .map_err(|err| { + routing_error(format!( + "cannot mirror HV_X64_MSR_TSC_INVARIANT_CONTROL = {value} into KVM: {err:#}" + )) + })?; + tracing::debug!(value, "mirrored HV_X64_MSR_TSC_INVARIANT_CONTROL into KVM"); + *kvm_value = value; + } + Ok(()) + } +} + +impl KvmPartitionInner { + fn time_abi_vps(&self) -> impl Iterator)> { + self.vps.iter().map(|vp| { + ( + vp.vp_info().base.vp_index, + self.kvm.vp(vp.vp_info().apic_id), + ) + }) + } +} + +impl TimeAbiBackend for KvmPartitionInner { + fn native_tsc_hz(&self) -> Result { + self.vp_kvm(VpIndex::BSP).tsc_frequency_hz().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("KVM_GET_TSC_KHZ failed on VP 0: {err:#}"), + ) + }) + } + + fn lapic_hz(&self) -> Result { + // The partition never sets the bus cycle, so it is KVM's default, + // which KVM reports for the capability. Kernels before 6.11 lack the + // capability (0) and have the same fixed 1 ns cycle. + match self + .kvm + .check_extension(kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS) + { + Ok(0 | 1) => Ok(LAPIC_HZ_KVM), + Ok(ns) => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("KVM's default APIC bus cycle is {ns} ns, not 1 ns"), + )), + Err(err) => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("cannot query KVM_CAP_X86_APIC_BUS_CYCLES_NS: {err:#}"), + )), + } + } + + fn preflight(&self) -> Result { + // Partition creation fails with E_IDENTITY_ROUTING unless the MSR + // filter and the user-space MSR exits are installed, so a built + // partition always routes the identity MSRs to OpenVMM. + let native_khz = self.native_tsc_hz()? / 1000; + for (vp_index, vp) in self.time_abi_vps() { + if !vp.has_tsc_offset_attr() { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!( + "VP {} lacks KVM_VCPU_TSC_OFFSET (Linux 5.16 or later)", + vp_index.index() + ), + )); + } + let scaling = |message: String| { + TimeAbiError::new( + TimeAbiCode::TscScalingActive, + format!("VP {}: {message}", vp_index.index()), + ) + }; + let khz = vp.tsc_frequency_hz().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("KVM_GET_TSC_KHZ failed on VP {}: {err:#}", vp_index.index()), + ) + })? / 1000; + if khz != native_khz { + return Err(scaling(format!( + "the TSC runs at {khz} kHz, VP 0 at {native_khz} kHz" + ))); + } + // Without scaling, the guest TSC is the host TSC plus the offset. + // KVM scales only outside its tolerance (250 ppm by default), + // which moves the value far outside the bracket once the host + // has been up for about a second. + let offset = vp + .tsc_offset() + .map_err(|err| scaling(format!("cannot read KVM_VCPU_TSC_OFFSET: {err:#}")))?; + let mut guest_tsc = [0]; + let (bracket, result) = + HostTscBracket::around(|| vp.get_msrs(&[x86defs::X86X_MSR_TSC], &mut guest_tsc)); + result.map_err(|err| scaling(format!("cannot read IA32_TSC: {err:#}")))?; + if !tsc_is_unscaled(guest_tsc[0], offset, bracket) { + return Err(scaling(format!( + "IA32_TSC {:#x} is not the host TSC in [{:#x}, {:#x}] plus the offset {offset:#x}", + guest_tsc[0], bracket.start, bracket.end + ))); + } + } + tracing::info!( + native_khz, + vps = self.vps.len(), + "time ABI KVM preflight: identity MSRs exit to OpenVMM, common TSC offset, no scaling" + ); + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::CommonOffset, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + Ok(cpuid_leaves(&self.bsp_cpuid)) + } + + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + Ok(self + .time_abi + .as_ref() + .map(|time_abi| time_abi.surface.clone())) + } + + fn capture_anchor(&self) -> Result { + let tsc_hz = self.native_tsc_hz()?; + // With every VP stopped, VP 0's TSC is the host TSC plus its offset + // (preflight verified that it is not scaled). Reading the offset once + // and pairing the host TSC with the host sample is much tighter than + // pairing the sample with a KVM_GET_MSRS round trip. + let offset = self.vp_kvm(VpIndex::BSP).tsc_offset().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read VP 0's TSC offset: {err:#}"), + ) + })?; + let paired = paired_host_sample(tsc_hz, "capture anchor")?; + let tsc = paired.host_tsc.wrapping_add(offset); + tracing::debug!( + tsc, + sample = ?paired.sample, + pairing_ns = paired.pairing_ns, + "time ABI capture anchor" + ); + Ok(TscAnchor { + tsc, + sample: paired.sample, + pairing_ns: paired.pairing_ns, + }) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let readback_error = + |message: String| TimeAbiError::new(TimeAbiCode::TscSyncReadback, message); + let tsc_hz = self.native_tsc_hz()?; + // The offsets before the set, logged for diagnosis: each vCPU's + // creation-time offset, since a time ABI restore writes no saved TSC. + let previous = self + .time_abi_vps() + .map(|(vp_index, vp)| { + vp.tsc_offset().map_err(|err| { + readback_error(format!( + "cannot read VP {}'s TSC offset: {err:#}", + vp_index.index() + )) + }) + }) + .collect::, _>>()?; + + // The restore anchor. Every guest TSC equals the target at the host + // instant of `host_tsc` and runs from there. + let paired = paired_host_sample(tsc_hz, "restore anchor")?; + let host_tsc = paired.host_tsc; + let target_tsc = target(&paired.sample)?; + let offset = target_tsc.wrapping_sub(host_tsc); + for (vp_index, vp) in self.time_abi_vps() { + vp.set_tsc_offset(offset).map_err(|err| { + readback_error(format!( + "cannot set VP {}'s TSC offset to {offset:#x}: {err:#}", + vp_index.index() + )) + })?; + } + let readback = self + .time_abi_vps() + .map(|(vp_index, vp)| { + vp.tsc_offset() + .map(|value| (vp_index, value)) + .map_err(|err| { + readback_error(format!( + "cannot read back VP {}'s TSC offset: {err:#}", + vp_index.index() + )) + }) + }) + .collect::, _>>()?; + check_offset_readback(offset, &readback)?; + tracing::info!( + target_tsc, + host_tsc, + offset = format_args!("{offset:#x}"), + pairing_ns = paired.pairing_ns, + vps = readback.len(), + previous_minus_target_cycles = ?previous + .iter() + .map(|previous| host_tsc.wrapping_add(*previous).wrapping_sub(target_tsc) as i64) + .collect::>(), + "time ABI synchronized TSC set" + ); + Ok(TscSetReport { + target: target_tsc, + sample: paired.sample, + readback, + method: TscSyncMethod::CommonOffset, + }) + } +} + +/// Rejects partition capabilities that model Hyper-V (`hv1`), which the +/// identity leaves must not turn on. No KVM clock can appear: every KVM +/// signature leaf is removed. +pub(crate) fn check_capabilities(caps: &virt::PartitionCapabilities) -> Result<(), KvmError> { + if caps.hv1 { + return Err(KvmError::TimeAbi(routing_error( + "partition capabilities expose hv1", + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; + use virt::time_abi::identity::time_abi_cpuid; + use virt::time_abi::msr::MSR_APIC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_FREQUENCY; + use virt::time_abi::msr::MSR_VP_INDEX; + + #[test] + fn filter_denies_exactly_the_owned_msrs() { + let identity = [ + (0x4000_0000, 0x4000_01ff), + (0x3b, 0x3b), + (0x6e0, 0x6e0), + (0x88, 0x8a), + (0x116, 0x116), + (0x118, 0x11b), + (0x11e, 0x11e), + ]; + let amd = [(0xc001_0015, 0xc001_0015), (0xc001_1029, 0xc001_1029)]; + assert_eq!( + identity_msr_filter().to_vec(), + time_abi_msr_filter(CpuVendor::Intel) + ); + for (vendor, owned) in [ + (CpuVendor::Intel, identity.to_vec()), + (CpuVendor::Amd, [identity.as_slice(), &amd].concat()), + ] { + let filter = time_abi_msr_filter(vendor); + assert_eq!(filter.len(), owned.len(), "{vendor}"); + for (range, (first, last)) in filter.iter().zip(owned) { + assert_eq!(range.base, first, "{vendor}"); + assert_eq!(range.base + range.nmsrs - 1, last, "{vendor}"); + assert!(range.read && range.write, "{vendor}"); + assert!(range.bitmap.iter().all(|&byte| byte == 0), "{vendor}"); + } + } + } + + #[test] + fn legacy_l2_cache_msrs_are_the_legacy_stubs() { + let hidden: Vec = LEGACY_L2_CACHE_MSRS.into_iter().flatten().collect(); + assert_eq!(hidden, super::super::MYSTERY_MSRS); + } + + fn kvm_entry(function: u32, index: Option, result: [u32; 4]) -> kvm::kvm_cpuid_entry2 { + kvm::kvm_cpuid_entry2 { + function, + index: index.unwrap_or(0), + flags: if index.is_some() { + kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX + } else { + 0 + }, + eax: result[0], + ebx: result[1], + ecx: result[2], + edx: result[3], + ..Default::default() + } + } + + #[test] + fn supported_surface_is_kvms_cpuid_width_and_arch_capabilities() { + let supported = [ + kvm_entry(0, None, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + kvm_entry(7, Some(0), [0, 0xd19f_4fbb, 0, 0xbc00_0400]), + // A 46-bit physical address width. + kvm_entry(0x8000_0008, None, [0x302e, 0, 0, 0]), + ]; + let surface = supported_surface(&supported, 0x4000_0000_0c00_00eb); + let leaves: Vec<_> = surface + .cpuid + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .collect(); + assert_eq!( + leaves, + [ + (0, None, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + (7, Some(0), [0, 0xd19f_4fbb, 0, 0xbc00_0400]), + (0x8000_0008, None, [0x302e, 0, 0, 0]), + ] + ); + assert_eq!(surface.physical_address_width, 46); + let msrs: Vec<_> = surface + .msrs + .iter() + .map(|msr| (msr.index, msr.supported, msr.controllable)) + .collect(); + assert_eq!(msrs, [(0x10a, 0x4000_0000_0c00_00eb, !0)]); + } + + #[test] + fn supported_surface_clears_psfd_without_spec_ctrl() { + // AMD's PSFD (0x80000008 EBX[28]) without IBRS, STIBP, or SSBD: KVM + // would not let the guest write SPEC_CTRL to use it. + let psfd = 1 << 28; + let lone = [kvm_entry(0x8000_0008, None, [0x30, psfd, 0, 0])]; + assert_eq!(supported_surface(&lone, 0).cpuid[0].result[1], 0); + // With SSBD (bit 24), SPEC_CTRL is the guest's, and PSFD stays. + let ssbd = 1 << 24; + let with_ssbd = [kvm_entry(0x8000_0008, None, [0x30, psfd | ssbd, 0, 0])]; + assert_eq!( + supported_surface(&with_ssbd, 0).cpuid[0].result[1], + psfd | ssbd + ); + } + + #[test] + fn arch_capabilities_follow_the_profile() { + // KVM 7.0 on a Skylake-SP host presents ITS_NO (bit 62), RFDS_NO + // (27), SKIP_L1DFL_VMENTRY (3), and IF_PSCHANGE_MC_NO (6). + let supported = (1 << 62) | (1 << 27) | (1 << 3) | (1 << 6); + let enumerated = CpuidLeafSet::new(vec![CpuidLeaf::new(7, [0, 0, 0, 1 << 29]).indexed(0)]); + // The Skylake profile pins its whole mask clear, ITS_NO included; + // outside it, KVM's own value stays. + let skylake = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + assert_eq!( + profile_arch_capabilities(skylake, supported, &enumerated), + Some(1 << 6) + ); + // The Ice Lake profile pins RDCL_NO, MDS_NO, TAA_NO, and RFDS_NO set. + let icelake = cpu_profile::pinned("intel.icelake-sp.v1").unwrap(); + assert_eq!( + profile_arch_capabilities(icelake, supported, &enumerated), + Some(0x0800_0121 | (1 << 6)) + ); + // Without the CPUID enumeration the guest's reads raise #GP, and KVM + // would refuse the write: nothing is presented. + assert_eq!( + profile_arch_capabilities(skylake, supported, &CpuidLeafSet::new(Vec::new())), + None + ); + } + + fn kvm_like_base() -> Vec { + vec![ + CpuidLeaf::new(0, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + CpuidLeaf::new( + 1, + [ + 0x0005_0654, + 0x0010_0800, + 0xfffa_3203 | (1 << 24), + 0x0f8b_fbff, + ], + ), + CpuidLeaf::new(6, [0x4, 0, 0, 0]), + CpuidLeaf::new(7, [0, 0xd19f_4fbb | (1 << 1), 0, 0]).indexed(0), + CpuidLeaf::new(0xa, [0x0740_4f04, 0, 0, 0x603]), + CpuidLeaf::new(0x15, [2, 0xd4, 0x017d_7840, 0]), + // KVM's signature and features, and a stray leaf at another + // hypervisor base. + CpuidLeaf::new(0x4000_0000, [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0x4000_0001, [0x0100_8efb, 0, 0, 0]), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0x8000_0000, [0x8000_0008, 0, 0, 0]), + CpuidLeaf::new(0x8000_0001, [0, 0, 0x121, 0x2c10_0800]), + CpuidLeaf::new(0x8000_0007, [0, 0, 0, 0x100]), + ] + } + + /// An effective CPUID shaped like a CPU profile's: the CPU's leaves + /// without a hypervisor leaf, and the time ABI's identity, zero, and time + /// leaves. + fn effective_like(vp_count: u32) -> CpuidLeafSet { + let mut leaves: Vec<_> = kvm_like_base() + .into_iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + leaves.extend(time_abi_cpuid(vp_count, true).leaves().iter().copied()); + CpuidLeafSet::new(leaves) + } + + #[test] + fn partition_cpuid_is_the_time_abi_cpuid_verbatim() { + let time_abi = effective_like(4); + let cpuid = partition_cpuid(&time_abi); + let leaves = |set: &CpuidLeafSet| -> Vec<_> { + set.leaves() + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect() + }; + assert_eq!(leaves(&cpuid), leaves(&time_abi)); + let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, 4).unwrap(); + virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); + // No KVM signature at any base, and a leaf the table does not list + // reads zero. + assert_eq!(cpuid.result(0x4000_0100, 0, &[0; 4]), [0; 4]); + assert_eq!(cpuid.result(0x8000_001f, 0, &[0; 4]), [0; 4]); + } + + #[test] + fn capabilities_of_the_partition_cpuid_exclude_hv1() { + let topology = vm_topology::processor::TopologyBuilder::new_x86() + .build(2) + .unwrap(); + let cpuid = partition_cpuid(&effective_like(2)); + let mut lookup = |leaf, subleaf| { + let mut result = cpuid.result(leaf, subleaf, &[0; 4]); + if leaf == 1 { + // Match the topology's APIC mode and drop XSAVE, which the + // fake CPUID does not describe. + result[2] &= !((1 << 21) | (1 << 26) | (1 << 27)); + if topology.apic_mode() != vm_topology::processor::x86::ApicMode::XApic { + result[2] |= 1 << 21; + } + } + result + }; + let caps = virt::PartitionCapabilities::from_cpuid( + &topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + .unwrap(); + check_capabilities(&caps).unwrap(); + let unmasked = virt::PartitionCapabilities::from_cpuid(&topology, &mut lookup).unwrap(); + assert!(check_capabilities(&unmasked).is_err()); + } + + #[test] + fn cpuid_leaves_keep_significant_indices() { + let entries = [ + kvm::kvm_cpuid_entry2 { + function: 7, + index: 0, + flags: kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX, + ebx: 0x1234, + ..Default::default() + }, + kvm::kvm_cpuid_entry2 { + function: 0x4000_0001, + index: 0, + flags: 0, + eax: 0x3123_7648, + ..Default::default() + }, + ]; + let leaves = cpuid_leaves(&entries); + assert_eq!( + (leaves[0].function, leaves[0].index, leaves[0].result), + (7, Some(0), [0, 0x1234, 0, 0]) + ); + assert_eq!( + (leaves[1].function, leaves[1].index, leaves[1].result), + (0x4000_0001, None, [0x3123_7648, 0, 0, 0]) + ); + } + + #[test] + fn host_sample_pairing_uses_the_bracket_midpoint() { + // 2 GHz: 2 cycles per ns. + let hz = 2_000_000_000; + let bracket = HostTscBracket { + start: 1_000, + end: 1_401, + }; + // Midpoint 1200, half width 201 cycles rounds up to 101 ns. + assert_eq!(pair_host_sample(bracket, hz), Some((1_200, 101))); + // A half width of MAX_ANCHOR_PAIRING_NS (2 cycles per ns) is at the + // anchor bound. + let at_bound = HostTscBracket { + start: 0, + end: 4 * MAX_ANCHOR_PAIRING_NS, + }; + assert_eq!( + pair_host_sample(at_bound, hz), + Some((2 * MAX_ANCHOR_PAIRING_NS, MAX_ANCHOR_PAIRING_NS)) + ); + let beyond = HostTscBracket { + start: 0, + end: 4 * MAX_ANCHOR_PAIRING_NS + 2, + }; + assert_eq!( + pair_host_sample(beyond, hz), + Some((2 * MAX_ANCHOR_PAIRING_NS + 1, MAX_ANCHOR_PAIRING_NS + 1)) + ); + // A TSC that went backwards cannot be paired. + let backwards = HostTscBracket { start: 10, end: 9 }; + assert_eq!(pair_host_sample(backwards, hz), None); + // Near the top of the TSC range. + let high = HostTscBracket { + start: u64::MAX - 100, + end: u64::MAX, + }; + assert_eq!(pair_host_sample(high, hz), Some((u64::MAX - 50, 25))); + } + + fn host_sample(ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns: ns, + monotonic_ns: ns, + } + } + + #[test] + fn anchors_sample_again_until_paired_within_the_bound() { + // 1 GHz: one cycle per ns. Two brackets wider than the bound, then a + // tight one. + let wide = 2 * MAX_ANCHOR_PAIRING_NS; + let mut brackets = [(0, wide + 10_000), (0, wide + 5_000), (1_000, 1_100)].into_iter(); + let paired = pair_within_bound(1_000_000_000, "capture anchor", || { + let (start, end) = brackets.next().unwrap(); + (HostTscBracket { start, end }, Ok(host_sample(start))) + }) + .unwrap(); + assert_eq!((paired.host_tsc, paired.pairing_ns), (1_050, 50)); + assert_eq!(paired.sample, host_sample(1_000)); + } + + #[test] + fn anchors_fail_after_bounded_attempts() { + let mut attempts = 0; + let error = pair_within_bound(1_000_000_000, "restore anchor", || { + attempts += 1; + let end = 2 * MAX_ANCHOR_PAIRING_NS + attempts as u64; + (HostTscBracket { start: 0, end }, Ok(host_sample(0))) + }) + .unwrap_err(); + assert_eq!(attempts, ANCHOR_ATTEMPTS); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!( + error.message.contains("restore anchor"), + "{}", + error.message + ); + let tightest = format!("tightest pairing: {} ns", MAX_ANCHOR_PAIRING_NS + 1); + assert!(error.message.contains(&tightest), "{}", error.message); + + let error = pair_within_bound(1_000_000_000, "capture anchor", || { + (HostTscBracket { start: 10, end: 9 }, Ok(host_sample(0))) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!( + error.message.contains("went backwards"), + "{}", + error.message + ); + + // A host clock failure ends the anchor at once, with its own code. + let error = pair_within_bound(1_000_000_000, "capture anchor", || { + ( + HostTscBracket { start: 0, end: 1 }, + Err(TimeAbiError::new(TimeAbiCode::HostIdentity, "no clock")), + ) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::HostIdentity); + } + + #[test] + fn unscaled_tsc_is_host_plus_offset() { + let bracket = HostTscBracket { + start: 5_000, + end: 6_000, + }; + // Offsets wrap: a guest TSC below the host TSC has an offset above + // 2^63. + for offset in [0, 1 << 40, 0u64.wrapping_sub(4_000)] { + assert!(tsc_is_unscaled( + 5_500u64.wrapping_add(offset), + offset, + bracket + )); + assert!(tsc_is_unscaled( + 5_000u64.wrapping_add(offset), + offset, + bracket + )); + assert!(tsc_is_unscaled( + 6_000u64.wrapping_add(offset), + offset, + bracket + )); + assert!(!tsc_is_unscaled( + 4_999u64.wrapping_add(offset), + offset, + bracket + )); + assert!(!tsc_is_unscaled( + 6_001u64.wrapping_add(offset), + offset, + bracket + )); + } + // 250 ppm of scaling at a host TSC of 10^12 is far outside the + // bracket. + let host = 1_000_000_000_000u64; + let bracket = HostTscBracket { + start: host, + end: host + 20_000, + }; + let scaled = host + host / 4_000; + assert!(!tsc_is_unscaled(scaled, 0, bracket)); + } + + #[test] + fn common_offset_reaches_the_target_at_the_anchor() { + for (target, host_tsc) in [ + (5_000_000u64, 1_000u64), + (1_000, 5_000_000), + (u64::MAX, 0), + (0, u64::MAX), + ] { + let offset = target.wrapping_sub(host_tsc); + assert_eq!(host_tsc.wrapping_add(offset), target); + } + } + + #[test] + fn offset_readback_requires_every_vp() { + let vps = |values: &[u64]| -> Vec<(VpIndex, u64)> { + values + .iter() + .enumerate() + .map(|(index, &value)| (VpIndex::new(index as u32), value)) + .collect() + }; + check_offset_readback(7, &vps(&[7, 7, 7, 7])).unwrap(); + check_offset_readback(7, &[]).unwrap(); + let error = check_offset_readback(7, &vps(&[7, 8, 7, 9])).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 1: 0x8"), "{}", error.message); + assert!(error.message.contains("VP 3: 0x9"), "{}", error.message); + } + + fn time_abi(msrs: Arc, vendor: CpuVendor) -> KvmTimeAbi { + KvmTimeAbi { + msrs, + kvm_invariant_control: None, + surface: SupportedCpuSurface::default(), + arch_capabilities: None, + amd_config_msrs: vendor == CpuVendor::Amd, + } + } + + /// Maps an MSR exit result to `Some(Ok(value))`, `Some(Err(()))` for #GP, + /// or `None` when the time ABI does not handle the MSR. + fn outcome(result: Option>) -> Option> { + result.map(|result| { + result.map_err(|err| assert!(matches!(err, MsrError::InvalidAccess), "{err:?}")) + }) + } + + #[test] + fn msr_exits_serve_the_identity_and_hide_tsc_adjust() { + let msrs = Arc::new(TimeAbiMsrs::new()); + msrs.declare(DeclaredRates { + tsc_hz: 2_194_804_000, + apic_hz: LAPIC_HZ_KVM, + }) + .unwrap(); + let time_abi = time_abi(msrs.clone(), CpuVendor::Intel); + let vp = VpIndex::new(2); + let read = |msr| outcome(time_abi.read_msr(vp, msr)); + let write = |msr, value| outcome(time_abi.write_msr(vp, msr, value)); + assert_eq!(read(MSR_VP_INDEX), Some(Ok(2))); + assert_eq!(read(MSR_TSC_FREQUENCY), Some(Ok(2_194_804_000))); + assert_eq!(read(MSR_APIC_FREQUENCY), Some(Ok(1_000_000_000))); + // Hyper-V MSRs that KVM would emulate raise #GP. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_00b0, + ] { + assert_eq!(read(msr), Some(Err(())), "{msr:#x}"); + assert_eq!(write(msr, 1), Some(Err(())), "{msr:#x}"); + } + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(Err(()))); + assert_eq!(write(MSR_IA32_TSC_ADJUST, 0), Some(Err(()))); + assert_eq!(read(MSR_IA32_TSC_DEADLINE), Some(Err(()))); + assert_eq!(write(MSR_IA32_TSC_DEADLINE, 1), Some(Err(()))); + // So do the legacy L2-cache MSRs that the legacy path stubs. + for msr in [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e] { + assert_eq!(read(msr), Some(Err(())), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(Err(())), "{msr:#x}"); + } + // Other unknown MSRs keep their existing handling, and so do AMD's + // configuration MSRs under an Intel profile, which the filter does + // not route here. + for msr in [0x87, 0x8b, 0x117, 0x11c, 0x11f, 0xc001_0015, 0xc001_1029] { + assert_eq!(read(msr), None, "{msr:#x}"); + } + assert_eq!(write(0x4000_0200, 0), None); + // The invariant TSC control is guest state in the shared handler. + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 2), Some(Err(()))); + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 1), Some(Ok(()))); + assert_eq!(msrs.tsc_invariant_control(), 1); + assert_eq!(read(MSR_TSC_INVARIANT_CONTROL), Some(Ok(1))); + } + + /// Under an AMD profile, the filter routes HWCR and DE_CFG to OpenVMM, + /// which serves the time ABI's fixed values. + #[test] + fn msr_exits_serve_amd_config_msrs_under_an_amd_profile() { + let time_abi = time_abi(Arc::new(TimeAbiMsrs::new()), CpuVendor::Amd); + let vp = VpIndex::new(1); + let read = |msr| outcome(time_abi.read_msr(vp, msr)); + let write = |msr, value| outcome(time_abi.write_msr(vp, msr, value)); + assert_eq!(read(0xc001_0015), Some(Ok(1 << 24))); + assert_eq!(read(0xc001_1029), Some(Ok(1 << 1))); + assert_eq!(write(0xc001_0015, 1 << 24), Some(Ok(()))); + assert_eq!(write(0xc001_0015, 1 << 24 | 1 << 30), Some(Err(()))); + assert_eq!(write(0xc001_1029, 0), Some(Err(()))); + assert_eq!(read(MSR_VP_INDEX), Some(Ok(1))); + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(Err(()))); + assert_eq!(read(0xc001_0010), None); + } + + #[test] + fn cycle_conversion_rounds_up() { + assert_eq!(cycles_to_ns_ceil(0, 2_000_000_000), 0); + assert_eq!(cycles_to_ns_ceil(1, 2_000_000_000), 1); + // One millisecond at the bare-metal KVM host's rate, and a hair over a + // microsecond. + assert_eq!(cycles_to_ns_ceil(2_194_804, 2_194_804_000), 1_000_000); + assert_eq!(cycles_to_ns_ceil(2_195, 2_194_804_000), 1_001); + assert_eq!(cycles_to_ns_ceil(u64::MAX, 1), u64::MAX); + } +} + +/// Tests against the host's KVM. +#[cfg(test)] +mod kvm_tests { + use super::*; + use test_with_tracing::test; + + fn partition(vp_count: u32) -> kvm::Partition { + let kvm = kvm::Kvm::new().unwrap(); + let mut vm = kvm.new_vm(kvm::VmType::Default).unwrap(); + for vcpu in 0..vp_count { + vm.add_vp(vcpu).unwrap(); + } + vm + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn identity_msr_filter_installs() { + let vm = partition(1); + install_identity_msr_filter(&vm, CpuVendor::Amd).unwrap(); + install_identity_msr_filter(&vm, CpuVendor::Intel).unwrap(); + // Host-initiated accesses bypass the filter. + let mut value = [0]; + vm.vp(0) + .get_msrs(&[x86defs::X86X_MSR_TSC], &mut value) + .unwrap(); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn common_offset_is_exact_on_every_vcpu() { + for vp_count in [1, 2, 4, 8] { + let vm = partition(vp_count); + let hz = vm.vp(0).tsc_frequency_hz().unwrap(); + // Desynchronize the vCPUs first, as a restore's IA32_TSC writes + // may. + for vcpu in 0..vp_count { + vm.vp(vcpu) + .set_tsc_offset(u64::from(vcpu) * 1_000_003) + .unwrap(); + } + let (bracket, ()) = HostTscBracket::around(|| ()); + let target = hz * 3600; + let offset = target.wrapping_sub(bracket.end); + for vcpu in 0..vp_count { + vm.vp(vcpu).set_tsc_offset(offset).unwrap(); + } + for vcpu in 0..vp_count { + let vp = vm.vp(vcpu); + assert!(vp.has_tsc_offset_attr()); + assert_eq!( + vp.tsc_offset().unwrap(), + offset, + "vCPU {vcpu} of {vp_count}" + ); + let mut guest = [0]; + let (bracket, result) = + HostTscBracket::around(|| vp.get_msrs(&[x86defs::X86X_MSR_TSC], &mut guest)); + result.unwrap(); + assert!( + tsc_is_unscaled(guest[0], offset, bracket), + "vCPU {vcpu} of {vp_count}: {:#x} {bracket:x?}", + guest[0] + ); + } + } + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn host_samples_pair_within_the_anchor_bound() { + let vm = partition(1); + let hz = vm.vp(0).tsc_frequency_hz().unwrap(); + let mut worst = 0; + for _ in 0..1000 { + worst = worst.max(paired_host_sample(hz, "test anchor").unwrap().pairing_ns); + } + tracing::info!(worst, "worst host sample pairing, ns"); + assert!(worst <= MAX_ANCHOR_PAIRING_NS); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn invariant_control_mirror_reaches_kvm() { + let vm = partition(2); + let msrs = Arc::new(TimeAbiMsrs::new()); + let time_abi = KvmTimeAbi::new( + &vm, + &[0, 1], + msrs.clone(), + SupportedCpuSurface::default(), + None, + CpuVendor::Intel, + ) + .unwrap(); + if time_abi.kvm_invariant_control.is_none() { + tracing::info!("KVM does not implement HV_X64_MSR_TSC_INVARIANT_CONTROL"); + return; + } + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + time_abi.sync_invariant_control(&vm.vp(1)).unwrap(); + let mut value = [0]; + // The control is partition-wide: every vCPU reads the mirrored value. + vm.vp(0) + .get_msrs(&[MSR_TSC_INVARIANT_CONTROL], &mut value) + .unwrap(); + assert_eq!(value[0], 1); + // A reset returns it to 0, which a host write may set in KVM. + msrs.reset(); + time_abi.sync_invariant_control(&vm.vp(0)).unwrap(); + vm.vp(1) + .get_msrs(&[MSR_TSC_INVARIANT_CONTROL], &mut value) + .unwrap(); + assert_eq!(value[0], 0); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn profile_arch_capabilities_reach_the_vcpu() { + let kvm = kvm::Kvm::new().unwrap(); + let surface = supported_surface( + &kvm.supported_cpuid().unwrap(), + kvm.feature_msr(MSR_IA32_ARCH_CAPABILITIES).unwrap(), + ); + assert!(surface.physical_address_width >= 36); + let supported = surface.msrs[0].supported; + tracing::info!( + supported = format_args!("{supported:#x}"), + "KVM's ARCH_CAPABILITIES" + ); + let vm = partition(1); + // KVM accepts the MSR once the vCPU's CPUID enumerates it. + let enumerated = CpuidLeafSet::new(vec![CpuidLeaf::new(7, [0, 0, 0, 1 << 29]).indexed(0)]); + vm.vp(0) + .set_cpuid(&[kvm::kvm_cpuid_entry2 { + function: 7, + flags: kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX, + edx: 1 << 29, + ..Default::default() + }]) + .unwrap(); + let profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + let value = profile_arch_capabilities(profile, supported, &enumerated).unwrap(); + let time_abi = KvmTimeAbi::new( + &vm, + &[0], + Arc::new(TimeAbiMsrs::new()), + surface, + Some(value), + profile.cpu_vendor(), + ) + .unwrap(); + time_abi.set_arch_capabilities(&vm.vp(0)).unwrap(); + let mut read = [0]; + vm.vp(0) + .get_msrs(&[MSR_IA32_ARCH_CAPABILITIES], &mut read) + .unwrap(); + assert_eq!(read[0], value); + assert_eq!(read[0] & (1 << 62), 0, "ITS_NO is pinned clear"); + } +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs index 743d04cdceb..7fe4c3f538f 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs @@ -275,7 +275,6 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { }; let events = self.kvm().get_vcpu_events()?; - // N.B. KVM has no way to get back the pending extint vector. let event = if events.exception.pending != 0 { Some(vp::PendingEvent::Exception { vector: events.exception.nr, @@ -288,7 +287,10 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { }, }) } else { - None + self.vp + .runner + .pending_extint() + .map(|vector| vp::PendingEvent::ExtInt { vector }) }; let interruption = if events.exception.injected != 0 { @@ -386,9 +388,7 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { let _ = parameter; } Some(vp::PendingEvent::ExtInt { vector }) => { - // N.B. KVM has no way to clear a pending (but non-injected) - // extint interrupt. - self.kvm().interrupt(vector.into())?; + self.vp.runner.restore_pending_extint(vector)?; } None => {} } @@ -527,6 +527,14 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { self.set_register_state(tsc) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } diff --git a/vmm_core/virt_kvm/src/lib.rs b/vmm_core/virt_kvm/src/lib.rs index 4900032d49c..0019c108e6e 100644 --- a/vmm_core/virt_kvm/src/lib.rs +++ b/vmm_core/virt_kvm/src/lib.rs @@ -91,6 +91,12 @@ pub enum KvmError { #[cfg(guest_arch = "x86_64")] #[error("failed to compute topology cpuid")] TopologyCpuid(#[source] virt::x86::topology::UnknownVendor), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("the time ABI does not support {0}")] + TimeAbiUnsupported(&'static str), } #[derive(Inspect)] @@ -161,6 +167,11 @@ struct KvmPartitionInner { gic_nr_irqs: u32, #[cfg(guest_arch = "x86_64")] synic_ports: virt::synic::SynicPortMap, + /// The NVX time ABI state, for a partition built with a time ABI + /// configuration. + #[cfg(guest_arch = "x86_64")] + #[inspect(skip)] + time_abi: Option, } // TODO: Chunk this up into smaller types. @@ -189,6 +200,9 @@ enum KvmRunVpError { #[cfg(guest_arch = "x86_64")] #[error("failed to inject an extint interrupt")] ExtintInterrupt(#[source] kvm::Error), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(virt::time_abi::TimeAbiError), } pub struct KvmProcessorBinder { diff --git a/vmm_core/virt_mshv/Cargo.toml b/vmm_core/virt_mshv/Cargo.toml index 723f24bd8c2..80724fb5814 100644 --- a/vmm_core/virt_mshv/Cargo.toml +++ b/vmm_core/virt_mshv/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [target.'cfg(target_os = "linux")'.dependencies] aarch64defs.workspace = true +cpu_profile.workspace = true headervec.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true @@ -33,12 +34,12 @@ mshv-bindings = { workspace = true, features = ["with-serde", "fam-wrappers"] } mshv-ioctls.workspace = true libc.workspace = true parking_lot.workspace = true -signal-hook.workspace = true thiserror.workspace = true tracing.workspace = true zerocopy.workspace = true [dev-dependencies] +pal_async.workspace = true test_with_tracing.workspace = true [build-dependencies] diff --git a/vmm_core/virt_mshv/src/aarch64/mod.rs b/vmm_core/virt_mshv/src/aarch64/mod.rs index 73027449aa0..2c5c89d33f2 100644 --- a/vmm_core/virt_mshv/src/aarch64/mod.rs +++ b/vmm_core/virt_mshv/src/aarch64/mod.rs @@ -31,7 +31,6 @@ use hvdef::HvMessageType; use hvdef::HvPartitionPropertyCode; use hvdef::Vtl; use hvdef::hypercall::HvRegisterAssoc; -use pal::unix::pthread::Pthread; use pci_core::msi::SignalMsi; use std::sync::Arc; use virt::Hv1; @@ -260,11 +259,7 @@ impl virt::Partition for MshvPartition { if vp.needs_yield.request_yield() { let thread = vp.thread.read(); if let Some(thread) = *thread { - if thread != Pthread::current() { - thread - .signal(libc::SIGRTMIN()) - .expect("thread cancel signal failed"); - } + crate::run_vp::cancel(thread).expect("thread cancel signal failed"); } } } diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 542b2a256ee..bb69770fb55 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -9,6 +9,8 @@ #[cfg(guest_arch = "aarch64")] mod aarch64; +mod run_vp; +mod timing; #[cfg(guest_arch = "x86_64")] mod x86_64; @@ -55,7 +57,6 @@ use std::os::fd::AsFd; use std::os::fd::AsRawFd; use std::os::fd::IntoRawFd as _; use std::sync::Arc; -use std::sync::Once; use std::sync::Weak; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -143,6 +144,7 @@ impl<'a> MshvProtoPartition<'a> { /// Performs the post-init partition setup common to both architectures: /// creates VPs, BSP, installs intercepts, sets up the signal handler, /// and checks for unsupported VTL2 configuration. + /// On x86_64, the BSP is instead created when partition memory is finalized. fn new(config: ProtoPartitionConfig<'a>, vmfd: VmFd) -> Result { if config.processor_topology.vp_count() > u8::MAX as u32 { return Err(ErrorInner::TooManyVps(config.processor_topology.vp_count()).into()); @@ -157,10 +159,16 @@ impl<'a> MshvProtoPartition<'a> { needs_yield: NeedsYield::new(), message_queues: MessageQueues::new(), message_queues_pending: AtomicBool::new(false), + extint_pending: AtomicBool::new(false), + #[cfg(guest_arch = "x86_64")] + created: AtomicBool::new(false), + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool::new(false), waker: RwLock::new(None), }) .collect(); + #[cfg(guest_arch = "aarch64")] let bsp = vmfd .create_vcpu(0) .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; @@ -189,18 +197,7 @@ impl<'a> MshvProtoPartition<'a> { }) .map_err(|e| ErrorInner::InstallIntercept(e.into()))?; - // Set up a signal for forcing vcpufd.run() to exit with EINTR. - static SIGNAL_HANDLER_INIT: Once = Once::new(); - // SAFETY: The signal handler does not perform any actions that are - // forbidden for signal handlers to perform, as it performs nothing. - SIGNAL_HANDLER_INIT.call_once(|| unsafe { - signal_hook::low_level::register(libc::SIGRTMIN(), || { - // Signal handler does nothing other than enabling run_fd() - // ioctl to return with EINTR, when the associated signal is - // sent to run_fd() thread. - }) - .unwrap(); - }); + run_vp::init().map_err(ErrorInner::RunVpSignal)?; if let Some(hv_config) = &config.hv_config { if hv_config.vtl2.is_some() { @@ -212,8 +209,11 @@ impl<'a> MshvProtoPartition<'a> { config, #[cfg(guest_arch = "x86_64")] isolation: arch::MshvProtoPartitionIsolation::None, + #[cfg(guest_arch = "x86_64")] + time_abi_host: None, vmfd, vps, + #[cfg(guest_arch = "aarch64")] bsp, }) } @@ -233,8 +233,13 @@ pub struct MshvProtoPartition<'a> { config: ProtoPartitionConfig<'a>, #[cfg(guest_arch = "x86_64")] isolation: arch::MshvProtoPartitionIsolation, + /// The processor features the host partition offered, read when the + /// partition carries the time ABI. + #[cfg(guest_arch = "x86_64")] + time_abi_host: Option, vmfd: VmFd, vps: Vec, + #[cfg(guest_arch = "aarch64")] bsp: VcpuFd, } @@ -307,8 +312,12 @@ struct MshvPartitionInner { vmfd: VmFd, /// The BSP's VcpuFd, retained for partition-level register access /// (VM state get/set). Only used while VPs are stopped. + #[cfg(guest_arch = "aarch64")] #[inspect(skip)] bsp_vcpufd: VcpuFd, + #[cfg(guest_arch = "x86_64")] + #[inspect(rename = "memory_finalized", with = "|x| x.get().is_some()")] + finalized: std::sync::OnceLock, #[inspect(skip)] memory: Mutex, gm: GuestMemory, @@ -319,15 +328,22 @@ struct MshvPartitionInner { irq_routes: virt::irqcon::IrqRoutes, #[inspect(skip)] gsi_states: Mutex>, + #[cfg(guest_arch = "aarch64")] caps: virt::PartitionCapabilities, synic_ports: virt::synic::SynicPortMap, #[cfg(guest_arch = "x86_64")] + #[inspect(flatten)] + config: arch::finalize::CreationConfig, + #[cfg(guest_arch = "x86_64")] software_devices: virt::x86::apic_software_device::ApicSoftwareDevices, #[inspect(skip)] isolation: MshvIsolationState, /// Set to `true` when partition time is frozen (e.g. during reset). /// The first VP to enter `run_vp` after a freeze will thaw time. time_frozen: Mutex, + /// The NVX time ABI state, for a partition built with it. + #[cfg(guest_arch = "x86_64")] + time_abi: Option, /// aarch64 GIC MSI controller config, used to decode PCIe MSIs into SPI /// assertions via a v2m frame. #[cfg(guest_arch = "aarch64")] @@ -343,6 +359,20 @@ struct MshvVpInner { /// Set by device threads after enqueuing a message to signal the VP /// thread to flush its message queues. message_queues_pending: AtomicBool, + /// Set when the userspace PIC pulses LINT0 and cleared after ExtINT delivery. + #[cfg(guest_arch = "x86_64")] + extint_pending: AtomicBool, + /// Set once this VP exists in the hypervisor. The BSP is created when + /// partition memory is finalized and an application processor when its + /// binder is first bound, so a restore-time VP prefix leaves the suffix + /// uncreated. + #[cfg(guest_arch = "x86_64")] + created: AtomicBool, + /// Set when the VMM writes this VP's LAPIC state. The next VP run + /// consumes it and re-asserts the highest pending edge-triggered vector + /// (see `MshvProcessor::assert_pending_interrupt`). + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool, /// Waker for the VP run loop task. Set by the VP thread, used by device /// threads to re-poll the run loop when new messages are enqueued. waker: RwLock>, @@ -495,13 +525,8 @@ struct MshvVpRunner<'a> { } impl MshvVpRunner<'_> { - fn run(&mut self) -> Result { - self.vcpufd.run().map(|msg| { - // SAFETY: hv_message and HvMessage have the same size - // (256 bytes) and compatible layout (header + 240-byte - // payload). - unsafe { std::mem::transmute::(msg) } - }) + fn run(&mut self) -> io::Result { + run_vp::run(self.vcpufd) } #[cfg(guest_arch = "x86_64")] @@ -622,6 +647,7 @@ impl virt::Processor for MshvProcessor<'_> { dev: &impl CpuIo, ) -> Result { let vpinner = self.inner; + run_vp::prepare_thread(); let _cleaner = MshvVpInnerCleaner { vpinner }; assert!(vpinner.thread.write().replace(Pthread::current()).is_none()); @@ -630,6 +656,20 @@ impl virt::Processor for MshvProcessor<'_> { .thaw_time() .expect("failed to thaw partition time"); + // A LAPIC state written since the last run may hold a pending vector + // that the hypervisor hasn't delivered, and partition time may have + // been frozen and thawed since then. Assert it again now that the VP + // is about to run. + #[cfg(guest_arch = "x86_64")] + if vpinner.lapic_written.swap(false, Ordering::AcqRel) { + if let Err(err) = self.reassert_pending_interrupt() { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to re-assert a pending LAPIC vector" + ); + } + } + // Ensure any messages present from a state restore are flushed on // the first loop iteration. if vpinner.message_queues.pending_sints() != 0 { @@ -665,12 +705,15 @@ impl virt::Processor for MshvProcessor<'_> { } } + #[cfg(guest_arch = "x86_64")] + self.request_extint_notification(); + match self.runner.run() { Ok(exit) => { self.handle_exit(&exit, dev).await?; } - Err(e) => match e.errno() { - libc::EAGAIN | libc::EINTR => {} + Err(e) => match e.raw_os_error() { + Some(libc::EAGAIN) | Some(libc::EINTR) => {} _ => tracing::error!( error = &e as &dyn std::error::Error, "vcpufd.run returned error" @@ -731,6 +774,8 @@ impl> From for Error { // TODO: Chunk this up into smaller types. #[derive(Error, Debug)] enum ErrorInner { + #[error("failed to initialize MSHV VP cancellation signal")] + RunVpSignal(#[source] io::Error), #[error("operation not supported")] NotSupported, #[error("create_vm failed")] @@ -745,6 +790,15 @@ enum ErrorInner { #[cfg(guest_arch = "x86_64")] #[error(transparent)] Snp(#[from] arch::SnpError), + #[cfg(guest_arch = "x86_64")] + #[error("guest memory is not attached")] + GuestMemoryNotAttached, + #[cfg(guest_arch = "x86_64")] + #[error("partition memory has not been finalized")] + PartitionNotFinalized, + #[cfg(guest_arch = "x86_64")] + #[error("partition memory was already finalized")] + PartitionAlreadyFinalized, #[error("vtl2 not supported")] Vtl2NotSupported, #[error("isolation not supported")] @@ -807,6 +861,13 @@ enum ErrorInner { error: KernelError, ty: u8, }, + #[cfg(guest_arch = "x86_64")] + #[error("failed to re-assert pending LAPIC vector {vector:#x}")] + AssertPendingInterrupt { + #[source] + error: KernelError, + vector: u8, + }, #[error("failed to reset state")] ResetState(#[source] Box>), #[error("install intercept failed")] @@ -822,6 +883,15 @@ enum ErrorInner { #[cfg(guest_arch = "x86_64")] #[error("failed to create virtual device")] NewDevice(#[source] virt::x86::apic_software_device::DeviceIdInUse), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("failed to get host partition property {0:?}")] + GetHostPartitionProperty(HvPartitionPropertyCode, #[source] KernelError), + #[cfg(guest_arch = "x86_64")] + #[error("failed to query the CPUID values of the fingerprint probe partition")] + FingerprintCpuid(#[source] KernelError), } /// Equivalent to [`MshvError`] but has a much better error message. @@ -1006,7 +1076,7 @@ impl virt::PartitionMemoryMap for MshvPartitionInner { exec, ) .entered(); - let mapped = self.isolation.map_user_memory(&self.vmfd, mem_region)?; + let mapped = self.isolation.map_memory_timed(&self.vmfd, mem_region)?; state.ranges[slot_to_use] = Some(MshvMemoryRange { region: mem_region, mapped, diff --git a/vmm_core/virt_mshv/src/run_vp.rs b/vmm_core/virt_mshv/src/run_vp.rs new file mode 100644 index 00000000000..1b9cb590e51 --- /dev/null +++ b/vmm_core/virt_mshv/src/run_vp.rs @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Race-free cancellation of the blocking MSHV_RUN_VP ioctl. +//! +//! A signal delivered before ioctl entry must not be lost. Latch early signals +//! in thread-local storage, and redirect signals in the final check-to-syscall +//! interval to an EINTR return. Once the syscall returns, preserve its result: +//! an intercepted guest instruction must not be discarded. + +use hvdef::HvMessage; +use mshv_ioctls::VcpuFd; +use pal::unix::pthread::Pthread; +use std::io; +use std::os::fd::AsRawFd; +use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use zerocopy::FromZeros; + +std::thread_local! { + static CANCELLED: AtomicBool = const { AtomicBool::new(false) }; +} + +fn signal() -> i32 { + // KVM owns SIGRTMIN and has a different signal-handler contract. + libc::SIGRTMIN() + 1 +} + +pub(super) fn init() -> io::Result<()> { + static INIT: OnceLock> = OnceLock::new(); + (*INIT.get_or_init(|| { + // SAFETY: zero is valid for sigaction's C fields. The handler receives + // a kernel-provided ucontext because SA_SIGINFO is set. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handle_signal as *const () as usize; + // Preserve restart behavior for unrelated syscalls. A restarted guarded + // syscall resumes inside the protected interval and is redirected too. + action.sa_flags = libc::SA_SIGINFO | libc::SA_RESTART; + // SAFETY: the mask and action are valid for these synchronous C calls. + let failed = unsafe { + libc::sigemptyset(&mut action.sa_mask) != 0 + || libc::sigaction(signal(), &action, std::ptr::null_mut()) != 0 + }; + if failed { + Err(io::Error::last_os_error() + .raw_os_error() + .expect("signal setup returned an OS error")) + } else { + Ok(()) + } + })) + .map_err(io::Error::from_raw_os_error) +} + +pub(super) fn prepare_thread() { + // Initialize TLS before publishing the thread as a cancellation target. + CANCELLED.with(|_| {}); +} + +pub(super) fn cancel(thread: Pthread) -> io::Result<()> { + if thread == Pthread::current() { + CANCELLED.with(|cancelled| cancelled.store(true, Ordering::Relaxed)); + Ok(()) + } else { + thread.signal(signal()) + } +} + +/// # Safety +/// Must be invoked as a SA_SIGINFO handler with a valid signal context. +unsafe extern "C" fn handle_signal( + _signal: i32, + _info: *mut libc::siginfo_t, + context: *mut libc::c_void, +) { + CANCELLED.with(|cancelled| cancelled.store(true, Ordering::Relaxed)); + // SAFETY: SA_SIGINFO supplies a valid, exclusively borrowed signal context. + let context = unsafe { &mut *context.cast::() }; + interrupt_entry(context); +} + +fn interrupt_entry(context: &mut libc::ucontext_t) { + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + let ip = context.uc_mcontext.gregs[libc::REG_RIP as usize] as usize; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + let ip = context.uc_mcontext.pc as usize; + + let start = guarded_syscall as *const () as usize; + let end = openvmm_mshv_syscall_return as *const () as usize; + if (start..end).contains(&ip) { + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + context.uc_mcontext.gregs[libc::REG_RIP as usize] = end as _; + context.uc_mcontext.gregs[libc::REG_RAX as usize] = -i64::from(libc::EINTR); + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + context.uc_mcontext.pc = end as _; + context.uc_mcontext.regs[0] = (-i64::from(libc::EINTR)) as _; + } + } +} + +unsafe extern "C" { + fn openvmm_mshv_syscall_return(); +} + +// These are host syscall ABIs, not guest register layouts. The naked functions +// never change the stack or callee-saved registers, so the signal handler can +// return directly from any instruction before the syscall's return boundary. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +#[unsafe(naked)] +/// # Safety +/// Arguments must satisfy the syscall ABI. The cancellation pointer must refer +/// to a live AtomicBool initialized on the calling thread. +unsafe extern "C" fn guarded_syscall( + _number: libc::c_long, + _arg0: usize, + _arg1: usize, + _arg2: usize, + _cancelled: *const bool, +) -> libc::c_long { + core::arch::naked_asm!( + "mov rax, rdi", + "mov rdi, rsi", + "mov rsi, rdx", + "mov rdx, rcx", + "cmp byte ptr [r8], 0", + "jne 2f", + "syscall", + ".global openvmm_mshv_syscall_return", + ".hidden openvmm_mshv_syscall_return", + "openvmm_mshv_syscall_return:", + "ret", + "2:", + "mov rax, -{eintr}", + "ret", + eintr = const libc::EINTR, + ); +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "aarch64")] +#[unsafe(naked)] +/// # Safety +/// Arguments must satisfy the syscall ABI. The cancellation pointer must refer +/// to a live AtomicBool initialized on the calling thread. +unsafe extern "C" fn guarded_syscall( + _number: libc::c_long, + _arg0: usize, + _arg1: usize, + _arg2: usize, + _cancelled: *const bool, +) -> libc::c_long { + core::arch::naked_asm!( + "mov x8, x0", + "mov x0, x1", + "mov x1, x2", + "mov x2, x3", + "ldarb w9, [x4]", + "cbnz w9, 2f", + "svc #0", + ".global openvmm_mshv_syscall_return", + ".hidden openvmm_mshv_syscall_return", + "openvmm_mshv_syscall_return:", + "ret", + "2:", + "mov x0, #-{eintr}", + "ret", + eintr = const libc::EINTR, + ); +} + +/// # Safety +/// The caller must provide valid arguments and pointers for the syscall. +unsafe fn syscall(number: libc::c_long, args: [usize; 3]) -> io::Result { + CANCELLED.with(|cancelled| { + if cancelled.swap(false, Ordering::Relaxed) { + return Err(io::Error::from_raw_os_error(libc::EINTR)); + } + // SAFETY: the caller provides the syscall arguments; the cancellation + // flag is live and is read atomically by the guarded assembly. + let result = + unsafe { guarded_syscall(number, args[0], args[1], args[2], cancelled.as_ptr()) }; + if result < 0 { + Err(io::Error::from_raw_os_error(-result as i32)) + } else { + Ok(result) + } + }) +} + +pub(super) fn run(vcpu: &VcpuFd) -> io::Result { + const { + assert!(size_of::() == size_of::()); + } + let mut message = HvMessage::new_zeroed(); + // SAFETY: the borrowed VP fd remains open, and the kernel may write one + // complete intercept message to this live, correctly sized output buffer. + let result = unsafe { + syscall( + libc::SYS_ioctl, + [ + vcpu.as_raw_fd() as usize, + mshv_ioctls::MSHV_RUN_VP() as usize, + std::ptr::from_mut(&mut message) as usize, + ], + )? + }; + if result != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("MSHV_RUN_VP returned unexpected result {result}"), + )); + } + Ok(message) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::os::unix::net::UnixStream; + use std::sync::mpsc; + use std::time::Duration; + use test_with_tracing::test; + + fn initialize() { + init().unwrap(); + prepare_thread(); + CANCELLED.with(|cancelled| cancelled.store(false, Ordering::Relaxed)); + } + + fn getpid() -> io::Result { + // SAFETY: getpid has no pointer arguments or memory side effects. + unsafe { syscall(libc::SYS_getpid, [0; 3]) } + } + + #[test] + fn signal_before_entry_is_latched() { + initialize(); + Pthread::current().signal(signal()).unwrap(); + assert_eq!(getpid().unwrap_err().raw_os_error(), Some(libc::EINTR)); + assert_eq!(getpid().unwrap(), i64::from(std::process::id())); + } + + #[test] + fn same_thread_cancellation_is_latched() { + initialize(); + cancel(Pthread::current()).unwrap(); + assert_eq!(getpid().unwrap_err().raw_os_error(), Some(libc::EINTR)); + assert_eq!(getpid().unwrap(), i64::from(std::process::id())); + } + + #[test] + fn syscall_errors_are_preserved() { + initialize(); + // SAFETY: -1 is an invalid fd; close cannot release a live descriptor. + let result = unsafe { syscall(libc::SYS_close, [usize::MAX, 0, 0]) }; + assert_eq!(result.unwrap_err().raw_os_error(), Some(libc::EBADF)); + } + + #[test] + fn entry_redirect_preserves_completed_syscalls() { + let start = guarded_syscall as *const () as usize; + let end = openvmm_mshv_syscall_return as *const () as usize; + assert!(start < end); + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + let restarted_syscall_ip = end - 2; + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "aarch64")] + let restarted_syscall_ip = end - 4; + for (ip, interrupted) in [ + (start - 1, false), + (start, true), + (restarted_syscall_ip, true), + (end - 1, true), + (end, false), + (end + 1, false), + ] { + // SAFETY: zero is valid for this C structure; the test accesses + // only its integer instruction-pointer and return-value fields. + let mut context: libc::ucontext_t = unsafe { std::mem::zeroed() }; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + context.uc_mcontext.gregs[libc::REG_RIP as usize] = ip as _; + context.uc_mcontext.gregs[libc::REG_RAX as usize] = 123; + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + context.uc_mcontext.pc = ip as _; + context.uc_mcontext.regs[0] = 123; + } + interrupt_entry(&mut context); + let expected_ip = if interrupted { end } else { ip }; + let expected_result = if interrupted { + -i64::from(libc::EINTR) + } else { + 123 + }; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + assert_eq!( + context.uc_mcontext.gregs[libc::REG_RIP as usize] as usize, + expected_ip + ); + assert_eq!( + context.uc_mcontext.gregs[libc::REG_RAX as usize], + expected_result + ); + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + assert_eq!(context.uc_mcontext.pc as usize, expected_ip); + assert_eq!(context.uc_mcontext.regs[0] as i64, expected_result); + } + } + } + + #[test] + fn signal_interrupts_blocking_syscall() { + init().unwrap(); + let (reader, mut writer) = UnixStream::pair().unwrap(); + let (ready_send, ready_recv) = mpsc::channel(); + let (done_send, done_recv) = mpsc::channel(); + let task = std::thread::spawn(move || { + initialize(); + ready_send.send(Pthread::current()).unwrap(); + let mut byte = 0u8; + // SAFETY: the socket stays open and the byte is a live output + // buffer for a one-byte read throughout the blocking syscall. + let result = unsafe { + syscall( + libc::SYS_read, + [ + reader.as_raw_fd() as usize, + std::ptr::from_mut(&mut byte) as usize, + 1, + ], + ) + }; + done_send.send(result).unwrap(); + }); + cancel(ready_recv.recv().unwrap()).unwrap(); + let result = done_recv.recv_timeout(Duration::from_secs(2)); + if matches!(result, Err(mpsc::RecvTimeoutError::Timeout)) { + // Release a regressed read before failing instead of leaking a + // blocked test thread. + writer.write_all(&[0]).unwrap(); + } + task.join().unwrap(); + assert_eq!( + result + .expect("cancellation did not interrupt read") + .unwrap_err() + .raw_os_error(), + Some(libc::EINTR) + ); + } +} diff --git a/vmm_core/virt_mshv/src/timing.rs b/vmm_core/virt_mshv/src/timing.rs new file mode 100644 index 00000000000..9d719928a15 --- /dev/null +++ b/vmm_core/virt_mshv/src/timing.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Timing diagnostics for MSHV guest memory registration. + +use crate::MshvIsolationState; +use mshv_bindings::mshv_user_mem_region; +use mshv_ioctls::VmFd; + +impl MshvIsolationState { + /// Maps user memory like `map_user_memory` and emits an + /// `MSHV_SET_GUEST_MEMORY completed` event with the elapsed time. + pub(crate) fn map_memory_timed( + &self, + vmfd: &VmFd, + region: mshv_user_mem_region, + ) -> anyhow::Result { + let started = std::time::Instant::now(); + let result = self.map_user_memory(vmfd, region); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "MSHV_SET_GUEST_MEMORY completed" + ); + result + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/extint.rs b/vmm_core/virt_mshv/src/x86_64/extint.rs new file mode 100644 index 00000000000..568576d2339 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/extint.rs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! PIC ExtINT delivery through LINT0 for non-isolated partitions. +//! +//! A LINT0 pulse from the userspace PIC marks the VP and kicks its run loop, +//! which requests an interrupt deliverability notification. Once the VP can +//! accept an interrupt, the PIC vector is injected as a pending ExtINT event. + +use crate::MshvPartitionInner; +use crate::MshvProcessor; +use crate::VcpuFdExt; +use hvdef::HvMessage; +use hvdef::HvX64RegisterName; +use hvdef::Vtl; +use hvdef::hypercall::HvRegisterAssoc; +use virt::VpIndex; +use virt::io::CpuIo; + +impl MshvPartitionInner { + pub(super) fn pulse_lint(&self, vp_index: VpIndex, vtl: Vtl, lint: u8) { + if self.isolation.snp().is_some() { + // MSHV isolated VPs cannot receive PIC ExtINT through LINT0. + tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "ignored isolated lint pulse"); + return; + } + if vtl != Vtl::Vtl0 || lint != 0 { + tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "unsupported lint pulse"); + return; + } + let Some(vp) = self.vps.get(vp_index.index() as usize) else { + tracelimit::warn_ratelimited!(?vp_index, "lint pulse for invalid VP"); + return; + }; + vp.extint_pending + .store(true, std::sync::atomic::Ordering::Release); + if let Some(waker) = &*vp.waker.read() { + waker.wake_by_ref(); + } + if let Some(thread) = *vp.thread.read() { + crate::run_vp::cancel(thread).expect("thread interrupt signal failed"); + } + } +} + +impl MshvProcessor<'_> { + /// Requests an interrupt deliverability notification while a LINT0 + /// ExtINT is pending. + pub(crate) fn request_extint_notification(&mut self) { + use std::sync::atomic::Ordering; + + let vpinner = self.inner; + if vpinner.extint_pending.load(Ordering::Acquire) + && !self.deliverability_notifications.interrupt_notification() + { + let notifications = self + .deliverability_notifications + .with_interrupt_notification(true); + self.partition + .vmfd + .register_deliverabilty_notifications( + self.vpindex.index(), + u64::from(notifications), + ) + .expect("requesting deliverability is not a fallible operation"); + self.deliverability_notifications = notifications; + } + } + + pub(super) fn handle_interrupt_deliverable(&mut self, message: &HvMessage, dev: &impl CpuIo) { + let message = message.as_message::(); + if message.deliverable_type != hvdef::HvX64PendingInterruptionType::HV_X64_PENDING_INTERRUPT + { + tracelimit::warn_ratelimited!( + deliverable_type = ?message.deliverable_type, + "unsupported interruption deliverability notification" + ); + return; + } + + self.inner + .extint_pending + .store(false, std::sync::atomic::Ordering::Release); + let notifications = self + .deliverability_notifications + .with_interrupt_notification(false); + self.partition + .vmfd + .register_deliverabilty_notifications(self.vpindex.index(), u64::from(notifications)) + .expect("requesting deliverability is not a fallible operation"); + self.deliverability_notifications = notifications; + + if let Some(vector) = dev.acknowledge_pic_interrupt() { + let event = hvdef::HvX64PendingExtIntEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXT_INT) + .with_vector(vector); + self.runner + .vcpufd + .set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::PendingEvent0, + u128::from(event), + ))]) + .expect("setting a pending ExtINT event is not a fallible operation"); + } + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs new file mode 100644 index 00000000000..4f513eed433 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Partition finalization after guest memory is attached. +//! +//! On x86_64 the BSP is created only after all guest memory is attached. The +//! partition capabilities are then discovered from the BSP, so they are not +//! available before [`MshvPartitionInner::finalize_memory`] completes. + +use super::hv1_reference_tsc_page_supported; +use crate::Error; +use crate::ErrorInner; +use crate::MshvPartitionInner; +use inspect::Inspect; +use mshv_ioctls::VcpuFd; +use std::sync::OnceLock; +use virt::ProtoPartitionConfig; +use virt::VpIndex; + +/// Partition state that exists only after memory finalization. +pub(crate) struct MshvFinalizedPartition { + pub(super) bsp_vcpufd: VcpuFd, + pub(super) caps: virt::PartitionCapabilities, +} + +/// The CPUID entries that the partition capabilities read +/// (`X86PartitionCapabilities::from_cpuid`): the maximum and feature leaves, +/// SGX, every architectural XSAVE component, the address sizes and SEV +/// leaves, and the hypervisor leaves. Finalization reads them in one bulk +/// call. +const CAPS_CPUID_ENTRIES: [(u32, u32); 32] = [ + (0x0, 0), + (0x1, 0), + (0x7, 0), + (0x12, 2), + (0xd, 0), + (0xd, 1), + (0xd, 2), + (0xd, 3), + (0xd, 4), + (0xd, 5), + (0xd, 6), + (0xd, 7), + (0xd, 8), + (0xd, 9), + (0xd, 10), + (0xd, 11), + (0xd, 12), + (0xd, 13), + (0xd, 14), + (0xd, 15), + (0xd, 16), + (0xd, 17), + (0xd, 18), + (0xd, 19), + (0x8000_0000, 0), + (0x8000_0001, 0), + (0x8000_0008, 0), + (0x8000_001f, 0), + (hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0), + (hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0), + (hvdef::HV_CPUID_FUNCTION_MS_HV_FEATURES, 0), + (hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION, 0), +]; + +/// Partition creation settings that are needed after the partition is built. +#[derive(Inspect)] +pub(crate) struct CreationConfig { + pub(super) cpuid: virt::CpuidLeafSet, + #[inspect(skip)] + pub(super) processor_topology: vm_topology::processor::ProcessorTopology, + pub(super) hv_configured: bool, +} + +impl CreationConfig { + pub(super) fn new(cpuid: virt::CpuidLeafSet, config: &ProtoPartitionConfig<'_>) -> Self { + Self { + cpuid, + processor_topology: (*config.processor_topology).clone(), + hv_configured: config.hv_config.is_some(), + } + } +} + +fn finalized_partition(finalized: &OnceLock) -> Result<&T, Error> { + finalized + .get() + .ok_or_else(|| ErrorInner::PartitionNotFinalized.into()) +} + +fn finalize_memory_once( + finalized: &OnceLock, + memory_attached: bool, + initialize: impl FnOnce() -> Result, +) -> Result<(), Error> { + if finalized.get().is_some() { + return Err(ErrorInner::PartitionAlreadyFinalized.into()); + } + if !memory_attached { + return Err(ErrorInner::GuestMemoryNotAttached.into()); + } + + finalized + .set(initialize()?) + .map_err(|_| ErrorInner::PartitionAlreadyFinalized.into()) +} + +/// Returns the hypervisor maximum CPUID leaf for the SNP hypervisor CPUID +/// overrides. +/// +/// This reads the configured CPUID results, since the BSP that could be +/// queried does not exist until memory is finalized. +pub(super) fn snp_native_hv_max_leaf(cpuid: &[virt::CpuidLeaf]) -> u32 { + cpuid + .iter() + .find(|leaf| { + leaf.function == hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION + && leaf.index.unwrap_or(0) == 0 + }) + .map(|leaf| leaf.result[0]) + .unwrap_or(hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION) +} + +impl MshvPartitionInner { + pub(super) fn finalized(&self) -> Result<&MshvFinalizedPartition, Error> { + finalized_partition(&self.finalized) + } + + pub(super) fn caps(&self) -> &virt::PartitionCapabilities { + &self + .finalized + .get() + .expect("partition memory must be finalized before capability access") + .caps + } + + pub(super) fn isolation_type(&self) -> virt::IsolationType { + if self.isolation.snp().is_some() { + virt::IsolationType::Snp + } else { + virt::IsolationType::None + } + } + + /// Creates the BSP and discovers the partition capabilities, once guest + /// memory is attached. + pub(super) fn finalize_memory(&self) -> Result<(), Error> { + let memory_attached = self.memory.lock().ranges.iter().any(Option::is_some); + + finalize_memory_once(&self.finalized, memory_attached, || { + let _span = tracing::info_span!("mshv create BSP", vp_index = 0).entered(); + let started = std::time::Instant::now(); + let result = self.create_vp(VpIndex::BSP); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "MSHV_CREATE_VCPU completed" + ); + let bsp_vcpufd = result?; + if let Some(time_abi) = &self.time_abi { + time_abi.register_cpuid(&self.vmfd, &bsp_vcpufd, &self.config.cpuid)?; + } + let caps = self.build_caps(&bsp_vcpufd)?; + Ok(MshvFinalizedPartition { bsp_vcpufd, caps }) + }) + } + + fn build_caps(&self, bsp: &VcpuFd) -> Result { + // One bulk read serves the entries the capabilities read, instead of + // one hypercall each (about 15 of them, 9 us apiece on bare metal); + // any other entry falls back to a single read. + let prefetched = + super::time_abi::vp_cpuid_many(bsp, VpIndex::BSP.index(), &CAPS_CPUID_ENTRIES) + .inspect_err(|error| { + tracing::debug!( + error = error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, reading the capabilities one entry at a time" + ); + }) + .unwrap_or_default(); + let mut cpuid_error = None; + let mut cpuid = |function, index| { + if let Some(position) = CAPS_CPUID_ENTRIES + .iter() + .position(|&entry| entry == (function, index)) + .filter(|&position| position < prefetched.len()) + { + return prefetched[position]; + } + bsp.get_cpuid_values(function, index, 0, 0) + .unwrap_or_else(|error| { + cpuid_error.get_or_insert(error); + [0; 4] + }) + }; + // The time ABI identity must not make the partition look like an hv1 + // or KVM-clock guest, so its capabilities ignore the hypervisor range. + let cpuid_caps = if self.time_abi.is_some() { + virt::PartitionCapabilities::from_cpuid( + &self.config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut cpuid), + ) + } else { + virt::PartitionCapabilities::from_cpuid(&self.config.processor_topology, &mut cpuid) + }; + let mut caps = match (cpuid_caps, cpuid_error) { + (Ok(caps), None) => caps, + (result, error) => { + tracing::warn!( + error = error.as_ref().map(|error| error as &dyn std::error::Error), + capabilities_error = result + .err() + .as_ref() + .map(|error| error as &dyn std::error::Error), + "failed to query CPUID capabilities, falling back to partition properties; some features may be unavailable" + ); + self.caps_from_properties(bsp)? + } + }; + caps.hv1 = self.config.hv_configured; + caps.hv1_reference_tsc_page = hv1_reference_tsc_page_supported( + caps.hv1, + self.isolation_type(), + caps.hv1_reference_tsc_page, + ); + caps.tsc_deadline = false; + caps.xsaves_state_bv_broken = true; + // Ordinary state access does not freeze the partition clock. + caps.can_freeze_time = false; + if self.time_abi.is_some() && caps.hv1 { + return Err(virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::IdentityRouting, + "the time ABI partition capabilities include hv1", + ) + .into()); + } + Ok(caps) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn finalization_requires_attached_memory_and_runs_once() { + let finalized = OnceLock::new(); + let events = RefCell::new(Vec::new()); + + let result = finalize_memory_once(&finalized, false, || { + events.borrow_mut().push("backend initialized"); + Ok(42) + }); + assert!(matches!( + result, + Err(Error(ErrorInner::GuestMemoryNotAttached)) + )); + assert!(events.borrow().is_empty()); + + events.borrow_mut().push("memory attached"); + finalize_memory_once(&finalized, true, || { + events.borrow_mut().push("backend initialized"); + Ok(42) + }) + .unwrap(); + assert_eq!( + events.into_inner(), + ["memory attached", "backend initialized"] + ); + assert_eq!(finalized_partition(&finalized).unwrap(), &42); + + let result = finalize_memory_once(&finalized, true, || Ok(43)); + assert!(matches!( + result, + Err(Error(ErrorInner::PartitionAlreadyFinalized)) + )); + assert_eq!(finalized_partition(&finalized).unwrap(), &42); + } + + #[test] + fn finalization_propagates_backend_failure() { + let finalized = OnceLock::<()>::new(); + + let result = + finalize_memory_once(&finalized, true, || Err(ErrorInner::NotSupported.into())); + + assert!(matches!(result, Err(Error(ErrorInner::NotSupported)))); + assert!(finalized.get().is_none()); + } + + #[test] + fn finalized_state_rejects_early_access() { + let finalized = OnceLock::<()>::new(); + + assert!(matches!( + finalized_partition(&finalized), + Err(Error(ErrorInner::PartitionNotFinalized)) + )); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/fingerprint.rs b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs new file mode 100644 index 00000000000..48f9ddac5e3 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the MSHV backend: the guest CPU surface and time +//! capabilities that the Microsoft hypervisor supports on this host, and the +//! CPUID entries outside a CPU profile that a partition configured from it +//! presents. + +use super::profile_features; +use super::time_abi; +use crate::Error; +use crate::ErrorInner; +use crate::KernelError; +use crate::LinuxMshv; +use crate::VcpuFdExt; +use crate::create_vm_with_retry; +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64RegisterName; +use hvdef::hypercall::HvRegisterAssoc; +use mshv_ioctls::VcpuFd; +use mshv_ioctls::VmFd; +use virt::time_abi::TimeAbiCode; + +const METHOD: &str = "HvCallGetVpCpuidValues on a probe partition that enables every processor \ + and XSAVE feature of the host partition"; + +/// The host partition properties that the probe partition enables, which +/// together with its CPUID define the guest CPU surface. +const FEATURE_BANKS: [HvPartitionPropertyCode; 3] = [ + HvPartitionPropertyCode::ProcessorFeatures0, + HvPartitionPropertyCode::ProcessorFeatures1, + HvPartitionPropertyCode::ProcessorXsaveFeatures, +]; + +/// Other host partition properties recorded in the fingerprint. +const HOST_PROPERTIES: &[HvPartitionPropertyCode] = &[ + HvPartitionPropertyCode::ProcessorVendor, + HvPartitionPropertyCode::ProcessorClockFrequency, + HvPartitionPropertyCode::ProcessorCLFlushSize, + HvPartitionPropertyCode::PhysicalAddressWidth, + HvPartitionPropertyCode::XsaveStates, + HvPartitionPropertyCode::MaxXsaveDataSize, + HvPartitionPropertyCode::FeatureBankCount, + HvPartitionPropertyCode::RootProcessorFeatures0, + HvPartitionPropertyCode::RootProcessorFeatures1, + HvPartitionPropertyCode::RootProcessorXsaveFeatures, + HvPartitionPropertyCode::RootSyntheticProcFeatures, + HvPartitionPropertyCode::PrivilegeFlags, + HvPartitionPropertyCode::SyntheticProcFeatures, +]; + +/// Properties of the probe partition recorded in the fingerprint. +const PROBE_PROPERTIES: &[HvPartitionPropertyCode] = &[ + HvPartitionPropertyCode::ProcessorFeatures0, + HvPartitionPropertyCode::ProcessorFeatures1, + HvPartitionPropertyCode::ProcessorXsaveFeatures, + HvPartitionPropertyCode::ProcessorClockFrequency, + HvPartitionPropertyCode::ApicFrequency, + HvPartitionPropertyCode::PhysicalAddressWidth, + HvPartitionPropertyCode::XsaveStates, + HvPartitionPropertyCode::MaxXsaveDataSize, + HvPartitionPropertyCode::UnimplementedMsrAction, +]; + +/// The TSC invariant control MSR of the Hyper-V interface, whose intercept +/// probes the MSR-intercept capability. +const HV_X64_MSR_TSC_INVARIANT_CONTROL: u32 = 0x4000_0118; + +/// The TSC value written to the probe virtual processor. A probe partition +/// starts near zero, so a read-back between this value and twice it shows +/// that the write took effect. +const PROBE_TSC: u64 = 1 << 40; + +impl LinuxMshv { + /// Returns the guest CPU surface and time capabilities that the + /// hypervisor supports on this host. + /// + /// The CPUID table is what the virtual processor of a transient probe + /// partition reports when the partition enables every processor feature + /// bank and XSAVE feature of the host partition. The probe partition has + /// no memory, never runs, and is destroyed before this returns. It also + /// probes per-VP TSC writes, partition time freezing, and MSR-index + /// intercepts. + pub fn cpu_fingerprint(&self) -> Result { + let host_property = |code: HvPartitionPropertyCode| { + self.mshv + .get_host_partition_property(code.0) + .map_err(KernelError::from) + }; + let mut banks = [0; FEATURE_BANKS.len()]; + for (bank, code) in banks.iter_mut().zip(FEATURE_BANKS) { + *bank = host_property(code) + .map_err(|error| ErrorInner::GetHostPartitionProperty(code, error))?; + } + let [features0, features1, xsave_features] = banks; + + let args = mshv_bindings::mshv_create_partition_v2 { + pt_flags: 1 << mshv_bindings::MSHV_PT_BIT_LAPIC + | 1 << mshv_bindings::MSHV_PT_BIT_X2APIC + | 1 << mshv_bindings::MSHV_PT_BIT_GPA_SUPER_PAGES + | 1 << mshv_bindings::MSHV_PT_BIT_CPU_AND_XSAVE_FEATURES, + pt_isolation: mshv_bindings::MSHV_PT_ISOLATION_NONE as u64, + pt_num_cpu_fbanks: mshv_bindings::MSHV_NUM_CPU_FEATURES_BANKS as u16, + // The banks hold the features to disable. + pt_cpu_fbanks: [!features0, !features1], + pt_disabled_xsave: !xsave_features, + ..Default::default() + }; + let vmfd = create_vm_with_retry(&self.mshv, &args)?; + vmfd.initialize() + .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + let vp = vmfd + .create_vcpu(0) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + + let cpuid = + cpu_profile::cpuid::enumerate(|leaf, subleaf| vp.get_cpuid_values(leaf, subleaf, 0, 0)) + .map_err(|e| ErrorInner::FingerprintCpuid(e.into()))?; + + let mut fingerprint = BackendFingerprint::new("mshv", METHOD, cpuid); + for (code, value) in FEATURE_BANKS.into_iter().zip(banks) { + fingerprint.set_feature_bank(format!("mshv.host.{code:?}"), value); + } + for &code in HOST_PROPERTIES { + fingerprint.record(&format!("mshv.host.{code:?}"), host_property(code)); + } + for &code in PROBE_PROPERTIES { + fingerprint.record( + &format!("mshv.probe.{code:?}"), + vmfd.get_partition_property(code.0) + .map_err(KernelError::from), + ); + } + + let probe_value = |name: &str| fingerprint.values.get(name).map(|value| value.0); + let tsc_frequency_hz = probe_value("mshv.probe.ProcessorClockFrequency"); + let lapic_timer_frequency_hz = probe_value("mshv.probe.ApicFrequency"); + let tsc_offset_control = probe_tsc_write(&vp, &mut fingerprint); + let time_freeze = probe_time_freeze(&vmfd, &mut fingerprint); + let msr_index_intercept = probe_msr_index_intercept(&vmfd, &mut fingerprint); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + time.lapic_timer_frequency_hz = lapic_timer_frequency_hz; + time.tsc_offset_control = Some(tsc_offset_control); + time.time_freeze = Some(time_freeze); + // The hypervisor offers no TSC scaling control to the root. + time.tsc_scaling = None; + time.msr_intercepts + .insert("X64MsrIndex".to_owned(), msr_index_intercept); + + drop(vp); + drop(vmfd); + Ok(fingerprint) + } + + /// Returns what VP 0 of a transient probe partition configured from + /// `profile` reads at every entry of the host's CPUID outside the + /// profile's tables ([`cpu_profile::unlisted_cpuid_candidates`]), each + /// read at subleaf 0 if subleaf-independent, for the `--cpu-fingerprint` + /// check that those entries read zero (`E_CPU_UNLISTED`). + /// + /// It measures what a time ABI cold boot checks on VP 0 of its partition. + /// The probe partition is created as a microVM's time ABI partition is: + /// without isolation, x2APIC, or SMT, and with the processor feature banks + /// and XSAVE features that derive from the profile within what the host + /// partition offers. So, unlike [`Self::cpu_fingerprint`]'s probe + /// partition with every feature of the host partition, it does not + /// present the XSAVE components of features that no profile enables, such + /// as CET's. VP 0 reads the candidates as a cold boot reads them, with its + /// reset XCR0 and XSS, in one bulk `HvCallGetVpCpuidValues` where the + /// hypervisor accepts it. The probe registers no CPUID results, which + /// cover only the profile's own entries. The partition has no memory, + /// never runs, and is destroyed before this returns. + /// + /// Fails with `E_PROFILE_UNSUPPORTED` if the host partition does not + /// offer the profile's features. + pub fn profile_unlisted_cpuid(&self, profile: &CpuProfile) -> Result, Error> { + let host = profile_features::host_features(&self.mshv)?; + let features = profile_features::time_abi_features(profile, host)?; + let args = time_abi::with_features( + super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false)?, + features, + ); + let vmfd = create_vm_with_retry(&self.mshv, &args)?; + vmfd.initialize() + .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + let vp = vmfd + .create_vcpu(0) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + + let candidates = + cpu_profile::unlisted_cpuid_candidates(profile, &profile_features::host_cpuid_table()); + let reads: Vec<(u32, u32)> = candidates + .iter() + .map(|&(leaf, subleaf)| (leaf, subleaf.unwrap_or(0))) + .collect(); + let (values, _) = time_abi::vp0_cpuid_entries(&vp, &reads, TimeAbiCode::CpuUnlisted)?; + + drop(vp); + drop(vmfd); + Ok(candidates + .into_iter() + .zip(values) + .map(|((leaf, subleaf), registers)| CpuidEntry::new(leaf, subleaf, registers)) + .collect()) + } +} + +/// Writes the TSC of the probe virtual processor and reads it back, and +/// returns whether the write took effect. +fn probe_tsc_write(vp: &VcpuFd, fingerprint: &mut BackendFingerprint) -> bool { + let result = (|| { + vp.set_hvdef_regs(&[HvRegisterAssoc::from((HvX64RegisterName::Tsc, PROBE_TSC))])?; + let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; + vp.get_hvdef_regs(&mut registers)?; + Ok::<_, KernelError>(registers[0].value.as_u64()) + })(); + match result { + Ok(tsc) => tsc.wrapping_sub(PROBE_TSC) < PROBE_TSC, + Err(error) => { + fingerprint.set_unavailable("mshv.probe.tsc_write", &error); + false + } + } +} + +/// Freezes and thaws the probe partition's time, and returns whether the +/// hypervisor accepted the freeze. Reading the property back is optional: +/// some hypervisors accept `TimeFreeze` writes but not reads. +fn probe_time_freeze(vmfd: &VmFd, fingerprint: &mut BackendFingerprint) -> bool { + let code = HvPartitionPropertyCode::TimeFreeze.0; + if let Err(error) = vmfd.set_partition_property(code, 1) { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.freeze", &KernelError::from(error)); + return false; + } + match vmfd.get_partition_property(code) { + Ok(value) => fingerprint.set_value("mshv.probe.TimeFreeze.frozen", value), + Err(error) => { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.read", &KernelError::from(error)) + } + } + if let Err(error) = vmfd.set_partition_property(code, 0) { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.thaw", &KernelError::from(error)); + } + true +} + +/// Installs an MSR-index intercept on the probe partition, and returns +/// whether the hypervisor accepted it. +fn probe_msr_index_intercept(vmfd: &VmFd, fingerprint: &mut BackendFingerprint) -> bool { + let result = vmfd.install_intercept(mshv_bindings::mshv_install_intercept { + access_type_mask: mshv_bindings::HV_INTERCEPT_ACCESS_MASK_READ + | mshv_bindings::HV_INTERCEPT_ACCESS_MASK_WRITE, + intercept_type: mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX, + intercept_parameter: mshv_bindings::hv_intercept_parameters { + msr_index: HV_X64_MSR_TSC_INVARIANT_CONTROL, + }, + }); + match result { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable( + "mshv.probe.install_intercept.X64MsrIndex", + &KernelError::from(error), + ); + false + } + } +} + +#[cfg(test)] +mod tests { + use crate::LinuxMshv; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires /dev/mshv"] + fn fingerprint_reports_the_supported_surface() { + let mshv = LinuxMshv::new().unwrap(); + let fingerprint = mshv.cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "MSHV fingerprint"); + assert_eq!(fingerprint, mshv.cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert_eq!(fingerprint.feature_banks.len(), super::FEATURE_BANKS.len()); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert!(fingerprint.time.lapic_timer_frequency_hz.is_some()); + } + + /// The fingerprint check reads the entries outside the host's profile on + /// a partition configured from the profile, as a cold boot does: they + /// read zero there, even where the probe partition with every feature of + /// the host partition presents host data, such as the CET XSAVE + /// components of a CET-capable host. + #[test] + #[ignore = "requires /dev/mshv"] + fn a_partition_of_the_host_profile_reads_zero_outside_it() { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let mshv = LinuxMshv::new().unwrap(); + let presented = mshv.profile_unlisted_cpuid(profile).unwrap(); + let candidates = cpu_profile::unlisted_cpuid_candidates( + profile, + &super::profile_features::host_cpuid_table(), + ); + assert_eq!( + presented + .iter() + .map(cpuid::CpuidEntry::key) + .collect::>(), + candidates + ); + cpu_profile::check_unlisted_cpuid(profile, &presented).unwrap(); + let fingerprint = mshv.cpu_fingerprint().unwrap(); + let all_features = cpu_profile::unlisted_cpuid_violations(profile, &fingerprint.cpuid); + println!( + "{}: {} host entries outside the profile read zero on its partition: {:x?}; the \ + probe partition with every feature presents {all_features:?}", + profile.id(), + candidates.len(), + candidates + ); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index fae75f93332..4227ff21105 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -3,6 +3,12 @@ //! x86_64-specific implementation of the mshv hypervisor backend. +mod extint; +pub(crate) mod finalize; +mod fingerprint; +mod profile_features; +pub(crate) mod time_abi; +mod tsc; mod vm_state; mod vp_state; @@ -36,7 +42,6 @@ use hvdef::hypercall::HvRegisterAssoc; use memory_range::MemoryRange; use mshv_ioctls::InterruptRequest; use mshv_ioctls::VcpuFd; -use pal::unix::pthread::Pthread; use parking_lot::Mutex; use pci_core::msi::SignalMsi; use std::os::fd::AsRawFd; @@ -117,6 +122,9 @@ impl virt::Hypervisor for LinuxMshv { }; let isolation = config.isolation.isolation_type(); validate_snp_cpuid_offload_config(isolation, self.snp_disable_cpuid_offload)?; + if config.time_abi.is_some() { + time_abi::validate_partition(isolation, config.hv_config.is_some())?; + } let snp = isolation == virt::IsolationType::Snp; let x2apic = matches!( config.processor_topology.apic_mode(), @@ -128,12 +136,21 @@ impl virt::Hypervisor for LinuxMshv { x2apic, config.processor_topology.smt_enabled(), )?; + let (create_args, time_abi_host) = match &config.time_abi { + Some(time_abi) => { + let host = profile_features::host_features(&self.mshv)?; + let features = time_abi::partition_features(&time_abi.cpu_profile, host)?; + (time_abi::with_features(create_args, features), Some(host)) + } + None => (tsc::with_features1(create_args), None), + }; let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; // Set synthetic processor features before initialization when the // guest interface is configured. SNP partitions require the smaller - // early-property feature set accepted by the hypervisor. + // early-property feature set accepted by the hypervisor. A time ABI + // partition has none: OpenVMM serves its identity MSRs. if config.hv_config.is_some() || snp { let synthetic_features = if snp { snp_synthetic_features() @@ -155,6 +172,10 @@ impl virt::Hypervisor for LinuxMshv { vmfd.initialize() .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + if let Some(time_abi) = &config.time_abi { + time_abi::route_identity_msrs(&vmfd, time_abi.cpu_profile.cpu_vendor())?; + } + if snp { let snp_policy = igvm_snp_config.as_ref().map_or_else( || { @@ -215,6 +236,7 @@ impl virt::Hypervisor for LinuxMshv { }; let mut proto = MshvProtoPartition::new(config, vmfd)?; proto.isolation = isolation; + proto.time_abi_host = time_abi_host; Ok(proto) } } @@ -270,6 +292,56 @@ fn partition_create_args( }) } +/// Registers `leaf` as the partition-wide result of CPUID intercepts for its +/// leaf (and subleaf, if it has one), applied over the hypervisor's own result +/// under the leaf's mask. +fn register_cpuid_result(vmfd: &mshv_ioctls::VmFd, leaf: &virt::CpuidLeaf) -> Result<(), Error> { + register_cpuid_result_for(vmfd, hvdef::HV_ANY_VP, leaf) +} + +/// Registers `leaf` as the result of CPUID intercepts for its leaf (and +/// subleaf) on VP `vp_index` only, or on every VP for `HV_ANY_VP`. +fn register_cpuid_result_for( + vmfd: &mshv_ioctls::VmFd, + vp_index: u32, + leaf: &virt::CpuidLeaf, +) -> Result<(), Error> { + let input = hvdef::hypercall::RegisterInterceptResultCpuid { + partition_id: 0, + vp_index, + intercept_type: hvdef::hypercall::HvInterceptType::HvInterceptTypeX64Cpuid, + parameters: hvdef::hypercall::HvRegisterX64CpuidResultParameters { + input: hvdef::hypercall::HvRegisterX64CpuidResultParametersInput { + eax: leaf.function, + ecx: leaf.index.unwrap_or(0), + subleaf_specific: u8::from(leaf.index.is_some()), + always_override: 1, + padding: 0, + }, + result: hvdef::hypercall::HvRegisterX64CpuidResultParametersOutput { + eax: leaf.result[0], + eax_mask: leaf.mask[0], + ebx: leaf.result[1], + ebx_mask: leaf.mask[1], + ecx: leaf.result[2], + ecx_mask: leaf.mask[2], + edx: leaf.result[3], + edx_mask: leaf.mask[3], + }, + }, + _reserved: 0, + }; + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallRegisterInterceptResult.0, + in_sz: size_of_val(&input) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + ..Default::default() + }; + vmfd.hvcall(&mut args) + .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; + Ok(()) +} + fn snp_synthetic_features() -> hvdef::HvPartitionSyntheticProcessorFeatures { hvdef::HvPartitionSyntheticProcessorFeatures::new() .with_hypervisor_present(true) @@ -277,13 +349,13 @@ fn snp_synthetic_features() -> hvdef::HvPartitionSyntheticProcessorFeatures { .with_access_partition_reference_counter(true) .with_access_synic_regs(true) .with_access_synthetic_timer_regs(true) - // HACK: Do not advertise AccessPartitionReferenceTsc to the ACI SNP - // kernel yet. Physical Hyper-V and Cloud Hypervisor both advertise + // HACK: Do not advertise AccessPartitionReferenceTsc to SNP guests yet. + // Physical Hyper-V and Cloud Hypervisor both advertise // it, relying on the hypervisor/MSHV rather than userspace to service // HV_X64_MSR_REFERENCE_TSC and its page. OpenVMM launch-imports GPA - // 0x3402000 normally; the ACI kernel then shares it as its retained + // 0x3402000 normally; the guest kernel then shares it as its retained // Hyper-V `tsc_pg`. MSHV accepts the initial import but later reports - // that GPA as unaccepted when device I/O begins. Keeping ACI on the + // that GPA as unaccepted when device I/O begins. Keeping the guest on the // reference counter MSR makes virtio-blk write/readback work. // Re-enable this after tracing and fixing MSHV's SNP PSC/host-access // handling for the shared reference-TSC page. @@ -334,10 +406,13 @@ fn validate_snp_cpuid_offload_config( Ok(()) } -impl MshvProtoPartition<'_> { +impl MshvPartitionInner { /// Build partition capabilities from partition properties instead of /// CPUID. - fn caps_from_properties(&self) -> Result { + fn caps_from_properties( + &self, + bsp: &VcpuFd, + ) -> Result { use virt::x86::X86PartitionCapabilities; use virt::x86::XsaveCapabilities; use x86defs::cpuid::Vendor; @@ -365,12 +440,11 @@ impl MshvProtoPartition<'_> { .get_partition_property(HvPartitionPropertyCode::MaxXsaveDataSize.0) .map_err(|e| ErrorInner::GetPartitionProperty(e.into()))?; - let reset_rdx = if self.config.isolation.isolation_type() == virt::IsolationType::Snp { + let reset_rdx = if self.isolation_type() == virt::IsolationType::Snp { 0 } else { let mut assoc = [HvRegisterAssoc::from((HvX64RegisterName::Rdx, 0u64))]; - self.bsp - .get_hvdef_regs(&mut assoc) + bsp.get_hvdef_regs(&mut assoc) .map_err(ErrorInner::Register)?; assoc[0].value.as_u64() }; @@ -385,13 +459,13 @@ impl MshvProtoPartition<'_> { vm_topology::processor::x86::ApicMode::X2ApicEnabled ); - let hv1 = self.config.hv_config.is_some(); + let hv1 = self.config.hv_configured; Ok(X86PartitionCapabilities { vendor, hv1, hv1_reference_tsc_page: hv1_reference_tsc_page_supported( hv1, - self.config.isolation.isolation_type(), + self.isolation_type(), true, ), xsave: XsaveCapabilities { @@ -408,6 +482,9 @@ impl MshvProtoPartition<'_> { cet_ss: false, sgx: false, tsc_aux: false, + // MSHV does not reliably deliver TSC deadline events to direct-boot + // guests. Fall back to the LAPIC count-mode clockevent. + tsc_deadline: false, vtom: None, physical_address_width: self .vmfd @@ -422,7 +499,9 @@ impl MshvProtoPartition<'_> { nested_virt: false, }) } +} +impl MshvProtoPartition<'_> { fn max_physical_address_size(&self) -> u8 { self.vmfd .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) @@ -467,91 +546,54 @@ impl ProtoPartition for MshvProtoPartition<'_> { let expose_hypervisor = self.config.hv_config.is_some(); cpuid.extend(snp_cpuid_overrides(expose_hypervisor)); if expose_hypervisor { - let native_max_leaf = self - .bsp - .get_cpuid_values(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0, 0, 0) - .map(|values| values[0]) - .unwrap_or(hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION); + let native_max_leaf = finalize::snp_native_hv_max_leaf(&cpuid); cpuid.extend(snp_hv_cpuid_overrides(native_max_leaf)); } } - let cpuid = virt::CpuidLeafSet::new(cpuid); - - // Apply CPUID overrides partition-wide. - for leaf in cpuid.leaves().iter() { - let input = hvdef::hypercall::RegisterInterceptResultCpuid { - partition_id: 0, - vp_index: hvdef::HV_ANY_VP, - intercept_type: hvdef::hypercall::HvInterceptType::HvInterceptTypeX64Cpuid, - parameters: hvdef::hypercall::HvRegisterX64CpuidResultParameters { - input: hvdef::hypercall::HvRegisterX64CpuidResultParametersInput { - eax: leaf.function, - ecx: leaf.index.unwrap_or(0), - subleaf_specific: u8::from(leaf.index.is_some()), - always_override: 1, - padding: 0, - }, - result: hvdef::hypercall::HvRegisterX64CpuidResultParametersOutput { - eax: leaf.result[0], - eax_mask: leaf.mask[0], - ebx: leaf.result[1], - ebx_mask: leaf.mask[1], - ecx: leaf.result[2], - ecx_mask: leaf.mask[2], - edx: leaf.result[3], - edx_mask: leaf.mask[3], - }, - }, - _reserved: 0, - }; - let mut args = mshv_bindings::mshv_root_hvcall { - code: hvdef::HypercallCode::HvCallRegisterInterceptResult.0, - in_sz: size_of_val(&input) as u16, - in_ptr: std::ptr::addr_of!(input) as u64, - ..Default::default() - }; - self.vmfd - .hvcall(&mut args) - .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; - } + let cpuid = match &self.config.time_abi { + Some(time_abi) => time_abi::partition_cpuid(&time_abi.cpuid), + None => virt::CpuidLeafSet::new(cpuid), + }; - let caps = { - let mut cpuid_error = None; - let cpuid_caps = virt::PartitionCapabilities::from_cpuid( - self.config.processor_topology, - &mut |function, index| { - self.bsp - .get_cpuid_values(function, index, 0, 0) - .unwrap_or_else(|err| { - cpuid_error.get_or_insert(err); - [0; 4] - }) - }, - ); - let mut caps = match (cpuid_caps, cpuid_error) { - (Ok(caps), None) => caps, - (result, error) => { - tracing::warn!( - error = error.as_ref().map(|err| err as &dyn std::error::Error), - capabilities_error = result - .err() - .as_ref() - .map(|err| err as &dyn std::error::Error), - "failed to query CPUID capabilities, falling back to partition properties; some features may be unavailable" - ); - self.caps_from_properties()? - } - }; - caps.hv1 = self.config.hv_config.is_some(); - caps.hv1_reference_tsc_page = hv1_reference_tsc_page_supported( - caps.hv1, - self.config.isolation.isolation_type(), - caps.hv1_reference_tsc_page, + // A time ABI partition reads the host's CPUID once, on another thread, + // which also finds the CPU profile's unlisted candidates in it: its + // supported CPU surface derives from the table, and its effective + // CPUID report covers the candidates (`E_CPU_UNLISTED`), both after + // guest memory registration. Those entries need no results of their + // own: the hypervisor's guest view reads zero there, as the CPUID + // sweep hardware test checks. + let spawned = std::time::Instant::now(); + let host_cpuid = self + .config + .time_abi + .as_ref() + .map(|config| time_abi::HostCpuidSource::spawn(&config.cpu_profile)); + let host_cpuid_spawn_us = spawned.elapsed().as_micros() as u64; + + // Apply CPUID overrides partition-wide. A time ABI partition registers + // its results once VP 0 exists, only where VP 0's own view differs + // (see `MshvTimeAbi::register_cpuid`). + if self.config.time_abi.is_none() { + let started = std::time::Instant::now(); + for leaf in cpuid.leaves() { + register_cpuid_result(&self.vmfd, leaf)?; + } + tracing::info!( + leaves = cpuid.leaves().len(), + elapsed_us = started.elapsed().as_micros() as u64, + "registered MSHV CPUID results" ); - caps.xsaves_state_bv_broken = true; - caps.can_freeze_time = true; - caps - }; + } + let time_abi = self.config.time_abi.as_ref().map(|config| { + time_abi::MshvTimeAbi::new( + config, + &cpuid, + self.time_abi_host + .expect("a time ABI partition reads the host's features"), + host_cpuid.expect("started for a time ABI partition"), + host_cpuid_spawn_us, + ) + }); let apic_id_map = self .config @@ -573,7 +615,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { let time_frozen = isolation.is_isolated(); let inner = Arc::new(MshvPartitionInner { vmfd: self.vmfd, - bsp_vcpufd: self.bsp, + finalized: Default::default(), memory: Default::default(), gm: config.guest_memory.clone(), mem_layout: config.mem_layout.clone(), @@ -582,12 +624,13 @@ impl ProtoPartition for MshvProtoPartition<'_> { gsi_states: Mutex::new(Box::new( [crate::irqfd::GsiState::Unallocated; crate::irqfd::NUM_GSIS], )), - caps, synic_ports: Default::default(), + config: finalize::CreationConfig::new(cpuid, &self.config), software_devices: ApicSoftwareDevices::new(apic_id_map), isolation, // SNP partition creation set TimeFreeze=1 before this object was built. time_frozen: Mutex::new(time_frozen), + time_abi, }); inner.add_snp_vmsa_mapping()?; @@ -617,6 +660,14 @@ impl virt::Partition for MshvPartition { self.inner.isolation.initial_vp_state_source() } + fn finalize_memory(&self) -> Result<(), Self::Error> { + self.inner.finalize_memory() + } + + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner.time_abi.as_ref().map(|_| self as _) + } + fn supports_initial_page_acceptance( &self, ) -> Option<&dyn virt::AcceptInitialPages> { @@ -637,7 +688,7 @@ impl virt::Partition for MshvPartition { } fn caps(&self) -> &virt::PartitionCapabilities { - &self.inner.caps + self.inner.caps() } fn request_msi(&self, _vtl: Vtl, request: MsiRequest) { @@ -657,11 +708,7 @@ impl virt::Partition for MshvPartition { if vp.needs_yield.request_yield() { let thread = vp.thread.read(); if let Some(thread) = *thread { - if thread != Pthread::current() { - thread - .signal(libc::SIGRTMIN()) - .expect("thread cancel signal failed"); - } + crate::run_vp::cancel(thread).expect("thread cancel signal failed"); } } } @@ -673,13 +720,7 @@ impl virt::X86Partition for MshvPartition { } fn pulse_lint(&self, vp_index: VpIndex, vtl: Vtl, lint: u8) { - // TODO: Implement LINT injection for non-isolated MSHV partitions. - // - // The legacy PIC/PIT are temporarily attached for direct-boot TSC - // calibration, but MSHV isolated VPs cannot receive PIC ExtINT through - // LINT0. The guest must route runtime interrupts through the IOAPIC/MSI - // path; PIC-dependent isolated guests are not supported. - tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "ignored lint pulse"); + self.inner.pulse_lint(vp_index, vtl, lint) } } @@ -778,6 +819,52 @@ impl virt::irqcon::IoApicRouting for MshvPartitionInner { // Processor binding and run loop // --------------------------------------------------------------------------- +impl MshvPartitionInner { + /// Creates the hypervisor VP and records that it exists. Every VP is + /// created here, so the time ABI's synchronized TSC set sees exactly the + /// created VPs. + /// + /// A time ABI partition's per-VP topology leaves (`0xB`, `0x1F`, and + /// AMD's `0x8000001E`) get this VP's own results here, with its APIC + /// identity: the hypervisor does not provide it for these partitions, + /// and accepts a per-VP result only once the VP exists. + fn create_vp(&self, vp_index: VpIndex) -> Result { + if let Some(time_abi) = &self.time_abi { + time_abi.check_vp_creation(vp_index)?; + } + let vcpufd = self + .vmfd + .create_vcpu( + u8::try_from(vp_index.index()).expect("VP count validated at partition creation"), + ) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + if self.time_abi.is_some() { + let apic_id = self.vp(vp_index).vp_info.apic_id; + let reserved_vps_per_socket = self.config.processor_topology.reserved_vps_per_socket(); + for leaf in self + .config + .cpuid + .leaves() + .iter() + .filter(|leaf| time_abi::is_per_vp_leaf(leaf.function)) + { + register_cpuid_result_for( + &self.vmfd, + vp_index.index(), + &time_abi::with_vp_identity(leaf, apic_id, reserved_vps_per_socket), + )?; + } + } + self.vp(vp_index) + .created + .store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(time_abi) = &self.time_abi { + time_abi.check_vp_creation(vp_index)?; + } + Ok(vcpufd) + } +} + impl virt::BindProcessor for MshvProcessorBinder { type Processor<'a> = MshvProcessor<'a> @@ -787,17 +874,13 @@ impl virt::BindProcessor for MshvProcessorBinder { fn bind(&mut self) -> Result, Self::Error> { let inner = &self.partition.vps[self.vpindex.index() as usize]; + let finalized = self.partition.finalized()?; let vcpufd = if self.vpindex.is_bsp() { - &self.partition.bsp_vcpufd + &finalized.bsp_vcpufd } else { if self.vcpufd.is_none() { - let vcpufd = self - .partition - .vmfd - .create_vcpu(u8::try_from(self.vpindex.index()).expect("validated above")) - .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; - self.vcpufd = Some(vcpufd); + self.vcpufd = Some(self.partition.create_vp(self.vpindex)?); } self.vcpufd.as_ref().unwrap() }; @@ -838,7 +921,7 @@ impl virt::BindProcessor for MshvProcessorBinder { if this.partition.isolation.snp().is_none() { // Set the APIC state. let apic_base = - virt::vp::Apic::at_reset(&this.partition.caps, &this.inner.vp_info).apic_base; + virt::vp::Apic::at_reset(&finalized.caps, &this.inner.vp_info).apic_base; let regs = &[ HvRegisterAssoc::from(( @@ -852,7 +935,7 @@ impl virt::BindProcessor for MshvProcessorBinder { )), ]; - let reg_count = if this.partition.caps.x2apic { 2 } else { 3 }; + let reg_count = if finalized.caps.x2apic { 2 } else { 3 }; vcpufd .set_hvdef_regs(®s[..reg_count]) @@ -919,6 +1002,17 @@ impl MshvProcessor<'_> { let msg = exit.as_message::(); dev.handle_eoi(msg.interrupt_vector); } + HvMessageType::HvMessageTypeX64InterruptionDeliverable => { + self.handle_interrupt_deliverable(exit, dev); + } + // Only a time ABI partition installs MSR intercepts. + HvMessageType::HvMessageTypeMsrIntercept => { + let partition = self.partition; + let Some(time_abi) = &partition.time_abi else { + panic!("Unhandled vcpu exit code {:?}", exit.header.typ); + }; + self.handle_time_abi_msr_intercept(time_abi, exit); + } exit_type => { panic!("Unhandled vcpu exit code {exit_type:?}"); } @@ -1011,7 +1105,7 @@ impl EmulatorSupport for MshvEmulationState<'_> { } fn vendor(&self) -> x86defs::cpuid::Vendor { - self.partition.caps.vendor + self.partition.caps().vendor } fn gp(&mut self, reg: x86emu::Gp) -> u64 { diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs new file mode 100644 index 00000000000..d934c2db65a --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MSHV processor features derived from a CPU profile, and the CPU surface +//! that MSHV supports on this host. +//! +//! A partition presents a CPU feature only when its processor feature bank +//! bit (or XSAVE feature bit) is set, and some bits also decide which MSRs the +//! guest may use. [`time_abi_features`] starts from the features the host +//! partition offers, and [`hv_banks::profile_features`] makes every bit that +//! the profile decides ([`hv_banks::mapped_mask`]) follow the profile. The +//! other bits, such as the nested-paging and VMX details, keep OpenVMM's +//! legacy policy: what the host offers of OpenVMM's supported lists. The +//! CPUID intercept results then present core's time ABI CPUID verbatim (the +//! profile's complete effective CPUID), including the descriptive leaves and +//! the time policy bits a host lacks. Only the banks and the surface read the +//! profile itself. +//! +//! [`supported_cpu_surface`] reports what MSHV supports on this host without a +//! probe partition: the host partition's processor features, the host's CPUID +//! restricted to them ([`hv_banks::restrict_cpuid_to_features`]), and the +//! guest physical address width. +//! +//! The ignored hardware tests (`profile_features::hw`) check the shared +//! feature table, the surface, and the derived features against MSHV. + +use crate::KernelError; +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::cpuid::EXTENDED_LEAF_BASE; +use cpu_profile::cpuid::HYPERVISOR_LEAF_BASE; +use cpu_profile::hv_banks; +use cpu_profile::hv_banks::HvFeatureWord; +use cpu_profile::hv_banks::HvFeatures; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use mshv_ioctls::Mshv; +use virt::CpuidLeaf; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; + +/// Returns the features of OpenVMM's supported lists, which every MSHV +/// partition without the time ABI enables. +pub(crate) fn legacy_features() -> HvFeatures { + HvFeatures { + banks: [ + super::supported_processor_features().into_bits(), + super::supported_processor_features1().into_bits(), + ], + xsave: super::supported_xsave_features().into_bits(), + } +} + +/// Reads the features that the host partition offers its child partitions. +/// Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn host_features(mshv: &Mshv) -> Result { + let property = |code: HvPartitionPropertyCode| { + mshv.get_host_partition_property(code.0).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot read the host partition's {code:?}: {}", + super::time_abi::error_chain(&KernelError::from(error)) + ), + ) + }) + }; + Ok(HvFeatures { + banks: [ + property(HvPartitionPropertyCode::ProcessorFeatures0)?, + property(HvPartitionPropertyCode::ProcessorFeatures1)?, + ], + xsave: property(HvPartitionPropertyCode::ProcessorXsaveFeatures)?, + }) +} + +/// Returns the processor features of a time ABI partition with CPU profile +/// `profile` on a host that offers `host`. +/// +/// The bits the profile decides start from what the host offers; the other +/// bits keep OpenVMM's legacy policy. On top, the time ABI's policy keeps the +/// invariant TSC and hides the TSC-deadline timer, `IA32_TSC_ADJUST`, and +/// APERF/MPERF, whatever the profile says. Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn time_abi_features( + profile: &CpuProfile, + host: HvFeatures, +) -> Result { + let legacy = legacy_features(); + let mut start = host; + for word in HvFeatureWord::ALL { + *start.word_mut(word) &= legacy.word(word) | hv_banks::mapped_mask(word); + } + let mut features = hv_banks::profile_features(profile, start) + .map_err(|error| TimeAbiError::new(TimeAbiCode::ProfileUnsupported, error.message))?; + features.banks[1] = + super::time_abi::processor_features1(Bank1::from_bits(features.banks[1])).into_bits(); + Ok(features) +} + +/// Returns the CPUID result of the host processor for `leaf` and `subleaf`. +// The host CPU is identified with the CPUID instruction of the host, which +// exists only on x86-64 hosts. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + let result = std::arch::x86_64::__cpuid_count(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(not(target_arch = "x86_64"))] +fn host_cpuid(_leaf: u32, _subleaf: u32) -> [u32; 4] { + [0; 4] +} + +/// Returns the host's CPUID table, as the CPUID instruction enumerates it, +/// without the hypervisor range: that describes the host's hypervisor rather +/// than the processor, so neither the supported CPU surface nor the unlisted +/// candidates use it, and every CPUID in the root partition exits to the +/// hypervisor. +pub(crate) fn host_cpuid_table() -> Vec { + cpu_profile::cpuid::enumerate_basic_and_extended(|leaf, subleaf| { + Ok::<_, std::convert::Infallible>(host_cpuid(leaf, subleaf)) + }) + .unwrap_or_else(|never| match never {}) +} + +/// Returns the CPU surface that MSHV supports on this host, given the host's +/// CPUID table ([`host_cpuid_table`]), the features the host partition +/// offers, and the guest physical address width. +/// +/// This is what a partition that enables every offered feature presents, +/// approximated without creating one: the host's CPUID without the mapped +/// features `host` lacks. `IA32_ARCH_CAPABILITIES` follows the banks. +pub(crate) fn supported_cpu_surface( + host_cpuid: Vec, + host: HvFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + surface_from_host_cpuid(host_cpuid, host, physical_address_width) +} + +fn surface_from_host_cpuid( + mut cpuid: Vec, + host: HvFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + // The hypervisor range is the host's own Hyper-V interface, not the + // guest's, whose identity the time ABI defines. + cpuid.retain(|entry| !(HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&entry.leaf.0)); + hv_banks::restrict_cpuid_to_features(&mut cpuid, host); + let msr = hv_banks::arch_capabilities_msr(host.banks); + SupportedCpuSurface { + cpuid: cpuid + .iter() + .map(|entry| { + let leaf = CpuidLeaf::new(entry.leaf.0, entry.registers()); + match entry.subleaf { + Some(subleaf) => leaf.indexed(subleaf.0), + None => leaf, + } + }) + .collect(), + physical_address_width, + msrs: vec![SupportedMsrValue { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::Pinned; + use hvdef::HvX64PartitionProcessorFeatures as Bank0; + use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; + + /// `IA32_ARCH_CAPABILITIES`. + const MSR_ARCH_CAPABILITIES: u32 = 0x10a; + + /// Features that the host partition offered on the fleet's MSHV hosts + /// (CPU fingerprints): the bare-metal Skylake-SP host and the 8573C + /// runners (Emerald Rapids, nested). + const SKYLAKE_SP_MSHV_HOST: HvFeatures = HvFeatures { + banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], + xsave: 0x3fff, + }; + const AZURE_8573C: HvFeatures = HvFeatures { + banks: [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }; + + const HOSTS: [(&str, HvFeatures); 2] = [ + ("intel.skylake-sp.v1", SKYLAKE_SP_MSHV_HOST), + ("intel.emeraldrapids.v1", AZURE_8573C), + ]; + + fn profile(id: &str) -> &'static CpuProfile { + cpu_profile::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) + } + + fn has(features: &HvFeatures, word: HvFeatureWord, mask: u64) -> bool { + features.word(word) & mask != 0 + } + + fn bank1(f: fn(Bank1) -> Bank1) -> u64 { + f(Bank1::new()).into_bits() + } + + #[test] + fn pinned_profiles_derive_on_their_hosts() { + let legacy = legacy_features(); + let invariant = bank1(|b| b.with_tsc_invariant_support(true)); + for (id, host) in HOSTS { + let profile = profile(id); + let features = time_abi_features(profile, host).unwrap(); + for word in HvFeatureWord::ALL { + // Only the forced invariant TSC may exceed what the host offers. + let allowed = if word == HvFeatureWord::Bank1 { + invariant + } else { + 0 + }; + let gained = features.word(word) & !host.word(word); + assert_eq!(gained & !allowed, 0, "{id}: {} gained bits", word.name()); + // The bits the profile does not decide keep the legacy policy. + let unmapped = !hv_banks::mapped_mask(word) & !allowed; + assert_eq!( + features.word(word) & unmapped, + host.word(word) & legacy.word(word) & unmapped, + "{id}: {} unmapped bits", + word.name() + ); + } + // Every mapped bit follows the profile, on its vendor's bits. + for feature in HV_FEATURES + .iter() + .filter(|feature| !feature.is_time_policy()) + { + let offered = has(&host, feature.word, feature.mask); + let kept = has(&features, feature.word, feature.mask); + match feature.pinned_by(profile) { + Pinned::Clear => assert!(!kept, "{id}: {}", feature.name()), + Pinned::Set => assert!(kept, "{id}: {}", feature.name()), + Pinned::Unpinned => assert_eq!(kept, offered, "{id}: {}", feature.name()), + } + } + // The time policy. + assert!(has(&features, HvFeatureWord::Bank1, invariant), "{id}"); + for mask in [ + bank1(|b| b.with_tsc_deadline_tmr_support(true)), + bank1(|b| b.with_tsc_adjust_support(true)), + bank1(|b| b.with_a_count_m_count_support(true)), + ] { + assert!( + !has(&features, HvFeatureWord::Bank1, mask), + "{id}: {mask:#x}" + ); + } + let (value, mask) = profile.msr(MSR_ARCH_CAPABILITIES).unwrap(); + assert_eq!( + hv_banks::arch_capabilities_from_banks(features.banks) & mask, + value & mask, + "{id}" + ); + } + } + + #[test] + fn bits_the_profile_does_not_decide_keep_the_legacy_policy() { + let features = + time_abi_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_MSHV_HOST).unwrap(); + // The bare-metal host offers the deprecated HLE and RTM bits, which no CPUID + // feature maps and OpenVMM never listed: they stay off. + for mask in [ + Bank0::new().with_hle_support_deprecated(true).into_bits(), + Bank0::new().with_rtm_support_deprecated(true).into_bits(), + ] { + assert!( + has(&SKYLAKE_SP_MSHV_HOST, HvFeatureWord::Bank0, mask), + "{mask:#x}" + ); + assert!(!has(&features, HvFeatureWord::Bank0, mask), "{mask:#x}"); + } + // Unrestricted guest support is listed and offered: it stays on. + let unrestricted = Bank0::new() + .with_unrestricted_guest_support(true) + .into_bits(); + assert!(has(&features, HvFeatureWord::Bank0, unrestricted)); + } + + #[test] + fn skylake_presents_no_arch_capabilities() { + // The Skylake-SP profile pins every bank-derived bit clear, including + // RDCL_NO, IBRS_ALL, and TSX_CTRL, which OpenVMM's lists enable. + let features = + time_abi_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_MSHV_HOST).unwrap(); + assert_eq!(hv_banks::arch_capabilities_from_banks(features.banks), 0); + } + + #[test] + fn a_missing_feature_is_unsupported() { + let mut host = SKYLAKE_SP_MSHV_HOST; + host.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); + host.banks[0] &= !Bank0::new().with_ibrs_support(true).into_bits(); + let error = time_abi_features(profile("intel.skylake-sp.v1"), host).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + assert!(error.message.contains("avx512_support"), "{error}"); + assert!(error.message.contains("ibrs_support"), "{error}"); + let error = + time_abi_features(profile("intel.emeraldrapids.v1"), SKYLAKE_SP_MSHV_HOST).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + } + + #[test] + fn the_time_policy_bits_need_no_host_support() { + // Azure's MSHV offers no invariant TSC; the profile pins it, the CPUID + // results supply it, and the bank bit is forced as before. + let invariant = bank1(|b| b.with_tsc_invariant_support(true)); + assert!(!has(&AZURE_8573C, HvFeatureWord::Bank1, invariant)); + let features = time_abi_features(profile("intel.emeraldrapids.v1"), AZURE_8573C).unwrap(); + assert!(has(&features, HvFeatureWord::Bank1, invariant)); + } + + #[test] + fn the_surface_follows_the_banks() { + let stibp = Bank0::new().with_stibp_support(true).into_bits(); + let host = HvFeatures { + banks: [stibp, 0], + xsave: 0, + }; + // The host lacks L1D_FLUSH and IBRS, so their bits go, and STIBP stays. + // Bit 0, which no feature controls, keeps the host's value, and the + // hypervisor range is dropped. + let cpuid = vec![ + CpuidEntry::new(0, None, [7, 0, 0, 0]), + CpuidEntry::new(7, Some(0), [0, 0, 0, (1 << 28) | (1 << 27) | (1 << 26) | 1]), + CpuidEntry::new( + HYPERVISOR_LEAF_BASE, + None, + [HYPERVISOR_LEAF_BASE + 5, 0, 0, 0], + ), + ]; + let surface = surface_from_host_cpuid(cpuid, host, 46); + let leaves: Vec<_> = surface + .cpuid + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .collect(); + assert_eq!( + leaves, + [ + (0, None, [7, 0, 0, 0]), + (7, Some(0), [0, 0, 0, (1 << 27) | 1]), + ] + ); + assert_eq!(surface.physical_address_width, 46); + let [msr] = surface.msrs.as_slice() else { + panic!("{:?}", surface.msrs) + }; + assert_eq!(msr.index, MSR_ARCH_CAPABILITIES); + assert_eq!(msr.supported, 0); + assert_eq!(msr.controllable, hv_banks::ARCH_CAPABILITIES_BANK_MASK); + } + + #[test] + fn the_surface_supports_each_host_profile() { + // On a host whose CPUID already matches its profile, the surface of the + // fingerprinted banks supports the profile. + for (id, host) in HOSTS { + let profile = profile(id); + let cpuid = profile + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let surface = surface_from_host_cpuid(cpuid, host, 52); + let host_surface = cpu_profile::HostCpuSurface { + cpuid: surface + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(), + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + }; + let violations = cpu_profile::support_violations(profile, &host_surface); + assert!(violations.is_empty(), "{id}: {violations:#?}"); + } + } + + /// Returns the XCR0 state components that MSHV enables with `features`: + /// x87 and SSE with XSAVE itself, then AVX, MPX's bound registers, + /// AVX-512's opmask and upper ZMM states, and AMX's tile configuration and + /// data. No MSHV feature controls PKRU. + fn xcr0_components(features: &HvFeatures) -> u64 { + let xsave = XsaveBank::from_bits(features.xsave); + [ + (xsave.xsave_support(), 0x3), + (xsave.avx_support(), 1 << 2), + (xsave.mpx_support(), 0x3 << 3), + (xsave.avx512_support(), 0x7 << 5), + (xsave.amx_tile_support(), 0x3 << 17), + ] + .into_iter() + .filter(|&(enabled, _)| enabled) + .fold(0, |xcr0, (_, components)| xcr0 | components) + } + + #[test] + fn the_xsave_features_enable_the_profile_xcr0() { + // The features derive from the profile's CPUID bits; the components + // they enable are the profile's XCR0, with MPX on Skylake-SP only. + for (id, host) in HOSTS { + let profile = profile(id); + let features = time_abi_features(profile, host).unwrap(); + assert_eq!(xcr0_components(&features), profile.xcr0(), "{id}"); + } + } +} + +/// Hardware tests: `cargo test -p virt_mshv -- --ignored --nocapture +/// --test-threads=1 profile_features::hw`. +#[cfg(test)] +mod hw { + use super::*; + use cpu_profile::HostCpuSignature; + use cpu_profile::hv_banks::CpuidBit; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::HvFeature; + use mshv_ioctls::VcpuFd; + use mshv_ioctls::VmFd; + + /// A transient partition with one VP that never runs. + struct Probe { + vmfd: VmFd, + vp: VcpuFd, + } + + fn probe_partition(mshv: &Mshv, features: HvFeatures) -> Result { + let args = super::super::time_abi::with_features( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + features, + ); + let vmfd = crate::create_vm_with_retry(mshv, &args).map_err(|e| e.to_string())?; + vmfd.initialize().map_err(|e| e.to_string())?; + let vp = vmfd.create_vcpu(0).map_err(|e| e.to_string())?; + Ok(Probe { vmfd, vp }) + } + + /// The vendor of this host's CPU, whose CPUID bits the features control. + fn host_vendor() -> cpu_profile::CpuVendor { + cpu_profile::CpuVendor::from_cpuid_vendor(&HostCpuSignature::current().vendor()) + .unwrap_or(cpu_profile::CpuVendor::Intel) + } + + /// The leaves and subleaves of the mapped features on `vendor`'s CPUs. + fn feature_leaves(vendor: cpu_profile::CpuVendor) -> Vec<(u32, u32)> { + let mut leaves: Vec<_> = HV_FEATURES + .iter() + .filter_map(|feature| feature.cpuid_for(vendor)) + .map(|cpuid| (cpuid.leaf, cpuid.subleaf)) + .collect(); + leaves.sort_unstable(); + leaves.dedup(); + leaves + } + + fn feature_cpuid(probe: &Probe, leaves: &[(u32, u32)]) -> Vec<[u32; 4]> { + leaves + .iter() + .map(|&(leaf, subleaf)| probe.vp.get_cpuid_values(leaf, subleaf, 0, 0).unwrap()) + .collect() + } + + /// Returns the CPUID bits set in `base` and clear in `probe`. + fn removed(leaves: &[(u32, u32)], base: &[[u32; 4]], probe: &[[u32; 4]]) -> Vec { + let mut bits = Vec::new(); + for (index, &(leaf, subleaf)) in leaves.iter().enumerate() { + for register in 0..4 { + let gone = base[index][register] & !probe[index][register]; + for bit in (0..32).filter(|bit| gone & (1 << bit) != 0) { + bits.push(CpuidBit::new(leaf, subleaf, register, bit)); + } + } + } + bits + } + + fn list(bits: &[CpuidBit]) -> String { + if bits.is_empty() { + return "nothing".to_owned(); + } + bits.iter() + .map(ToString::to_string) + .collect::>() + .join(" ") + } + + /// Whether another feature bit controls the same CPUID bit on `vendor`'s + /// CPUs, so that clearing one of them alone may leave it set. + fn shared(feature: &HvFeature, vendor: cpu_profile::CpuVendor) -> bool { + feature.cpuid_for(vendor).is_some_and(|bit| { + HV_FEATURES + .iter() + .filter(|other| other.cpuid_for(vendor) == Some(bit)) + .count() + > 1 + }) + } + + /// Clears each feature bit the host offers, one at a time, and checks that + /// every mapped one removes its CPUID bit on this host's vendor. + #[test] + #[ignore = "requires /dev/mshv"] + fn features_control_the_mapped_cpuid_bits() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let vendor = host_vendor(); + println!( + "{vendor}: host: bank0 {:#x} bank1 {:#x} xsave {:#x}", + host.banks[0], host.banks[1], host.xsave + ); + let leaves = feature_leaves(vendor); + let base = feature_cpuid(&probe_partition(&mshv, host).unwrap(), &leaves); + let mut failures = Vec::new(); + for word in HvFeatureWord::ALL { + for bit in (0..64).filter(|bit| host.word(word) & (1 << bit) != 0) { + let mask = 1u64 << bit; + let feature = HV_FEATURES + .iter() + .find(|feature| feature.word == word && feature.mask == mask); + let name = feature.map_or("(unmapped)", |feature| feature.name()); + let mut features = host; + *features.word_mut(word) &= !mask; + let probe = match probe_partition(&mshv, features) { + Ok(probe) => feature_cpuid(&probe, &leaves), + Err(error) => { + println!("{} bit {bit} {name}: rejected: {error}", word.name()); + continue; + } + }; + let removed = removed(&leaves, &base, &probe); + let mut verdict = ""; + if let Some((feature, cpuid)) = + feature.and_then(|feature| Some((feature, feature.cpuid_for(vendor)?))) + { + let index = leaves + .iter() + .position(|&key| key == (cpuid.leaf, cpuid.subleaf)) + .unwrap(); + let offered = base[index][cpuid.register] & (1 << cpuid.bit) != 0; + if offered && !shared(feature, vendor) && !removed.contains(&cpuid) { + verdict = " MISMATCH"; + failures.push(format!( + "{} bit {bit} {name}: expected {} removed {}", + word.name(), + cpuid, + list(&removed) + )); + } + } + println!( + "{} bit {bit} {name}: removes {}{verdict}", + word.name(), + list(&removed) + ); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Converts `surface` to the profile crate's form. `presentation` says + /// whose view its CPUID is: the root's for the cheap surface, a guest's + /// for a probe partition. + fn host_surface( + surface: &SupportedCpuSurface, + presentation: cpu_profile::CpuidPresentation, + ) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + presentation, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } + } + + /// Compares the cheap surface (host CPUID and banks) with the surface of a + /// probe partition that enables every offered feature, which is how the + /// CPU fingerprint measures it: every pinned profile must get the same + /// support verdict from both. The host's own profile must also pass the + /// full check on the probe's guest view, which requires the entries + /// outside its tables to read zero, as `--cpu-fingerprint` checks them. + #[test] + #[ignore = "requires /dev/mshv"] + fn the_cheap_surface_gives_the_probe_verdicts() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let probe = probe_partition(&mshv, host).unwrap(); + let width = probe + .vmfd + .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) + .unwrap() as u8; + let probe_cpuid = cpu_profile::cpuid::enumerate(|leaf, subleaf| { + probe.vp.get_cpuid_values(leaf, subleaf, 0, 0) + }) + .unwrap(); + // The probe partition's own CPUID already presents its features. + let msr = hv_banks::arch_capabilities_msr(host.banks); + let probe_surface = SupportedCpuSurface { + cpuid: probe_cpuid + .iter() + .filter(|entry| !(HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&entry.leaf.0)) + .map(|entry| { + let leaf = CpuidLeaf::new(entry.leaf.0, entry.registers()); + match entry.subleaf { + Some(subleaf) => leaf.indexed(subleaf.0), + None => leaf, + } + }) + .collect(), + physical_address_width: width, + msrs: vec![SupportedMsrValue { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }], + }; + + let mut timings = Vec::new(); + let mut cheap = None; + for _ in 0..10 { + let started = std::time::Instant::now(); + cheap = Some(supported_cpu_surface(host_cpuid_table(), host, width)); + timings.push(started.elapsed().as_micros()); + } + timings.sort_unstable(); + let cheap = cheap.unwrap(); + println!( + "host {}: cheap surface {} leaves in {} us (p50 of 10; min {} max {}), probe surface {} leaves", + HostCpuSignature::current(), + cheap.cpuid.len(), + timings[5], + timings[0], + timings[9], + probe_surface.cpuid.len() + ); + + // Report the bits that differ, for the record. The probe's bits that the + // cheap surface lacks would make it under-report. + let cheap_host = host_surface(&cheap, cpu_profile::CpuidPresentation::PassThroughHostView); + let probe_host = host_surface( + &probe_surface, + cpu_profile::CpuidPresentation::PassThroughGuestView, + ); + let mut under = 0; + for entry in &probe_host.cpuid { + let (leaf, subleaf) = entry.key(); + let theirs = entry.registers(); + let ours = cpu_profile::cpuid::lookup(&cheap_host.cpuid, leaf, subleaf.unwrap_or(0)) + .unwrap_or_default(); + for register in 0..4 { + if theirs[register] != ours[register] { + under += (theirs[register] & !ours[register]).count_ones(); + println!( + " {leaf:#x}.{}:{} probe {:#010x} cheap {:#010x} (only probe {:#x}, only cheap {:#x})", + subleaf.unwrap_or(0), + ["eax", "ebx", "ecx", "edx"][register], + theirs[register], + ours[register], + theirs[register] & !ours[register], + ours[register] & !theirs[register] + ); + } + } + } + println!("bits only the probe has: {under}"); + + let mut failures = Vec::new(); + for profile in cpu_profile::pinned_profiles() { + let theirs = cpu_profile::support_violations(profile, &probe_host); + let ours = cpu_profile::support_violations(profile, &cheap_host); + println!( + "{}: probe {} violations, cheap {} violations", + profile.id(), + theirs.len(), + ours.len() + ); + if theirs != ours { + failures.push(format!( + "{}: probe {theirs:#?} cheap {ours:#?}", + profile.id() + )); + } + } + let own = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + assert!( + cpu_profile::support_violations(own, &cheap_host).is_empty(), + "{}", + own.id() + ); + // The probe partition is a guest's view, as `--cpu-fingerprint` + // records it, so the full check also requires every entry outside the + // profile's tables to read zero there (`E_CPU_UNLISTED`). + cpu_profile::verify_support(own, &probe_host) + .unwrap_or_else(|error| panic!("probe partition: {error}")); + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Characterizes how MSHV composes CPUID results, using leaf 4, whose + /// subleaves differ natively: + /// + /// - A subleaf result applies over the hypervisor's own value, so the bits + /// it leaves unmasked stay native (the per-VP APIC IDs and the runtime + /// XSAVE sizes rely on this), and the subleaves it does not name stay + /// native. + /// - A whole-leaf result answers every subleaf. + /// - The hypervisor refuses a whole-leaf result and a subleaf result for + /// the same leaf, whichever comes second. So unlisted subleaves cannot + /// read zero through one whole-leaf result under the listed ones. + #[test] + #[ignore = "requires /dev/mshv"] + fn cpuid_results_compose_over_native_values() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let read = |probe: &Probe| -> Vec<[u32; 4]> { + (0..4) + .map(|subleaf| probe.vp.get_cpuid_values(4, subleaf, 0, 0).unwrap()) + .collect() + }; + let native = read(&probe_partition(&mshv, host).unwrap()); + println!("native leaf 4: {native:#010x?}"); + assert_ne!(native[1], [0; 4], "leaf 4 has no native subleaf 1"); + let whole = CpuidLeaf::new(4, [0; 4]); + let pinned = [0x1111_1111, 0x2222_2222, 0x3333_3333]; + // Subleaf 0 pins EBX through EDX and leaves EAX to the hypervisor. + let listed = CpuidLeaf::new(4, [0, pinned[0], pinned[1], pinned[2]]) + .indexed(0) + .masked([0, !0, !0, !0]); + + let probe = probe_partition(&mshv, host).unwrap(); + super::super::register_cpuid_result(&probe.vmfd, &listed).unwrap(); + let refused = super::super::register_cpuid_result(&probe.vmfd, &whole).unwrap_err(); + let leaf4 = read(&probe); + println!("subleaf first: {leaf4:#010x?}; whole leaf refused: {refused}"); + assert_eq!(leaf4[0][1..], pinned); + assert_eq!(leaf4[0][0], native[0][0], "unmasked bits stay native"); + assert_eq!(leaf4[1..], native[1..], "unnamed subleaves stay native"); + + let probe = probe_partition(&mshv, host).unwrap(); + super::super::register_cpuid_result(&probe.vmfd, &whole).unwrap(); + let refused = super::super::register_cpuid_result(&probe.vmfd, &listed).unwrap_err(); + let leaf4 = read(&probe); + println!("whole leaf first: {leaf4:#010x?}; subleaf refused: {refused}"); + assert!(leaf4.iter().all(|subleaf| *subleaf == [0; 4])); + } + + /// Creates a partition with this host's profile features and no CPUID + /// results, and checks that the banks alone present every mapped feature + /// as the profile pins it. The CPUID results supply the rest. + #[test] + #[ignore = "requires /dev/mshv"] + fn host_profile_features_present_the_profile() { + let profile = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let features = time_abi_features(profile, host).unwrap(); + println!( + "{}: bank0 {:#x} bank1 {:#x} xsave {:#x} (host {:#x} {:#x} {:#x})", + profile.id(), + features.banks[0], + features.banks[1], + features.xsave, + host.banks[0], + host.banks[1], + host.xsave + ); + let probe = probe_partition(&mshv, features).unwrap(); + let mut failures = Vec::new(); + let mut unmapped = 0; + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let presented = probe + .vp + .get_cpuid_values(leaf, subleaf.unwrap_or(0), 0, 0) + .unwrap(); + for (register, &value) in presented.iter().enumerate() { + let mask = entry.masks()[register]; + let differs = (value ^ entry.values()[register]) & mask; + for bit in (0..32).filter(|bit| differs & (1 << bit) != 0) { + let at = CpuidBit::new(leaf, subleaf.unwrap_or(0), register, bit); + let feature = HV_FEATURES + .iter() + .find(|feature| feature.cpuid_for(profile.cpu_vendor()) == Some(at)); + match feature { + Some(feature) if !feature.is_time_policy() => failures.push(format!( + "{at} ({}): presented {}, pinned {}", + feature.name(), + value >> bit & 1, + entry.values()[register] >> bit & 1 + )), + _ => unmapped += 1, + } + } + } + } + println!( + "{}: {unmapped} pinned bits that no feature maps differ from the hypervisor's own CPUID", + profile.id() + ); + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// A partition with the derived features supports exactly the profile's + /// XSAVE state components: its own `CPUID.(0xd,0)` (XCR0) and + /// `CPUID.(0xd,1)` (IA32_XSS), before any CPUID result applies. A PKRU + /// state the profile lacks is reported rather than failed, because no MSHV + /// feature controls it. + #[test] + #[ignore = "requires /dev/mshv"] + fn host_profile_features_enable_the_profile_xsave_components() { + const XCR0_PKRU: u64 = 1 << 9; + let profile = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let features = time_abi_features(profile, host).unwrap(); + let probe = probe_partition(&mshv, features).unwrap(); + let partition = |subleaf, low: usize| { + let registers = probe.vp.get_cpuid_values(0xd, subleaf, 0, 0).unwrap(); + u64::from(registers[low]) | u64::from(registers[3]) << 32 + }; + let host_cpuid = host_cpuid_table(); + let root = |subleaf, low: usize| { + cpu_profile::cpuid::lookup(&host_cpuid, 0xd, subleaf).map_or(0, |registers| { + u64::from(registers[low]) | u64::from(registers[3]) << 32 + }) + }; + let (xcr0, xss) = (partition(0, 0), partition(1, 2)); + println!( + "{}: xsave features {:#x}: XCR0 {xcr0:#x} (profile {:#x}, root {:#x}), \ + IA32_XSS {xss:#x} (profile {:#x}, root {:#x})", + profile.id(), + features.xsave, + profile.xcr0(), + root(0, 0), + profile.xss(), + root(1, 2) + ); + let unpinned_pkru = XCR0_PKRU & !profile.xcr0(); + if xcr0 & unpinned_pkru != 0 { + println!( + "{}: the partition supports PKRU, which the profile lacks", + profile.id() + ); + } + assert_eq!(xcr0 & !unpinned_pkru, profile.xcr0(), "XCR0 components"); + assert_eq!(xss, profile.xss(), "IA32_XSS components"); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/snp.rs b/vmm_core/virt_mshv/src/x86_64/snp.rs index 6986af272c1..f184f4c6e92 100644 --- a/vmm_core/virt_mshv/src/x86_64/snp.rs +++ b/vmm_core/virt_mshv/src/x86_64/snp.rs @@ -592,10 +592,10 @@ pub(super) fn snp_hv_cpuid_overrides(native_max_leaf: u32) -> [virt::CpuidLeaf; // isolation CPUID contract from MSHV_PT_ISOLATION_SNP. Cloud Hypervisor // does not install an equivalent override, but we have not confirmed // whether its environment receives the isolation leaves correctly from - // MSHV. Without these overrides, ACI Linux does not recognize a + // MSHV. Without these overrides, the guest kernel does not recognize a // non-paravisor Hyper-V SNP guest and uses the hypercall-page overlay // instead of direct VMMCALL hypercalls. Correctly describing isolation - // also keeps ACI's restricted-injection doorbell EOI path active, making + // also keeps the restricted-injection doorbell EOI path active, making // the previous APIC-access recommendation mask unnecessary. [ // Make the isolation configuration leaf discoverable. @@ -628,8 +628,8 @@ pub(super) fn snp_start_vp_vmsa_gpa( context: &hvdef::hypercall::InitialVpContextX64, ) -> Option { let encoded = context.rip; - // TODO: Confirm this ACI-specific overload is the intended Microsoft - // Hypervisor SNP contract. ACI zeroes the nominal register context and + // TODO: Confirm this launch-context overload is the intended Microsoft + // Hypervisor SNP contract. The guest zeroes the nominal register context and // stores `vmsa_gpa | 1` in its first eight bytes for HvCallStartVP. if encoded & 1 == 0 || context.as_bytes()[size_of::()..] @@ -773,7 +773,8 @@ impl MshvPartitionInner { let sev_control = mshv_bindings::snp::get_sev_control_register(vmsa_gpa / hvdef::HV_PAGE_SIZE); - self.bsp_vcpufd + self.finalized()? + .bsp_vcpufd .set_hvdef_regs(&[HvRegisterAssoc::from(( HvX64RegisterName::SevControl, sev_control, @@ -791,7 +792,8 @@ impl MshvPartitionInner { if count > x86defs::snp::HV_PSP_CPUID_LEAF_COUNT_MAX { return Err(SnpError::TooManyCpuidEntries(count).into()); } - if self.caps.hv1 { + let finalized = self.finalized()?; + if finalized.caps.hv1 { // TODO: Determine the correct long-term strategy for exposing // synthetic Hyper-V CPUID leaves to direct-boot SNP guests: include // them in the measured CPUID page, rely on GHCB CPUID fallback, or @@ -826,12 +828,12 @@ impl MshvPartitionInner { for leaf in &mut page.cpuid_leaf_info[..count] { let values = get_snp_cpuid_values( - &self.bsp_vcpufd, + &finalized.bsp_vcpufd, leaf.eax_in, leaf.ecx_in, leaf.xfem_in, leaf.xss_in, - self.caps.hv1, + finalized.caps.hv1, ) .map_err(|e| SnpError::Cpuid(e.into()))?; leaf.eax_out = values[0]; @@ -1346,7 +1348,7 @@ impl MshvProcessor<'_> { 0, 0, 0, - self.partition.caps.hv1, + self.partition.caps().hv1, ) .map_err(|err| { tracelimit::error_ratelimited!( @@ -1525,7 +1527,7 @@ impl MshvProcessor<'_> { index, xfem, xss, - self.partition.caps.hv1, + self.partition.caps().hv1, ) .map_err(|err| { tracelimit::error_ratelimited!( @@ -2075,7 +2077,7 @@ mod tests { } #[test] - fn parses_aci_snp_start_vp_context() { + fn parses_snp_start_vp_context() { let mut context = hvdef::hypercall::InitialVpContextX64::new_zeroed(); context.rip = 0x517001; assert_eq!(snp_start_vp_vmsa_gpa(&context), Some(0x517000)); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs new file mode 100644 index 00000000000..d03a38eb4eb --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -0,0 +1,2646 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI on MSHV. +//! +//! A partition built with a [`TimeAbiConfig`] shows the guest the time ABI's +//! minimal Hyper-V identity, served by OpenVMM rather than by the hypervisor: +//! +//! - The partition has no synthetic processor features, so the hypervisor +//! reports no hypervisor CPUID leaves and serves no synthetic MSR itself. +//! With them, it would serve `HV_X64_MSR_VP_INDEX` and the frequency MSRs +//! natively and never deliver their intercepts, and +//! `HV_X64_MSR_TSC_FREQUENCY` would report this host's rate instead of the +//! declared one. +//! - MSR-index intercepts route reads of the four identity MSRs and writes of +//! `HV_X64_MSR_TSC_INVARIANT_CONTROL` to [`TimeAbiMsrs`]. The hypervisor +//! raises #GP itself for writes to the three read-only identity MSRs, even +//! when they are intercepted, and for every other MSR of the identity range. +//! Under an AMD CPU profile, intercepts also route HWCR and DE_CFG, which +//! OpenVMM serves with the time ABI's fixed values ([`AMD_CONFIG_MSRS`]); +//! no AMD MSHV host has verified them yet. +//! - CPUID intercept results present the configured CPUID verbatim: it is the +//! CPU profile's complete effective CPUID, and the backend adds nothing to +//! it. The per-VP topology leaves (the extended topology leaves and AMD's +//! `0x8000001E`) are registered once per VP with that VP's APIC identity, +//! which the hypervisor does not provide for these partitions. Entries the +//! effective CPUID does not list show the hypervisor's own guest view, +//! which reads zero there: the CPUID sweep hardware test checks it, and +//! verification (`E_CPU_UNLISTED`) checks the ones the host enumerates at +//! every boot and restore. The hypervisor reports no hypervisor leaf of its +//! own, which preflight verifies at sentinel leaves. +//! - The processor feature banks follow the CPU profile (see +//! [`profile_features`](super::profile_features)), keep the invariant TSC, +//! and hide the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. +//! - The synchronized TSC set freezes partition time, writes the target to +//! every created VP, reads it back, and resumes partition time right after a +//! good read-back. Guest time therefore runs from the restore anchor, as on +//! the other backends, instead of waiting for the first VP run. +//! +//! These semantics were verified on hypervisor builds 10.0.26100.30000 (bare +//! metal) and 10.0.26100.9444 (Azure nested), where they are identical. + +use crate::KernelError; +use crate::MshvPartition; +use crate::MshvPartitionInner; +use crate::MshvProcessor; +use crate::VcpuFdExt; +use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; +use cpu_profile::PartitionProfile; +use cpu_profile::hv_banks::HvFeatures; +use hvdef::HvInterceptAccessType; +use hvdef::HvMessage; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64RegisterName; +use hvdef::hypercall::HV_INTERCEPT_ACCESS_MASK_READ_WRITE; +use hvdef::hypercall::HvRegisterAssoc; +use inspect::Inspect; +use mshv_ioctls::VcpuFd; +use mshv_ioctls::VmFd; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::identity::IDENTITY_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::msr::AMD_CONFIG_MSRS; +use virt::time_abi::msr::MSR_APIC_FREQUENCY; +use virt::time_abi::msr::MSR_TSC_FREQUENCY; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; +use virt::time_abi::msr::MSR_VP_INDEX; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; +use virt::time_abi::surface::SupportedCpuSurface; + +/// The identity MSRs that OpenVMM serves. The hypervisor raises #GP for +/// every other MSR of the identity range. +pub(crate) const ROUTED_MSRS: [u32; 4] = [ + MSR_VP_INDEX, + MSR_TSC_FREQUENCY, + MSR_APIC_FREQUENCY, + MSR_TSC_INVARIANT_CONTROL, +]; + +/// How many bracketed TSC reads a capture anchor tries before giving up. +const ANCHOR_ATTEMPTS: usize = 64; + +/// A capture anchor pairing this tight ends the search early. +const ANCHOR_TARGET_PAIRING_NS: u64 = 1_000; + +/// Leaves read from VP 0 for the effective CPUID besides the programmed ones: +/// the maximum basic and extended leaves. +const EFFECTIVE_CPUID_MAX_LEAVES: [u32; 2] = [0, 0x8000_0000]; + +/// CPUID leaves of the identity range that preflight reads from VP 0 to check +/// that the zero leaves took effect: the first one past the identity, the +/// Hyper-V isolation leaf `0x40000081` (where `"VS#1"` would appear), and the +/// last one, plus three hypervisor signature bases. +const PREFLIGHT_SENTINEL_LEAVES: [u32; 6] = [ + 0x4000_0006, + 0x4000_0081, + 0x4000_00ff, + 0x4000_0100, + 0x4000_0200, + 0x4000_ff00, +]; + +/// Time ABI state of an MSHV partition. +#[derive(Inspect)] +pub(crate) struct MshvTimeAbi { + /// The identity MSR handler, shared with the worker. + #[inspect( + rename = "tsc_invariant_control", + with = "|x| x.tsc_invariant_control()" + )] + msrs: Arc, + /// The time ABI CPUID. Its partition-wide results are registered where + /// VP 0's own view differs (see [`Self::register_cpuid`]), and its + /// extended topology leaves for each VP when it is created. + #[inspect(skip)] + cpuid: Vec, + /// The effective CPUID: VP 0's view of the time ABI CPUID's leaves at + /// reset, and of the host's entries outside the CPU profile's tables, + /// read once before any VP runs. Later reads would fold in VP state (the + /// hypervisor applies the VP's XCR0, XSS, and control registers), so the + /// capture and the restore of one snapshot would disagree. + #[inspect(skip)] + effective_cpuid: std::sync::OnceLock>, + /// Set by the synchronized TSC set. VP creation fails afterwards. + #[inspect(with = "|x| x.load(Ordering::Relaxed)")] + vp_set_sealed: AtomicBool, + /// The processor features the host partition offered at creation, for + /// the supported CPU surface. + #[inspect(skip)] + host_features: HvFeatures, + /// The CPU profile, whose unlisted candidates the host's CPUID gives. + #[inspect(skip)] + cpu_profile: PartitionProfile, + /// Where the host's CPUID table comes from. After its first use, this + /// holds only the drained channel, if any, which the partition frees when + /// it drops. + #[inspect(skip)] + host_cpuid_source: parking_lot::Mutex>, + /// The host's CPUID table and the CPU profile's unlisted candidates in + /// it, from [`Self::host_cpuid_source`] at first use. + #[inspect(skip)] + host_cpuid: std::sync::OnceLock, + /// How long partition build took to start reading the host's CPUID, in + /// microseconds. + #[inspect(skip)] + host_cpuid_spawn_us: u64, +} + +/// The host's CPUID table, for the supported CPU surface and the unlisted +/// candidates. +pub(super) enum HostCpuidSource { + /// Already read. + Ready(HostCpuid), + /// Being read on another thread, which sends the table with its unlisted + /// candidates and how long it took, in microseconds. Every CPUID + /// instruction in the root partition exits to the hypervisor, so this + /// keeps the read off the start path: its first use comes after guest + /// memory registration. + Pending(std::sync::mpsc::Receiver<(HostCpuid, u64)>), +} + +impl HostCpuidSource { + /// Starts reading the host's CPUID table and finding `cpu_profile`'s + /// unlisted candidates in it on another thread, or does both here if no + /// thread can start. + pub(super) fn spawn(cpu_profile: &PartitionProfile) -> Self { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let thread_profile = cpu_profile.clone(); + // The thread is detached, so it releases its own stack when it exits + // instead of a join on the start path. + let read = move || { + let started = std::time::Instant::now(); + let host = HostCpuid::read(&thread_profile); + let _ = sender.send((host, started.elapsed().as_micros() as u64)); + }; + match std::thread::Builder::new() + .name("mshv-host-cpuid".into()) + .spawn(read) + { + Ok(_) => Self::Pending(receiver), + Err(error) => { + tracing::warn!( + error = &error as &dyn std::error::Error, + "cannot start a thread to read the host CPUID, reading it now" + ); + Self::Ready(HostCpuid::read(cpu_profile)) + } + } + } +} + +/// The host's CPUID table and the CPU profile's unlisted candidates in it. +pub(super) struct HostCpuid { + table: Vec, + /// The entries the host's CPUID enumerates outside the CPU profile's + /// tables (`cpu_profile::unlisted_cpuid_candidates`). The effective CPUID + /// reports VP 0's view there, which core requires to be zero + /// (`E_CPU_UNLISTED`). + unlisted_candidates: Vec<(u32, Option)>, + /// How long the other thread took to read the table and find the + /// candidates, in microseconds, if it did. + read_us: Option, + /// How long the first use waited for them, in microseconds. The + /// effective CPUID's log line reports both, so the first use logs + /// nothing of its own: a separate event cost about 25 us on the start + /// path. + wait_us: u64, +} + +impl HostCpuid { + /// Finds `cpu_profile`'s unlisted candidates in `table`. + fn new(cpu_profile: &CpuProfile, table: Vec) -> Self { + let unlisted_candidates = cpu_profile::unlisted_cpuid_candidates(cpu_profile, &table); + Self { + table, + unlisted_candidates, + read_us: None, + wait_us: 0, + } + } + + /// Reads the host's CPUID table on this thread. + fn read(cpu_profile: &CpuProfile) -> Self { + Self::new(cpu_profile, super::profile_features::host_cpuid_table()) + } +} + +impl MshvTimeAbi { + pub(super) fn new( + config: &TimeAbiConfig, + cpuid: &CpuidLeafSet, + host_features: HvFeatures, + host_cpuid: HostCpuidSource, + host_cpuid_spawn_us: u64, + ) -> Self { + Self { + msrs: config.msrs.clone(), + cpuid: cpuid.leaves().to_vec(), + effective_cpuid: std::sync::OnceLock::new(), + vp_set_sealed: AtomicBool::new(false), + host_features, + cpu_profile: config.cpu_profile.clone(), + host_cpuid_source: parking_lot::Mutex::new(Some(host_cpuid)), + host_cpuid: std::sync::OnceLock::new(), + host_cpuid_spawn_us, + } + } + + /// Registers the partition-wide results of the time ABI CPUID that VP 0's + /// own view does not already present, once VP 0 exists and before any VP + /// runs or the effective CPUID is read. + /// + /// Under the CPU profile's processor features the hypervisor's own view + /// presents most of the profile, and every hypervisor-range leaf past the + /// identity reads zero. A result it already presents would change nothing + /// the guest reads but cost a hypercall (about 3.3 us on bare metal and + /// 5 us nested), so one bulk read of VP 0's view at reset decides. The + /// effective CPUID report reads every leaf back, and core checks it. If + /// the bulk read fails, every result is registered. The extended topology + /// leaves get per-VP results when each VP is created (see `create_vp`). + pub(super) fn register_cpuid( + &self, + vmfd: &VmFd, + bsp: &VcpuFd, + cpuid: &CpuidLeafSet, + ) -> Result<(), crate::Error> { + let started = std::time::Instant::now(); + let leaves: Vec<&CpuidLeaf> = cpuid + .leaves() + .iter() + .filter(|leaf| !is_per_vp_leaf(leaf.function)) + .collect(); + let entries: Vec<(u32, u32)> = leaves + .iter() + .map(|leaf| (leaf.function, leaf.index.unwrap_or(0))) + .collect(); + let native = vp_cpuid_many(bsp, VpIndex::BSP.index(), &entries) + .inspect_err(|error| { + tracing::warn!( + error = error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, registering every time ABI CPUID result" + ); + }) + .ok(); + let read_us = started.elapsed().as_micros() as u64; + let mut registered = 0; + for (index, leaf) in leaves.iter().enumerate() { + if native + .as_ref() + .is_some_and(|native| presents(leaf, native[index])) + { + continue; + } + super::register_cpuid_result(vmfd, leaf)?; + registered += 1; + } + tracing::info!( + leaves = cpuid.leaves().len(), + per_vp_leaves = cpuid.leaves().len() - leaves.len(), + native_leaves = leaves.len() - registered, + registered_leaves = registered, + host_cpuid_spawn_us = self.host_cpuid_spawn_us, + read_us, + elapsed_us = started.elapsed().as_micros() as u64, + "registered MSHV CPUID results" + ); + Ok(()) + } + + /// Returns the host's CPUID table and the unlisted candidates, waiting + /// for the read that partition creation started if it is still running. + fn host_cpuid(&self) -> &HostCpuid { + self.host_cpuid.get_or_init(|| { + let started = std::time::Instant::now(); + let mut source = self.host_cpuid_source.lock(); + let (mut host, read_us) = match source.take() { + Some(HostCpuidSource::Ready(host)) => (host, None), + Some(HostCpuidSource::Pending(receiver)) => { + let received = receiver.recv(); + // Freeing the channel can hand its memory back to the + // kernel, which took about 12 us on a bare-metal host, + // so the channel lives until the partition drops. + *source = Some(HostCpuidSource::Pending(receiver)); + match received { + Ok((host, read_us)) => (host, Some(read_us)), + // The reading thread panicked. + Err(_) => (HostCpuid::read(&self.cpu_profile), None), + } + } + None => (HostCpuid::read(&self.cpu_profile), None), + }; + drop(source); + host.read_us = read_us; + host.wait_us = started.elapsed().as_micros() as u64; + host + }) + } + + /// Fails with `E_VP_LATE_CREATION` once the synchronized TSC set has run. + /// + /// VP creation checks this both before and after it marks a VP created, + /// and the synchronized set seals before it collects the created VPs, so a + /// VP is either written by the set or fails creation. + pub(super) fn check_vp_creation(&self, vp_index: VpIndex) -> Result<(), TimeAbiError> { + if self.vp_set_sealed.load(Ordering::SeqCst) { + return Err(TimeAbiError::new( + TimeAbiCode::VpLateCreation, + format!( + "VP {} was instantiated after the synchronized TSC set", + vp_index.index() + ), + )); + } + Ok(()) + } +} + +/// Fails unless the partition can carry the time ABI: it must not be isolated +/// and must not expose the Hyper-V guest interface, whose synthetic processor +/// features would serve the identity MSRs natively. +pub(super) fn validate_partition( + isolation: virt::IsolationType, + hv_configured: bool, +) -> Result<(), TimeAbiError> { + if isolation != virt::IsolationType::None { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!("the MSHV time ABI does not support {isolation:?} isolation"), + )); + } + if hv_configured { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + "the MSHV time ABI cannot be combined with the Hyper-V guest interface", + )); + } + Ok(()) +} + +/// Returns processor feature bank 1 for a time ABI partition: `supported`, +/// with the invariant TSC, and without the TSC-deadline timer, +/// `IA32_TSC_ADJUST`, or APERF/MPERF. +pub(super) fn processor_features1( + supported: hvdef::HvX64PartitionProcessorFeatures1, +) -> hvdef::HvX64PartitionProcessorFeatures1 { + supported + .with_tsc_invariant_support(true) + .with_tsc_deadline_tmr_support(false) + .with_tsc_adjust_support(false) + .with_a_count_m_count_support(false) +} + +/// Returns the processor features of a time ABI partition with CPU profile +/// `profile` on a host whose partition offers `host`: the profile's, within +/// what the host offers, under the time ABI's policy (see +/// [`time_abi_features`](super::profile_features::time_abi_features)). Fails +/// with `E_PROFILE_UNSUPPORTED`. +pub(super) fn partition_features( + profile: &CpuProfile, + host: HvFeatures, +) -> Result { + let features = super::profile_features::time_abi_features(profile, host)?; + tracing::info!( + cpu_profile = profile.id(), + bank0 = features.banks[0], + bank1 = features.banks[1], + xsave = features.xsave, + host_bank0 = host.banks[0], + host_bank1 = host.banks[1], + host_xsave = host.xsave, + "MSHV processor features from the CPU profile" + ); + Ok(features) +} + +/// Applies `features` to the partition creation arguments, whose banks hold +/// the disabled features. +pub(super) fn with_features( + mut args: mshv_bindings::mshv_create_partition_v2, + features: HvFeatures, +) -> mshv_bindings::mshv_create_partition_v2 { + args.pt_cpu_fbanks = [!features.banks[0], !features.banks[1]]; + args.pt_disabled_xsave = !features.xsave; + args +} + +/// Returns the MSR-index intercept that routes reads and writes of `msr` to +/// OpenVMM. +fn msr_index_intercept(msr: u32) -> mshv_bindings::mshv_install_intercept { + mshv_bindings::mshv_install_intercept { + access_type_mask: HV_INTERCEPT_ACCESS_MASK_READ_WRITE, + intercept_type: mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX, + intercept_parameter: mshv_bindings::hv_intercept_parameters { + as_uint64: u64::from(msr), + }, + } +} + +/// Returns the MSRs that a time ABI partition with a CPU profile of `vendor` +/// routes to OpenVMM: the identity MSRs it serves, and under an AMD profile +/// AMD's configuration MSRs, whose fixed values OpenVMM serves too +/// ([`AMD_CONFIG_MSRS`]). +pub(super) fn routed_msrs(vendor: CpuVendor) -> Vec { + let mut msrs = ROUTED_MSRS.to_vec(); + if vendor == CpuVendor::Amd { + msrs.extend(AMD_CONFIG_MSRS.iter().map(|&(msr, _)| msr)); + } + msrs +} + +/// Routes the time ABI's MSRs of a CPU profile of `vendor` to OpenVMM +/// ([`routed_msrs`]). Fails with `E_IDENTITY_ROUTING`, naming the MSR whose +/// intercept the hypervisor refused. +pub(super) fn route_identity_msrs(vmfd: &VmFd, vendor: CpuVendor) -> Result<(), TimeAbiError> { + install_msr_intercepts(vmfd, &routed_msrs(vendor)) +} + +fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> { + for &msr in msrs { + vmfd.install_intercept(msr_index_intercept(msr)) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "cannot intercept MSR {msr:#x}: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + } + Ok(()) +} + +/// Returns the CPUID table of a time ABI partition: the time ABI CPUID +/// verbatim. +/// +/// It is the CPU profile's complete effective CPUID (the profile's leaves, +/// OpenVMM's topology leaves and APIC mode, and the identity and explicit zero +/// leaves), so neither the worker's leaves nor the backend's own add a leaf or +/// a bit. The partition registers its results where VP 0's own view differs +/// ([`MshvTimeAbi::register_cpuid`]): without synthetic processor features the +/// hypervisor reports every hypervisor-range leaf past the identity as zero, +/// and under the profile's processor features it presents most of the +/// profile. The hypervisor's own values also show where a result's mask is +/// clear (each VP's APIC identity, the runtime XSAVE sizes) and where no +/// result applies: the reserved entries, which its guest view reads as zero. +pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { + CpuidLeafSet::new(config.leaves().to_vec()) +} + +/// Returns whether CPUID leaf `function` carries a field that differs per VP +/// and that the hypervisor does not provide for a time ABI partition: the +/// extended topology leaves `0xB` and `0x1F`, whose `EDX` is the VP's x2APIC +/// ID, and AMD's processor topology leaf `0x8000001E`, whose extended APIC, +/// compute unit, and node IDs are the VP's. The hypervisor does not +/// implement the extended topology leaves for these partitions and reads +/// that `EDX` as 0 on every VP, so each VP gets its own results; without +/// `0x8000001E`'s, a guest that takes its APIC ID from there would log +/// `APIC ID mismatch` on every AP. Leaf 1's initial APIC ID, the other +/// per-VP field, is the hypervisor's own. +pub(super) fn is_per_vp_leaf(function: u32) -> bool { + matches!(function, 0xb | 0x1f | 0x8000_001e) +} + +/// Returns `leaf` for the VP with APIC ID `apic_id` in a partition with +/// `reserved_vps_per_socket` APIC IDs per socket, its per-VP fields set as +/// `virt::x86::topology` sets the BSP's: the x2APIC ID in the extended +/// topology leaves' `EDX`, and in `0x8000001E` the extended APIC ID, the +/// compute unit ID within the socket, and the socket's node ID. +pub(super) fn with_vp_identity( + leaf: &CpuidLeaf, + apic_id: u32, + reserved_vps_per_socket: u32, +) -> CpuidLeaf { + let mut leaf = *leaf; + if leaf.function == 0x8000_001e { + let ebx = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(leaf.result[1]); + let threads_per_compute_unit = u32::from(ebx.threads_per_compute_unit()) + 1; + let reserved_vps_per_socket = reserved_vps_per_socket.max(1); + leaf.result[0] = apic_id; + leaf.result[1] = ebx + .with_compute_unit_id( + (apic_id % reserved_vps_per_socket / threads_per_compute_unit) as u8, + ) + .into(); + leaf.result[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(leaf.result[2]) + .with_node_id((apic_id / reserved_vps_per_socket) as u8) + .into(); + } else { + leaf.result[3] = apic_id; + } + let per_vp = virt::x86::topology::per_vp_cpuid_bits(leaf.function); + for (mask, per_vp) in leaf.mask.iter_mut().zip(per_vp) { + *mask |= per_vp; + } + leaf +} + +/// An intercepted access to an identity MSR. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MsrAccess { + Read, + Write(u64), +} + +/// How an intercepted identity MSR access completes. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MsrOutcome { + /// The read returns this value. + Read(u64), + /// The write takes effect. + Written, + /// The access raises #GP. + Fault, +} + +/// Serves an intercepted access of a partition whose CPU profile is of +/// `vendor`: under an AMD profile, AMD's configuration MSRs with their fixed +/// values, and otherwise the identity MSRs through their handler. +fn serve_time_abi_msr( + msrs: &TimeAbiMsrs, + vendor: CpuVendor, + vp: VpIndex, + msr: u32, + access: MsrAccess, +) -> MsrOutcome { + let amd = || match access { + MsrAccess::Read => amd_config_msr_read(msr).map(|value| Ok(MsrOutcome::Read(value))), + MsrAccess::Write(value) => { + amd_config_msr_write(msr, value).map(|r| r.map(|()| MsrOutcome::Written)) + } + }; + let identity = || match access { + MsrAccess::Read => msrs.read(vp, msr).map(|r| r.map(MsrOutcome::Read)), + MsrAccess::Write(value) => msrs + .write(vp, msr, value) + .map(|r| r.map(|()| MsrOutcome::Written)), + }; + let outcome = if vendor == CpuVendor::Amd { + amd().or_else(identity) + } else { + identity() + }; + match outcome { + Some(Ok(outcome)) => outcome, + Some(Err(_)) => MsrOutcome::Fault, + None => { + // Only the routed MSRs are intercepted, so this is a hypervisor + // defect; the access still faults as an unknown MSR would. + tracelimit::error_ratelimited!( + vp = vp.index(), + msr, + "MSR intercept outside the time ABI's routed MSRs" + ); + MsrOutcome::Fault + } + } +} + +/// Returns the pending-event register value that raises #GP(0). +fn gp_fault_event() -> u128 { + hvdef::HvX64PendingExceptionEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) + .with_vector(x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into()) + .with_deliver_error_code(true) + .with_error_code(0) + .into() +} + +impl MshvProcessor<'_> { + /// Completes an intercepted access to a time ABI MSR, or raises #GP. + pub(super) fn handle_time_abi_msr_intercept( + &mut self, + time_abi: &MshvTimeAbi, + message: &HvMessage, + ) { + let info = message.as_message::(); + let access = if info.header.intercept_access_type == HvInterceptAccessType::WRITE { + MsrAccess::Write((info.rdx << 32) | (info.rax & 0xffff_ffff)) + } else { + MsrAccess::Read + }; + let outcome = serve_time_abi_msr( + &time_abi.msrs, + time_abi.cpu_profile.cpu_vendor(), + self.vpindex, + info.msr_number, + access, + ); + tracing::trace!( + vp = self.vpindex.index(), + msr = info.msr_number, + ?access, + ?outcome, + "time ABI MSR access" + ); + let next_rip = info.header.rip + u64::from(info.header.instruction_len()); + match outcome { + MsrOutcome::Read(value) => { + let rp = self.runner.reg_page(); + rp.gp_registers[x86emu::Gp::RAX as usize] = value & 0xffff_ffff; + rp.gp_registers[x86emu::Gp::RDX as usize] = value >> 32; + rp.rip = next_rip; + rp.dirty.set_general_purpose(true); + rp.dirty.set_instruction_pointer(true); + } + MsrOutcome::Written => { + let rp = self.runner.reg_page(); + rp.rip = next_rip; + rp.dirty.set_instruction_pointer(true); + } + MsrOutcome::Fault => { + // The faulting instruction does not retire, so RIP stays. + self.runner + .vcpufd + .set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::PendingEvent0, + gp_fault_event(), + ))]) + .expect("failed to inject #GP for an identity MSR access"); + } + } + } +} + +/// Returns the VPs that exist in the hypervisor, given whether each VP of the +/// topology was created. A restore-time VP prefix leaves the suffix uncreated. +fn created_vps(created: impl IntoIterator) -> Vec { + created + .into_iter() + .enumerate() + .filter_map(|(index, created)| created.then_some(VpIndex::new(index as u32))) + .collect() +} + +/// Checks that every VP read back `target`. Fails with +/// `E_TSC_SYNC_READBACK`, naming the first VP that differs. +fn check_readback(target: u64, readback: &[(VpIndex, u64)]) -> Result<(), TimeAbiError> { + if let Some((vp, value)) = readback.iter().find(|(_, value)| *value != target) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "VP {} holds TSC {value:#x} instead of the synchronized {target:#x}", + vp.index() + ), + )); + } + Ok(()) +} + +/// One bracketed read of VP 0's TSC: host samples taken immediately before +/// and after the read. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct AnchorCandidate { + tsc: u64, + before: HostTimeSample, + after: HostTimeSample, +} + +impl AnchorCandidate { + /// The host monotonic time the read took, in nanoseconds. + fn width_ns(&self) -> u64 { + self.after + .monotonic_ns + .saturating_sub(self.before.monotonic_ns) + } + + /// Pairs the TSC with the midpoint of the bracket, which differs from the + /// read instant by at most half the bracket. UTC is derived from the first + /// sample and the monotonic midpoint, so a UTC step inside the bracket + /// does not skew it. + fn anchor(&self) -> TscAnchor { + let half = self.width_ns() / 2; + TscAnchor { + tsc: self.tsc, + sample: HostTimeSample { + utc_ns: self.before.utc_ns + half, + monotonic_ns: self.before.monotonic_ns + half, + }, + pairing_ns: self.width_ns().div_ceil(2), + } + } +} + +/// Chooses the tightest candidate. Fails with `E_TSC_ANCHOR` if none pairs +/// within [`MAX_ANCHOR_PAIRING_NS`]. +fn select_anchor(candidates: &[AnchorCandidate]) -> Result { + let best = candidates + .iter() + .min_by_key(|candidate| candidate.width_ns()) + .ok_or_else(|| TimeAbiError::new(TimeAbiCode::TscAnchor, "no TSC read was attempted"))?; + let anchor = best.anchor(); + if anchor.pairing_ns > MAX_ANCHOR_PAIRING_NS { + return Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "the tightest of {} VP 0 TSC reads pairs within {} ns, above the {MAX_ANCHOR_PAIRING_NS} ns bound", + candidates.len(), + anchor.pairing_ns + ), + )); + } + Ok(anchor) +} + +/// Formats an error with its sources, for failure messages. +pub(super) fn error_chain(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(error) = source { + text.push_str(": "); + text.push_str(&error.to_string()); + source = error.source(); + } + text +} + +/// The input of `HvCallGetVpRegisters` and `HvCallSetVpRegisters` for one +/// register of another VP. +#[repr(C)] +struct VpRegisterInput { + header: hvdef::hypercall::GetSetVpRegisters, + register: HvRegisterAssoc, +} + +fn vp_register_input(vp: VpIndex, register: HvRegisterAssoc) -> VpRegisterInput { + VpRegisterInput { + header: hvdef::hypercall::GetSetVpRegisters { + partition_id: 0, + vp_index: vp.index(), + target_vtl: hvdef::hypercall::HvInputVtl::CURRENT_VTL, + rsvd: [0; 3], + }, + register, + } +} + +/// Writes the TSC of a created VP through `HvCallSetVpRegisters`. +fn set_vp_tsc(vmfd: &VmFd, vp: VpIndex, tsc: u64) -> Result<(), KernelError> { + let input = vp_register_input(vp, HvRegisterAssoc::from((HvX64RegisterName::Tsc, tsc))); + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallSetVpRegisters.0, + in_sz: size_of::() as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + reps: 1, + ..Default::default() + }; + vmfd.hvcall(&mut args)?; + if args.reps != 1 { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + Ok(()) +} + +/// Reads the TSC of a created VP through `HvCallGetVpRegisters`. +fn get_vp_tsc(vmfd: &VmFd, vp: VpIndex) -> Result { + // The input is the header and one register name; the name occupies the + // first four bytes of the register association. + let input = vp_register_input(vp, HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0u64))); + let mut output = [0u64; 2]; + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallGetVpRegisters.0, + in_sz: (size_of::() + size_of::()) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + out_sz: size_of_val(&output) as u16, + out_ptr: output.as_mut_ptr() as u64, + reps: 1, + ..Default::default() + }; + vmfd.hvcall(&mut args)?; + if args.reps != 1 { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + Ok(output[0]) +} + +/// Reads one CPUID leaf as VP 0 sees it. +fn vp_cpuid(bsp: &VcpuFd, function: u32, index: u32) -> Result<[u32; 4], KernelError> { + Ok(bsp.get_cpuid_values(function, index, 0, 0)?) +} + +/// The most CPUID entries that one `HvCallGetVpCpuidValues` reads. Its input, +/// a header and one leaf description per entry, must fit in the page that the +/// kernel copies it to. +const CPUID_READS_PER_CALL: usize = 128; + +/// The input of `HvCallGetVpCpuidValues` for up to [`CPUID_READS_PER_CALL`] +/// entries. +#[repr(C, packed)] +struct VpCpuidValuesInput { + header: mshv_bindings::hv_input_get_vp_cpuid_values, + leaves: [mshv_bindings::hv_cpuid_leaf_info; CPUID_READS_PER_CALL], +} + +const _: () = assert!(size_of::() <= 4096); + +/// Reads CPUID `entries`, each a leaf and subleaf, as VP `vp_index` sees them +/// with the registered results applied, which is how +/// `VcpuFd::get_cpuid_values` reads one entry. `HvCallGetVpCpuidValues` is a +/// rep hypercall, so one call reads up to [`CPUID_READS_PER_CALL`] entries +/// instead of paying a hypercall per entry. +pub(super) fn vp_cpuid_many( + vp: &VcpuFd, + vp_index: u32, + entries: &[(u32, u32)], +) -> Result, KernelError> { + use mshv_bindings::hv_cpuid_leaf_info; + use mshv_bindings::hv_input_get_vp_cpuid_values; + use mshv_bindings::hv_output_get_vp_cpuid_values; + + let mut input = VpCpuidValuesInput { + header: hv_input_get_vp_cpuid_values { + vp_index, + ..Default::default() + }, + leaves: [hv_cpuid_leaf_info::default(); CPUID_READS_PER_CALL], + }; + // SAFETY: both fields of the flags union are the same 32 bits. + unsafe { + let flags = &mut input.header.flags.__bindgen_anon_1; + flags.set_use_vp_xfem_xss(1); + flags.set_apply_registered_values(1); + } + let mut output = [hv_output_get_vp_cpuid_values::default(); CPUID_READS_PER_CALL]; + let mut results = Vec::with_capacity(entries.len()); + let mut pending = entries; + while !pending.is_empty() { + let count = pending.len().min(CPUID_READS_PER_CALL); + for (leaf, &(eax, ecx)) in input.leaves.iter_mut().zip(&pending[..count]) { + *leaf = hv_cpuid_leaf_info { + eax, + ecx, + xfem: 0, + xss: 0, + }; + } + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallGetVpCpuidValues.0, + reps: count as u16, + in_sz: (size_of::() + + count * size_of::()) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + out_sz: (count * size_of::()) as u16, + out_ptr: output.as_mut_ptr() as u64, + ..Default::default() + }; + vp.hvcall(&mut args)?; + // A call that completes only some of its entries is continued with + // the rest. + let completed = usize::from(args.reps); + if completed == 0 || completed > count { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + // SAFETY: both fields of the output union are the same four + // registers. + results.extend( + output[..completed] + .iter() + .map(|values| unsafe { values.as_uint32 }), + ); + pending = &pending[completed..]; + } + Ok(results) +} + +impl MshvPartitionInner { + fn time_abi_state(&self) -> Result<&MshvTimeAbi, TimeAbiError> { + self.time_abi.as_ref().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "the partition was built without the time ABI", + ) + }) + } + + fn time_abi_bsp(&self, code: TimeAbiCode) -> Result<&VcpuFd, TimeAbiError> { + self.finalized() + .map(|finalized| &finalized.bsp_vcpufd) + .map_err(|error| TimeAbiError::new(code, format!("VP 0 is unavailable: {error}"))) + } + + /// Returns the effective CPUID, reading it from VP 0 the first time. The + /// first read happens in preflight, before any VP runs, on both cold boot + /// and restore, so it is VP 0's view at reset. + fn time_abi_effective_cpuid(&self) -> Result<&[CpuidLeaf], TimeAbiError> { + let state = self.time_abi_state()?; + if let Some(leaves) = state.effective_cpuid.get() { + return Ok(leaves); + } + let bsp = self.time_abi_bsp(TimeAbiCode::CpuSurface)?; + let started = std::time::Instant::now(); + // VP 0 reads, in the report's order: the maximum leaves, the time ABI + // CPUID's leaves, and the entries outside the profile's tables where + // the host's CPUID has any. Guests read the hypervisor's own values + // wherever no result was registered (see `register_cpuid`) and at the + // unlisted entries (at subleaf 0 for a subleaf-independent entry), + // which core requires to read zero. + let reads: Vec<(u32, Option)> = EFFECTIVE_CPUID_MAX_LEAVES + .into_iter() + .map(|function| (function, None)) + .chain(state.cpuid.iter().map(|leaf| (leaf.function, leaf.index))) + .chain(state.host_cpuid().unlisted_candidates.iter().copied()) + .collect(); + let entries: Vec<(u32, u32)> = reads + .iter() + .map(|&(function, index)| (function, index.unwrap_or(0))) + .collect(); + let (values, bulk) = vp0_cpuid_entries(bsp, &entries, TimeAbiCode::CpuSurface)?; + let leaves: Vec<_> = reads + .iter() + .zip(values) + .map(|(&(function, index), result)| { + let leaf = CpuidLeaf::new(function, result); + match index { + Some(index) => leaf.indexed(index), + None => leaf, + } + }) + .collect(); + let host = state.host_cpuid(); + tracing::info!( + leaves = leaves.len(), + reads = entries.len(), + bulk, + unlisted_candidates = host.unlisted_candidates.len(), + host_cpuid_entries = host.table.len(), + host_cpuid_read_us = host.read_us, + host_cpuid_wait_us = host.wait_us, + elapsed_us = started.elapsed().as_micros() as u64, + "MSHV effective CPUID read" + ); + // A concurrent first read stores identical values. + let _ = state + .effective_cpuid + .set(CpuidLeafSet::new(leaves).into_leaves()); + Ok(state.effective_cpuid.get().expect("set above")) + } + + /// Checks that the effective CPUID holds the identity leaves as + /// registered, and that VP 0 reads zero at the sentinel leaves. + fn check_identity_cpuid(&self, bsp: &VcpuFd) -> Result<(), TimeAbiError> { + let state = self.time_abi_state()?; + let effective = CpuidLeafSet::new(self.time_abi_effective_cpuid()?.to_vec()); + let identity = state.cpuid.iter().filter(|leaf| { + leaf.function <= IDENTITY_MAX_LEAF && IDENTITY_CPUID_RANGE.contains(&leaf.function) + }); + for leaf in identity { + let actual = effective.result(leaf.function, leaf.index.unwrap_or(0), &[0; 4]); + check_leaf(leaf, actual)?; + } + let sentinels = PREFLIGHT_SENTINEL_LEAVES.map(|function| (function, 0)); + let (values, _) = vp0_cpuid_entries(bsp, &sentinels, TimeAbiCode::IdentityRouting)?; + for (&(function, _), actual) in sentinels.iter().zip(values) { + check_leaf(&CpuidLeaf::new(function, [0; 4]), actual)?; + } + Ok(()) + } +} + +/// Reads CPUID `entries` of VP 0 in one bulk call ([`vp_cpuid_many`]), +/// falling back to one read per entry, with a warning, if the bulk call +/// fails. Returns the values and whether the bulk call served them; a read +/// that fails fails with `code`. +pub(super) fn vp0_cpuid_entries( + bsp: &VcpuFd, + entries: &[(u32, u32)], + code: TimeAbiCode, +) -> Result<(Vec<[u32; 4]>, bool), TimeAbiError> { + match vp_cpuid_many(bsp, VpIndex::BSP.index(), entries) { + Ok(values) => Ok((values, true)), + Err(error) => { + tracing::warn!( + error = &error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, reading one entry per call" + ); + let values = entries + .iter() + .map(|&(function, index)| { + vp_cpuid(bsp, function, index).map_err(|error| { + TimeAbiError::new( + code, + format!( + "cannot read CPUID {function:#x}/{index:#x} of VP 0: {}", + error_chain(&error) + ), + ) + }) + }) + .collect::, _>>()?; + Ok((values, false)) + } + } +} + +/// Returns whether VP 0's `actual` result agrees with `leaf` under its mask. +fn presents(leaf: &CpuidLeaf, actual: [u32; 4]) -> bool { + let mut expected = actual; + leaf.apply(&mut expected); + actual == expected +} + +/// Fails with `E_IDENTITY_ROUTING` unless VP 0's `actual` result agrees with +/// `leaf` under its mask. +fn check_leaf(leaf: &CpuidLeaf, actual: [u32; 4]) -> Result<(), TimeAbiError> { + if !presents(leaf, actual) { + let mut expected = actual; + leaf.apply(&mut expected); + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "VP 0 reads CPUID {:#x} as {actual:#x?}, expected {expected:#x?}", + leaf.function + ), + )); + } + Ok(()) +} + +impl TimeAbiBackend for MshvPartition { + fn native_tsc_hz(&self) -> Result { + let hz = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!( + "cannot read the ProcessorClockFrequency partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "the ProcessorClockFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn lapic_hz(&self) -> Result { + let hz = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::ApicFrequency.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!( + "cannot read the ApicFrequency partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the ApicFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn preflight(&self) -> Result { + // The MSR intercepts were installed at partition creation, which + // fails with E_IDENTITY_ROUTING otherwise. Check that the CPUID + // results took effect. + let bsp = self.inner.time_abi_bsp(TimeAbiCode::IdentityRouting)?; + self.inner.check_identity_cpuid(bsp)?; + + // Freezing partition time checks that it can be frozen. Time stays + // frozen, as after a reset, until the synchronized TSC set resumes it + // after its read-back or a VP first runs, so a restore freezes it + // only once. + self.inner.freeze_time().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot freeze partition time: {}", error_chain(&error)), + ) + })?; + + // The guest TSC is the host TSC plus an offset: OpenVMM never sets a + // TSC frequency or multiplier for an MSHV partition. + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + Ok(self.inner.time_abi_effective_cpuid()?.to_vec()) + } + + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + let started = std::time::Instant::now(); + let state = self.inner.time_abi_state()?; + let width = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot read the PhysicalAddressWidth partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + let surface = super::profile_features::supported_cpu_surface( + state.host_cpuid().table.clone(), + state.host_features, + width as u8, + ); + tracing::info!( + leaves = surface.cpuid.len(), + physical_address_width = surface.physical_address_width, + elapsed_us = started.elapsed().as_micros() as u64, + "MSHV supported CPU surface" + ); + Ok(Some(surface)) + } + + fn capture_anchor(&self) -> Result { + let mut candidates = Vec::with_capacity(ANCHOR_ATTEMPTS); + for _ in 0..ANCHOR_ATTEMPTS { + let before = sample_host_time()?; + let tsc = get_vp_tsc(&self.inner.vmfd, VpIndex::BSP).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read the TSC of VP 0: {}", error_chain(&error)), + ) + })?; + let after = sample_host_time()?; + let candidate = AnchorCandidate { tsc, before, after }; + candidates.push(candidate); + if candidate.width_ns() <= 2 * ANCHOR_TARGET_PAIRING_NS { + break; + } + } + let anchor = select_anchor(&candidates)?; + tracing::info!( + tsc = anchor.tsc, + pairing_ns = anchor.pairing_ns, + attempts = candidates.len(), + "time ABI: capture anchor" + ); + Ok(anchor) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let started = std::time::Instant::now(); + let state = self.inner.time_abi_state()?; + // Seal before collecting the created VPs; see + // `MshvTimeAbi::check_vp_creation`. + state.vp_set_sealed.store(true, Ordering::SeqCst); + let created = created_vps( + self.inner + .vps + .iter() + .map(|vp| vp.created.load(Ordering::SeqCst)), + ); + if created.first() != Some(&VpIndex::BSP) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "VP 0 does not exist", + )); + } + + // Freeze partition time so that every VP holds the target from the + // anchor until the read-back, then resume it right away. + self.inner.freeze_time().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot freeze partition time: {}", error_chain(&error)), + ) + })?; + let frozen = started.elapsed(); + + let sample = sample_host_time()?; + let target = target(&sample)?; + + let vmfd = &self.inner.vmfd; + let failed = |vp: VpIndex, what: &str, error: KernelError| { + TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "cannot {what} the TSC of VP {}: {}", + vp.index(), + error_chain(&error) + ), + ) + }; + for &vp in &created { + set_vp_tsc(vmfd, vp, target).map_err(|error| failed(vp, "write", error))?; + } + let written = started.elapsed(); + let readback = created + .iter() + .map(|&vp| { + get_vp_tsc(vmfd, vp) + .map(|value| (vp, value)) + .map_err(|error| failed(vp, "read back", error)) + }) + .collect::, _>>()?; + let result = check_readback(target, &readback); + // A failed set leaves time frozen; the restore fails and tears the + // partition down. + let thawed = result.is_ok(); + if thawed { + self.inner.thaw_time().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot resume partition time: {}", error_chain(&error)), + ) + })?; + } + tracing::info!( + created_vps = created.len(), + vp_capacity = self.inner.vps.len(), + target, + readback_equal = result.is_ok(), + thawed, + freeze_us = frozen.as_micros() as u64, + write_us = (written - frozen).as_micros() as u64, + total_us = started.elapsed().as_micros() as u64, + "time ABI: synchronized TSC set while partition time is frozen" + ); + result?; + Ok(TscSetReport { + target, + sample, + readback, + method: TscSyncMethod::FrozenWrite, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::time_abi_cpuid; + + const VP_COUNT: u32 = 8; + + fn declared_msrs() -> TimeAbiMsrs { + let msrs = TimeAbiMsrs::new(); + msrs.declare(DeclaredRates::new(2_194_844_582, 200_000_000).unwrap()) + .unwrap(); + msrs + } + + /// Serves an intercepted access under an Intel profile, which routes the + /// identity MSRs only. + fn serve_identity_msr( + msrs: &TimeAbiMsrs, + vp: VpIndex, + msr: u32, + access: MsrAccess, + ) -> MsrOutcome { + serve_time_abi_msr(msrs, CpuVendor::Intel, vp, msr, access) + } + + /// An AMD profile also routes HWCR and DE_CFG, which OpenVMM serves with + /// the time ABI's fixed values. + #[test] + fn amd_profiles_route_and_serve_the_amd_config_msrs() { + assert_eq!(routed_msrs(CpuVendor::Intel), ROUTED_MSRS); + assert_eq!( + routed_msrs(CpuVendor::Amd), + [ + 0x4000_0002, + 0x4000_0022, + 0x4000_0023, + 0x4000_0118, + 0xc001_0015, + 0xc001_1029 + ] + ); + let msrs = declared_msrs(); + let serve = + |msr, access| serve_time_abi_msr(&msrs, CpuVendor::Amd, VpIndex::new(2), msr, access); + assert_eq!( + serve(0xc001_0015, MsrAccess::Read), + MsrOutcome::Read(1 << 24) + ); + assert_eq!( + serve(0xc001_1029, MsrAccess::Read), + MsrOutcome::Read(1 << 1) + ); + assert_eq!( + serve(0xc001_0015, MsrAccess::Write(1 << 24)), + MsrOutcome::Written + ); + assert_eq!( + serve(0xc001_0015, MsrAccess::Write(1 << 24 | 1 << 30)), + MsrOutcome::Fault + ); + assert_eq!(serve(0xc001_1029, MsrAccess::Write(0)), MsrOutcome::Fault); + assert_eq!(serve(MSR_VP_INDEX, MsrAccess::Read), MsrOutcome::Read(2)); + // An Intel profile does not route them, so an intercept faults. + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, 0xc001_0015, MsrAccess::Read), + MsrOutcome::Fault + ); + } + + #[test] + fn identity_msr_reads_follow_the_abi_table() { + let msrs = declared_msrs(); + let read = |vp, msr| serve_identity_msr(&msrs, VpIndex::new(vp), msr, MsrAccess::Read); + assert_eq!(read(3, MSR_VP_INDEX), MsrOutcome::Read(3)); + assert_eq!(read(0, MSR_TSC_FREQUENCY), MsrOutcome::Read(2_194_844_582)); + assert_eq!(read(5, MSR_APIC_FREQUENCY), MsrOutcome::Read(200_000_000)); + assert_eq!(read(1, MSR_TSC_INVARIANT_CONTROL), MsrOutcome::Read(0)); + } + + #[test] + fn frequency_reads_fault_before_the_rates_are_declared() { + let msrs = TimeAbiMsrs::new(); + for msr in [MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + } + assert_eq!( + serve_identity_msr(&msrs, VpIndex::new(2), MSR_VP_INDEX, MsrAccess::Read), + MsrOutcome::Read(2) + ); + } + + #[test] + fn tsc_invariant_control_accepts_only_zero_and_one() { + let msrs = declared_msrs(); + let vp = VpIndex::new(1); + let read = || serve_identity_msr(&msrs, vp, MSR_TSC_INVARIANT_CONTROL, MsrAccess::Read); + let write = |value| { + serve_identity_msr( + &msrs, + vp, + MSR_TSC_INVARIANT_CONTROL, + MsrAccess::Write(value), + ) + }; + for value in [1, 0, 1] { + assert_eq!(write(value), MsrOutcome::Written); + assert_eq!(read(), MsrOutcome::Read(value)); + } + for value in [2, 3, (1 << 63) | 1, u64::MAX] { + assert_eq!(write(value), MsrOutcome::Fault); + assert_eq!(read(), MsrOutcome::Read(1)); + } + } + + #[test] + fn read_only_and_undefined_identity_msrs_fault() { + let msrs = declared_msrs(); + for msr in [MSR_VP_INDEX, MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(0)), + MsrOutcome::Fault + ); + } + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_01ff, + ] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(1)), + MsrOutcome::Fault + ); + } + } + + #[test] + fn msrs_outside_the_identity_range_fault() { + let msrs = declared_msrs(); + for msr in [0x10, 0x3fff_ffff, 0x4000_0200] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + } + } + + #[test] + fn routed_msrs_are_the_served_identity_msrs() { + assert_eq!( + ROUTED_MSRS, + [0x4000_0002, 0x4000_0022, 0x4000_0023, 0x4000_0118] + ); + for msr in ROUTED_MSRS { + let intercept = msr_index_intercept(msr); + assert_eq!( + intercept.access_type_mask, + HV_INTERCEPT_ACCESS_MASK_READ_WRITE + ); + assert_eq!( + intercept.intercept_type, + mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX + ); + // SAFETY: `msr_index_intercept` writes the union through + // `as_uint64`, so all eight bytes are initialized. + let parameter = unsafe { intercept.intercept_parameter.as_uint64 }; + assert_eq!(parameter, u64::from(msr)); + } + } + + #[test] + fn gp_fault_event_raises_gp_with_error_code_zero() { + let event = hvdef::HvX64PendingExceptionEvent::from(gp_fault_event()); + assert!(event.event_pending()); + assert_eq!(event.event_type(), hvdef::HV_X64_PENDING_EVENT_EXCEPTION); + assert_eq!(event.vector(), 13); + assert!(event.deliver_error_code()); + assert_eq!(event.error_code(), 0); + } + + #[test] + fn partition_cpuid_is_the_time_abi_cpuid_verbatim() { + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(&config); + let leaves = |set: &CpuidLeafSet| -> Vec<_> { + set.leaves() + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect() + }; + assert_eq!(leaves(&cpuid), leaves(&config)); + + let result = |function| cpuid.result(function, 0, &[0; 4]); + assert_eq!( + result(0x4000_0000), + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074] + ); + assert_eq!(result(0x4000_0001), [0x3123_7648, 0, 0, 0]); + assert_eq!(result(0x4000_0002), [0x0058_564e, 0x0001_0000, 0, 0]); + assert_eq!(result(0x4000_0003), [0x8860, 0, 0, 0x100]); + assert_eq!(result(0x4000_0004), [0, 0xffff_ffff, 0, 0]); + assert_eq!(result(0x4000_0005), [VP_COUNT, VP_COUNT, 0, 0]); + assert_eq!(result(0x4000_0081), [0; 4]); + let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, VP_COUNT).unwrap(); + + // The time bits are the configured ones, and the bits the table does + // not mask are left to the hypervisor. + let [_, _, ecx, _] = cpuid.result(1, 0, &[!0; 4]); + assert_eq!(ecx & (1 << 31), 1 << 31, "hypervisor present"); + assert_eq!(ecx & (1 << 24), 0, "TSC-deadline timer"); + assert_eq!(ecx & (1 << 15), 0, "PDCM"); + assert_eq!(cpuid.result(6, 0, &[!0; 4]), [4, 0, 0, 0]); + assert_eq!(cpuid.result(0x15, 0, &[!0; 4]), [0; 4]); + assert_eq!(cpuid.result(0x8000_0007, 0, &[!0; 4]), [0, 0, 0, 0x100]); + } + + #[test] + fn per_vp_topology_leaves_take_each_vp_apic_identity() { + for function in [0xb, 0x1f, 0x8000_001e] { + assert!(is_per_vp_leaf(function), "{function:#x}"); + } + // Leaf 1's initial APIC ID is the hypervisor's own per-VP value. + for function in [0, 1, 4, 0xd, 0x4000_0000, 0x8000_001d] { + assert!(!is_per_vp_leaf(function), "{function:#x}"); + } + let level = CpuidLeaf::new(0xb, [1, 2, 0x100, 0]) + .indexed(0) + .masked([!0, !0, !0, 0]); + let leaf = with_vp_identity(&level, 5, 8); + assert_eq!( + (leaf.function, leaf.index, leaf.result, leaf.mask), + (0xb, Some(0), [1, 2, 0x100, 5], [!0; 4]) + ); + + // AMD's processor topology, as the partition table holds it: the + // threads per compute unit and the nodes per processor, with the + // per-VP IDs unmasked. With SMT, APIC IDs 2 and 3 share compute unit + // 1; APIC ID 13 is compute unit 2 of socket 1's node. + let amd = + CpuidLeaf::new(0x8000_001e, [0, 0x100, 0, 0]).masked([0, 0xffff_ff00, 0xffff_ff00, !0]); + for (apic_id, expected) in [ + (0, [0, 0x100, 0, 0]), + (3, [3, 0x101, 0, 0]), + (13, [13, 0x102, 1, 0]), + ] { + let leaf = with_vp_identity(&amd, apic_id, 8); + assert_eq!(leaf.result, expected, "APIC ID {apic_id}"); + assert_eq!(leaf.mask, [!0; 4], "APIC ID {apic_id}"); + } + } + + #[test] + fn the_effective_cpuid_reads_the_host_entries_outside_the_profile() { + let profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + let entry = cpu_profile::cpuid::CpuidEntry::new; + let mut host: Vec<_> = profile + .cpuid() + .iter() + .map(|listed| { + let (leaf, subleaf) = listed.key(); + entry(leaf, subleaf, listed.values()) + }) + .collect(); + // A cache past the profile's terminator and leaves past its maxima. + host.push(entry(4, Some(5), [0x121, 0, 0, 0])); + host.push(entry(0x17, None, [1, 0, 0, 0])); + host.push(entry(0x8000_0009, None, [1, 0, 0, 0])); + // The VM's topology and the identity range are not candidates. + host.push(entry(0xb, Some(2), [0, 0, 2, 0])); + host.push(entry(0x4000_0000, None, [0x4000_000b, 0, 0, 0])); + host.sort_by_key(cpu_profile::cpuid::CpuidEntry::key); + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: PartitionProfile::pinned(profile.id()).unwrap(), + }; + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(&config.cpuid), + super::super::profile_features::legacy_features(), + HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, host)), + 0, + ); + assert_eq!( + state.host_cpuid().unlisted_candidates, + [(4, Some(5)), (0x17, None), (0x8000_0009, None)] + ); + } + + #[test] + fn the_host_cpuid_is_read_in_the_background() { + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: PartitionProfile::pinned("intel.skylake-sp.v1").unwrap(), + }; + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(&config.cpuid), + super::super::profile_features::legacy_features(), + HostCpuidSource::spawn(&config.cpu_profile), + 0, + ); + let here = HostCpuid::read(&config.cpu_profile); + // The reading thread may run on another CPU, whose per-CPU fields + // (the APIC IDs) differ, so compare the entries it enumerates. The + // candidates exclude the topology leaves, so they are the same. + let keys = |table: &[cpu_profile::cpuid::CpuidEntry]| { + table + .iter() + .map(cpu_profile::cpuid::CpuidEntry::key) + .collect::>() + }; + let host = state.host_cpuid(); + assert!(host.read_us.is_some(), "read on another thread"); + assert_eq!(keys(&host.table), keys(&here.table)); + assert_eq!(host.unlisted_candidates, here.unlisted_candidates); + } + + #[test] + fn a_result_is_presented_when_the_view_agrees_under_its_mask() { + let leaf = CpuidLeaf::new(6, [4, 0, 0, 0]).masked([!0, !0, 1, 0]); + assert!(presents(&leaf, [4, 0, 0, 0])); + // Bits outside the mask are the hypervisor's own. + assert!(presents(&leaf, [4, 0, 2, 0x55])); + assert!(!presents(&leaf, [0, 0, 0, 0])); + assert!(!presents(&leaf, [4, 0, 1, 0])); + } + + #[test] + fn time_abi_bank1_keeps_invariant_tsc_and_hides_the_other_time_features() { + let supported = super::super::supported_processor_features1(); + assert!(supported.tsc_deadline_tmr_support()); + assert!(supported.tsc_adjust_support()); + assert!(supported.a_count_m_count_support()); + let features = processor_features1(supported); + assert!(features.tsc_invariant_support()); + assert!(!features.tsc_deadline_tmr_support()); + assert!(!features.tsc_adjust_support()); + assert!(!features.a_count_m_count_support()); + // Nothing else changes. + let time_bits = hvdef::HvX64PartitionProcessorFeatures1::new() + .with_tsc_invariant_support(true) + .with_tsc_deadline_tmr_support(true) + .with_tsc_adjust_support(true) + .with_a_count_m_count_support(true); + let others = !u64::from(time_bits); + assert_eq!(u64::from(features) & others, u64::from(supported) & others); + + let features = HvFeatures { + banks: [0x1234, 0x5678], + xsave: 0x3f, + }; + let args = with_features( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + features, + ); + let banks = args.pt_cpu_fbanks; + let disabled_xsave = args.pt_disabled_xsave; + assert_eq!(banks, [!0x1234, !0x5678]); + assert_eq!(disabled_xsave, !0x3f); + } + + #[test] + fn time_abi_rejects_isolation_and_the_hyperv_interface() { + validate_partition(virt::IsolationType::None, false).unwrap(); + for (isolation, hv) in [ + (virt::IsolationType::Snp, false), + (virt::IsolationType::None, true), + ] { + assert_eq!( + validate_partition(isolation, hv).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + } + + #[test] + fn created_vps_skip_an_uncreated_suffix() { + let vps = |created: &[bool]| -> Vec { + created_vps(created.iter().copied()) + .into_iter() + .map(|vp| vp.index()) + .collect() + }; + assert_eq!(vps(&[true]), [0]); + assert_eq!( + vps(&[true, true, true, true, false, false, false, false]), + [0, 1, 2, 3] + ); + assert_eq!(vps(&[false, false]), Vec::::new()); + } + + #[test] + fn readback_must_equal_the_target_on_every_vp() { + let target = 0x1234_5678_9abc; + let readback: Vec<_> = (0..4).map(|vp| (VpIndex::new(vp), target)).collect(); + check_readback(target, &readback).unwrap(); + check_readback(target, &[]).unwrap(); + + let mut skewed = readback.clone(); + skewed[2].1 += 1; + let error = check_readback(target, &skewed).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 2"), "{error}"); + } + + #[test] + fn late_vp_creation_fails_after_the_synchronized_set() { + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: PartitionProfile::pinned("intel.skylake-sp.v1").unwrap(), + }; + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(&config.cpuid), + super::super::profile_features::legacy_features(), + HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, Vec::new())), + 0, + ); + state.check_vp_creation(VpIndex::new(4)).unwrap(); + state.vp_set_sealed.store(true, Ordering::SeqCst); + let error = state.check_vp_creation(VpIndex::new(4)).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::VpLateCreation); + assert_eq!( + error.to_string(), + "[E_VP_LATE_CREATION] VP 4 was instantiated after the synchronized TSC set" + ); + } + + fn sample(utc_ns: u64, monotonic_ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns, + monotonic_ns, + } + } + + #[test] + fn capture_anchor_pairs_with_the_bracket_midpoint() { + let candidate = AnchorCandidate { + tsc: 42, + before: sample(1_000_000, 500), + after: sample(1_003_001, 3_501), + }; + assert_eq!(candidate.width_ns(), 3_001); + assert_eq!( + candidate.anchor(), + TscAnchor { + tsc: 42, + sample: sample(1_001_500, 2_000), + pairing_ns: 1_501, + } + ); + } + + #[test] + fn capture_anchor_ignores_a_utc_step_inside_the_bracket() { + let candidate = AnchorCandidate { + tsc: 7, + before: sample(5_000_000, 100), + after: sample(1_000, 2_100), + }; + assert_eq!(candidate.anchor().sample, sample(5_001_000, 1_100)); + } + + #[test] + fn capture_anchor_selects_the_tightest_bracket_within_the_bound() { + let candidate = |tsc, width| AnchorCandidate { + tsc, + before: sample(0, 0), + after: sample(width, width), + }; + let anchor = select_anchor(&[ + candidate(1, 30_000), + candidate(2, 8_000), + candidate(3, 12_000), + ]) + .unwrap(); + assert_eq!((anchor.tsc, anchor.pairing_ns), (2, 4_000)); + + // The bound applies to the pairing: half the bracket. + let widest = 2 * MAX_ANCHOR_PAIRING_NS; + assert_eq!( + select_anchor(&[candidate(4, widest)]).unwrap().pairing_ns, + MAX_ANCHOR_PAIRING_NS + ); + let error = + select_anchor(&[candidate(5, widest + 1), candidate(6, 5 * widest)]).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert_eq!(select_anchor(&[]).unwrap_err().code, TimeAbiCode::TscAnchor); + } + + #[test] + fn error_chain_includes_sources() { + let error = KernelError::Hypercall { + code: hvdef::HypercallCode::HvCallInstallIntercept, + error: hvdef::HvError::AccessDenied, + }; + let text = error_chain(&error); + assert!(text.starts_with("hypercall"), "{text}"); + assert!(text.contains(": "), "{text}"); + } +} + +/// Hardware tests. Run them on an MSHV root partition with +/// `cargo test -p virt_mshv -- --ignored --nocapture --test-threads=1 time_abi::hw`. +#[cfg(test)] +mod hw { + use super::*; + use crate::LinuxMshv; + use guestmem::GuestMemory; + use hvdef::Vtl; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virt::BindProcessor; + use virt::Hypervisor; + use virt::Partition; + use virt::PartitionConfig; + use virt::PartitionMemoryMapper; + use virt::ProtoPartition; + use virt::ProtoPartitionConfig; + use virt::time_abi::identity::check_identity; + use virt::time_abi::identity::check_time_bits; + use virt::time_abi::rate::LAPIC_HZ_HYPERV; + use virt::time_abi::rate::check_plausible_tsc_hz; + use vm_topology::memory::MemoryLayout; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use vmcore::vmtime::VmTime; + use vmcore::vmtime::VmTimeKeeper; + + const VP_CAPACITY: u32 = 4; + + /// Returns the pinned CPU profile of this host's generation. + fn host_profile() -> PartitionProfile { + let selected = cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()).unwrap(); + PartitionProfile::pinned(selected.id()).unwrap() + } + + /// Returns the effective CPUID of this host's profile for `topology`, and + /// the CPUID table that core programs for it (`TimeAbiConfig::cpuid`). + fn host_profile_cpuid( + topology: &vm_topology::processor::ProcessorTopology, + ) -> (cpu_profile::EffectiveCpuid, CpuidLeafSet) { + let effective = virt::time_abi::cpuid::effective_cpuid(&host_profile(), topology).unwrap(); + let table = virt::time_abi::cpuid::backend_cpuid(&effective); + (effective, table) + } + /// Converts a backend's CPU surface to the profile crate's form, as core + /// does for the support check. + fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } + } + + fn percentile(sorted: &[u64], percent: usize) -> u64 { + sorted[(sorted.len() - 1) * percent / 100] + } + + /// Whether `effective` lists CPUID `function` at subleaf `index`. + fn is_listed(effective: &cpu_profile::EffectiveCpuid, function: u32, index: u32) -> bool { + effective + .results() + .any(|result| result.function == function && result.index.is_none_or(|i| i == index)) + } + + /// The CPUID entries that a sweep of VP 0 probes for `effective`. + /// + /// - Near, where guests enumerate: subleaves 0 to 63 of every indexed + /// leaf, and the four leaves past each maximum. + /// - Far, where no enumeration reaches, without the entries `effective` + /// lists: subleaves 64 to 255 of every indexed leaf, the leaves past the + /// near ones up to 0xff and 0x800000ff, the hypervisor range up to + /// 0x400001ff, and a few distant leaves. + fn sweep_probes(effective: &cpu_profile::EffectiveCpuid) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let near = indexed + .iter() + .flat_map(|&function| (0..64).map(move |index| (function, index))) + .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))) + .collect(); + let far = indexed + .iter() + .flat_map(|&function| (64..256).map(move |index| (function, index))) + .chain((max_basic + 5..=0xff).map(|function| (function, 0))) + .chain((max_extended + 5..=0x8000_00ff).map(|function| (function, 0))) + .chain((0x4000_0000..=0x4000_01ff).map(|function| (function, 0))) + .chain( + [ + 0x100, + 0x1000, + 0x2000_0000, + 0x3fff_ffff, + 0x8000_0100, + 0x8fff_ffff, + 0xc000_0000, + 0xc000_0001, + 0xffff_ffff, + ] + .map(|function| (function, 0)), + ) + .filter(|&(function, index)| !is_listed(effective, function, index)) + .collect(); + (near, far) + } + + /// Builds a time ABI partition, binds the first `created` VPs, and runs + /// every backend primitive against the hypervisor. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn time_abi_backend_on_hardware(driver: DefaultDriver) { + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(VP_CAPACITY) + .unwrap(); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + + for created in [1, 2, VP_CAPACITY] { + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let started = std::time::Instant::now(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(host_profile_cpuid(&processor_topology).1), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: host_profile(), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let build_us = started.elapsed().as_micros(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + for binder in &mut binders[..created as usize] { + binder.bind().unwrap(); + } + + assert!(!partition.caps().hv1); + let backend = partition.time_abi().expect("a time ABI partition"); + + let started = std::time::Instant::now(); + let preflight = backend.preflight().unwrap(); + let preflight_us = started.elapsed().as_micros(); + assert_eq!( + preflight, + BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + } + ); + // Preflight leaves partition time frozen, as a reset does, until + // the synchronized TSC set or the first VP run resumes it. + assert!(*partition.inner.time_frozen.lock()); + partition.inner.thaw_time().unwrap(); + let tsc_hz = backend.native_tsc_hz().unwrap(); + check_plausible_tsc_hz(tsc_hz).unwrap(); + assert_eq!(backend.lapic_hz().unwrap(), LAPIC_HZ_HYPERV); + + // The supported surface is cheap and supports this host's profile. + let started = std::time::Instant::now(); + let surface = backend + .supported_cpu_surface() + .unwrap() + .expect("MSHV reports its CPU surface"); + let surface_us = started.elapsed().as_micros(); + let selected = host_profile(); + let profile = &*selected; + cpu_profile::verify_support(profile, &host_cpu_surface(&surface)).unwrap(); + + let started = std::time::Instant::now(); + let effective = CpuidLeafSet::new(backend.effective_cpuid().unwrap()); + let effective_us = started.elapsed().as_micros(); + let mut lookup = |leaf, subleaf| effective.result(leaf, subleaf, &[0; 4]); + check_time_bits(&mut lookup, true).unwrap(); + check_identity(&mut lookup, VP_CAPACITY).unwrap(); + + // The report holds VP 0's view at every entry the host's CPUID has + // outside the profile's tables, and each reads zero. + let candidates = cpu_profile::unlisted_cpuid_candidates( + profile, + &super::super::profile_features::host_cpuid_table(), + ); + for &(function, index) in &candidates { + assert!( + effective + .leaves() + .iter() + .any(|leaf| leaf.function == function && leaf.index == index), + "{function:#x} {index:?} is missing from the effective CPUID" + ); + } + let entries: Vec<_> = effective + .leaves() + .iter() + .map(|leaf| { + cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result) + }) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap(); + println!( + "time ABI backend: {} unlisted candidates read zero: {candidates:x?}", + candidates.len() + ); + + // The hypervisor folds VP state into CPUID reads, so the effective + // CPUID must stay VP 0's view at reset, whatever the guest does. + let bsp = &partition.inner.finalized().unwrap().bsp_vcpufd; + let live_before = bsp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let mut cr4 = [HvRegisterAssoc::from((HvX64RegisterName::Cr4, 0u64))]; + bsp.get_hvdef_regs(&mut cr4).unwrap(); + let reset_cr4 = cr4[0].value.as_u64(); + let osxsave = bsp.set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::Cr4, + reset_cr4 | (1 << 18), + ))]); + let live_after = bsp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let key = |leaves: &[CpuidLeaf]| -> Vec<(u32, Option, [u32; 4])> { + leaves + .iter() + .map(|l| (l.function, l.index, l.result)) + .collect() + }; + assert_eq!( + key(&backend.effective_cpuid().unwrap()), + key(effective.leaves()) + ); + bsp.set_hvdef_regs(&[HvRegisterAssoc::from((HvX64RegisterName::Cr4, reset_cr4))]) + .unwrap(); + let osxsave = format!( + "cr4_osxsave_write={} live_cpuid1_ecx={:#x}->{:#x}", + osxsave.is_ok(), + live_before[2], + live_after[2] + ); + + let mut pairings: Vec = (0..200) + .map(|_| backend.capture_anchor().unwrap().pairing_ns) + .collect(); + pairings.sort_unstable(); + let first = backend.capture_anchor().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(200)); + let second = backend.capture_anchor().unwrap(); + let elapsed_ns = second.sample.monotonic_ns - first.sample.monotonic_ns; + let measured_hz = + u128::from(second.tsc - first.tsc) * 1_000_000_000 / u128::from(elapsed_ns); + let deviation_ppm = (measured_hz as f64 - tsc_hz as f64) / tsc_hz as f64 * 1_000_000.0; + assert!( + deviation_ppm.abs() < 200.0, + "anchors measure {measured_hz} Hz against {tsc_hz} Hz" + ); + + let target = 1_000_000_000_000; + let mut anchor = None; + let started = std::time::Instant::now(); + let report = backend + .set_synchronized_tsc(&mut |sample| { + anchor = Some(*sample); + Ok(target) + }) + .unwrap(); + let set_us = started.elapsed().as_micros(); + assert_eq!(report.target, target); + assert_eq!(Some(report.sample), anchor); + assert_eq!(report.method, TscSyncMethod::FrozenWrite); + let expected: Vec<_> = (0..created).map(|vp| (VpIndex::new(vp), target)).collect(); + assert_eq!(report.readback, expected); + + // Time resumes right after the read-back, and every VP advances + // from the target together. + assert!(!*partition.inner.time_frozen.lock()); + std::thread::sleep(std::time::Duration::from_millis(10)); + let running: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + assert!( + running.iter().all(|&tsc| tsc > target + tsc_hz / 200), + "{running:?}" + ); + let spread = running.iter().max().unwrap() - running.iter().min().unwrap(); + assert!(spread < tsc_hz / 1000, "{running:?}"); + if created < VP_CAPACITY { + let error = binders[created as usize].bind().err().unwrap(); + assert!( + error.to_string().starts_with("[E_VP_LATE_CREATION]"), + "{error}" + ); + } + + // A failing target aborts the set and leaves time frozen. + let error = backend + .set_synchronized_tsc(&mut |_| { + Err(TimeAbiError::new(TimeAbiCode::DowntimeNegative, "test")) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::DowntimeNegative); + assert!(*partition.inner.time_frozen.lock()); + let frozen_at: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(10)); + for (vp, &tsc) in frozen_at.iter().enumerate() { + assert_eq!( + get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp as u32)).unwrap(), + tsc + ); + } + + // A later thaw (the first VP run) resumes every VP together. + partition.inner.thaw_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let after: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + assert!(after.iter().all(|&tsc| tsc > running[0] + tsc_hz / 200)); + + println!( + "time ABI backend: created {created}/{VP_CAPACITY} tsc_hz={tsc_hz} \ + build_us={build_us} preflight_us={preflight_us} effective_cpuid_us={effective_us} \ + ({} leaves) surface_us={surface_us} ({} leaves, {}-bit) set_us={set_us} \ + anchor_pairing_ns min={} p50={} p99={} max={} \ + anchor_rate_deviation_ppm={deviation_ppm:.3} {osxsave} thawed={after:?}", + effective.leaves().len(), + surface.cpuid.len(), + surface.physical_address_width, + pairings[0], + percentile(&pairings, 50), + percentile(&pairings, 99), + pairings[pairings.len() - 1], + ); + } + } + + /// Sweeps, as VP 0 of a time ABI partition observes them, subleaves 0 to + /// 63 of every indexed leaf of the effective CPUID and the four leaves + /// past the maximum basic and extended leaves. Listed results must match + /// under their masks. A leaf or subleaf that the table does not list must + /// read zero, which is the hypervisor's own guest view there (the backend + /// registers no result for it), except past the extended topology leaves' + /// terminators: those are reserved, and the sweep only reports them. One + /// rep hypercall must read every probe as the single reads do, and the + /// test times both ways. Farther out, where no guest enumeration reaches + /// (subleaves 64 to 255, leaves up to 0xff and 0x800000ff, the hypervisor + /// range up to 0x400001ff, and a few distant leaves), every unlisted entry + /// must read zero as well (see `sweep_probes`). Last, a non-zero value + /// planted at a reserved entry that the host enumerates must reach the + /// backend's effective CPUID report and fail core's `E_CPU_UNLISTED` + /// check. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(2) + .unwrap(); + let (effective, table) = host_profile_cpuid(&processor_topology); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(table), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: host_profile(), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + binders[0].bind().unwrap(); + let bsp = &partition.inner.finalized().unwrap().bsp_vcpufd; + + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let (probes, far) = sweep_probes(&effective); + let (mut count, mut listed_failures, mut unlisted, mut reserved) = + (0, Vec::new(), Vec::new(), Vec::new()); + let mut single = Vec::with_capacity(probes.len()); + for &(function, index) in &probes { + count += 1; + let listed = effective.results().find(|result| { + result.function == function && result.index.is_none_or(|i| i == index) + }); + let (expected, mask) = + listed.map_or(([0; 4], [!0; 4]), |result| (result.result, result.mask)); + let actual = bsp.get_cpuid_values(function, index, 0, 0).unwrap(); + single.push(actual); + if (0..4).all(|register| (actual[register] ^ expected[register]) & mask[register] == 0) + { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if listed.is_some() { + listed_failures.push(format!("{line} expected {expected:08x?} mask {mask:08x?}")); + } else if is_per_vp_leaf(function) { + reserved.push(line); + } else { + unlisted.push(line); + } + } + println!( + "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} listed results differ, {} unlisted results are not zero, {} reserved topology entries are not zero", + host_profile().id(), + indexed.len(), + listed_failures.len(), + unlisted.len(), + reserved.len() + ); + for line in listed_failures.iter().chain(&unlisted).chain(&reserved) { + println!(" {line}"); + } + assert!(listed_failures.is_empty(), "{listed_failures:#?}"); + assert!(unlisted.is_empty(), "{unlisted:#?}"); + + // One rep hypercall reads the same values, registered results + // included, over several calls when the probes exceed one call. + assert_eq!( + vp_cpuid_many(bsp, 0, &probes).unwrap(), + single, + "bulk and single CPUID reads differ" + ); + // Time both ways over the entries that the effective CPUID report + // reads, as `time_abi_effective_cpuid` does, and the bulk read with + // the host's unlisted candidates as well. + let report: Vec<(u32, u32)> = effective + .results() + .map(|result| (result.function, result.index.unwrap_or(0))) + .collect(); + let profile = host_profile(); + let candidates = cpu_profile::unlisted_cpuid_candidates( + &profile, + &super::super::profile_features::host_cpuid_table(), + ); + let with_candidates: Vec<(u32, u32)> = report + .iter() + .copied() + .chain( + candidates + .iter() + .map(|&(function, index)| (function, index.unwrap_or(0))), + ) + .collect(); + let (mut single_us, mut bulk_us, mut candidates_us) = (Vec::new(), Vec::new(), Vec::new()); + for _ in 0..11 { + let started = std::time::Instant::now(); + for &(function, index) in &report { + bsp.get_cpuid_values(function, index, 0, 0).unwrap(); + } + single_us.push(started.elapsed().as_micros() as u64); + let started = std::time::Instant::now(); + vp_cpuid_many(bsp, 0, &report).unwrap(); + bulk_us.push(started.elapsed().as_micros() as u64); + let started = std::time::Instant::now(); + vp_cpuid_many(bsp, 0, &with_candidates).unwrap(); + candidates_us.push(started.elapsed().as_micros() as u64); + } + single_us.sort_unstable(); + bulk_us.sort_unstable(); + candidates_us.sort_unstable(); + println!( + "bulk CPUID read: {} probes match single reads; {} entries in {} us one per call, {} us in one call, {} us with the {} unlisted candidates (p50 of 11)", + probes.len(), + report.len(), + single_us[5], + bulk_us[5], + candidates_us[5], + candidates.len() + ); + + // Farther out, every entry the effective CPUID does not list must read + // zero too (see `sweep_probes`). + let values = vp_cpuid_many(bsp, 0, &far).unwrap(); + let (mut far_unlisted, mut far_reserved) = (Vec::new(), Vec::new()); + for (&(function, index), actual) in far.iter().zip(&values) { + if *actual == [0; 4] { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if is_per_vp_leaf(function) { + far_reserved.push(line); + } else { + far_unlisted.push(line); + } + } + println!( + "{} far probes: {} unlisted results are not zero, {} reserved topology entries are not zero", + far.len(), + far_unlisted.len(), + far_reserved.len() + ); + for line in far_unlisted.iter().chain(&far_reserved) { + println!(" {line}"); + } + assert!(far_unlisted.is_empty(), "{far_unlisted:#?}"); + + // The negative path of verification step 6. A non-zero value at a + // reserved entry that the host enumerates, planted here as a + // registered result, must reach the backend's effective CPUID report, + // which core then rejects with E_CPU_UNLISTED: the report reads the + // hypervisor's view at the candidates and never synthesizes zero. + // Nothing has read the report before, so its first read sees the plant. + let &(function, index) = candidates + .first() + .expect("the host enumerates reserved entries"); + let planted = CpuidLeaf::new(function, [0x5a5a_5a5a, 0, 0, 0]); + let planted = match index { + Some(index) => planted.indexed(index), + None => planted, + }; + super::super::register_cpuid_result(&partition.inner.vmfd, &planted).unwrap(); + let backend_report = partition.inner.time_abi_effective_cpuid().unwrap(); + assert!( + backend_report.iter().any(|leaf| leaf.function == function + && leaf.index == index + && leaf.result == planted.result), + "the report lacks the planted {function:#x} {index:?}" + ); + let entries: Vec<_> = backend_report + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + let error = cpu_profile::check_unlisted_cpuid(&profile, &entries).unwrap_err(); + assert_eq!( + error.code, + cpu_profile::ProfileErrorCode::CpuUnlisted, + "{error}" + ); + println!("planted {function:#x}.{index:?} = {planted:x?}: {error}"); + } + + /// Every VP of a time ABI partition reads its own APIC identity in the + /// per-VP topology leaves, which OpenVMM registers per VP because the + /// hypervisor reads it as 0 on every VP: the x2APIC ID in `EDX` of the + /// extended topology leaves, and, under an AMD profile, the extended + /// APIC, compute unit, and node IDs of `0x8000001E`; and the table's + /// other registers. Leaf 1's initial APIC ID is the hypervisor's own and + /// must be the VP's too. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn extended_topology_reports_each_vp_x2apic_id(driver: DefaultDriver) { + const VPS: u32 = 4; + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(VPS) + .unwrap(); + let (_, table) = host_profile_cpuid(&processor_topology); + let topology_leaves: Vec = table + .leaves() + .iter() + .filter(|leaf| is_per_vp_leaf(leaf.function)) + .copied() + .collect(); + assert!(!topology_leaves.is_empty()); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(table), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: host_profile(), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + for binder in &mut binders { + binder.bind().unwrap(); + } + let finalized = partition.inner.finalized().unwrap(); + let mut checked = 0; + for (vp, binder) in processor_topology.vps_arch().zip(&binders) { + let vcpufd = if vp.base.vp_index.is_bsp() { + &finalized.bsp_vcpufd + } else { + binder.vcpufd.as_ref().unwrap() + }; + for leaf in &topology_leaves { + let index = leaf.index.unwrap_or(0); + let actual = vcpufd.get_cpuid_values(leaf.function, index, 0, 0).unwrap(); + let expected = with_vp_identity( + leaf, + vp.apic_id, + processor_topology.reserved_vps_per_socket(), + ); + assert!( + (0..4).all(|r| (actual[r] ^ expected.result[r]) & expected.mask[r] == 0), + "VP {} CPUID {:#x}.{index}: {actual:08x?}, expected {:08x?}", + vp.base.vp_index.index(), + leaf.function, + expected.result + ); + checked += 1; + } + let [_, ebx, _, _] = vcpufd.get_cpuid_values(1, 0, 0, 0).unwrap(); + assert_eq!( + ebx >> 24, + vp.apic_id & 0xff, + "VP {} initial APIC ID", + vp.base.vp_index.index() + ); + } + println!( + "{VPS} VPs with x2APIC IDs {:?}: {checked} extended topology entries carry each VP's ID, and leaf 1 its initial APIC ID", + processor_topology + .vps_arch() + .map(|vp| vp.apic_id) + .collect::>() + ); + } + + /// The negative path of identity routing: the hypervisor refuses an + /// MSR-index intercept for `IA32_ARCH_CAPABILITIES` (AccessDenied), which + /// must surface as `E_IDENTITY_ROUTING` naming the MSR. + #[test] + #[ignore = "requires /dev/mshv"] + fn refused_msr_intercept_fails_identity_routing() { + let mshv = mshv_ioctls::Mshv::new().unwrap(); + let features = partition_features( + &host_profile(), + super::super::profile_features::host_features(&mshv).unwrap(), + ) + .unwrap(); + let args = with_features( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + features, + ); + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + route_identity_msrs(&vmfd, CpuVendor::Intel).unwrap(); + let error = install_msr_intercepts(&vmfd, &[0x10a]).unwrap_err(); + println!("refused intercept: {error}"); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + assert!(error.message.contains("MSR 0x10a"), "{error}"); + } + + /// Reports whether the processor feature banks can expose `BHI_CTRL` + /// (CPUID.(EAX=7,ECX=2):EDX[4]) to a guest: Linux treats a guest with it + /// as not vulnerable to indirect target selection. + #[test] + #[ignore = "requires /dev/mshv"] + fn bhi_ctrl_exposure() { + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap(); + let line = |prefix: &str| { + cpuinfo + .lines() + .find(|line| line.starts_with(prefix)) + .unwrap_or_default() + }; + let has = |line: &str, token: &str| line.split_whitespace().any(|word| word == token); + println!( + "host: {}; root flags bhi_ctrl={} bugs its={}", + line("model name"), + has(line("flags"), "bhi_ctrl"), + has(line("bugs"), "its") + ); + let mshv = mshv_ioctls::Mshv::new().unwrap(); + match mshv.get_host_partition_property(HvPartitionPropertyCode::ProcessorFeatures1.0) { + Ok(bank1) => { + let bank1 = hvdef::HvX64PartitionProcessorFeatures1::from(bank1); + println!( + "host ProcessorFeatures1={:#018x}: bhi_dis_support={} bhi_no_support={}", + u64::from(bank1), + bank1.bhi_dis_support(), + bank1.bhi_no_support() + ); + } + Err(error) => println!("host ProcessorFeatures1 unavailable: {error}"), + } + let supported = super::super::supported_processor_features1(); + for (name, features1) in [ + ("default", supported), + ("time-abi", processor_features1(supported)), + ( + "bhi_dis", + processor_features1(supported).with_bhi_dis_support(true), + ), + ( + "bhi_dis+bhi_no", + processor_features1(supported) + .with_bhi_dis_support(true) + .with_bhi_no_support(true), + ), + ] { + let mut args = super::super::partition_create_args( + &virt::ProtoPartitionIsolation::None, + false, + false, + ) + .unwrap(); + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(features1); + args.pt_cpu_fbanks = banks; + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + let vp = vmfd.create_vcpu(0).unwrap(); + let leaf7 = vp.get_cpuid_values(7, 0, 0, 0).unwrap(); + let leaf7_2 = vp.get_cpuid_values(7, 2, 0, 0).unwrap(); + println!( + "banks={name}: CPUID.7.0={leaf7:#010x?} CPUID.7.2={leaf7_2:#010x?} \ + BHI_CTRL={}", + leaf7[0] >= 2 && leaf7_2[3] & (1 << 4) != 0 + ); + } + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/tsc.rs b/vmm_core/virt_mshv/src/x86_64/tsc.rs new file mode 100644 index 00000000000..c2c403eccee --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/tsc.rs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The TSC-deadline timer policy of MSHV partitions. +//! +//! MSHV does not reliably deliver TSC-deadline timer events to direct-boot +//! guests, so no partition exposes the timer. The time ABI applies its own +//! feature bank, which hides it too. + +/// Returns the processor features (bank 1) to expose: the supported features +/// without the TSC-deadline timer. +fn supported_features1() -> hvdef::HvX64PartitionProcessorFeatures1 { + super::supported_processor_features1().with_tsc_deadline_tmr_support(false) +} + +/// Applies [`supported_features1`] to the partition creation arguments, whose +/// banks hold the disabled features. +pub(super) fn with_features1( + mut args: mshv_bindings::mshv_create_partition_v2, +) -> mshv_bindings::mshv_create_partition_v2 { + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(supported_features1()); + args.pt_cpu_fbanks = banks; + args +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::x86_64::partition_create_args; + + #[test] + fn partitions_do_not_expose_the_tsc_deadline_timer() { + let processor_features1 = supported_features1(); + assert!(!processor_features1.tsc_deadline_tmr_support()); + assert!(processor_features1.tsc_adjust_support()); + let args = with_features1( + partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(), + ); + let pt_cpu_fbanks = args.pt_cpu_fbanks; + assert_eq!(pt_cpu_fbanks[1], !u64::from(processor_features1)); + } + + /// The feature bank alone hides the TSC-deadline timer in CPUID, so no + /// CPUID result needs to hide it as well. + #[test] + #[ignore = "requires /dev/mshv"] + fn the_feature_bank_hides_the_tsc_deadline_timer_in_cpuid() { + for (exposed, features1) in [ + (true, super::super::supported_processor_features1()), + (false, supported_features1()), + ] { + let mut args = + partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(); + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(features1); + args.pt_cpu_fbanks = banks; + let mshv = mshv_ioctls::Mshv::new().unwrap(); + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + let vp = vmfd.create_vcpu(0).unwrap(); + let [_, _, ecx, _] = vp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let tsc_deadline = ecx & (1 << 24) != 0; + println!("bank 1 tsc_deadline_tmr_support={exposed}: CPUID.1:ECX[24]={tsc_deadline}"); + if !exposed { + assert!(!tsc_deadline); + } + } + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/vm_state.rs b/vmm_core/virt_mshv/src/x86_64/vm_state.rs index a956e7cccbb..588c9e283f7 100644 --- a/vmm_core/virt_mshv/src/x86_64/vm_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vm_state.rs @@ -33,6 +33,7 @@ impl MshvPartition { }); self.inner + .finalized()? .bsp_vcpufd .get_hvdef_regs(&mut assoc[..]) .map_err(ErrorInner::Register)?; @@ -54,6 +55,7 @@ impl MshvPartition { regs.get_values(assoc.iter_mut().map(|assoc| &mut assoc.value)); self.inner + .finalized()? .bsp_vcpufd .set_hvdef_regs(&assoc[..]) .map_err(ErrorInner::Register)?; @@ -65,7 +67,7 @@ impl AccessVmState for &'_ MshvPartition { type Error = Error; fn caps(&self) -> &virt::PartitionCapabilities { - &self.inner.caps + self.inner.caps() } fn commit(&mut self) -> Result<(), Self::Error> { diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index 50c4527632d..769f7a7d73f 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -11,6 +11,7 @@ use mshv_bindings::MSHV_VP_STATE_SIEFP; use mshv_bindings::MSHV_VP_STATE_SIMP; use mshv_bindings::MSHV_VP_STATE_SYNTHETIC_TIMERS; use mshv_bindings::mshv_get_set_vp_state; +use mshv_ioctls::InterruptRequest; use std::ptr::NonNull; use std::sync::OnceLock; use virt::state::HvRegisterState; @@ -117,6 +118,61 @@ impl MshvProcessor<'_> { let hv_state: hvdef::HvX64InterruptControllerState = (*lapic).into(); self.set_state(mshv_bindings::MSHV_VP_STATE_LAPIC, hv_state.as_bytes()) } + + /// Re-asserts the highest pending edge-triggered vector of the VP's + /// current LAPIC state; see [`Self::assert_pending_interrupt`]. + pub(crate) fn reassert_pending_interrupt(&self) -> Result<(), Error> { + if self.partition.isolation.snp().is_some() { + return Ok(()); + } + let lapic = self.get_lapic()?; + self.assert_pending_interrupt(&lapic) + } + + /// Re-asserts the highest-priority pending edge-triggered vector of a + /// LAPIC state just written through the state page. + /// + /// The hypervisor doesn't re-evaluate a halted VP's interrupts when the + /// VMM writes its IRR through the LAPIC state page, as a snapshot restore + /// does, or the restore clock when it queues the vector of an expired + /// one-shot timer. Such a VP stays halted until some other interrupt + /// arrives, and so do the timers it owns: on a bare-metal host, 2 s after a + /// restore, VPs were still halted with the timer vector pending. Asserting + /// a vector that is already pending through the interrupt path is + /// idempotent, and wakes the VP when the vector is deliverable: the + /// hypervisor still applies the PPR and IF rules. + fn assert_pending_interrupt(&self, lapic: &ApicRegisters) -> Result<(), Error> { + let Some(vector) = highest_pending_edge_vector(lapic) else { + return Ok(()); + }; + self.partition + .vmfd + .request_virtual_interrupt(&InterruptRequest { + interrupt_type: hvdef::HvInterruptType::HvX64InterruptTypeFixed.0, + apic_id: self.inner.vp_info.apic_id.into(), + vector: vector.into(), + level_triggered: false, + logical_destination_mode: false, + long_mode: false, + }) + .map_err(|error| ErrorInner::AssertPendingInterrupt { + error: error.into(), + vector, + })?; + Ok(()) + } +} + +/// Returns the highest-priority vector pending in `lapic`'s IRR that is +/// edge-triggered (TMR clear). Vectors 0 to 15 are skipped: they aren't valid +/// fixed interrupts, and the Hyper-V state page reuses their IRR bits (for +/// example for the NMI pending bit). Level-triggered vectors are left to +/// their IOAPIC, whose EOI handling an edge assertion would bypass. +fn highest_pending_edge_vector(lapic: &ApicRegisters) -> Option { + (16..=255u8).rev().find(|&vector| { + let (word, bit) = (usize::from(vector / 32), vector % 32); + lapic.irr[word] >> bit & 1 == 1 && lapic.tmr[word] >> bit & 1 == 0 + }) } struct PageAlignedBuffer { @@ -181,7 +237,7 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { type Error = Error; fn caps(&self) -> &virt::PartitionCapabilities { - &self.partition.caps + self.partition.caps() } fn commit(&mut self) -> Result<(), Self::Error> { @@ -219,11 +275,11 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { fn xsave(&mut self) -> Result { let xsave = self.get_state( mshv_bindings::MSHV_VP_STATE_XSAVE, - self.partition.caps.xsave.compact_len as usize, + self.partition.caps().xsave.compact_len as usize, )?; Ok(vp::Xsave::from_compact( xsave.as_bytes(), - &self.partition.caps, + self.partition.caps(), )) } @@ -270,6 +326,12 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { let mut lapic = *value.registers(); lapic.set_hv_apic_nmi_pending(nmi_pending); self.set_lapic(&lapic)?; + if self.partition.isolation.snp().is_none() { + self.assert_pending_interrupt(&lapic)?; + self.inner + .lapic_written + .store(true, std::sync::atomic::Ordering::Release); + } Ok(()) } @@ -330,6 +392,14 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { self.set_register_state(value) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } @@ -414,3 +484,67 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { Err(ErrorInner::NotSupported.into()) } } + +#[cfg(test)] +mod tests { + use super::highest_pending_edge_vector; + use virt::vp::ApicRegisters; + use zerocopy::FromZeros; + + fn pending(irr: &[u8], level: &[u8]) -> ApicRegisters { + let mut lapic = ApicRegisters::new_zeroed(); + for &vector in irr { + lapic.irr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + for &vector in level { + lapic.tmr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + lapic + } + + #[test] + fn nothing_pending_asserts_nothing() { + assert_eq!(highest_pending_edge_vector(&pending(&[], &[])), None); + } + + #[test] + fn the_expired_timer_vector_is_asserted() { + // Linux's LOCAL_TIMER_VECTOR, as queued by the restore clock. + assert_eq!( + highest_pending_edge_vector(&pending(&[0xec], &[])), + Some(0xec) + ); + } + + #[test] + fn the_highest_edge_vector_wins() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x30, 0xec, 0x41], &[])), + Some(0xec) + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0xff, 0x10], &[])), + Some(0xff) + ); + } + + #[test] + fn level_triggered_vectors_are_left_to_their_ioapic() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41], &[0x41])), + None + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41, 0x30], &[0x41])), + Some(0x30) + ); + } + + #[test] + fn reserved_vectors_and_the_nmi_pending_bit_are_ignored() { + let mut lapic = pending(&[], &[]); + lapic.set_hv_apic_nmi_pending(true); + lapic.irr[0] |= 0xffff; + assert_eq!(highest_pending_edge_vector(&lapic), None); + } +} diff --git a/vmm_core/virt_support_apic/src/lib.rs b/vmm_core/virt_support_apic/src/lib.rs index 3d593c50939..77e7b48e50d 100644 --- a/vmm_core/virt_support_apic/src/lib.rs +++ b/vmm_core/virt_support_apic/src/lib.rs @@ -43,7 +43,8 @@ use x86defs::msi::MsiAddress; use x86defs::msi::MsiData; const NANOS_PER_TICK: u64 = 5; // 200Mhz -const TIMER_FREQUENCY: u64 = 1_000_000_000 / NANOS_PER_TICK; +/// The emulated LAPIC timer bus frequency in Hz, before its clock divider. +pub const TIMER_FREQUENCY: u64 = 1_000_000_000 / NANOS_PER_TICK; const APIC_VERSION: u32 = 0x50014; const ICR_LOW_MASK: Icr = Icr::new() diff --git a/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs b/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs index 51004c6af8f..09c29beb184 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs @@ -240,7 +240,7 @@ fn validate_vtl_access_event( hvdef::HV_X64_PENDING_EVENT_MEMORY_INTERCEPT ); - assert_eq!(event.target_vtl, expected_vtl.into()); + assert_eq!(event.target_vtl, u8::from(expected_vtl)); if check_execute { assert_eq!(event.access_type, hvdef::HvInterceptAccessType::EXECUTE); diff --git a/vmm_core/virt_support_x86emu/tests/tests/common.rs b/vmm_core/virt_support_x86emu/tests/tests/common.rs index 47925767030..3b0fc919974 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/common.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/common.rs @@ -97,7 +97,7 @@ pub fn validate_gpf_event(pending_event: hvdef::HvX64PendingEvent) { assert_eq!( event.vector(), - x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into() + u16::from(x86defs::Exception::GENERAL_PROTECTION_FAULT.0) ); assert!(event.deliver_error_code()); diff --git a/vmm_core/virt_support_x86emu/tests/tests/translate.rs b/vmm_core/virt_support_x86emu/tests/tests/translate.rs index 850cae4b38a..d212929c761 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/translate.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/translate.rs @@ -433,7 +433,7 @@ async fn translate_gva_page_faults() { assert_eq!( injected_event.vector(), - x86defs::Exception::PAGE_FAULT.0.into(), + u16::from(x86defs::Exception::PAGE_FAULT.0), "expected page fault vector type for {:?}", c ); diff --git a/vmm_core/virt_whp/Cargo.toml b/vmm_core/virt_whp/Cargo.toml index f110a5f90f0..f125b564f97 100644 --- a/vmm_core/virt_whp/Cargo.toml +++ b/vmm_core/virt_whp/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [target.'cfg(windows)'.dependencies] chipset_device.workspace = true +cpu_profile.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true hv1_structs.workspace = true @@ -51,5 +52,8 @@ x86emu.workspace = true [build-dependencies] build_rs_guest_arch.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vmm_core/virt_whp/src/apic.rs b/vmm_core/virt_whp/src/apic.rs index 60fcb73aeb5..b217b0aab19 100644 --- a/vmm_core/virt_whp/src/apic.rs +++ b/vmm_core/virt_whp/src/apic.rs @@ -314,11 +314,62 @@ impl WhpProcessor<'_> { .whp(vtl) .set_apic(&apic.as_page()) .for_op("set apic state")?; + self.assert_pending_interrupt(vtl, &apic)?; + if vtl == Vtl::Vtl0 { + self.inner.lapic_written.store(true, Ordering::Release); + } } } Ok(()) } + + /// Asserts the highest-priority pending edge-triggered vector of an + /// offloaded LAPIC state just written with `set_apic` through WHP's + /// interrupt path. + /// + /// A snapshot restore writes saved IRR bits this way, and so does the + /// restore clock when it queues the vector of a one-shot timer that + /// expired during the downtime. The hypervisor does not always deliver + /// such a vector to a halted VP: on a bare-metal host, 2 s after every legacy + /// 8-VP restore, a VP was still halted with the timer vector pending, and + /// its timers stalled until some other interrupt woke it. Asserting a + /// vector that is already pending is idempotent, and wakes the VP when + /// the vector is deliverable: the hypervisor still applies the PPR and IF + /// rules. + fn assert_pending_interrupt(&self, vtl: Vtl, apic: &vp::ApicRegisters) -> Result<(), Error> { + let Some(vector) = highest_pending_edge_vector(apic) else { + return Ok(()); + }; + self.vp + .partition + .vtlp(vtl) + .whp + .interrupt( + whp::abi::WHvX64InterruptTypeFixed, + whp::abi::WHvX64InterruptDestinationModePhysical, + whp::abi::WHvX64InterruptTriggerModeEdge, + self.inner.vp_info.apic_id, + vector.into(), + ) + .for_op("assert a pending LAPIC vector")?; + Ok(()) + } + + /// Asserts the highest pending edge-triggered vector of the VP's current + /// offloaded VTL0 LAPIC state again, if the VMM wrote that state since the + /// VP last ran: partition time may have been suspended and resumed since + /// the write (the legacy restore clock does both), so the VP runs only + /// after the assertion. + pub(crate) fn reassert_written_lapic(&mut self) -> Result<(), Error> { + if !self.inner.lapic_written.swap(false, Ordering::AcqRel) { + return Ok(()); + } + let apic = vp::ApicRegisters::from_page( + &self.vp.whp(Vtl::Vtl0).get_apic().for_op("get apic state")?, + ); + self.assert_pending_interrupt(Vtl::Vtl0, &apic) + } pub(crate) fn apic_write(&mut self, address: u64, data: &[u8], dev: &impl CpuIo) { if let Some(lapic) = self.state.vtls.lapic(self.state.active_vtl) { lapic @@ -750,3 +801,79 @@ impl ApicState { *nmi_pending = false; } } + +/// Returns the highest-priority vector pending in `apic`'s IRR that is +/// edge-triggered (TMR clear). Vectors 0 to 15 are skipped: they are not valid +/// fixed interrupts, and the Hyper-V LAPIC state page reuses their IRR bits +/// (for example for the NMI pending bit). Level-triggered vectors are left to +/// their IOAPIC, whose EOI handling an edge assertion would bypass. +fn highest_pending_edge_vector(apic: &vp::ApicRegisters) -> Option { + (16..=255u8).rev().find(|&vector| { + let (word, bit) = (usize::from(vector / 32), vector % 32); + apic.irr[word] >> bit & 1 == 1 && apic.tmr[word] >> bit & 1 == 0 + }) +} + +#[cfg(test)] +mod tests { + use super::highest_pending_edge_vector; + use virt::x86::vp::ApicRegisters; + use zerocopy::FromZeros; + + fn pending(irr: &[u8], level: &[u8]) -> ApicRegisters { + let mut apic = ApicRegisters::new_zeroed(); + for &vector in irr { + apic.irr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + for &vector in level { + apic.tmr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + apic + } + + #[test] + fn nothing_pending_asserts_nothing() { + assert_eq!(highest_pending_edge_vector(&pending(&[], &[])), None); + } + + #[test] + fn the_expired_timer_vector_is_asserted() { + // Linux's LOCAL_TIMER_VECTOR, as the restore clock queues it. + assert_eq!( + highest_pending_edge_vector(&pending(&[0xec], &[])), + Some(0xec) + ); + } + + #[test] + fn the_highest_edge_vector_wins() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x30, 0xec, 0x41], &[])), + Some(0xec) + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0xff, 0x10], &[])), + Some(0xff) + ); + } + + #[test] + fn level_triggered_vectors_are_left_to_their_ioapic() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41], &[0x41])), + None + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41, 0x30], &[0x41])), + Some(0x30) + ); + } + + #[test] + fn reserved_vectors_and_the_nmi_pending_bit_are_ignored() { + let mut apic = pending(&[], &[]); + apic.set_hv_apic_nmi_pending(true); + apic.irr[0] |= 0xffff; + assert_eq!(highest_pending_edge_vector(&apic), None); + } +} diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs new file mode 100644 index 00000000000..d312bb66055 --- /dev/null +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID policy for the CPU compatibility contract: the per-VP topology +//! results returned on CPUID exits, and hiding the L0 GPA pinning +//! enlightenment. + +use crate::WhpProcessor; +use inspect::Inspect; +use x86defs::cpuid::CpuidFunction; + +/// The L0 pinning requirement describes memory owned by OpenVMM, not memory +/// owned by OpenVMM's guest. OpenVMM does not expose the GPA pin/unpin +/// hypercalls, so this leaf keeps the enlightenment from being passed through +/// to the guest. +pub(crate) fn mask_gpa_pinning_enlightenment() -> virt::CpuidLeaf { + let mask = hvdef::HvEnlightenmentInformation::new() + .with_use_gpa_pinning_hypercall(true) + .into_bits(); + virt::CpuidLeaf::new( + hvdef::HV_CPUID_FUNCTION_MS_HV_ENLIGHTENMENT_INFORMATION, + [0; 4], + ) + .masked([ + mask as u32, + (mask >> 32) as u32, + (mask >> 64) as u32, + (mask >> 96) as u32, + ]) +} + +/// Processor topology parameters of the per-VP topology CPUID results. +#[derive(Inspect)] +pub(crate) struct CpuidTopology { + reserved_vps_per_socket: u32, + smt_enabled: bool, + /// The extended topology subleaves past the core level (leaves 0Bh and + /// 1Fh, subleaf 2 and up) that a time ABI partition's effective CPUID + /// lists, such as the terminating subleaf, sorted. Other partitions list + /// none. + #[inspect(debug)] + listed_extended_topology: Vec<(u32, u32)>, +} + +impl CpuidTopology { + pub(crate) fn new(topology: &vm_topology::processor::ProcessorTopology) -> Self { + Self { + reserved_vps_per_socket: topology.reserved_vps_per_socket(), + smt_enabled: topology.smt_enabled(), + listed_extended_topology: Vec::new(), + } + } + + /// Keeps the extended topology subleaves past the core level that + /// `cpuid`, a time ABI partition's effective CPUID, lists: each VP sees + /// them with its own x2APIC ID, instead of the zeros that end the + /// enumeration on other partitions. + pub(crate) fn with_listed_extended_topology(mut self, cpuid: &virt::CpuidLeafSet) -> Self { + let mut listed: Vec<(u32, u32)> = cpuid + .leaves() + .iter() + .filter(|leaf| is_extended_topology(leaf.function)) + .filter_map(|leaf| Some((leaf.function, leaf.index.filter(|&index| index >= 2)?))) + .collect(); + listed.sort_unstable(); + listed.dedup(); + self.listed_extended_topology = listed; + self + } + + fn lists(&self, function: u32, index: u32) -> bool { + self.listed_extended_topology + .binary_search(&(function, index)) + .is_ok() + } +} + +fn is_extended_topology(function: u32) -> bool { + matches!( + CpuidFunction(function), + CpuidFunction::ExtendedTopologyEnumeration | CpuidFunction::V2ExtendedTopologyEnumeration + ) +} + +/// Reports `apic_id` for the SMT and core levels of the extended topology +/// leaves and for each further subleaf that is `listed`, and terminates the +/// enumeration after the core level otherwise. +fn fixup_extended_topology(index: u32, apic_id: u32, listed: bool, result: &mut [u32; 4]) { + if index >= 2 && !listed { + *result = [0; 4]; + } else { + result[3] = apic_id; + } +} + +impl WhpProcessor<'_> { + /// Replaces the BSP identity in the partition's topology CPUID results + /// with this VP's APIC-derived identity. + pub(crate) fn fixup_topology_cpuid(&self, function: u32, index: u32, default: &mut [u32; 4]) { + fixup_vp_topology_cpuid( + &self.vp.partition.cpuid_topology, + self.inner.vp_info.apic_id, + function, + index, + default, + ); + } +} + +/// Replaces the BSP identity in the partition's topology CPUID results with +/// the identity of the VP whose APIC ID is `apic_id`, as the CPUID exit +/// handler does for each VP. +pub(crate) fn fixup_vp_topology_cpuid( + topology: &CpuidTopology, + apic_id: u32, + function: u32, + index: u32, + default: &mut [u32; 4], +) { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => { + let ebx = x86defs::cpuid::VersionAndFeaturesEbx::from(default[1]); + default[1] = ebx.with_initial_apic_id(apic_id as u8).into(); + } + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => { + fixup_extended_topology(index, apic_id, topology.lists(function, index), default); + } + CpuidFunction::ProcessorTopologyDefinition => { + default[0] = x86defs::cpuid::ProcessorTopologyDefinitionEax::from(default[0]) + .with_extended_apic_id(apic_id) + .into(); + let threads_per_core = if topology.smt_enabled { 2 } else { 1 }; + default[1] = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(default[1]) + .with_compute_unit_id( + ((apic_id % topology.reserved_vps_per_socket) / threads_per_core) as u8, + ) + .with_threads_per_compute_unit((threads_per_core - 1) as u8) + .into(); + default[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(default[2]) + .with_node_id((apic_id / topology.reserved_vps_per_socket) as u8) + .with_nodes_per_processor(0) + .into(); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::CpuidTopology; + use super::fixup_extended_topology; + use super::fixup_vp_topology_cpuid; + use super::mask_gpa_pinning_enlightenment; + + #[test] + fn l0_gpa_pinning_enlightenment_is_not_exposed() { + let enlightenment = hvdef::HvEnlightenmentInformation::new() + .with_use_gpa_pinning_hypercall(true) + .with_nested(true); + let bits = enlightenment.into_bits(); + let mut result = [ + bits as u32, + (bits >> 32) as u32, + (bits >> 64) as u32, + (bits >> 96) as u32, + ]; + + mask_gpa_pinning_enlightenment().apply(&mut result); + + let result = hvdef::HvEnlightenmentInformation::from( + result[0] as u128 + | (result[1] as u128) << 32 + | (result[2] as u128) << 64 + | (result[3] as u128) << 96, + ); + assert!(!result.use_gpa_pinning_hypercall()); + assert!(result.nested()); + } + + #[test] + fn extended_topology_uses_canonical_levels_and_terminates() { + for index in [0, 1] { + for listed in [false, true] { + let mut result = [1, 2, 3, 99]; + fixup_extended_topology(index, 7, listed, &mut result); + assert_eq!(result, [1, 2, 3, 7]); + } + } + + for index in [2, 3, u32::MAX] { + let mut result = [1, 2, 3, 99]; + fixup_extended_topology(index, 7, false, &mut result); + assert_eq!(result, [0; 4]); + } + } + + /// A time ABI partition's effective CPUID lists the subleaf that ends the + /// enumeration (core's `terminate_extended_topology`): each VP sees it + /// with its own x2APIC ID. Subleaves past it still read zero, and other + /// partitions keep ending the enumeration with zeros. + #[test] + fn listed_extended_topology_subleaves_keep_their_values() { + let topology = vm_topology::processor::TopologyBuilder::new_x86() + .vps_per_socket(4) + .build(4) + .unwrap(); + let terminator = |function| { + virt::CpuidLeaf::new(function, [0, 0, 2, 0]) + .indexed(2) + .masked([!0, !0, !0, 0]) + }; + let cpuid = virt::CpuidLeafSet::new(vec![ + virt::CpuidLeaf::new(0xb, [1, 1, 0x100, 0]) + .indexed(0) + .masked([!0, !0, !0, 0]), + terminator(0xb), + terminator(0x1f), + ]); + let time_abi = CpuidTopology::new(&topology).with_listed_extended_topology(&cpuid); + assert_eq!(time_abi.listed_extended_topology, [(0xb, 2), (0x1f, 2)]); + let legacy = CpuidTopology::new(&topology); + for function in [0xb, 0x1f] { + for apic_id in [0, 3] { + let mut result = [0, 0, 2, 99]; + fixup_vp_topology_cpuid(&time_abi, apic_id, function, 2, &mut result); + assert_eq!(result, [0, 0, 2, apic_id]); + + let mut result = [5, 6, 7, 8]; + fixup_vp_topology_cpuid(&time_abi, apic_id, function, 3, &mut result); + assert_eq!(result, [0; 4]); + + let mut result = [0, 0, 2, 99]; + fixup_vp_topology_cpuid(&legacy, apic_id, function, 2, &mut result); + assert_eq!(result, [0; 4]); + } + } + } +} diff --git a/vmm_core/virt_whp/src/fingerprint.rs b/vmm_core/virt_whp/src/fingerprint.rs new file mode 100644 index 00000000000..7b97f0b4266 --- /dev/null +++ b/vmm_core/virt_whp/src/fingerprint.rs @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the WHP backend: the guest CPU surface and time +//! capabilities that the Windows Hypervisor Platform supports on this host. + +use crate::Error; +use crate::WhpResultExt; +use crate::profile_features::WhpFeatures; +use crate::profile_features::profile_features; +use crate::time_abi::host_cpuid; +use crate::time_abi::processor_features; +use crate::time_abi::unlisted_cpuid; +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; +use whp::abi::WHV_PROCESSOR_FEATURES; +use whp::abi::WHV_PROCESSOR_FEATURES1; +use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; +use whp::abi::WHV_X64_MSR_EXIT_BITMAP; + +const METHOD: &str = "WHvGetVirtualProcessorCpuidOutput on a probe partition with every processor \ + and XSAVE feature that WHP reports as available, and an in-hypervisor x2APIC"; + +/// The TSC value written to the probe virtual processor. A probe partition +/// starts near zero, so a read-back between this value and twice it shows +/// that the write took effect. +const PROBE_TSC: u64 = 1 << 40; + +/// The MSR exits that WHP can deliver, by `WHV_X64_MSR_EXIT_BITMAP` name. +const MSR_EXITS: &[(&str, WHV_X64_MSR_EXIT_BITMAP)] = &[ + ("UnhandledMsrs", WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs), + ("TscMsrWrite", WHV_X64_MSR_EXIT_BITMAP::TscMsrWrite), + ("TscMsrRead", WHV_X64_MSR_EXIT_BITMAP::TscMsrRead), + ( + "ApicBaseMsrWrite", + WHV_X64_MSR_EXIT_BITMAP::ApicBaseMsrWrite, + ), + ( + "MiscEnableMsrRead", + WHV_X64_MSR_EXIT_BITMAP::MiscEnableMsrRead, + ), + ( + "McUpdatePatchLevelMsrRead", + WHV_X64_MSR_EXIT_BITMAP::McUpdatePatchLevelMsrRead, + ), +]; + +/// Returns the guest CPU surface and time capabilities that WHP supports on +/// this host. +/// +/// The CPUID table is what the virtual processor of a transient probe +/// partition reports. The probe partition enables every processor and XSAVE +/// feature that WHP reports as available, because WHP's default processor +/// features, which OpenVMM's partitions use today, omit some of them, such +/// as the speculation controls of `CPUID.(7,0):EDX` and PSFD. If WHP rejects +/// the available features, the probe partition falls back to the default +/// features and the fingerprint records the error. The probe partition uses +/// the in-hypervisor x2APIC, has no memory, never runs, and is deleted +/// before this returns. The fingerprint also records the processor +/// capabilities (0x1000 through 0x1009), probes per-VP TSC writes and +/// partition time suspension on the probe partition, and probes TSC +/// frequency virtualization on a second partition object that is never set +/// up. +pub fn cpu_fingerprint() -> Result { + let available = whp::capabilities::processor_features() + .ok() + .zip(whp::capabilities::processor_xsave_features().ok()); + let mut rejected_features = None; + let mut partition = None; + if let Some((processor, xsave)) = available { + match probe_partition(ProbeFeatures::Available(processor, xsave)) { + Ok(probe) => partition = Some(probe), + Err(error) => rejected_features = Some(error), + } + } + let partition = match partition { + Some(partition) => partition, + None => probe_partition(ProbeFeatures::Default)?, + }; + let cpuid = probe_cpuid(&partition)?; + + let mut fingerprint = BackendFingerprint::new("whp", METHOD, cpuid); + if let Some(error) = rejected_features { + fingerprint.set_unavailable("whp.probe.ProcessorFeatures", &error); + } + record_capabilities(&mut fingerprint); + let vp = partition.vp(0); + + let tsc_frequency_hz = fingerprint.record( + "whp.probe.ProcessorClockFrequency", + partition.tsc_frequency(), + ); + let lapic_timer_frequency_hz = fingerprint.record( + "whp.probe.InterruptClockFrequency", + partition.apic_frequency(), + ); + fingerprint.record( + "whp.probe.PhysicalAddressWidth", + partition.physical_address_width().map(u64::from), + ); + + let tsc_write = (|| { + vp.set_register(whp::Register64::Tsc, PROBE_TSC)?; + vp.get_register(whp::Register64::Tsc) + })(); + let tsc_offset_control = match tsc_write { + Ok(tsc) => tsc.wrapping_sub(PROBE_TSC) < PROBE_TSC, + Err(error) => { + fingerprint.set_unavailable("whp.probe.tsc_write", &error); + false + } + }; + let time_freeze = match partition + .suspend_time() + .and_then(|()| partition.resume_time()) + { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable("whp.probe.suspend_time", &error); + false + } + }; + drop(partition); + + let tsc_scaling = + tsc_frequency_hz.map(|frequency_hz| probe_tsc_scaling(frequency_hz, &mut fingerprint)); + let msr_exits = fingerprint + .values + .get("whp.capability.X64MsrExitBitmap") + .map(|bitmap| bitmap.0); + let exits = fingerprint + .values + .get("whp.capability.ExtendedVmExits") + .map(|exits| exits.0); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + time.lapic_timer_frequency_hz = lapic_timer_frequency_hz; + time.tsc_offset_control = Some(tsc_offset_control); + time.time_freeze = Some(time_freeze); + time.tsc_scaling = tsc_scaling; + if let Some(exits) = exits { + time.msr_intercepts.insert( + "X64MsrExit".to_owned(), + whp::abi::WHV_EXTENDED_VM_EXITS(exits) + .is_set(whp::abi::WHV_EXTENDED_VM_EXITS::X64MsrExit), + ); + } + if let Some(msr_exits) = msr_exits { + for &(name, bit) in MSR_EXITS { + time.msr_intercepts.insert( + name.to_owned(), + WHV_X64_MSR_EXIT_BITMAP(msr_exits).is_set(bit), + ); + } + } + Ok(fingerprint) +} + +/// Returns what VP 0 of a transient probe partition configured from +/// `profile` reads at every entry of the host's CPUID outside the profile's +/// tables ([`cpu_profile::unlisted_cpuid_candidates`]), each read at +/// subleaf 0 if subleaf-independent, for the `--cpu-fingerprint` check that +/// those entries read zero (`E_CPU_UNLISTED`). +/// +/// It measures what a time ABI cold boot checks on VP 0 of its partition. +/// The probe partition's processor feature banks and XSAVE features derive +/// from the profile as `WhpTimeAbi::configure` derives them, without the +/// features that the time ABI hides, so that, unlike [`cpu_fingerprint`]'s +/// probe partition with every available feature, it does not present the +/// XSAVE components of features that no profile enables, such as CET's. It +/// programs no CPUID results, which cover only the profile's own entries. +/// The partition has no memory, never runs, and is deleted before this +/// returns. +/// +/// Fails with `E_PROFILE_UNSUPPORTED` if WHP cannot present the profile's +/// features. +pub fn profile_unlisted_cpuid(profile: &CpuProfile) -> Result, Error> { + let available = + whp::capabilities::processor_features().for_op("query the processor features")?; + let available_xsave = + whp::capabilities::processor_xsave_features().for_op("query the XSAVE features")?; + let derived = profile_features( + profile, + WhpFeatures { + banks: [available.bank0.0, available.bank1.0], + xsave: available_xsave.0, + }, + )?; + let mut features = available; + features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); + features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); + let partition = probe_partition(ProbeFeatures::Profile( + processor_features(features), + WHV_PROCESSOR_XSAVE_FEATURES(derived.xsave), + ))?; + let vp = partition.vp(0); + let leaves = unlisted_cpuid(profile, host_cpuid(), |leaf, subleaf| { + vp.get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .for_op("query the probe virtual processor CPUID")?; + Ok(leaves + .into_iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect()) +} + +/// The processor and XSAVE features of a probe partition. +enum ProbeFeatures { + /// WHP's default processor features. + Default, + /// Every processor and XSAVE feature that WHP reports as available. + Available(whp::ProcessorFeatures, WHV_PROCESSOR_XSAVE_FEATURES), + /// The features of a CPU profile's time ABI partition, set through the + /// feature banks as a time ABI partition sets them. + Profile(whp::ProcessorFeatures, WHV_PROCESSOR_XSAVE_FEATURES), +} + +/// Creates a probe partition with one virtual processor, the in-hypervisor +/// x2APIC, and `features`. +fn probe_partition(features: ProbeFeatures) -> Result { + let mut config = + whp::PartitionConfig::new().for_op("create the fingerprint probe partition")?; + config + .set_property(whp::PartitionProperty::ProcessorCount(1)) + .for_op("set the probe partition processor count")?; + config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeX2Apic, + )) + .for_op("set the probe partition APIC emulation mode")?; + let (processor, xsave) = match features { + ProbeFeatures::Default => (None, None), + ProbeFeatures::Available(processor, xsave) => ( + Some(whp::PartitionProperty::ProcessorFeatures(processor)), + Some(xsave), + ), + ProbeFeatures::Profile(processor, xsave) => ( + Some(whp::PartitionProperty::ProcessorFeaturesBanks(processor)), + Some(xsave), + ), + }; + if let Some(processor) = processor { + config + .set_property(processor) + .for_op("set the probe partition processor features")?; + } + if let Some(xsave) = xsave { + config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures(xsave)) + .for_op("set the probe partition XSAVE features")?; + } + let partition = config + .create() + .for_op("set up the fingerprint probe partition")?; + partition + .create_vp(0) + .create() + .for_op("create the probe virtual processor")?; + Ok(partition) +} + +/// Returns the CPUID table that the virtual processor of the probe +/// partition reports. +fn probe_cpuid(partition: &whp::Partition) -> Result, Error> { + let vp = partition.vp(0); + cpu_profile::cpuid::enumerate(|leaf, subleaf| { + vp.get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .for_op("query the probe virtual processor CPUID") +} + +/// Records the WHP capabilities: the processor feature banks as feature +/// banks, and the others as values. +fn record_capabilities(fingerprint: &mut BackendFingerprint) { + use whp::capabilities; + + match capabilities::processor_features_banks() { + Ok(banks) => { + fingerprint.set_value( + "whp.capability.ProcessorFeaturesBanks.count", + banks.BanksCount.into(), + ); + for (index, bank) in banks.Banks.iter().enumerate() { + fingerprint.set_feature_bank( + format!("whp.capability.ProcessorFeaturesBanks.bank{index}"), + *bank, + ); + } + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFeaturesBanks", &error), + } + match capabilities::processor_features_bank0() { + Ok(features) => { + fingerprint.set_feature_bank("whp.capability.ProcessorFeatures", features.0) + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFeatures", &error), + } + match capabilities::processor_xsave_features() { + Ok(features) => { + fingerprint.set_feature_bank("whp.capability.ProcessorXsaveFeatures", features.0) + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorXsaveFeatures", &error), + } + match capabilities::synthetic_processor_features_banks() { + Ok(banks) => { + fingerprint.set_value( + "whp.capability.SyntheticProcessorFeaturesBanks.count", + banks.BanksCount.into(), + ); + for (index, bank) in banks.Banks.iter().enumerate() { + fingerprint.set_value( + format!("whp.capability.SyntheticProcessorFeaturesBanks.bank{index}"), + *bank, + ); + } + } + Err(error) => { + fingerprint.set_unavailable("whp.capability.SyntheticProcessorFeaturesBanks", &error) + } + } + match capabilities::processor_frequency_cap() { + Ok(cap) => { + for (name, value) in [ + ("Flags", cap.Flags), + ("HighestFrequencyMhz", cap.HighestFrequencyMhz), + ("NominalFrequencyMhz", cap.NominalFrequencyMhz), + ("LowestFrequencyMhz", cap.LowestFrequencyMhz), + ("FrequencyStepMhz", cap.FrequencyStepMhz), + ] { + fingerprint.set_value( + format!("whp.capability.ProcessorFrequencyCap.{name}"), + value.into(), + ); + } + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFrequencyCap", &error), + } + fingerprint.record( + "whp.capability.PerfmonFeatures", + capabilities::perfmon_features().map(|features| features.0), + ); + fingerprint.record( + "whp.capability.Features", + capabilities::features().map(|features| features.0), + ); + fingerprint.record( + "whp.capability.ExtendedVmExits", + capabilities::extended_vm_exits().map(|exits| exits.0), + ); + fingerprint.record( + "whp.capability.X64MsrExitBitmap", + capabilities::x64_msr_exit_bitmap().map(|bitmap| bitmap.0), + ); + fingerprint.record( + "whp.capability.ProcessorVendor", + capabilities::processor_vendor().map(|vendor| vendor.0.into()), + ); + fingerprint.record( + "whp.capability.ProcessorClFlushSize", + capabilities::processor_cl_flush_size().map(u64::from), + ); + fingerprint.record( + "whp.capability.ProcessorClockFrequency", + capabilities::processor_clock_frequency(), + ); + fingerprint.record( + "whp.capability.InterruptClockFrequency", + capabilities::interrupt_clock_frequency(), + ); +} + +/// Asks WHP to virtualize half of the host TSC frequency for a partition +/// that is never set up, and returns whether WHP accepted it. +fn probe_tsc_scaling(host_frequency_hz: u64, fingerprint: &mut BackendFingerprint) -> bool { + let result = whp::PartitionConfig::new().and_then(|mut config| { + config + .set_property(whp::PartitionProperty::ProcessorCount(1))? + .set_property(whp::PartitionProperty::ProcessorClockFrequency( + host_frequency_hz / 2, + ))?; + Ok(()) + }); + match result { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable("whp.probe.ProcessorClockFrequency.set", &error); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::cpu_fingerprint; + use super::profile_unlisted_cpuid; + use crate::time_abi::host_cpuid; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires WHP"] + fn fingerprint_reports_the_supported_surface() { + let fingerprint = cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "WHP fingerprint"); + assert_eq!(fingerprint, cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert!(fingerprint.time.lapic_timer_frequency_hz.is_some()); + assert_eq!(fingerprint.time.tsc_offset_control, Some(true)); + // WHP accepts every feature that it reports as available. + assert!( + !fingerprint + .unavailable + .contains_key("whp.probe.ProcessorFeatures") + ); + } + + /// The fingerprint check reads the entries outside the host's profile on + /// a partition configured from the profile, as a cold boot does: they + /// read zero there, even where the probe partition with every available + /// feature presents host data, such as the CET XSAVE components of a + /// CET-capable host. + #[test] + #[ignore = "requires WHP"] + fn a_partition_of_the_host_profile_reads_zero_outside_it() { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let presented = profile_unlisted_cpuid(profile).unwrap(); + let candidates = cpu_profile::unlisted_cpuid_candidates(profile, host_cpuid()); + assert_eq!( + presented + .iter() + .map(cpuid::CpuidEntry::key) + .collect::>(), + candidates + ); + cpu_profile::check_unlisted_cpuid(profile, &presented).unwrap(); + let fingerprint = cpu_fingerprint().unwrap(); + let all_features = cpu_profile::unlisted_cpuid_violations(profile, &fingerprint.cpuid); + println!( + "{}: {} host entries outside the profile read zero on its partition; the probe \ + partition with every feature presents {all_features:?}", + profile.id(), + candidates.len() + ); + } +} diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index defd621bce1..49588c30d92 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -10,12 +10,24 @@ #![expect(clippy::undocumented_unsafe_blocks, clippy::missing_safety_doc)] mod apic; +#[cfg(guest_arch = "x86_64")] +mod cpu_contract; pub mod device; mod emu; +#[cfg(guest_arch = "x86_64")] +mod fingerprint; mod hypercalls; mod memory; +#[cfg(guest_arch = "x86_64")] +mod native_cpuid; +#[cfg(guest_arch = "x86_64")] +mod profile_features; mod regs; mod synic; +#[cfg(guest_arch = "x86_64")] +mod time_abi; +#[cfg(guest_arch = "x86_64")] +mod tsc; mod vm_state; mod vp; mod vp_state; @@ -77,6 +89,10 @@ use x86defs::cpuid::Vendor; #[cfg(guest_arch = "aarch64")] pub use aarch64::WHP_PMU_GSIV; +#[cfg(guest_arch = "x86_64")] +pub use fingerprint::cpu_fingerprint; +#[cfg(guest_arch = "x86_64")] +pub use fingerprint::profile_unlisted_cpuid; #[derive(Debug)] pub struct Whp { @@ -122,6 +138,12 @@ struct WhpPartitionInner { caps: virt::PartitionCapabilities, #[cfg(guest_arch = "x86_64")] cpuid: virt::CpuidLeafSet, + #[cfg(guest_arch = "x86_64")] + #[inspect(flatten)] + cpuid_topology: cpu_contract::CpuidTopology, + /// The NVX time ABI state, for a partition built with it. + #[cfg(guest_arch = "x86_64")] + time_abi: Option, vtl0_alias_map_offset: Option, monitor_page: MonitorPage, hvstate: Hv1State, @@ -154,6 +176,17 @@ struct VtlPartition { lapic: LocalApicKind, hypervisor_enlightened: bool, + + /// Set when WHP answers CPUID leaves 0 and 1 without exits. + #[cfg(guest_arch = "x86_64")] + #[inspect(with = "Option::is_some")] + native_cpuid_leaves: Option, + + /// The NVX time ABI state this partition was configured with, until the + /// partition build moves it to [`WhpPartitionInner`]. + #[cfg(guest_arch = "x86_64")] + #[inspect(skip)] + time_abi: Option, } impl VtlPartition { @@ -210,6 +243,11 @@ struct WhpVp { vtl2_wake: AtomicBool, /// Enable VTL2 at the next opportunity. vtl2_enable: AtomicBool, + /// Set when the VMM writes this VP's offloaded VTL0 LAPIC state. The next + /// VP run consumes it and asserts the highest pending edge-triggered + /// vector again (see `WhpProcessor::assert_pending_interrupt`). + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool, vp_info: TargetVpInfo, waker: RwLock>, } @@ -384,6 +422,8 @@ impl WhpVp { interrupt: NeedsYield::new(), vtl2_wake: false.into(), vtl2_enable: vtl2_enabled.into(), + #[cfg(guest_arch = "x86_64")] + lapic_written: false.into(), vp_info: vp, waker: Default::default(), } @@ -607,6 +647,11 @@ impl virt::Partition for WhpPartition { virt::InitialVpStateSource::Registers } + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner.time_abi.as_ref().map(|_| &*self.inner as _) + } + fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { if whp::capabilities::reset_partition() { Some(self) @@ -750,6 +795,17 @@ impl virt::BindProcessor for WhpProcessorBinder { } } + // WHP answers CPUID leaf 1, with this VP's initial APIC ID, without + // an exit. + #[cfg(guest_arch = "x86_64")] + if let Some(native_cpuid_leaves) = &vtlp.native_cpuid_leaves { + native_cpuid_leaves.verify_apic_id( + vp.vp.whp(vtl), + vp_info.base.vp_index.index(), + vp_info.apic_id, + )?; + } + #[cfg(guest_arch = "aarch64")] { let _ = vtlp; @@ -825,6 +881,9 @@ pub enum Error { GicV2NotSupported, #[error("failed to compute topology cpuid")] TopologyCpuid(#[source] virt::x86::topology::UnknownVendor), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), #[error("{0} is not supported on this architecture")] UnsupportedParameter(&'static str), #[error("WHP does not support nested virtualization on this host")] @@ -837,6 +896,30 @@ pub enum Error { NestedVirtIncompatibleWithIsolation, #[error("WHP does not support {0:?} isolation")] IsolationNotSupported(IsolationType), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("CPUID leaf {0:#x} has a programmed result but does not exit")] + NativeCpuidUnrouted(u32), + #[cfg(guest_arch = "x86_64")] + #[error( + "WHP answers CPUID leaf {function:#x} with {native:08x?} without an exit, but the partition's result is {expected:08x?}" + )] + NativeCpuidMismatch { + function: u32, + native: [u32; 4], + expected: [u32; 4], + }, + #[cfg(guest_arch = "x86_64")] + #[error( + "WHP reports initial APIC ID {native:#x} in CPUID leaf 1 of VP {vp_index}, but its APIC ID is {apic_id:#x}" + )] + NativeCpuidApicId { + vp_index: u32, + native: u32, + apic_id: u32, + }, } trait WhpResultExt { @@ -913,6 +996,16 @@ impl virt::Hypervisor for Whp { } } + #[cfg(guest_arch = "x86_64")] + if config.time_abi.is_some() { + time_abi::validate_partition( + isolation, + config.hv_config.is_some(), + nested_virt, + user_mode_apic, + )?; + } + let vtl0 = VtlPartition::new( &config, Vtl::Vtl0, @@ -987,7 +1080,8 @@ impl ProtoPartition for WhpProtoPartition<'_> { // the memory backing can resolve them on demand (soft large pages, lazy // commit). WHP on aarch64 does not deliver these faults, so the backing // must not defer any commit or protection to a fault. - cfg!(guest_arch = "x86_64") + // Pure COW restores intentionally leave these faults to WHP. + cfg!(guest_arch = "x86_64") && self.config.user_mode_memory_faults } fn build( @@ -1107,10 +1201,19 @@ impl WhpPartitionInner { // These are validated by VtlPartition::new and only consumed on x86_64. let _ = (user_mode_apic, offload_enlightenments, nested_virt); + #[cfg(guest_arch = "x86_64")] + let tsc_frequency = vtl0.whp.tsc_frequency().for_op("get tsc frequency")?; + #[cfg(guest_arch = "x86_64")] + let (vtl0, time_abi) = { + let mut vtl0 = vtl0; + let time_abi = vtl0.time_abi.take(); + (vtl0, time_abi) + }; + // FUTURE: register cpuid results with the hypervisor, and register // appropriate per-VP results where necessary (or tell the hypervisor // the AMD topology information so that it can provide per-VP results - // accurately). + // accurately). `native_cpuid` does so for leaves 0 and 1. #[cfg(guest_arch = "x86_64")] let cpuid = { use vm_topology::processor::x86::ApicMode; @@ -1119,15 +1222,9 @@ impl WhpPartitionInner { // Report x2apic support. When the APIC is in the hypervisor, the // hypervisor will do this automatically, but it doesn't hurt to do this // again. - let mask = [0, 0, 1 << 21, 0]; - let (value, use_apic_msrs) = match proto_config.processor_topology.apic_mode() { - ApicMode::XApic => ([0; 4], true), - ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => (mask, false), - }; - cpuid.push( - virt::CpuidLeaf::new(x86defs::cpuid::CpuidFunction::VersionAndFeatures.0, value) - .masked(mask), - ); + let apic_mode = proto_config.processor_topology.apic_mode(); + let use_apic_msrs = matches!(apic_mode, ApicMode::XApic); + cpuid.push(native_cpuid::x2apic_leaf(apic_mode)); // Add in the synthetic hv leaves if necessary. if proto_config.hv_config.is_some() { @@ -1148,6 +1245,8 @@ impl WhpPartitionInner { .map(|(f, v)| virt::CpuidLeaf::new(f.0, [v.eax, v.ebx, v.ecx, v.edx])), ); hv1_emulator::cpuid::process_hv_cpuid_leaves(&mut cpuid, false, [0; 4]); + } else { + cpuid.push(cpu_contract::mask_gpa_pinning_enlightenment()); } } @@ -1185,9 +1284,22 @@ impl WhpPartitionInner { ) .map_err(Error::TopologyCpuid)?; - virt::CpuidLeafSet::new(cpuid) + match &time_abi { + // The time ABI declares the TSC rate through its identity + // MSRs; leaf 0x15 stays zero. + Some(time_abi) => time_abi.partition_cpuid(cpuid)?, + None => { + let cpuid = tsc::add_frequency_leaves(cpuid, tsc_frequency, &vtl0)?; + virt::CpuidLeafSet::new(cpuid) + } + } }; + #[cfg(guest_arch = "x86_64")] + if let Some(native_cpuid_leaves) = &vtl0.native_cpuid_leaves { + native_cpuid_leaves.verify(&cpuid, |eax, ecx| vtl0.cpuid(eax, ecx))?; + } + let mut vtl0_alias_map_offset = None; let vtl2_emulation = if let Some(vtl2_config) = proto_config .hv_config @@ -1248,15 +1360,34 @@ impl WhpPartitionInner { #[cfg(guest_arch = "x86_64")] let caps = { - let mut caps = virt::x86::X86PartitionCapabilities::from_cpuid( - proto_config.processor_topology, - &mut |function, index| cpuid.result(function, index, &vtl0.cpuid(function, index)), - ) - .map_err(Error::Capabilities)?; + let mut lookup = + |function, index| cpuid.result(function, index, &vtl0.cpuid(function, index)); + let caps = if time_abi.is_some() { + // The time ABI identity must not make the partition look like + // an hv1 or KVM-clock guest, so its capabilities ignore the + // hypervisor range. + virt::x86::X86PartitionCapabilities::from_cpuid( + proto_config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + } else { + virt::x86::X86PartitionCapabilities::from_cpuid( + proto_config.processor_topology, + &mut lookup, + ) + }; + let mut caps = caps.map_err(Error::Capabilities)?; caps.can_freeze_time = true; caps.xsaves_state_bv_broken = true; caps.dr6_tsx_broken = true; caps.nested_virt = nested_virt; + if time_abi.is_some() && caps.hv1 { + return Err(virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::IdentityRouting, + "the time ABI partition capabilities include hv1", + ) + .into()); + } caps }; let vendor = match whp::capabilities::processor_vendor().for_op("get processor vendor")? { @@ -1288,7 +1419,6 @@ impl WhpPartitionInner { if vtl0.hypervisor_enlightened { Hv1State::Offloaded } else { - let tsc_frequency = vtl0.whp.tsc_frequency().for_op("get tsc frequency")?; let ref_time = ReferenceTimeSource::new(VmTimeReferenceTimeSource::new( proto_config.vmtime.clone(), )); @@ -1317,6 +1447,13 @@ impl WhpPartitionInner { caps, #[cfg(guest_arch = "x86_64")] cpuid, + #[cfg(guest_arch = "x86_64")] + cpuid_topology: match &time_abi { + Some(time_abi) => time_abi.cpuid_topology(proto_config.processor_topology), + None => cpu_contract::CpuidTopology::new(proto_config.processor_topology), + }, + #[cfg(guest_arch = "x86_64")] + time_abi, vtl0_alias_map_offset, monitor_page: MonitorPage::new(), hvstate, @@ -1437,6 +1574,8 @@ impl VtlPartition { } let mut hypervisor_enlightened = false; + #[cfg(guest_arch = "x86_64")] + let mut native_cpuid_leaves = None; let mut extended_exits = whp::abi::WHV_EXTENDED_VM_EXITS(0); @@ -1475,6 +1614,15 @@ impl VtlPartition { )) .for_op("set processor count")?; + #[cfg(guest_arch = "x86_64")] + let time_abi = config + .time_abi + .as_ref() + .map(|time_abi| { + time_abi::WhpTimeAbi::configure(time_abi, &mut whp_config, &mut extended_exits) + }) + .transpose()?; + #[cfg(guest_arch = "x86_64")] if nested_virt { match whp_config.set_property(whp::PartitionProperty::NestedVirtualization(true)) { @@ -1561,8 +1709,10 @@ impl VtlPartition { // for ROM regions, resulting in an extra syscall and C++ exception for // each such exit. We know locally whether memory is supposed to be // mapped writable, so we can avoid this. + // Pure writable-COW microVM restores leave these faults to WHP instead, + // avoiding one VP exit and populate call per first-touch page. // TODO-aarch64 - if cfg!(guest_arch = "x86_64") { + if cfg!(guest_arch = "x86_64") && config.user_mode_memory_faults { extended_exits |= whp::abi::WHV_EXTENDED_VM_EXITS::GpaAccessFaultExit; } @@ -1679,6 +1829,23 @@ impl VtlPartition { synth_features, )) .for_op("set synthetic processor features")?; + + // Keep CPUID leaves 0 and 1, which guest interrupt handlers + // can run, in the hypervisor (see `native_cpuid`). Other VTL + // and isolation configurations route more leaves through the + // exit handler. + #[cfg(guest_arch = "x86_64")] + if vtl == Vtl::Vtl0 + && hv_config.vtl2.is_none() + && !nested_virt + && !config.isolation.is_isolated() + && time_abi.is_none() + { + native_cpuid_leaves = native_cpuid::NativeCpuidLeaves::configure( + config.processor_topology, + &mut whp_config, + ); + } } else { with_overlays = hv_config.vtl2.is_none(); } @@ -1754,7 +1921,10 @@ impl VtlPartition { Box::new(memory::vtl2_mapper::VtlMemoryMapper::new(mapping_state)) } else { - Box::new(memory::WhpMemoryMapper::new(with_overlays)) + Box::new(memory::WhpMemoryMapper::with_lazy_registration( + with_overlays, + config.lazy_memory_registration, + )) }; Ok(Self { @@ -1768,6 +1938,10 @@ impl VtlPartition { mapper, lapic, hypervisor_enlightened, + #[cfg(guest_arch = "x86_64")] + native_cpuid_leaves, + #[cfg(guest_arch = "x86_64")] + time_abi, }) } diff --git a/vmm_core/virt_whp/src/memory.rs b/vmm_core/virt_whp/src/memory.rs index 3e78f0cdc5a..2d34d11b754 100644 --- a/vmm_core/virt_whp/src/memory.rs +++ b/vmm_core/virt_whp/src/memory.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod lazy_registration; pub mod vtl2_mapper; use super::VtlPartition; @@ -132,6 +133,15 @@ pub(crate) trait MemoryMapper: Inspect + Send + Sync { #[cfg_attr(guest_arch = "aarch64", expect(dead_code))] fn in_deferred_range(&self, gpa: u64) -> bool; + /// Registers the deferred range containing `gpa`, if any. + fn map_deferred_on_fault( + &self, + _partition: &dyn SimpleMemoryMap, + _gpa: u64, + ) -> anyhow::Result { + Ok(false) + } + /// Map all deferred ranges and put the mapper into a mapped state, where /// future map calls are no longer deferred. fn map_deferred(&self, partition: &dyn SimpleMemoryMap) -> anyhow::Result<()>; @@ -191,10 +201,12 @@ pub(crate) trait MemoryMapper: Inspect + Send + Sync { } /// Memory mapper implementation that does not support VTLs, but does support -/// overlays. +/// overlays and optional on-demand GPA registration. #[derive(Debug, Inspect)] pub(crate) struct WhpMemoryMapper { + #[inspect(rename = "state")] overlays: Option>, + overlays_supported: bool, } impl WhpMemoryMapper { @@ -205,6 +217,7 @@ impl WhpMemoryMapper { } else { None }, + overlays_supported: with_overlays, } } } @@ -230,7 +243,7 @@ impl MemoryMapper for WhpMemoryMapper { writable, executable: exec, }; - overlays.lock().map_range(partition, mapping); + overlays.lock().map_range(partition, mapping)?; } else { // SAFETY: caller guarantees `data` is a valid pointer // describing `size` bytes until this range is unmapped. @@ -255,7 +268,7 @@ impl MemoryMapper for WhpMemoryMapper { } fn overlays_supported(&self) -> bool { - self.overlays.is_some() + self.overlays_supported } fn add_overlay_page( @@ -268,6 +281,7 @@ impl MemoryMapper for WhpMemoryMapper { ) -> bool { self.overlays .as_ref() + .filter(|_| self.overlays_supported) .expect("cannot add overlays if not supported") .lock() .add_overlay_page( @@ -284,6 +298,7 @@ impl MemoryMapper for WhpMemoryMapper { fn remove_overlay_page(&self, partition: &dyn SimpleMemoryMap, gpa: u64) { self.overlays .as_ref() + .filter(|_| self.overlays_supported) .expect("cannot remove overlays if not supported") .lock() .remove_overlay_page(partition, gpa) @@ -295,6 +310,17 @@ impl MemoryMapper for WhpMemoryMapper { false } + fn map_deferred_on_fault( + &self, + partition: &dyn SimpleMemoryMap, + gpa: u64, + ) -> anyhow::Result { + match self.overlays.as_ref() { + Some(state) => state.lock().map_deferred_on_fault(partition, gpa), + None => Ok(false), + } + } + fn map_deferred(&self, _partition: &dyn SimpleMemoryMap) -> anyhow::Result<()> { Ok(()) } @@ -586,21 +612,25 @@ impl Overlay { } } -/// Tracks active mappings and overlay pages. Only used when the Hv1 emulator is -/// enabled, since otherwise there are no overlay pages and mapping requests can -/// be passed straight through to WHP. +/// Tracks active mappings, overlay pages, and optional on-demand GPA +/// registration. Without overlays or lazy registration, mapping requests pass +/// straight through to WHP. #[derive(Debug, Default, Inspect)] pub(crate) struct EmulatedOverlayState { + lazy_registration: bool, /// Active memory mappings. Non-overlapping, sorted by GPA. #[inspect(with = "|x| inspect::iter_by_key(x.iter().map(Mapping::as_inspect_kv))")] mappings: Vec, /// Active overlay pages. Non-overlapping, sorted by GPA. #[inspect(with = "|x| inspect::iter_by_key(x.iter().map(Overlay::as_inspect_kv))")] overlays: Vec, + /// Underlying GPA ranges already registered with WHP. + #[inspect(iter_by_index)] + registered_ranges: Vec, } impl EmulatedOverlayState { - fn map_range(&mut self, p: &dyn SimpleMemoryMap, mapping: Mapping) { + fn map_range(&mut self, p: &dyn SimpleMemoryMap, mapping: Mapping) -> anyhow::Result<()> { let index = self .mappings .binary_search_by_key(&mapping.range.start(), |m| m.range.end() - 1) @@ -609,8 +639,19 @@ impl EmulatedOverlayState { assert!(old_mapping.range.start() >= mapping.range.end()); } - let mut gpa = mapping.range.start(); - let end = mapping.range.end(); + self.map_initial_range(p, &mapping)?; + self.mappings.insert(index, mapping); + Ok(()) + } + + fn map_underlay_range( + &self, + p: &dyn SimpleMemoryMap, + mapping: &Mapping, + range: MemoryRange, + ) -> anyhow::Result<()> { + let mut gpa = range.start(); + let end = range.end(); while gpa < end { let o_index = self .overlays @@ -621,6 +662,13 @@ impl EmulatedOverlayState { .get(o_index) .map(|o| std::cmp::min(o.gpa, end)) .unwrap_or(end); + if this_end == gpa { + // An overlay page starts here; skip it without mapping. + gpa += HV_PAGE_SIZE; + continue; + } + // SAFETY: `range` is contained by `mapping.range`, whose data + // pointer remains valid until the matching unmap. unsafe { p.map_range( None, @@ -631,15 +679,16 @@ impl EmulatedOverlayState { gpa, mapping.writable, mapping.executable, - ) - .expect("cannot handle mapping failure"); + )?; } + if this_end == end { + break; + } // Skip the overlay page. gpa = this_end + HV_PAGE_SIZE; } - - self.mappings.insert(index, mapping); + Ok(()) } fn unmap_range(&mut self, p: &dyn SimpleMemoryMap, gpa: u64, len: u64) { @@ -661,6 +710,13 @@ impl EmulatedOverlayState { assert!(range.contains(&mapping.range)); } + if self.lazy_registration { + return self.unmap_registered_ranges(p, range); + } + self.unmap_underlay_range(p, range); + } + + fn unmap_underlay_range(&self, p: &dyn SimpleMemoryMap, range: MemoryRange) { let mut gpa = range.start(); let end = range.end(); while gpa < end { @@ -679,6 +735,9 @@ impl EmulatedOverlayState { .expect("cannot handle unmap failure"); } + if this_end == end { + break; + } // Skip the overlay page. gpa = this_end + HV_PAGE_SIZE; } @@ -716,6 +775,11 @@ impl EmulatedOverlayState { .unwrap(); let _overlay = self.overlays.remove(index); + if !self.underlay_registered(gpa) { + p.unmap_range(gpa, HV_PAGE_SIZE) + .expect("cannot handle unmap failure"); + return; + } // Remap the old page. let mut remapped = false; diff --git a/vmm_core/virt_whp/src/memory/lazy_registration.rs b/vmm_core/virt_whp/src/memory/lazy_registration.rs new file mode 100644 index 00000000000..61750ee48c4 --- /dev/null +++ b/vmm_core/virt_whp/src/memory/lazy_registration.rs @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! On-demand GPA registration for large guest-memory ranges. +//! +//! With lazy registration, [`WhpMemoryMapper`] registers only a bounded +//! prefix of each mapping with WHP when it is mapped, and registers the rest +//! in fixed-size chunks when the guest first accesses them. + +use super::EmulatedOverlayState; +use super::Mapping; +use super::SimpleMemoryMap; +use super::WhpMemoryMapper; +use crate::VtlPartition; +use memory_range::MemoryRange; +use parking_lot::Mutex; + +impl WhpMemoryMapper { + /// Creates a mapper that optionally registers large ranges with WHP as the + /// guest accesses them. + pub(crate) fn with_lazy_registration(with_overlays: bool, lazy_registration: bool) -> Self { + if !lazy_registration { + return Self::new(with_overlays); + } + Self { + overlays: Some(Mutex::new(EmulatedOverlayState::new(lazy_registration))), + overlays_supported: with_overlays, + } + } +} + +impl VtlPartition { + pub(crate) fn map_deferred_on_fault(&self, gpa: u64) -> anyhow::Result { + self.mapper.map_deferred_on_fault(&self.whp, gpa) + } +} + +impl EmulatedOverlayState { + // Bound synchronous restore registration to the smallest canonical guest + // size, then amortize later registration in large-page-sized chunks. + const INITIAL_REGISTRATION_BYTES: u64 = 64 * 1024 * 1024; + const REGISTRATION_CHUNK_BYTES: u64 = 2 * 1024 * 1024; + + fn new(lazy_registration: bool) -> Self { + Self { + lazy_registration, + mappings: Vec::new(), + overlays: Vec::new(), + registered_ranges: Vec::new(), + } + } + + /// Registers the initial part of a new `mapping` with WHP: all of it, or a + /// bounded prefix with lazy registration. + pub(super) fn map_initial_range( + &mut self, + p: &dyn SimpleMemoryMap, + mapping: &Mapping, + ) -> anyhow::Result<()> { + let registered_end = if self.lazy_registration { + mapping + .range + .start() + .saturating_add(Self::INITIAL_REGISTRATION_BYTES) + .min(mapping.range.end()) + } else { + mapping.range.end() + }; + let registered = MemoryRange::new(mapping.range.start()..registered_end); + self.map_underlay_range(p, mapping, registered)?; + if self.lazy_registration { + self.record_registered_range(registered); + } + Ok(()) + } + + /// Unregisters the registered ranges within the unmapped `range`. + pub(super) fn unmap_registered_ranges(&mut self, p: &dyn SimpleMemoryMap, range: MemoryRange) { + let registered_ranges = std::mem::take(&mut self.registered_ranges); + for registered in registered_ranges { + if range.contains(®istered) { + self.unmap_underlay_range(p, registered); + } else { + assert!(!range.overlaps(®istered)); + self.registered_ranges.push(registered); + } + } + } + + /// Returns whether the mapping page under `gpa` is registered with WHP. + pub(super) fn underlay_registered(&self, gpa: u64) -> bool { + !self.lazy_registration + || self + .registered_ranges + .iter() + .any(|range| range.contains_addr(gpa)) + } + + pub(super) fn map_deferred_on_fault( + &mut self, + p: &dyn SimpleMemoryMap, + gpa: u64, + ) -> anyhow::Result { + if !self.lazy_registration { + return Ok(false); + } + let Some(mapping) = self + .mappings + .iter() + .find(|mapping| mapping.range.contains_addr(gpa)) + else { + return Ok(false); + }; + if self + .registered_ranges + .iter() + .any(|range| range.contains_addr(gpa)) + { + return Ok(true); + } + + let chunk_start = mapping.range.start() + + (gpa - mapping.range.start()) / Self::REGISTRATION_CHUNK_BYTES + * Self::REGISTRATION_CHUNK_BYTES; + let chunk_end = chunk_start + .saturating_add(Self::REGISTRATION_CHUNK_BYTES) + .min(mapping.range.end()); + let range = MemoryRange::new(chunk_start..chunk_end); + self.map_underlay_range(p, mapping, range)?; + self.record_registered_range(range); + Ok(true) + } + + fn record_registered_range(&mut self, range: MemoryRange) { + assert!(!range.is_empty()); + let index = self + .registered_ranges + .partition_point(|registered| registered.start() < range.start()); + if let Some(previous) = index + .checked_sub(1) + .and_then(|previous| self.registered_ranges.get(previous)) + { + assert!(previous.end() <= range.start()); + } + if let Some(next) = self.registered_ranges.get(index) { + assert!(range.end() <= next.start()); + } + self.registered_ranges.insert(index, range); + } +} + +#[cfg(guest_arch = "x86_64")] +impl crate::WhpProcessor<'_> { + /// Registers the deferred RAM range under an unmapped-GPA access. Returns + /// whether the range was registered, so the access can be retried. + pub(crate) fn map_deferred_ram( + &self, + access: &whp::abi::WHV_MEMORY_ACCESS_CONTEXT, + backing_type: &super::x86::GpaBackingType, + ) -> bool { + if access.AccessInfo.GpaUnmapped() + && matches!(backing_type, super::x86::GpaBackingType::Ram { .. }) + { + match self.current_vtlp().map_deferred_on_fault(access.Gpa) { + Ok(true) => return true, + Ok(false) => {} + Err(err) => { + tracelimit::warn_ratelimited!( + gpa = access.Gpa, + error = ?err, + "failed to register deferred gpa range" + ); + } + } + } + false + } +} + +#[cfg(test)] +mod tests { + use crate::memory::*; + use sparse_mmap::alloc::Allocation; + use test_with_tracing::test; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct MapCall { + range: MemoryRange, + data: usize, + } + + #[derive(Debug, Default)] + struct TestPartition { + maps: Mutex>, + unmaps: Mutex>, + } + + impl SimpleMemoryMap for TestPartition { + unsafe fn map_range( + &self, + _process: Option>, + data: *mut u8, + size: usize, + addr: u64, + _writable: bool, + _exec: bool, + ) -> anyhow::Result<()> { + self.maps.lock().push(MapCall { + range: MemoryRange::new(addr..addr + size as u64), + data: data.addr(), + }); + Ok(()) + } + + fn unmap_range(&self, addr: u64, size: u64) -> anyhow::Result<()> { + self.unmaps.lock().push(MemoryRange::new(addr..addr + size)); + Ok(()) + } + } + + #[test] + fn lazy_registration_maps_a_bounded_prefix_and_fault_chunks() { + const MIB: u64 = 1024 * 1024; + let partition = TestPartition::default(); + let mapper = WhpMemoryMapper::with_lazy_registration(false, true); + let data = std::ptr::without_provenance_mut::(0x10000); + + // SAFETY: TestPartition only records the pointer and never dereferences it. + unsafe { + mapper + .map_range(&partition, None, data, (512 * MIB) as usize, 0, true, true) + .unwrap(); + } + assert_eq!( + *partition.maps.lock(), + [MapCall { + range: MemoryRange::new(0..64 * MIB), + data: data.addr(), + }] + ); + + assert!(mapper.map_deferred_on_fault(&partition, 257 * MIB).unwrap()); + assert!( + mapper + .map_deferred_on_fault(&partition, 257 * MIB + 4096) + .unwrap() + ); + assert_eq!( + *partition.maps.lock(), + [ + MapCall { + range: MemoryRange::new(0..64 * MIB), + data: data.addr(), + }, + MapCall { + range: MemoryRange::new(256 * MIB..258 * MIB), + data: data.wrapping_add((256 * MIB) as usize).addr(), + }, + ] + ); + + mapper.unmap_range(&partition, 0, 512 * MIB).unwrap(); + assert_eq!( + *partition.unmaps.lock(), + [ + MemoryRange::new(0..64 * MIB), + MemoryRange::new(256 * MIB..258 * MIB), + ] + ); + } + + #[test] + fn lazy_registration_restores_only_registered_overlay_underlays() { + const MIB: u64 = 1024 * 1024; + let partition = TestPartition::default(); + let mapper = WhpMemoryMapper::with_lazy_registration(true, true); + let data = std::ptr::without_provenance_mut::(0x10000); + + // SAFETY: TestPartition only records the pointer and never dereferences it. + unsafe { + mapper + .map_range(&partition, None, data, (128 * MIB) as usize, 0, true, true) + .unwrap(); + } + + let registered_gpa = MIB; + let registered_overlay = Arc::new(SharedMem::new( + Allocation::new(HV_PAGE_SIZE as usize).unwrap(), + )); + assert!(mapper.add_overlay_page( + &partition, + registered_gpa, + registered_overlay, + false, + false, + )); + mapper.remove_overlay_page(&partition, registered_gpa); + assert_eq!( + partition.maps.lock().last().copied(), + Some(MapCall { + range: MemoryRange::new(registered_gpa..registered_gpa + HV_PAGE_SIZE), + data: data.wrapping_add(registered_gpa as usize).addr(), + }) + ); + + let deferred_gpa = 100 * MIB; + let deferred_overlay = Arc::new(SharedMem::new( + Allocation::new(HV_PAGE_SIZE as usize).unwrap(), + )); + assert!(mapper.add_overlay_page(&partition, deferred_gpa, deferred_overlay, false, false,)); + mapper.remove_overlay_page(&partition, deferred_gpa); + assert_eq!( + partition.unmaps.lock().last().copied(), + Some(MemoryRange::new(deferred_gpa..deferred_gpa + HV_PAGE_SIZE)) + ); + } +} diff --git a/vmm_core/virt_whp/src/native_cpuid.rs b/vmm_core/virt_whp/src/native_cpuid.rs new file mode 100644 index 00000000000..25f734cd090 --- /dev/null +++ b/vmm_core/virt_whp/src/native_cpuid.rs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WHP-native CPUID leaves 0 and 1 for partitions with the Hyper-V guest +//! interface. +//! +//! Such a partition enables CPUID exits so that OpenVMM can apply the +//! partition's CPUID table. Without an exit list, WHP sends every CPUID to +//! OpenVMM, so each one leaves the hypervisor and its latency follows host +//! scheduling. That latency reaches guest code that runs CPUID with interrupts +//! disabled. The mu_msvm UEFI firmware's SynIC timer and SINT interrupt +//! handlers run CPUID leaves 0 and 1 five times per interrupt, because EDK2's +//! local APIC library probes for TDX and for the APIC base MSR on each EOI. +//! When host contention makes those exits outlast the firmware's 10 ms timer +//! period, the next tick is pending when the handler's `RestoreTPL()` enables +//! interrupts, so it nests on the same stack. The nesting repeats until the +//! firmware's DXE stack overflows. +//! +//! [`NativeCpuidLeaves::configure`] keeps these leaves in the hypervisor. +//! Before setup, it programs the partition-wide bits that the partition's +//! CPUID table sets in leaves 0 and 1 with `CpuidResultList2`, and WHP keeps +//! each VP's initial APIC ID. Every other leaf that the table sets, or that +//! the exit handler adjusts for each VP, stays in the CPUID exit list. After +//! the table is built, [`NativeCpuidLeaves::verify`] checks that the guest +//! sees the same results as through exits. + +use crate::Error; +use crate::WhpResultExt; +use std::ops::RangeInclusive; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::x86::topology::per_vp_cpuid_bits; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::ApicMode; +use whp::abi::WHV_CPUID_OUTPUT; +use whp::abi::WHV_X64_CPUID_RESULT2; +use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; +use x86defs::cpuid::CpuidFunction; +use x86defs::cpuid::VersionAndFeaturesEbx; +use x86defs::cpuid::VersionAndFeaturesEcx; + +/// The leaves that WHP answers without an exit. +const NATIVE_LEAVES: [u32; 2] = [ + CpuidFunction::VendorAndMaxFunction.0, + CpuidFunction::VersionAndFeatures.0, +]; + +/// The leaves outside the hypervisor range that still exit: the leaves that a +/// partition's CPUID table sets, other than leaves 0 and 1, and the leaves +/// that the exit handler adjusts for each VP. +const EXIT_LEAVES: [u32; 7] = [ + CpuidFunction::CacheParameters.0, + CpuidFunction::ExtendedTopologyEnumeration.0, + CpuidFunction::CoreCrystalClockInformation.0, + CpuidFunction::V2ExtendedTopologyEnumeration.0, + CpuidFunction::ExtendedAddressSpaceSizes.0, + CpuidFunction::CacheTopologyDefinition.0, + CpuidFunction::ProcessorTopologyDefinition.0, +]; + +/// The hypervisor leaves that still exit: the Hyper-V interface leaves, which +/// the CPUID table masks, and the virtualization stack's leaves, which the VMM +/// adds. Each exit-list entry adds about 25 µs to partition setup, so the rest +/// of the hypervisor range stays in the hypervisor, and +/// [`NativeCpuidLeaves::verify`] rejects a CPUID table that sets one of those +/// leaves. +const EXIT_HYPERVISOR_LEAVES: [RangeInclusive; 2] = [ + hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION..=0x4000_000f, + hvdef::VIRTUALIZATION_STACK_CPUID_VENDOR..=0x4000_008f, +]; + +/// A partition whose CPUID leaves 0 and 1 WHP answers without exits. +#[derive(Debug)] +pub(crate) struct NativeCpuidLeaves { + /// The CPUID exit list, sorted. + exits: Vec, +} + +impl NativeCpuidLeaves { + /// Programs `whp_config` so that WHP answers CPUID leaves 0 and 1 without + /// exits, for a partition with the in-hypervisor APIC and enlightenments. + /// + /// Returns `None`, leaving every leaf to exit, when a VP's APIC ID differs + /// from its index, the host's processor vendor is unknown, or WHP rejects + /// the programming. + pub(crate) fn configure( + topology: &ProcessorTopology, + whp_config: &mut whp::PartitionConfig, + ) -> Option { + // WHP's initial APIC ID is the VP index unless OpenVMM sets it, so + // only then does WHP's leaf 1 report each VP's APIC ID. + if topology + .vps_arch() + .any(|vp| vp.apic_id != vp.base.vp_index.index()) + { + return None; + } + + let results = native_results(topology, &host_cpuid)?; + if let Err(err) = + whp_config.set_property(whp::PartitionProperty::CpuidResultList2(&results)) + { + tracing::info!( + error = &err as &dyn std::error::Error, + "WHP lacks CPUID results; CPUID leaves 0 and 1 exit" + ); + return None; + } + + // The results programmed above match the CPUID table, so leaves 0 and + // 1 stay correct through exits if WHP rejects the exit list. + let exits = exit_leaves(); + if let Err(err) = whp_config.set_property(whp::PartitionProperty::CpuidExitList(&exits)) { + tracing::info!( + error = &err as &dyn std::error::Error, + "WHP rejects the CPUID exit list; CPUID leaves 0 and 1 exit" + ); + return None; + } + + Some(Self { exits }) + } + + /// Checks that every leaf of `cpuid`, the partition's CPUID table, reaches + /// the guest: a leaf exits, or it is leaf 0 or 1 and `native`, WHP's + /// result for VP 0, already matches the table outside the per-VP APIC + /// identity bits. + pub(crate) fn verify( + &self, + cpuid: &CpuidLeafSet, + native: impl Fn(u32, u32) -> [u32; 4], + ) -> Result<(), Error> { + for leaf in cpuid.leaves() { + if self.exits.binary_search(&leaf.function).is_ok() { + continue; + } + if !NATIVE_LEAVES.contains(&leaf.function) { + return Err(Error::NativeCpuidUnrouted(leaf.function)); + } + let index = leaf.index.unwrap_or(0); + let native = native(leaf.function, index); + let expected = cpuid.result(leaf.function, index, &native); + let per_vp = per_vp_cpuid_bits(leaf.function); + if (0..4).any(|i| (native[i] ^ expected[i]) & !per_vp[i] != 0) { + return Err(Error::NativeCpuidMismatch { + function: leaf.function, + native, + expected, + }); + } + } + Ok(()) + } + + /// Checks that WHP reports `apic_id` as the initial APIC ID in VP + /// `vp_index`'s leaf 1, which WHP answers without an exit. + pub(crate) fn verify_apic_id( + &self, + vp: whp::Processor<'_>, + vp_index: u32, + apic_id: u32, + ) -> Result<(), Error> { + let output = vp + .get_cpuid_output(CpuidFunction::VersionAndFeatures.0, 0) + .for_op("get cpuid output")?; + let native = VersionAndFeaturesEbx::from(output.Ebx).initial_apic_id(); + if u32::from(native) != apic_id & 0xff { + return Err(Error::NativeCpuidApicId { + vp_index, + native: native.into(), + apic_id, + }); + } + Ok(()) + } +} + +/// Returns the leaf 1 result that reports `apic_mode`'s x2APIC support. +pub(crate) fn x2apic_leaf(apic_mode: ApicMode) -> CpuidLeaf { + let mask = VersionAndFeaturesEcx::new().with_x2_apic(true).into(); + let value = match apic_mode { + ApicMode::XApic => 0, + ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => mask, + }; + CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, value, 0]).masked([0, 0, mask, 0]) +} + +/// Returns the CPUID exit list, sorted. +fn exit_leaves() -> Vec { + let mut exits: Vec = EXIT_LEAVES + .into_iter() + .chain(EXIT_HYPERVISOR_LEAVES.into_iter().flatten()) + .collect(); + exits.sort_unstable(); + exits.dedup(); + exits +} + +/// Returns the `CpuidResultList2` results for leaves 0 and 1: the bits of +/// the partition's CPUID table that are partition-wide and known before +/// setup, where `host` reads the host's CPUID. Returns `None` if the host's +/// processor vendor is unknown. +/// +/// Leaf 1 reports x2APIC support, the hypervisor, and the topology's +/// processors per package; WHP keeps each VP's initial APIC ID. Leaf 0 +/// raises the maximum basic leaf to the TSC frequency leaf when the host's +/// is lower. WHP's maximum leaves are the host's, and [`NativeCpuidLeaves::verify`] +/// checks both results against the table after setup. +fn native_results( + topology: &ProcessorTopology, + host: &dyn Fn(u32, u32) -> [u32; 4], +) -> Option> { + let hypervisor = VersionAndFeaturesEcx::new() + .with_hypervisor_present(true) + .into(); + let mut leaves = vec![ + x2apic_leaf(topology.apic_mode()), + CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, hypervisor, 0]) + .masked([0, 0, hypervisor, 0]), + ]; + virt::x86::topology::topology_cpuid(topology, host, &mut leaves).ok()?; + let cpuid = CpuidLeafSet::new( + leaves + .into_iter() + .filter(|leaf| leaf.function == CpuidFunction::VersionAndFeatures.0) + .collect(), + ); + + let mut results = Vec::new(); + let tsc_leaf = CpuidFunction::CoreCrystalClockInformation.0; + if host(CpuidFunction::VendorAndMaxFunction.0, 0)[0] < tsc_leaf { + results.push(result( + CpuidFunction::VendorAndMaxFunction.0, + [tsc_leaf, 0, 0, 0], + [!0, 0, 0, 0], + )); + } + for leaf in cpuid.leaves() { + let per_vp = per_vp_cpuid_bits(leaf.function); + let mask: [u32; 4] = std::array::from_fn(|i| leaf.mask[i] & !per_vp[i]); + results.push(result(leaf.function, leaf.result, mask)); + } + Some(results) +} + +/// Returns a partition-wide `CpuidResultList2` result for `function` that +/// sets the bits of `value` in `mask`. +fn result(function: u32, value: [u32; 4], mask: [u32; 4]) -> WHV_X64_CPUID_RESULT2 { + let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + }; + WHV_X64_CPUID_RESULT2 { + Function: function, + Index: 0, + VpIndex: 0, + Flags: WHV_X64_CPUID_RESULT2_FLAGS(0), + Output: output(std::array::from_fn(|i| value[i] & mask[i])), + Mask: output(mask), + } +} + +/// Returns the host's CPUID result. +fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + // The CPUID instruction exists only on x86-64 hosts, the only hosts of + // x86-64 WHP partitions. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + let _ = (leaf, subleaf); + [0; 4] + } +} + +#[cfg(test)] +mod tests { + use super::EXIT_HYPERVISOR_LEAVES; + use super::NATIVE_LEAVES; + use super::NativeCpuidLeaves; + use super::exit_leaves; + use super::native_results; + use super::x2apic_leaf; + use crate::Error; + use virt::CpuidLeaf; + use virt::CpuidLeafSet; + use vm_topology::processor::ProcessorTopology; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use whp::abi::WHV_X64_CPUID_RESULT2; + use x86defs::cpuid::VersionAndFeaturesEbx; + use x86defs::cpuid::VersionAndFeaturesEcx; + + const INTEL: [u32; 3] = [0x756e_6547, 0x6c65_746e, 0x4965_6e69]; + const AMD: [u32; 3] = [0x6874_7541, 0x444d_4163, 0x6974_6e65]; + + /// A Skylake-SP host: maximum basic leaf 0x16, GenuineIntel. + fn skylake(leaf: u32, _subleaf: u32) -> [u32; 4] { + match leaf { + 0 => [0x16, INTEL[0], INTEL[1], INTEL[2]], + _ => [0; 4], + } + } + + /// A host whose maximum basic leaf precedes the TSC frequency leaf. + fn haswell(leaf: u32, _subleaf: u32) -> [u32; 4] { + match leaf { + 0 => [0xd, INTEL[0], INTEL[1], INTEL[2]], + _ => [0; 4], + } + } + + /// An AMD host as an AMD EPYC 7763 reports it through WHP: maximum basic + /// leaf 0xd, AuthenticAMD, and four caches in leaf 0x8000001D. + fn milan(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [0xd, AMD[0], AMD[1], AMD[2]], + (0x8000_001d, 0) => [0x121, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 1) => [0x122, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 2) => [0x143, 0x01c0_003f, 0x3ff, 2], + (0x8000_001d, 3) => [0x163, 0x03c0_003f, 0x7fff, 1], + _ => [0; 4], + } + } + + fn topology(vps: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vps) + .x2apic(x2apic) + .build(vps) + .unwrap() + } + + fn output(result: &WHV_X64_CPUID_RESULT2) -> ([u32; 4], [u32; 4]) { + let registers = |o: whp::abi::WHV_CPUID_OUTPUT| [o.Eax, o.Ebx, o.Ecx, o.Edx]; + (registers(result.Output), registers(result.Mask)) + } + + /// The partition's CPUID table as `WhpPartitionInner` builds it on `host` + /// from WHP's leaf 0, plus its hypervisor leaves. + fn table( + topology: &ProcessorTopology, + host: &dyn Fn(u32, u32) -> [u32; 4], + native_max: u32, + ) -> CpuidLeafSet { + let mut leaves = vec![ + x2apic_leaf(topology.apic_mode()), + CpuidLeaf::new(1, [0, 0, 1 << 31, 0]).masked([0, 0, 1 << 31, 0]), + crate::cpu_contract::mask_gpa_pinning_enlightenment(), + CpuidLeaf::new(hvdef::VIRTUALIZATION_STACK_CPUID_VENDOR, [1, 2, 3, 4]), + CpuidLeaf::new(hvdef::VIRTUALIZATION_STACK_CPUID_PROPERTIES, [1, 2, 3, 4]), + ]; + virt::x86::topology::topology_cpuid(topology, host, &mut leaves).unwrap(); + leaves + .extend(virt::x86::tsc::tsc_frequency_cpuid_leaves(2_200_000_000, native_max).unwrap()); + CpuidLeafSet::new(leaves) + } + + /// WHP's result after the programmed results, for a host with + /// `native_max` and a VP with APIC ID `apic_id`. + fn native( + results: &[WHV_X64_CPUID_RESULT2], + native_max: u32, + apic_id: u8, + ) -> impl Fn(u32, u32) -> [u32; 4] { + let results = results.to_vec(); + move |leaf, _subleaf| { + let mut value = match leaf { + 0 => [native_max, INTEL[0], INTEL[1], INTEL[2]], + 1 => [ + 0x50654, + VersionAndFeaturesEbx::new() + .with_initial_apic_id(apic_id) + .with_clflush_line_size(8) + .into(), + VersionAndFeaturesEcx::new().with_sse3(true).into(), + 0x0f8b_fbff, + ], + _ => [0; 4], + }; + for result in results.iter().filter(|r| r.Function == leaf) { + let (output, mask) = output(result); + for i in 0..4 { + value[i] = value[i] & !mask[i] | output[i]; + } + } + value + } + } + + #[test] + fn exit_list_routes_the_table_and_per_vp_leaves() { + let exits = exit_leaves(); + assert!(exits.is_sorted()); + for leaf in NATIVE_LEAVES.into_iter().chain([0x4000_0010, 0x4000_00ff]) { + assert!(exits.binary_search(&leaf).is_err(), "leaf {leaf:#x}"); + } + for leaf in [0x4, 0xb, 0x15, 0x1f, 0x8000_0008, 0x8000_001d, 0x8000_001e] + .into_iter() + .chain(EXIT_HYPERVISOR_LEAVES.into_iter().flatten()) + { + assert!(exits.binary_search(&leaf).is_ok(), "leaf {leaf:#x}"); + } + assert_eq!(exits.len(), 39); + } + + #[test] + fn leaf_1_results_keep_the_per_vp_apic_id() { + let topology = topology(2, X2ApicState::Supported); + let results = native_results(&topology, &skylake).unwrap(); + let [leaf_1] = results.as_slice() else { + panic!("expected only leaf 1: {results:?}"); + }; + let (value, mask) = output(leaf_1); + assert_eq!(leaf_1.Function, 1); + let lps = VersionAndFeaturesEbx::new().with_lps_per_package(0xff); + assert_eq!(mask[1], u32::from(lps)); + assert_eq!( + VersionAndFeaturesEbx::from(value[1]).lps_per_package(), + topology.reserved_vps_per_socket() as u8 + ); + let ecx = VersionAndFeaturesEcx::from(value[2]); + assert!(ecx.x2_apic() && ecx.hypervisor_present()); + assert_eq!( + mask[2], + u32::from( + VersionAndFeaturesEcx::new() + .with_x2_apic(true) + .with_hypervisor_present(true) + ) + ); + assert_eq!([mask[0], mask[3]], [0, 0]); + } + + #[test] + fn xapic_partitions_clear_the_x2apic_bit() { + let results = native_results(&topology(2, X2ApicState::Unsupported), &skylake).unwrap(); + let (value, mask) = output(&results[0]); + assert!(VersionAndFeaturesEcx::from(mask[2]).x2_apic()); + assert!(!VersionAndFeaturesEcx::from(value[2]).x2_apic()); + } + + #[test] + fn leaf_0_reaches_the_tsc_leaf_only_when_the_host_stops_short() { + let topology = topology(2, X2ApicState::Supported); + assert!( + native_results(&topology, &skylake) + .unwrap() + .iter() + .all(|r| r.Function != 0) + ); + let results = native_results(&topology, &haswell).unwrap(); + let leaf_0 = results.iter().find(|r| r.Function == 0).unwrap(); + assert_eq!(output(leaf_0), ([0x15, 0, 0, 0], [!0, 0, 0, 0])); + } + + #[test] + fn verify_accepts_the_programmed_partition() { + for (host, native_max) in [ + (skylake as fn(u32, u32) -> [u32; 4], 0x16), + (haswell, 0xd), + (milan, 0xd), + ] { + let topology = topology(4, X2ApicState::Supported); + let results = native_results(&topology, &host).unwrap(); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let reported_max = native(&results, native_max, 0)(0, 0)[0]; + let cpuid = table(&topology, &host, reported_max); + for apic_id in [0, 3] { + native_leaves + .verify(&cpuid, native(&results, native_max, apic_id)) + .unwrap(); + } + } + } + + #[test] + fn verify_rejects_a_table_leaf_that_does_not_exit() { + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let results = native_results(&topology, &skylake).unwrap(); + for function in [7, 0x4000_0010] { + let cpuid = CpuidLeafSet::new(vec![ + CpuidLeaf::new(function, [0, 1, 0, 0]).masked([0, 1, 0, 0]), + ]); + assert!(matches!( + native_leaves.verify(&cpuid, native(&results, 0x16, 0)), + Err(Error::NativeCpuidUnrouted(f)) if f == function + )); + } + } + + #[test] + fn verify_rejects_a_leaf_1_bit_that_whp_does_not_report() { + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let cpuid = table(&topology, &skylake, 0x16); + // WHP without the programmed results reports no processors per + // package. + assert!(matches!( + native_leaves.verify(&cpuid, native(&[], 0x16, 0)), + Err(Error::NativeCpuidMismatch { function: 1, .. }) + )); + } + + #[test] + fn verify_rejects_a_maximum_basic_leaf_below_the_tsc_leaf() { + // The host reports leaf 0x16, so leaf 0 is not programmed, but WHP + // reports a lower maximum basic leaf. + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let results = native_results(&topology, &skylake).unwrap(); + let cpuid = table(&topology, &skylake, 0xd); + assert!(matches!( + native_leaves.verify(&cpuid, native(&results, 0xd, 0)), + Err(Error::NativeCpuidMismatch { function: 0, .. }) + )); + } + + #[test] + fn unknown_vendors_keep_every_leaf_exiting() { + let unknown = |leaf: u32, _subleaf: u32| match leaf { + 0 => [0x16, 1, 2, 3], + _ => [0; 4], + }; + assert!(native_results(&topology(2, X2ApicState::Supported), &unknown).is_none()); + } + + /// Programs a two-VP partition and checks what + /// `WHvGetVirtualProcessorCpuidOutput` returns for leaves 0 and 1. + #[test] + #[ignore = "requires WHP"] + fn whp_answers_the_programmed_leaves() { + let topology = topology(2, X2ApicState::Supported); + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(2)) + .unwrap(); + config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeX2Apic, + )) + .unwrap(); + config + .set_property(whp::PartitionProperty::ExtendedVmExits( + whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit, + )) + .unwrap(); + let native_leaves = NativeCpuidLeaves::configure(&topology, &mut config) + .expect("WHP accepts the CPUID results and the exit list"); + let partition = config.create().unwrap(); + for vp in 0..2 { + partition.create_vp(vp).create().unwrap(); + } + for vp in 0..2 { + let output = partition.vp(vp).get_cpuid_output(1, 0).unwrap(); + let ebx = VersionAndFeaturesEbx::from(output.Ebx); + let ecx = VersionAndFeaturesEcx::from(output.Ecx); + assert_eq!(ebx.initial_apic_id(), vp as u8); + assert_eq!( + ebx.lps_per_package(), + topology.reserved_vps_per_socket() as u8 + ); + assert!(ecx.x2_apic() && ecx.hypervisor_present()); + native_leaves + .verify_apic_id(partition.vp(vp), vp, vp) + .unwrap(); + } + let max = partition.vp(0).get_cpuid_output(0, 0).unwrap().Eax; + assert!(max >= 0x15, "maximum basic leaf {max:#x}"); + } +} diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs new file mode 100644 index 00000000000..672f91bd21d --- /dev/null +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -0,0 +1,888 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WHP processor features derived from a CPU profile, and the CPU surface +//! that WHP supports. +//! +//! A WHP partition presents a CPU feature only when the feature's processor +//! feature bank bit (or XSAVE feature bit) is set, and some of those bits also +//! decide which MSRs the guest may use (for example `IA32_SPEC_CTRL`). +//! [`profile_features`] derives the partition's features from its CPU profile +//! with the Hyper-V feature table that MSHV shares +//! ([`hv_banks::HV_FEATURES`]): it starts from the features WHP offers, clears +//! every bit whose CPUID feature the profile clears, and restricts the +//! `IA32_ARCH_CAPABILITIES` bits that the banks derive to the profile's pinned +//! value. Features that no bit controls (for example PKU), the descriptive +//! leaves (caches, the brand string), and the time policy bits a host lacks +//! (ARAT and invariant TSC on Azure) reach the guest through the time ABI's +//! CPUID results: the partition's complete effective CPUID, which +//! `configure` programs with `CpuidResultList2`. +//! +//! [`supported_surface`] reports what WHP supports without a probe partition. +//! The hardware tests check the table and the surface against what WHP +//! presents on a host. + +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::hv_banks; +use cpu_profile::hv_banks::HvFeatures; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; + +/// `IA32_ARCH_CAPABILITIES`. +#[cfg(test)] +const MSR_ARCH_CAPABILITIES: u32 = 0x10a; + +const EAX: usize = 0; +#[cfg(test)] +const EBX: usize = 1; +const ECX: usize = 2; +const EDX: usize = 3; + +/// The extended feature leaf. +#[cfg(test)] +const X1: u32 = 0x8000_0001; +/// The advanced power management leaf. +#[cfg(test)] +const X7: u32 = 0x8000_0007; +/// The AMD extended feature identifiers leaf. +const X8: u32 = 0x8000_0008; + +/// WHP processor features: feature banks 0 and 1, and the XSAVE features. +pub(crate) type WhpFeatures = HvFeatures; + +/// Returns the WHP features of a partition that presents `profile`, given +/// the features WHP offers ([`hv_banks::profile_features`]). Fails with +/// `E_PROFILE_UNSUPPORTED` when WHP cannot present the profile's features +/// or its `IA32_ARCH_CAPABILITIES`. +pub(crate) fn profile_features( + profile: &CpuProfile, + available: WhpFeatures, +) -> Result { + hv_banks::profile_features(profile, available) + .map_err(|err| TimeAbiError::new(TimeAbiCode::ProfileUnsupported, err.message)) +} + +/// Returns whether a partition with `available` features supports XSAVE +/// state component `component` (an XCR0 or `IA32_XSS` bit). PKRU has no WHP +/// feature: like PKU, it follows the host. +fn xsave_component_enabled(component: u32, available: WhpFeatures) -> bool { + let xsave = XsaveBank::from(available.xsave); + let bank1 = Bank1::from(available.banks[1]); + if !xsave.xsave_support() { + return false; + } + match component { + // x87, SSE, and PKRU. + 0 | 1 | 9 => true, + 2 => xsave.avx_support(), + 3 | 4 => xsave.mpx_support(), + 5..=7 => xsave.avx512_support(), + // CET user and supervisor state. + 11 | 12 => { + xsave.xsave_supervisor_support() && (bank1.cet_ss_support() || bank1.cet_ibt_support()) + } + 17 | 18 => xsave.amx_tile_support(), + _ => false, + } +} + +/// Returns the CPU surface that WHP supports for a partition with every +/// feature in `available`, without a probe partition: `host`, the host's +/// CPUID as the root partition sees it, with +/// +/// - every CPUID feature bit that a WHP feature controls cleared where +/// `available` lacks the feature ([`hv_banks::restrict_cpuid_to_features`]); +/// - only the XSAVE state components that `available` enables, in leaf +/// `0xD` (subleaves 0 and 1, and the component subleaves, whose layout is +/// the host's); +/// - no hypervisor-range leaf, since the partition has no synthetic +/// processor features. +/// +/// The guest physical address width, in the surface and in CPUID +/// `0x80000008` EAX[7:0], is `physical_address_width`, the partition's: WHP's +/// limit can differ from the host's. The other bits, the maximum leaves, and +/// the linear address width are the host's. `IA32_ARCH_CAPABILITIES` is what +/// the banks derive. +pub(crate) fn supported_surface( + host: &[CpuidEntry], + available: WhpFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + let components = (0..64) + .filter(|&component| xsave_component_enabled(component, available)) + .fold(0u64, |mask, component| mask | 1 << component); + let mut host: Vec = host + .iter() + .filter(|entry| !(0x4000_0000..=0x4fff_ffff).contains(&entry.leaf.0)) + .cloned() + .collect(); + hv_banks::restrict_cpuid_to_features(&mut host, available); + + let cpuid = host + .iter() + .filter_map(|entry| { + let (leaf, subleaf) = entry.key(); + let index = subleaf.unwrap_or(0); + if leaf == 0xd && (2..64).contains(&index) && components & (1 << index) == 0 { + return None; + } + let mut registers = entry.registers(); + match (leaf, index) { + (0xd, 0) => { + registers[EAX] &= components as u32; + registers[EDX] &= (components >> 32) as u32; + } + (0xd, 1) => { + registers[ECX] &= components as u32; + registers[EDX] &= (components >> 32) as u32; + } + (X8, _) => { + registers[EAX] = (registers[EAX] & !0xff) | u32::from(physical_address_width); + } + _ => {} + } + let result = virt::CpuidLeaf::new(leaf, registers); + Some(match subleaf { + Some(subleaf) => result.indexed(subleaf), + None => result, + }) + }) + .collect(); + + let arch_capabilities = hv_banks::arch_capabilities_msr(available.banks); + SupportedCpuSurface { + cpuid, + physical_address_width, + msrs: vec![SupportedMsrValue { + index: arch_capabilities.index, + supported: arch_capabilities.supported, + controllable: arch_capabilities.controllable, + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hvdef::HvX64PartitionProcessorFeatures as Bank0; + + /// Feature capabilities that WHP reported on the fleet's hosts (CPU + /// fingerprints): the bare-metal Skylake-SP host and the 8370C + /// runners (Ice Lake, nested). + const SKYLAKE_SP_WHP_HOST: WhpFeatures = WhpFeatures { + banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], + xsave: 0x3fff, + }; + const AZURE_8370C: WhpFeatures = WhpFeatures { + banks: [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1], + xsave: 0x7f_ffdf, + }; + + #[test] + fn profiles_derive_and_shortfalls_are_unsupported() { + let skylake = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + profile_features(skylake, SKYLAKE_SP_WHP_HOST).unwrap(); + let emerald = cpu_profile::pinned("intel.emeraldrapids.v1").unwrap(); + let error = profile_features(emerald, SKYLAKE_SP_WHP_HOST).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + assert!(error.message.contains("avx512"), "{error}"); + } + + #[test] + fn the_supported_surface_follows_the_capability() { + // A host that reports every feature, AVX-512 and AMX state, and a + // hypervisor leaf. + let mut host = vec![ + CpuidEntry::new(0, None, [0xd, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + CpuidEntry::new(1, None, [0x0005_0654, 0, !0, !0]), + CpuidEntry::new(7, Some(0), [0, !0, !0, !0]), + CpuidEntry::new(0xd, Some(0), [0x6_02e7, 0x2b00, 0x2b00, 0]), + CpuidEntry::new(0xd, Some(1), [0xf, 0, 0x1800, 0]), + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 1, 2, 3]), + CpuidEntry::new(X8, None, [0x3_302e, 0, 0, 0]), + ]; + for component in [2, 5, 6, 7, 9, 17, 18] { + host.push(CpuidEntry::new(0xd, Some(component), [8, 64, 0, 0])); + } + host.sort_by_key(CpuidEntry::key); + let mut available = AZURE_8370C; + available.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); + available.banks[0] &= !Bank0::new().with_smep_support(true).into_bits(); + let surface = supported_surface(&host, available, 0x2e); + let lookup = |leaf: u32, subleaf: u32| { + surface + .cpuid + .iter() + .find(|result| result.matches(leaf, subleaf)) + .map(|result| result.result) + }; + // A feature WHP does not offer is cleared; one it offers stays. + assert_eq!(lookup(7, 0).unwrap()[EBX] & (1 << 7), 0, "SMEP"); + assert_ne!(lookup(7, 0).unwrap()[EBX] & 1, 0, "FSGSBASE"); + assert_eq!(lookup(7, 0).unwrap()[EBX] & (1 << 16), 0, "AVX512F"); + // Bits no WHP feature controls keep the host's value. + assert_ne!(lookup(1, 0).unwrap()[EDX] & 1, 0, "FPU"); + // Only the XSAVE components the features enable remain: no AVX-512 + // and no AMX (the 8370C offers no AMX tiles). + assert_eq!(lookup(0xd, 0).unwrap()[EAX], 0x207); + assert!(lookup(0xd, 5).is_none() && lookup(0xd, 17).is_none()); + assert_eq!(lookup(0xd, 2).unwrap(), [8, 64, 0, 0]); + // No hypervisor leaf; the physical address width is the partition's. + assert!(lookup(0x4000_0000, 0).is_none()); + assert_eq!(surface.physical_address_width, 0x2e); + let narrow = supported_surface(&host, available, 0x27); + let x8 = narrow + .cpuid + .iter() + .find(|result| result.function == X8) + .unwrap(); + assert_eq!(x8.result[EAX], 0x3_3027); + assert_eq!(surface.msrs.len(), 1); + assert_eq!(surface.msrs[0].index, MSR_ARCH_CAPABILITIES); + } +} + +#[cfg(test)] +mod whp_tests { + //! Tests that need WHP. Run them on a host with + //! `cargo test -p virt_whp -- --ignored`. + + use super::*; + use cpu_profile::CpuVendor; + use cpu_profile::HostCpuSignature; + use cpu_profile::hv_banks::CpuidBit; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::HvFeature; + use cpu_profile::hv_banks::HvFeatureWord; + use whp::abi::WHV_CPUID_OUTPUT; + use whp::abi::WHV_PROCESSOR_FEATURES; + use whp::abi::WHV_PROCESSOR_FEATURES1; + use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; + use whp::abi::WHV_X64_CPUID_RESULT2; + use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; + use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; + + /// The leaves whose feature bits the WHP features control. + const FEATURE_LEAVES: [(u32, u32); 14] = [ + (1, 0), + (6, 0), + (7, 0), + (7, 1), + (7, 2), + (0xd, 0), + (0xd, 1), + (0x14, 0), + (0x19, 0), + (0x24, 0), + (X1, 0), + (X7, 0), + (X8, 0), + (0x8000_0021, 0), + ]; + + fn available() -> WhpFeatures { + let banks = whp::capabilities::processor_features().unwrap(); + let xsave = whp::capabilities::processor_xsave_features().unwrap(); + WhpFeatures { + banks: [banks.bank0.0, banks.bank1.0], + xsave: xsave.0, + } + } + + /// Creates a partition with `vp_count` VPs, `features`, and the CPUID + /// `results`. + fn probe_partition_with( + features: WhpFeatures, + vp_count: u32, + results: &[WHV_X64_CPUID_RESULT2], + ) -> Result { + let mut config = whp::PartitionConfig::new()?; + config.set_property(whp::PartitionProperty::ProcessorCount(vp_count))?; + config.set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + ))?; + let mut banks = whp::capabilities::processor_features()?; + banks.bank0 = WHV_PROCESSOR_FEATURES(features.banks[0]); + banks.bank1 = WHV_PROCESSOR_FEATURES1(features.banks[1]); + config.set_property(whp::PartitionProperty::ProcessorFeaturesBanks(banks))?; + config.set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(features.xsave), + ))?; + if !results.is_empty() { + config.set_property(whp::PartitionProperty::CpuidResultList2(results))?; + } + let partition = config.create()?; + for vp in 0..vp_count { + partition.create_vp(vp).create()?; + } + Ok(partition) + } + + /// Creates a one-VP partition with `features`. + fn probe_partition(features: WhpFeatures) -> Result { + probe_partition_with(features, 1, &[]) + } + + fn read_vp(partition: &whp::Partition, vp: u32, leaf: u32, subleaf: u32) -> [u32; 4] { + let output = partition.vp(vp).get_cpuid_output(leaf, subleaf).unwrap(); + [output.Eax, output.Ebx, output.Ecx, output.Edx] + } + + fn read(partition: &whp::Partition, leaf: u32, subleaf: u32) -> [u32; 4] { + read_vp(partition, 0, leaf, subleaf) + } + + fn feature_cpuid(partition: &whp::Partition) -> Vec<[u32; 4]> { + FEATURE_LEAVES + .iter() + .map(|&(leaf, subleaf)| read(partition, leaf, subleaf)) + .collect() + } + + /// The CPUID bits set in `base` and clear in `probe`. + fn removed(base: &[[u32; 4]], probe: &[[u32; 4]]) -> Vec { + let mut bits = Vec::new(); + for (index, &(leaf, subleaf)) in FEATURE_LEAVES.iter().enumerate() { + for register in 0..4 { + // Leaf 0xD.0 reports XSAVE sizes and components, and leaf 7.0 + // EAX the last subleaf; neither holds features. + if (leaf, subleaf) == (0xd, 0) || (leaf, subleaf, register) == (7, 0, EAX) { + continue; + } + let lost = base[index][register] & !probe[index][register]; + for bit in 0..32 { + if lost & (1 << bit) != 0 { + bits.push(CpuidBit::new(leaf, subleaf, register, bit)); + } + } + } + } + bits + } + + /// The vendor of this host's CPU, whose CPUID bits the features control. + fn host_vendor() -> CpuVendor { + CpuVendor::from_cpuid_vendor(&HostCpuSignature::current().vendor()) + .unwrap_or(CpuVendor::Intel) + } + + /// Whether another feature maps the same CPUID bit on `vendor`'s CPUs + /// (as IBRS and IBPB do on Intel's): WHP may derive the bit from either. + fn shared(feature: &HvFeature, vendor: CpuVendor) -> bool { + feature.cpuid_for(vendor).is_some_and(|bit| { + HV_FEATURES + .iter() + .any(|other| !std::ptr::eq(other, feature) && other.cpuid_for(vendor) == Some(bit)) + }) + } + + fn list(bits: &[CpuidBit]) -> String { + if bits.is_empty() { + return "none".to_owned(); + } + bits.iter() + .map(|bit| bit.to_string()) + .collect::>() + .join(" ") + } + + /// Clears each feature bit WHP offers, one at a time, and checks that the + /// CPUID bit the table maps to it on this host's vendor disappears. + /// Prints what every bit controls on this host. + #[test] + #[ignore = "requires WHP"] + fn features_control_the_mapped_cpuid_bits() { + let vendor = host_vendor(); + let available = available(); + let base = feature_cpuid(&probe_partition(available).unwrap()); + println!( + "{vendor}: available: bank0 {:#x} bank1 {:#x} xsave {:#x}", + available.banks[0], available.banks[1], available.xsave + ); + let mut failures = Vec::new(); + for bank in HvFeatureWord::ALL { + let word = available.word(bank); + for bit in (0..64).filter(|bit| word & (1 << bit) != 0) { + let mask = 1u64 << bit; + let feature = HV_FEATURES + .iter() + .find(|feature| feature.word == bank && feature.mask == mask); + let name = feature.map_or("(unmapped)", |feature| feature.name()); + let mut features = available; + *features.word_mut(bank) &= !mask; + let probe = match probe_partition(features) { + Ok(partition) => feature_cpuid(&partition), + Err(err) => { + println!("{} bit {bit} {name}: rejected: {err}", bank.name()); + continue; + } + }; + let removed = removed(&base, &probe); + let mut verdict = ""; + if let Some((feature, cpuid)) = + feature.and_then(|feature| Some((feature, feature.cpuid_for(vendor)?))) + { + let index = FEATURE_LEAVES + .iter() + .position(|&key| key == (cpuid.leaf, cpuid.subleaf)) + .unwrap(); + let offered = base[index][cpuid.register] & (1 << cpuid.bit) != 0; + if offered && !shared(feature, vendor) && !removed.contains(&cpuid) { + verdict = " MISMATCH"; + failures.push(format!( + "{} bit {bit} {name}: expected {} removed {}", + bank.name(), + cpuid, + list(&removed) + )); + } + } + println!( + "{} bit {bit} {name}: removes {}{verdict}", + bank.name(), + list(&removed) + ); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Derives the features for this host's profile and reports the profile + /// bits that the features alone do not present (the CPUID results must + /// supply them). Fails if a mapped feature bit disagrees. + #[test] + #[ignore = "requires WHP"] + fn host_profile_features_present_the_profile() { + let profile = match cpu_profile::select_auto(&HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let available = available(); + let features = profile_features(profile, available).unwrap(); + println!( + "{}: bank0 {:#x} -> {:#x}, bank1 {:#x} -> {:#x}, xsave {:#x} -> {:#x}", + profile.id(), + available.banks[0], + features.banks[0], + available.banks[1], + features.banks[1], + available.xsave, + features.xsave + ); + let partition = probe_partition(features).unwrap(); + let mut failures = Vec::new(); + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let subleaf = subleaf.unwrap_or(0); + let actual = read(&partition, leaf, subleaf); + for register in 0..4 { + let mask = entry.masks()[register]; + let differs = (actual[register] ^ entry.values()[register]) & mask; + if differs == 0 { + continue; + } + let bits: Vec<_> = (0..32) + .filter(|bit| differs & (1 << bit) != 0) + .map(|bit| CpuidBit::new(leaf, subleaf, register, bit)) + .collect(); + let mapped: Vec<_> = bits + .iter() + .filter(|&&bit| { + HV_FEATURES.iter().any(|feature| { + feature.cpuid_for(profile.cpu_vendor()) == Some(bit) + && !feature.is_time_policy() + }) + }) + .copied() + .collect(); + println!( + "{leaf:#x}.{subleaf} {}: actual {:#010x} profile {:#010x} mask {mask:#010x} ({})", + ["eax", "ebx", "ecx", "edx"][register], + actual[register], + entry.values()[register], + list(&bits) + ); + if !mapped.is_empty() { + failures.push(list(&mapped)); + } + } + } + assert!(failures.is_empty(), "mapped bits differ: {failures:?}"); + } + + fn output(registers: [u32; 4]) -> WHV_CPUID_OUTPUT { + WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + } + } + + fn result2( + leaf: u32, + subleaf: Option, + vp: Option, + value: [u32; 4], + mask: [u32; 4], + ) -> WHV_X64_CPUID_RESULT2 { + let mut flags = WHV_X64_CPUID_RESULT2_FLAGS(0); + if subleaf.is_some() { + flags |= WHvX64CpuidResult2FlagSubleafSpecific; + } + if vp.is_some() { + flags |= whp::abi::WHvX64CpuidResult2FlagVpSpecific; + } + WHV_X64_CPUID_RESULT2 { + Function: leaf, + Index: subleaf.unwrap_or(0), + VpIndex: vp.unwrap_or(0), + Flags: flags, + Output: output(value), + Mask: output(mask), + } + } + + /// Programs CPUID results with `CpuidResultList2` (a whole descriptive + /// leaf, a masked feature bit the banks set, a masked bit, and a subleaf + /// before setup; per-VP values after the VPs exist) and checks what + /// `WHvGetVirtualProcessorCpuidOutput` returns. + #[test] + #[ignore = "requires WHP"] + fn cpuid_result_list2_reaches_the_cpuid_output() { + let available = available(); + let native = probe_partition(available).unwrap(); + let native_7 = read(&native, 7, 0); + let native_x7 = read(&native, X7, 0); + let native_xsave1 = read(&native, 0xd, 1); + drop(native); + assert_ne!(native_7[EBX] & 1, 0, "the host lacks FSGSBASE"); + + let descriptors = [0x7603_6301, 0x00f0_b5ff, 0, 0x00c3_0000]; + let results = [ + result2(2, None, None, descriptors, [!0; 4]), + result2(7, Some(0), None, [0; 4], [0, 1, 0, 0]), + result2(X7, None, None, [0, 0, 0, 1 << 8], [0, 0, 0, 1 << 8]), + result2(0xd, Some(1), None, [0x5, 0, 0, 0], [0xf, 0, 0, 0]), + ]; + let partition = match probe_partition_with(available, 2, &results) { + Ok(partition) => partition, + Err(err) => panic!("WHP rejects CpuidResultList2: {err}"), + }; + let mut expected_7 = native_7; + expected_7[EBX] &= !1; + let mut expected_x7 = native_x7; + expected_x7[EDX] |= 1 << 8; + let mut expected_xsave1 = native_xsave1; + expected_xsave1[EAX] = (native_xsave1[EAX] & !0xf) | 0x5; + let mut checks = vec![ + ("leaf 2 (whole leaf)", read(&partition, 2, 0), descriptors), + ( + "leaf 7.0 (FSGSBASE masked clear)", + read(&partition, 7, 0), + expected_7, + ), + ( + "leaf 0x80000007 (invariant TSC masked set)", + read(&partition, X7, 0), + expected_x7, + ), + ( + "leaf 0xd.1 (subleaf, masked)", + read(&partition, 0xd, 1), + expected_xsave1, + ), + ("leaf 2 on VP 1", read_vp(&partition, 1, 2, 0), descriptors), + ]; + let per_vp = [ + result2(0x16, None, Some(0), [100, 200, 300, 0], [!0; 4]), + result2(0x16, None, Some(1), [101, 201, 301, 1], [!0; 4]), + ]; + let mut all = results.to_vec(); + all.extend_from_slice(&per_vp); + match partition.set_property(whp::PartitionProperty::CpuidResultList2(&all)) { + Ok(()) => { + checks.push(( + "leaf 0x16 on VP 0", + read_vp(&partition, 0, 0x16, 0), + [100, 200, 300, 0], + )); + checks.push(( + "leaf 0x16 on VP 1", + read_vp(&partition, 1, 0x16, 0), + [101, 201, 301, 1], + )); + } + Err(err) => println!("per-VP results after setup: rejected: {err}"), + } + let mut failures = Vec::new(); + for (name, actual, expected) in checks { + let verdict = if actual == expected { "ok" } else { "DIFFERS" }; + println!("{name}: {actual:08x?} expected {expected:08x?} {verdict}"); + if actual != expected { + failures.push(name); + } + } + assert!(failures.is_empty(), "{failures:?}"); + } + + /// Compares the cheap supported surface (host CPUID and WHP's + /// capabilities) with the probe partition's (`--cpu-fingerprint`): every + /// pinned profile gets the same `verify_support` verdict from both, and + /// the host's own profile passes. + #[test] + #[ignore = "requires WHP"] + fn cheap_surface_matches_the_probe_surface() { + let fingerprint = crate::fingerprint::cpu_fingerprint().unwrap(); + let probe = cpu_profile::HostCpuSurface::from_fingerprint(&fingerprint); + let started = std::time::Instant::now(); + let cheap = supported_surface( + crate::time_abi::host_cpuid(), + available(), + probe.physical_address_width, + ); + let elapsed = started.elapsed(); + let mut cpuid: Vec = cheap + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + let cheap = cpu_profile::HostCpuSurface { + cpuid, + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, + physical_address_width: cheap.physical_address_width, + msrs: cheap + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + }; + println!( + "cheap surface: {} entries in {} us; probe surface: {} entries; widths {} and {}", + cheap.cpuid.len(), + elapsed.as_micros(), + probe.cpuid.len(), + cheap.physical_address_width, + probe.physical_address_width + ); + let host = cpu_profile::select_auto(&HostCpuSignature::current()).ok(); + let mut mismatches = Vec::new(); + for profile in cpu_profile::pinned_profiles() { + // Where the two surfaces differ on a leaf the profile lists. + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let subleaf = subleaf.unwrap_or(0); + let ours = + cpu_profile::cpuid::lookup(&cheap.cpuid, leaf, subleaf).unwrap_or_default(); + let theirs = + cpu_profile::cpuid::lookup(&probe.cpuid, leaf, subleaf).unwrap_or_default(); + for register in 0..4 { + let differs = ours[register] ^ theirs[register]; + if differs != 0 { + println!( + "{}: {leaf:#x}.{subleaf} register {register}: cheap {:#010x} probe {:#010x} pinned mask {:#010x}", + profile.id(), + ours[register], + theirs[register], + entry.masks()[register] + ); + } + } + } + let ours = cpu_profile::support_violations(profile, &cheap); + let theirs = cpu_profile::support_violations(profile, &probe); + println!("{}: cheap {ours:?}; probe {theirs:?}", profile.id()); + if ours.is_empty() != theirs.is_empty() { + mismatches.push(profile.id()); + } + if host.is_some_and(|host| host.id() == profile.id()) { + assert!(ours.is_empty(), "{}: {ours:?}", profile.id()); + } + } + assert!(mismatches.is_empty(), "{mismatches:?}"); + } + + /// Times what a WHP `supported_cpu_surface()` costs with a probe + /// partition: creating a one-VP partition with every feature WHP offers, + /// reading its whole CPUID, and tearing it down. + #[test] + #[ignore = "requires WHP"] + fn supported_surface_probe_cost() { + for round in 0..5 { + let start = std::time::Instant::now(); + let partition = probe_partition(available()).unwrap(); + let created = start.elapsed(); + let mut reads = 0; + let entries = cpu_profile::cpuid::enumerate(|leaf, subleaf| { + reads += 1; + partition + .vp(0) + .get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .unwrap(); + let enumerated = start.elapsed(); + drop(partition); + let total = start.elapsed(); + println!( + "round {round}: create {created:?}, {reads} reads ({} entries) {:?}, teardown {:?}, total {total:?}", + entries.len(), + enumerated - created, + total - enumerated + ); + } + } + + /// A zeroed 4 KiB page that a test maps into a partition, freed on drop. + /// Declare it before the partition, so that it outlives the partition's + /// mapping of it, also when the test panics. + struct TestPage { + ptr: *mut u8, + layout: std::alloc::Layout, + } + + impl TestPage { + fn new() -> Self { + let layout = std::alloc::Layout::from_size_align(4096, 4096).unwrap(); + // SAFETY: the layout has a nonzero size. + let ptr = unsafe { std::alloc::alloc_zeroed(layout) }; + assert!(!ptr.is_null()); + Self { ptr, layout } + } + } + + impl Drop for TestPage { + fn drop(&mut self) { + // SAFETY: `ptr` was allocated with `layout`. + unsafe { std::alloc::dealloc(self.ptr, self.layout) }; + } + } + + /// Runs `cpuid; hlt` in real mode at the reset vector and checks how + /// `CpuidResultList2` reaches the guest: a leaf that also exits reports + /// the programmed result as the exit's default result, which OpenVMM's + /// exit handler starts from, and a leaf that does not exit returns it + /// directly. The time ABI exits some profile leaves (1, 4, 6, 7, ...), so + /// their profile values depend on the former. + #[test] + #[ignore = "requires WHP"] + fn cpuid_result_list2_feeds_exit_defaults() { + const EXIT_LEAF: u32 = 0x16; + const PLAIN_LEAF: u32 = 0x8000_0002; + let exit_value = [0x1111_0001, 0x2222_0002, 0x3333_0003, 0x4444_0004]; + let plain_value = [0x4141_4141, 0x4242_4242, 0x4343_4343, 0x4444_4444]; + let results = [ + result2(EXIT_LEAF, None, None, exit_value, [!0; 4]), + result2(PLAIN_LEAF, None, None, plain_value, [!0; 4]), + ]; + // At 0xffff0: cpuid; hlt; jmp back to the cpuid. Declared before the + // partition, so that it is freed after the partition is dropped. + let page = TestPage::new(); + let code = [0x0f, 0xa2, 0xf4, 0xeb, 0xfb]; + // SAFETY: the code fits in the page at offset 0xff0. + unsafe { std::ptr::copy_nonoverlapping(code.as_ptr(), page.ptr.add(0xff0), code.len()) }; + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(1)) + .unwrap(); + config + .set_property(whp::PartitionProperty::ExtendedVmExits( + whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit, + )) + .unwrap(); + config + .set_property(whp::PartitionProperty::CpuidExitList(&[EXIT_LEAF])) + .unwrap(); + config + .set_property(whp::PartitionProperty::CpuidResultList2(&results)) + .unwrap(); + let partition = config.create().unwrap(); + partition.create_vp(0).create().unwrap(); + + let rwx = whp::abi::WHV_MAP_GPA_RANGE_FLAGS( + whp::abi::WHvMapGpaRangeFlagRead.0 + | whp::abi::WHvMapGpaRangeFlagWrite.0 + | whp::abi::WHvMapGpaRangeFlagExecute.0, + ); + // SAFETY: `page` is dropped after the partition, so its memory is not + // reused while the partition maps it. + unsafe { partition.map_range(None, page.ptr, 4096, 0xff000, rwx) }.unwrap(); + + let vp = partition.vp(0); + let registers = |vp: &whp::Processor<'_>| { + [ + whp::Register64::Rax, + whp::Register64::Rbx, + whp::Register64::Rcx, + whp::Register64::Rdx, + ] + .map(|register| vp.get_register(register).unwrap() as u32) + }; + vp.set_register(whp::Register64::Rax, EXIT_LEAF.into()) + .unwrap(); + vp.set_register(whp::Register64::Rcx, 0).unwrap(); + let mut runner = vp.runner(); + + // The exiting leaf: its default result is the programmed one. + let (default, next_rip) = match runner.run().unwrap() { + whp::Exit { + vp_context, + reason: whp::ExitReason::Cpuid(info), + } => ( + [ + info.DefaultResultRax as u32, + info.DefaultResultRbx as u32, + info.DefaultResultRcx as u32, + info.DefaultResultRdx as u32, + ], + vp_context.Rip + u64::from(vp_context.InstructionLength()), + ), + exit => panic!("expected a CPUID exit: {exit:#x?}"), + }; + println!("exit leaf {EXIT_LEAF:#x}: default result {default:08x?}"); + // Complete the CPUID with the default result and continue to the HLT. + for (register, value) in [ + whp::Register64::Rax, + whp::Register64::Rbx, + whp::Register64::Rcx, + whp::Register64::Rdx, + ] + .into_iter() + .zip(default) + { + vp.set_register(register, value.into()).unwrap(); + } + vp.set_register(whp::Register64::Rip, next_rip).unwrap(); + match runner.run().unwrap().reason { + whp::ExitReason::Halt => {} + reason => panic!("expected a halt: {reason:#x?}"), + } + + // The plain leaf: the guest gets the programmed result directly. + vp.set_register(whp::Register64::Rax, PLAIN_LEAF.into()) + .unwrap(); + vp.set_register(whp::Register64::Rcx, 0).unwrap(); + match runner.run().unwrap().reason { + whp::ExitReason::Halt => {} + reason => panic!("expected a halt: {reason:#x?}"), + } + let plain = registers(&vp); + println!("plain leaf {PLAIN_LEAF:#x}: guest result {plain:08x?}"); + + assert_eq!(default, exit_value, "the exit's default result"); + assert_eq!(plain, plain_value, "the guest's result"); + } +} diff --git a/vmm_core/virt_whp/src/regs.rs b/vmm_core/virt_whp/src/regs.rs index 957782f4812..9e95e713282 100644 --- a/vmm_core/virt_whp/src/regs.rs +++ b/vmm_core/virt_whp/src/regs.rs @@ -75,6 +75,7 @@ mod x86_64 { HvX64RegisterName::Cr8 => whp::abi::WHvX64RegisterCr8, HvX64RegisterName::Xfem => whp::abi::WHvX64RegisterXCr0, HvX64RegisterName::Tsc => whp::abi::WHvX64RegisterTsc, + HvX64RegisterName::TscDeadline => whp::abi::WHvX64RegisterTscDeadline, HvX64RegisterName::Efer => whp::abi::WHvX64RegisterEfer, HvX64RegisterName::ApicBase => whp::abi::WHvX64RegisterApicBase, diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs new file mode 100644 index 00000000000..ebf08326545 --- /dev/null +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -0,0 +1,2995 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI v1 on WHP. +//! +//! Active only for partitions built with +//! [`ProtoPartitionConfig::time_abi`](virt::ProtoPartitionConfig::time_abi). +//! It implements the WHP column of "Backend obligations" in NVX +//! `doc/design/time-abi.md`: +//! +//! - **Partition.** No Hyper-V guest interface (so no synthetic processor +//! features), the in-hypervisor (offloaded) APIC, no isolation, no VTL2, +//! and no nested virtualization. Without synthetic features WHP reports no +//! hypervisor CPUID leaf and serves no synthetic MSR of its own. +//! - **Processor features.** Both feature banks and the XSAVE features derive +//! from the partition's CPU profile ([`profile_features`]), without the +//! TSC-deadline timer, `IA32_TSC_ADJUST`, or APERF/MPERF. WHP's default +//! banks omit speculation controls that the host offers (SPEC_CTRL, STIBP, +//! and SSBD on Skylake-SP, PSFD on Ice Lake), so they are never used. +//! - **CPUID.** [`TimeAbiConfig::cpuid`] is the partition's complete +//! effective CPUID. Every leaf of it outside the hypervisor range is a +//! `CpuidResultList2` result, which WHP returns without an exit. Only the +//! leaves WHP cannot present that way exit to OpenVMM: the leaves with +//! per-VP APIC identity fields (1, `0xB`, `0x1F`, and `0x8000001E`), which +//! the exit handler sets for each VP, and the identity leaves. The explicit +//! zero leaves of the identity range do not exit: WHP answers the whole +//! hypervisor range with zeros natively, which preflight samples. Each +//! exit-list entry costs about 25 µs of partition setup, and each native +//! CPUID read about 18 µs. +//! - **Invariant TSC.** The effective CPUID sets bit 8 of CPUID `0x80000007` +//! EDX on every host. Azure's WHP offers no `TscInvariantSupport` to its L1 +//! partitions, so there the bit comes from the CPUID result alone; host +//! qualification measures the invariance. +//! - **Identity MSRs.** `UnhandledMsrs` exits deliver every access to an MSR +//! that WHP does not implement, which without synthetic features includes +//! the whole identity range, to [`TimeAbiMsrs`] before any other MSR +//! handling. With their feature bits clear, WHP does not implement +//! `IA32_TSC_ADJUST` and `IA32_TSC_DEADLINE` either; they raise #GP, and +//! so do the legacy L2-cache MSRs that the backend stubs for the PCAT BIOS. +//! WHP does not implement AMD's HWCR and DE_CFG either: under an AMD CPU +//! profile, OpenVMM serves them with the time ABI's fixed values +//! ([`AMD_CONFIG_MSRS`](virt::time_abi::msr::AMD_CONFIG_MSRS)), ahead of +//! the legacy stubs, which read HWCR as 0. +//! - **Rates.** The guest TSC runs at the host rate: OpenVMM never sets the +//! `ProcessorClockFrequency` partition property. The offloaded APIC timer +//! runs at WHP's fixed 200 MHz (WHP cannot set `InterruptClockFrequency`). +//! - **Synchronized TSC set.** Partition time is suspended, the target is +//! written to every VP and read back while frozen, and partition time +//! resumes explicitly before any VP runs, so the guest TSC runs from the +//! restore anchor. Every VP exists from partition creation, so no VP can be +//! created after the set. +//! +//! These semantics were measured on Windows Server 2025 bare metal (Xeon Gold +//! 6138, Skylake-SP) and on Azure runners (Xeon Platinum 8370C and 8573C). + +use crate::WhpPartitionInner; +use crate::WhpProcessor; +use crate::WhpResultExt; +use crate::cpu_contract::CpuidTopology; +use crate::cpu_contract::fixup_vp_topology_cpuid; +use crate::profile_features::WhpFeatures; +use crate::profile_features::profile_features; +use crate::profile_features::supported_surface; +use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; +use cpu_profile::PartitionProfile; +use inspect::Inspect; +use std::sync::Arc; +use std::sync::OnceLock; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::IsolationType; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::x86::topology::per_vp_cpuid_bits; +use whp::abi::WHV_CPUID_OUTPUT; +use whp::abi::WHV_EXTENDED_VM_EXITS; +use whp::abi::WHV_PROCESSOR_FEATURES; +use whp::abi::WHV_PROCESSOR_FEATURES1; +use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; +use whp::abi::WHV_X64_CPUID_RESULT2; +use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; +use whp::abi::WHV_X64_MSR_EXIT_BITMAP; +use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; +use x86defs::cpuid::CpuidFunction; + +/// `IA32_TSC_ADJUST`, which the time ABI hides. +const MSR_IA32_TSC_ADJUST: u32 = 0x3b; + +/// `IA32_TSC_DEADLINE`, which the time ABI hides. +const MSR_IA32_TSC_DEADLINE: u32 = x86defs::X86X_MSR_TSC_DEADLINE; + +/// The legacy P6 L2-cache MSRs that the backend stubs for Windows booting +/// from the PCAT BIOS (`MYSTERY_MSRS` in `vp.rs`). No CPU profile pins them +/// and a microVM never probes them, so they raise #GP on a time ABI +/// partition, as on MSHV. +const LEGACY_L2_CACHE_MSRS: [u32; 9] = [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e]; + +/// The feature-bank bits the time ABI clears: the TSC-deadline timer, +/// `IA32_TSC_ADJUST`, and APERF/MPERF. +const HIDDEN_FEATURES1: WHV_PROCESSOR_FEATURES1 = WHV_PROCESSOR_FEATURES1( + WHV_PROCESSOR_FEATURES1::TscDeadlineTmrSupport.0 + | WHV_PROCESSOR_FEATURES1::TscAdjustSupport.0 + | WHV_PROCESSOR_FEATURES1::ACountMCountSupport.0, +); + +/// Leaves of the hypervisor range that preflight reads natively, to check +/// that WHP reports nothing there: the first leaf past the identity, the +/// Hyper-V virtualization stack interface leaf (where `"VS#1"` would +/// appear), the last identity leaf, and three hypervisor signature bases. +/// The explicit zero leaves among them stand for the rest, which WHP answers +/// the same way, and the guest's boot check reads them all. +const PREFLIGHT_SENTINEL_LEAVES: [u32; 6] = [ + 0x4000_0006, + 0x4000_0081, + 0x4000_00ff, + 0x4000_0100, + 0x4000_0200, + 0x4000_ff00, +]; + +/// Leaves the effective CPUID reports besides the time ABI CPUID: the +/// maximum basic and extended leaves. +const EFFECTIVE_CPUID_MAX_LEAVES: [u32; 2] = [ + CpuidFunction::VendorAndMaxFunction.0, + CpuidFunction::ExtendedMaxFunction.0, +]; + +/// `OSXSAVE` in CPUID `0x1` ECX: guest CR4 state, not configuration. +const ECX1_OSXSAVE: u32 = 1 << 27; +/// `OSPKE` in CPUID `0x7.0` ECX: guest CR4 state, not configuration. +const ECX7_OSPKE: u32 = 1 << 4; + +/// How many bracketed reads of VP 0's TSC a capture anchor takes at most. A +/// nested register read takes 17 to 25 µs and only an occasional one pairs +/// within the bound, so the search tries as many as MSHV does; capture +/// latency is not on the restore path. +const ANCHOR_ATTEMPTS: usize = 64; + +/// A capture anchor paired this tightly ends the search early. A WHP +/// register read takes 9.5 to 11 µs on bare metal and 17 to 25 µs nested. +const ANCHOR_TARGET_PAIRING_NS: u64 = 6_000; + +/// Time ABI state of a WHP partition. +#[derive(Inspect)] +pub(crate) struct WhpTimeAbi { + /// The identity MSR handler, shared with the worker. + #[inspect( + rename = "tsc_invariant_control", + with = "|x| x.tsc_invariant_control()" + )] + msrs: Arc, + /// The partition's complete effective CPUID, with the per-VP APIC + /// identity bits unmasked. + #[inspect(skip)] + cpuid: Arc, + /// The CPUID exit list. + #[inspect(with = "|x| format!(\"{x:#x?}\")")] + cpuid_exits: Vec, + /// Processor feature bank 0. + #[inspect(hex)] + features_bank0: u64, + /// Processor feature bank 1. + #[inspect(hex)] + features_bank1: u64, + /// The XSAVE features. + #[inspect(hex)] + features_xsave: u64, + /// The CPU profile the features derive from. + #[inspect(with = "|x| x.id()")] + cpu_profile: PartitionProfile, + /// How many `CpuidResultList2` entries present the effective CPUID. + cpuid_results: usize, + /// The effective CPUID record, computed once: it depends only on the + /// configuration, because it excludes guest state. + #[inspect(skip)] + effective: OnceLock>, +} + +impl WhpTimeAbi { + /// Programs a new WHP partition for the time ABI: the CPUID exits, the + /// processor features, the CPUID results, and the unhandled-MSR exits. + /// The partition must have passed [`validate_partition`]. + /// + /// The processor feature banks and the XSAVE features derive from the + /// CPU profile `config.cpu_profile` ([`profile_features`]), and + /// `config.cpuid`, the complete effective CPUID, becomes the CPUID results + /// ([`cpuid_results`]) and the exits ([`cpuid_exits`]). + pub(crate) fn configure( + config: &TimeAbiConfig, + whp_config: &mut whp::PartitionConfig, + extended_exits: &mut WHV_EXTENDED_VM_EXITS, + ) -> Result { + let profile = &config.cpu_profile; + + let available_exits = whp::capabilities::extended_vm_exits() + .map_err(|err| routing_error(format!("cannot query the extended VM exits: {err}")))?; + let exits = WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit; + if available_exits.0 & exits.0 != exits.0 { + return Err(routing_error(format!( + "WHP lacks CPUID or MSR exits (extended VM exits {:#x})", + available_exits.0 + ))); + } + *extended_exits |= exits; + + let cpuid_exits = cpuid_exits(&config.cpuid); + whp_config + .set_property(whp::PartitionProperty::CpuidExitList(&cpuid_exits)) + .map_err(|err| routing_error(format!("cannot set the CPUID exit list: {err}")))?; + + let available = whp::capabilities::processor_features().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query the processor features: {err}"), + ) + })?; + let available_xsave = whp::capabilities::processor_xsave_features().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query the XSAVE features: {err}"), + ) + })?; + let derived = profile_features( + profile, + WhpFeatures { + banks: [available.bank0.0, available.bank1.0], + xsave: available_xsave.0, + }, + )?; + let mut features = available; + features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); + features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); + let features = processor_features(features); + let xsave = derived.xsave; + whp_config + .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot set processor feature banks {:#x} and {:#x}: {err}", + features.bank0.0, features.bank1.0 + ), + ) + })?; + whp_config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(xsave), + )) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot set XSAVE features {xsave:#x}: {err}"), + ) + })?; + let cpuid_results = cpuid_results(&config.cpuid); + whp_config + .set_property(whp::PartitionProperty::CpuidResultList2(&cpuid_results)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot program the {} CPUID results of the effective CPUID: {err}", + cpuid_results.len() + ), + ) + })?; + + let msr_exits = whp::capabilities::x64_msr_exit_bitmap() + .map_err(|err| routing_error(format!("cannot query the MSR exits: {err}")))?; + if !msr_exits.is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) { + return Err(routing_error(format!( + "WHP lacks unhandled-MSR exits (MSR exit bitmap {:#x})", + msr_exits.0 + ))); + } + whp_config + .set_property(whp::PartitionProperty::X64MsrExitBitmap( + WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs, + )) + .map_err(|err| routing_error(format!("cannot enable unhandled-MSR exits: {err}")))?; + + tracing::info!( + cpuid_exits = cpuid_exits.len(), + cpuid_results = cpuid_results.len(), + cpu_profile = profile.id(), + available_bank0 = format_args!("{:#x}", available.bank0.0), + available_bank1 = format_args!("{:#x}", available.bank1.0), + bank0 = format_args!("{:#x}", features.bank0.0), + bank1 = format_args!("{:#x}", features.bank1.0), + xsave = format_args!("{xsave:#x}"), + tsc_invariant_feature = features + .bank1 + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), + "time ABI: WHP partition configured" + ); + + Ok(Self { + msrs: config.msrs.clone(), + cpuid: config.cpuid.clone(), + cpuid_exits, + features_bank0: features.bank0.0, + features_bank1: features.bank1.0, + features_xsave: xsave, + cpu_profile: profile.clone(), + cpuid_results: cpuid_results.len(), + effective: OnceLock::new(), + }) + } + + /// Returns the CPUID results of the partition (see [`partition_cpuid`]), + /// after checking that every result reaches the guest + /// ([`check_cpuid_delivery`]). + pub(crate) fn partition_cpuid( + &self, + own: Vec, + ) -> Result { + let cpuid = partition_cpuid(own, &self.cpuid, &self.cpuid_exits)?; + check_cpuid_delivery(&cpuid, &self.cpuid_exits)?; + Ok(cpuid) + } + + /// Returns the topology parameters of the per-VP CPUID results for + /// `topology`. The extended topology subleaves past the core level that + /// the effective CPUID lists, such as the subleaf that ends the + /// enumeration, keep their values, with each VP's x2APIC ID. + pub(crate) fn cpuid_topology( + &self, + topology: &vm_topology::processor::ProcessorTopology, + ) -> CpuidTopology { + CpuidTopology::new(topology).with_listed_extended_topology(&self.cpuid) + } +} + +fn routing_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::IdentityRouting, message) +} + +/// Returns the host's CPUID as the root partition sees it: every leaf and +/// subleaf of the basic and extended ranges that +/// [`cpu_profile::cpuid::enumerate_basic_and_extended`] walks, read once per +/// process. It describes the processor that WHP virtualizes, for +/// [`supported_surface`] and for the host entries outside the profile +/// ([`unlisted_cpuid`]). Neither uses the hypervisor range, which describes +/// the root's hypervisor, and each of its queries exits in a root partition. +pub(crate) fn host_cpuid() -> &'static [cpu_profile::cpuid::CpuidEntry] { + static HOST_CPUID: OnceLock> = OnceLock::new(); + HOST_CPUID.get_or_init(|| { + // The CPUID instruction exists only on x86-64 hosts, the only hosts + // of x86-64 WHP partitions. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + let query = |leaf, subleaf| { + let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); + Ok::<_, std::convert::Infallible>([result.eax, result.ebx, result.ecx, result.edx]) + }; + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + let query = |_, _| Ok::<_, std::convert::Infallible>([0; 4]); + cpu_profile::cpuid::enumerate_basic_and_extended(query) + .unwrap_or_else(|never| match never {}) + }) +} + +/// Returns VP 0's view, from `vp0`, at every entry of `host` outside +/// `profile`'s tables ([`cpu_profile::unlisted_cpuid_candidates`]), read at +/// subleaf 0 for a subleaf-independent entry. The time ABI reserves these +/// entries, but WHP serves its own value wherever the effective CPUID does +/// not program one, so core requires each to read zero (`E_CPU_UNLISTED`): +/// no reserved entry may expose a host feature. +pub(crate) fn unlisted_cpuid( + profile: &CpuProfile, + host: &[cpu_profile::cpuid::CpuidEntry], + mut vp0: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + cpu_profile::unlisted_cpuid_candidates(profile, host) + .into_iter() + .map(|(function, index)| { + let subleaf = index.unwrap_or(0); + let leaf = CpuidLeaf::new( + function, + normalize_cpuid(function, subleaf, vp0(function, subleaf)?), + ); + Ok(match index { + Some(index) => leaf.indexed(index), + None => leaf, + }) + }) + .collect() +} + +/// Fails unless the partition can carry the time ABI: no isolation, no +/// Hyper-V guest interface (whose synthetic processor features would serve +/// the identity CPUID and MSRs natively, and which VTL2 requires), no nested +/// virtualization, and the offloaded APIC. +pub(crate) fn validate_partition( + isolation: IsolationType, + hv_configured: bool, + nested_virt: bool, + user_mode_apic: bool, +) -> Result<(), TimeAbiError> { + if isolation != IsolationType::None { + return Err(routing_error(format!( + "the WHP time ABI does not support {isolation:?} isolation" + ))); + } + if hv_configured { + return Err(routing_error( + "the WHP time ABI cannot be combined with the Hyper-V guest interface", + )); + } + if nested_virt { + return Err(routing_error( + "the WHP time ABI cannot be combined with nested virtualization", + )); + } + if user_mode_apic { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the WHP time ABI requires the offloaded APIC, not the user-mode APIC", + )); + } + Ok(()) +} + +/// Returns whether WHP cannot present `function` through `CpuidResultList2` +/// alone, so that it must exit to OpenVMM: a leaf with per-VP APIC identity +/// fields, which the exit handler sets for each VP, or a hypervisor-range +/// leaf, which WHP does not serve without synthetic features. +fn must_exit(function: u32) -> bool { + per_vp_cpuid_bits(function) != [0; 4] || HYPERVISOR_CPUID_RANGE.contains(&function) +} + +/// Returns the CPUID exit list of a time ABI partition whose effective CPUID +/// is `config`: every leaf of it that [`must_exit`], except the explicit zero +/// leaves, sorted and unique. The other leaves are `CpuidResultList2` results +/// ([`cpuid_results`]). +pub(crate) fn cpuid_exits(config: &CpuidLeafSet) -> Vec { + let mut exits: Vec = config + .leaves() + .iter() + .filter(|leaf| must_exit(leaf.function) && !is_zero_fill(leaf)) + .map(|leaf| leaf.function) + .collect(); + exits.sort_unstable(); + exits.dedup(); + exits +} + +/// Returns the `CpuidResultList2` results of a time ABI partition whose +/// effective CPUID is `config`: one per leaf and subleaf outside the +/// hypervisor range, with its mask. The per-VP APIC identity bits are +/// unmasked in `config`, so they keep WHP's value for each VP. +/// +/// WHP returns these results for leaves without an exit, and as the default +/// result of a CPUID exit, on which OpenVMM applies `config` again. +pub(crate) fn cpuid_results(config: &CpuidLeafSet) -> Vec { + let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + }; + config + .leaves() + .iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) && leaf.mask != [0; 4]) + .map(|leaf| WHV_X64_CPUID_RESULT2 { + Function: leaf.function, + Index: leaf.index.unwrap_or(0), + VpIndex: 0, + Flags: if leaf.index.is_some() { + WHvX64CpuidResult2FlagSubleafSpecific + } else { + WHV_X64_CPUID_RESULT2_FLAGS(0) + }, + Output: output(std::array::from_fn(|i| leaf.result[i] & leaf.mask[i])), + Mask: output(leaf.mask), + }) + .collect() +} + +/// Returns the processor features of a time ABI partition: `available` +/// without the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. The +/// invariant TSC stays where WHP offers it. +pub(crate) fn processor_features(available: whp::ProcessorFeatures) -> whp::ProcessorFeatures { + let mut features = available; + features.bank1 = WHV_PROCESSOR_FEATURES1(features.bank1.0 & !HIDDEN_FEATURES1.0); + features +} + +/// Returns whether `leaf` is an explicit zero leaf past the identity, which +/// WHP answers with zeros natively when it does not exit. +fn is_zero_fill(leaf: &CpuidLeaf) -> bool { + leaf.function > IDENTITY_MAX_LEAF + && IDENTITY_CPUID_RANGE.contains(&leaf.function) + && leaf.index.is_none() + && leaf.result == [0; 4] + && leaf.mask == [!0; 4] +} + +/// Returns the CPUID results that the exit handler applies on a time ABI +/// partition: `own`, the backend's other results, for the leaves in `exits` +/// outside the hypervisor range, then `config`, so that the effective CPUID +/// overrides every other source. A leaf that does not exit reaches the guest +/// from `config` alone, through `CpuidResultList2`. +/// +/// Fails with `E_IDENTITY_ROUTING` if another result would shadow part of +/// `config`: [`CpuidLeafSet::result`] applies only the first matching leaf, +/// so a subleaf-less leaf would hide a subleaf of `config`. +pub(crate) fn partition_cpuid( + own: Vec, + config: &CpuidLeafSet, + exits: &[u32], +) -> Result { + let mut leaves: Vec = own + .into_iter() + .filter(|leaf| { + !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) + && exits.binary_search(&leaf.function).is_ok() + }) + .collect(); + leaves.extend(config.leaves().iter().copied()); + let cpuid = CpuidLeafSet::new(leaves); + for leaf in config.leaves() { + let index = leaf.index.unwrap_or(0); + for base in [[0; 4], [!0; 4]] { + let result = cpuid.result(leaf.function, index, &base); + let applied = result + .iter() + .zip(leaf.result) + .zip(leaf.mask) + .all(|((actual, expected), mask)| actual & mask == expected & mask); + if !applied { + return Err(routing_error(format!( + "another CPUID result shadows time ABI leaf {:#x}/{index:#x}", + leaf.function + ))); + } + } + } + Ok(cpuid) +} + +/// Checks that every leaf of `cpuid` reaches the guest: a leaf in the +/// hypervisor range exits, except the explicit zero leaves, which WHP +/// answers natively; any other leaf exits or is a `CpuidResultList2` result +/// ([`cpuid_results`]). Fails with `E_IDENTITY_ROUTING`, naming the first +/// leaf that would not. +pub(crate) fn check_cpuid_delivery( + cpuid: &CpuidLeafSet, + exits: &[u32], +) -> Result<(), TimeAbiError> { + if let Some(leaf) = cpuid.leaves().iter().find(|leaf| { + HYPERVISOR_CPUID_RANGE.contains(&leaf.function) + && !is_zero_fill(leaf) + && exits.binary_search(&leaf.function).is_err() + }) { + return Err(routing_error(format!( + "CPUID leaf {:#x} has a programmed result but does not exit", + leaf.function + ))); + } + Ok(()) +} + +/// Clears the bits of a CPUID result that reflect guest state rather than +/// configuration: `OSXSAVE` and `OSPKE` follow the VP's CR4, and the XSAVE +/// area sizes in `0xD.0` and `0xD.1` EBX follow its XCR0 and XSS. +fn normalize_cpuid(function: u32, index: u32, mut result: [u32; 4]) -> [u32; 4] { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => result[2] &= !ECX1_OSXSAVE, + CpuidFunction::ExtendedFeatures if index == 0 => result[2] &= !ECX7_OSPKE, + CpuidFunction::ExtendedStateEnumeration if index <= 1 => result[1] = 0, + _ => {} + } + result +} + +/// Returns the effective CPUID record: the results VP 0 sees, from `vp0`, +/// for the maximum basic and extended leaves and for every leaf of the +/// partition's CPUID results `cpuid` (the effective CPUID, and the backend's +/// own results for the leaves that exit), with guest-state bits cleared. The +/// explicit zero leaves are reported as programmed; preflight samples them +/// natively. +pub(crate) fn effective_cpuid( + cpuid: &CpuidLeafSet, + mut vp0: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + let mut leaves = Vec::with_capacity(EFFECTIVE_CPUID_MAX_LEAVES.len() + cpuid.leaves().len()); + for function in EFFECTIVE_CPUID_MAX_LEAVES { + leaves.push(CpuidLeaf::new(function, vp0(function, 0)?)); + } + for leaf in cpuid.leaves() { + let index = leaf.index.unwrap_or(0); + let result = if is_zero_fill(leaf) { + leaf.result + } else { + normalize_cpuid(leaf.function, index, vp0(leaf.function, index)?) + }; + let effective = CpuidLeaf::new(leaf.function, result); + leaves.push(match leaf.index { + Some(index) => effective.indexed(index), + None => effective, + }); + } + Ok(CpuidLeafSet::new(leaves).into_leaves()) +} + +/// Checks that WHP answers the [`PREFLIGHT_SENTINEL_LEAVES`] with zeros, +/// reading each with `native`. Fails with `E_IDENTITY_ROUTING`. +pub(crate) fn check_native_sentinels( + mut native: impl FnMut(u32) -> Result<[u32; 4], E>, +) -> Result<(), TimeAbiError> { + for function in PREFLIGHT_SENTINEL_LEAVES { + let result = native(function) + .map_err(|err| routing_error(format!("cannot read CPUID {function:#x}: {err}")))?; + if result != [0; 4] { + return Err(routing_error(format!( + "WHP reports CPUID {function:#x} as {result:#x?} instead of zeros" + ))); + } + } + Ok(()) +} + +/// Checks that the processor feature banks took effect: WHP's own CPUID +/// results, before the time ABI CPUID applies, must offer neither the +/// TSC-deadline timer, `IA32_TSC_ADJUST`, nor APERF/MPERF, or the guest +/// could use their MSRs. `native` reads WHP's result for a leaf and subleaf. +/// Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn check_feature_banks( + mut native: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result<(), TimeAbiError> { + const CHECKS: [(u32, usize, u32, &str); 3] = [ + (0x1, 2, 1 << 24, "the TSC-deadline timer"), + (0x6, 2, 1 << 0, "APERF/MPERF"), + (0x7, 1, 1 << 1, "IA32_TSC_ADJUST"), + ]; + for (function, register, bit, feature) in CHECKS { + let result = native(function, 0).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot read CPUID {function:#x}: {err}"), + ) + })?; + if result[register] & bit != 0 { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("the processor feature banks still offer {feature}"), + )); + } + } + Ok(()) +} + +/// Checks that the guest TSC runs at the host rate: the partition's +/// `ProcessorClockFrequency` must equal WHP's capability. Fails with +/// `E_TSC_SCALING_ACTIVE`. +pub(crate) fn check_unscaled(partition_hz: u64, capability_hz: u64) -> Result<(), TimeAbiError> { + if partition_hz != capability_hz { + return Err(TimeAbiError::new( + TimeAbiCode::TscScalingActive, + format!( + "the partition TSC runs at {partition_hz} Hz, but the host TSC runs at {capability_hz} Hz" + ), + )); + } + Ok(()) +} + +/// An access to an MSR that exited to OpenVMM. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum MsrAccess { + Read, + Write(u64), +} + +/// How a time ABI MSR access completes. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum MsrOutcome { + /// The read returns this value. + Read(u64), + /// The write takes effect. + Written, + /// The access raises #GP. + Fault, +} + +/// Serves an MSR access that the time ABI owns: the identity range through +/// `msrs`, the MSRs the time ABI hides, which raise #GP, and, for a CPU +/// profile of `vendor` AMD, AMD's configuration MSRs +/// ([`virt::time_abi::msr::AMD_CONFIG_MSRS`]). Returns `None` for every +/// other MSR, which the backend handles as before. +pub(crate) fn serve_msr( + msrs: &TimeAbiMsrs, + vendor: CpuVendor, + vp: VpIndex, + msr: u32, + access: MsrAccess, +) -> Option { + if matches!(msr, MSR_IA32_TSC_ADJUST | MSR_IA32_TSC_DEADLINE) { + tracelimit::info_ratelimited!( + vp = vp.index(), + msr, + ?access, + "time ABI: hidden timer MSR access raises #GP" + ); + return Some(MsrOutcome::Fault); + } + if LEGACY_L2_CACHE_MSRS.contains(&msr) { + return Some(MsrOutcome::Fault); + } + if vendor == CpuVendor::Amd { + let outcome = match access { + MsrAccess::Read => amd_config_msr_read(msr).map(MsrOutcome::Read), + MsrAccess::Write(value) => amd_config_msr_write(msr, value) + .map(|result| result.map_or(MsrOutcome::Fault, |()| MsrOutcome::Written)), + }; + if outcome.is_some() { + return outcome; + } + } + let outcome = match access { + MsrAccess::Read => msrs.read(vp, msr)?.map(MsrOutcome::Read), + MsrAccess::Write(value) => msrs.write(vp, msr, value)?.map(|()| MsrOutcome::Written), + }; + Some(outcome.unwrap_or(MsrOutcome::Fault)) +} + +/// One bracketed read of VP 0's TSC: host samples taken immediately before +/// and after the read. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct AnchorCandidate { + tsc: u64, + before: HostTimeSample, + after: HostTimeSample, +} + +impl AnchorCandidate { + /// The host monotonic time the read took, in nanoseconds. + fn width_ns(&self) -> u64 { + self.after + .monotonic_ns + .saturating_sub(self.before.monotonic_ns) + } + + /// Pairs the TSC with the midpoint of the bracket, which differs from the + /// read instant by at most half the bracket. UTC is derived from the + /// first sample and the monotonic midpoint, so a UTC step inside the + /// bracket does not skew it. + fn anchor(&self) -> TscAnchor { + let half = self.width_ns() / 2; + TscAnchor { + tsc: self.tsc, + sample: HostTimeSample { + utc_ns: self.before.utc_ns + half, + monotonic_ns: self.before.monotonic_ns + half, + }, + pairing_ns: self.width_ns().div_ceil(2), + } + } +} + +/// Chooses the tightest candidate. Fails with `E_TSC_ANCHOR` if none pairs +/// within `bound_ns`. +fn select_anchor(candidates: &[AnchorCandidate], bound_ns: u64) -> Result { + let best = candidates + .iter() + .min_by_key(|candidate| candidate.width_ns()) + .ok_or_else(|| TimeAbiError::new(TimeAbiCode::TscAnchor, "no TSC read was attempted"))?; + let anchor = best.anchor(); + if anchor.pairing_ns > bound_ns { + return Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "the tightest of {} VP 0 TSC reads pairs within {} ns, above the {bound_ns} ns bound", + candidates.len(), + anchor.pairing_ns + ), + )); + } + Ok(anchor) +} + +/// Checks that every VP read back `target`. Fails with +/// `E_TSC_SYNC_READBACK`, naming the first VP that differs. +fn check_readback(target: u64, readback: &[(VpIndex, u64)]) -> Result<(), TimeAbiError> { + if let Some((vp, value)) = readback.iter().find(|(_, value)| *value != target) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "VP {} holds TSC {value:#x} instead of the synchronized {target:#x}", + vp.index() + ), + )); + } + Ok(()) +} + +/// WHP's own CPUID results for VP 0, read on demand and kept for the rest +/// of one check: each read costs about 18 µs. +struct NativeCpuid<'a> { + partition: &'a whp::Partition, + results: Vec<((u32, u32), [u32; 4])>, +} + +impl<'a> NativeCpuid<'a> { + fn new(partition: &'a whp::Partition) -> Self { + Self { + partition, + results: Vec::new(), + } + } + + fn get(&mut self, function: u32, index: u32) -> Result<[u32; 4], whp::WHvError> { + if let Some((_, result)) = self + .results + .iter() + .find(|(key, _)| *key == (function, index)) + { + return Ok(*result); + } + let output = self.partition.vp(0).get_cpuid_output(function, index)?; + let result = [output.Eax, output.Ebx, output.Ecx, output.Edx]; + self.results.push(((function, index), result)); + Ok(result) + } +} + +/// The CPUID that one VP of a time ABI partition observes. +pub(crate) struct VpCpuid<'a> { + /// The partition's CPUID results, which the exit handler applies. + pub cpuid: &'a CpuidLeafSet, + /// The CPUID exit list. + pub exits: &'a [u32], + /// The topology behind the per-VP fields. + pub topology: &'a CpuidTopology, + /// The VP's APIC ID. + pub apic_id: u32, +} + +impl VpCpuid<'_> { + /// Returns the result the VP sees for `function` and `index`, given + /// `native`, which reads WHP's result (including its `CpuidResultList2` + /// result). A leaf that exits gets the partition's CPUID results over + /// WHP's result, with the VP's APIC identity, as the exit handler + /// computes them; any other leaf gets WHP's result. WHP's result is read + /// only if a bit of it shows through. + pub fn result( + &self, + function: u32, + index: u32, + native: impl FnOnce() -> Result<[u32; 4], E>, + ) -> Result<[u32; 4], E> { + if self.exits.binary_search(&function).is_err() { + return native(); + } + let programmed = self + .cpuid + .leaves() + .iter() + .find(|leaf| leaf.matches(function, index)); + let base = match programmed { + Some(leaf) if leaf.mask == [!0; 4] => [0; 4], + _ => native()?, + }; + let mut result = self.cpuid.result(function, index, &base); + fixup_vp_topology_cpuid(self.topology, self.apic_id, function, index, &mut result); + Ok(result) + } +} + +impl WhpPartitionInner { + fn time_abi_state(&self) -> Result<&WhpTimeAbi, TimeAbiError> { + self.time_abi.as_ref().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "the partition was built without the time ABI", + ) + }) + } + + /// Returns the effective CPUID record, computing it on first use: VP 0's + /// view of the effective CPUID, and of the host entries outside the + /// profile ([`unlisted_cpuid`]). + fn effective_record( + &self, + state: &WhpTimeAbi, + native: &mut NativeCpuid<'_>, + ) -> Result, TimeAbiError> { + if let Some(record) = state.effective.get() { + return Ok(record.clone()); + } + let started = std::time::Instant::now(); + let vp0 = VpCpuid { + cpuid: &self.cpuid, + exits: &state.cpuid_exits, + topology: &self.cpuid_topology, + apic_id: self.vps.first().map_or(0, |vp| vp.vp_info.apic_id), + }; + let mut read = |function, index| { + vp0.result(function, index, || native.get(function, index)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!("cannot read CPUID {function:#x}/{index:#x} of VP 0: {err}"), + ) + }) + }; + let mut record = effective_cpuid(&self.cpuid, &mut read)?; + let unlisted = unlisted_cpuid(&state.cpu_profile, host_cpuid(), &mut read)?; + tracing::info!( + results = record.len(), + unlisted = unlisted.len(), + native_reads = native.results.len(), + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP effective CPUID record" + ); + record.extend(unlisted); + Ok(state.effective.get_or_init(|| record).clone()) + } + + fn time_abi_vps(&self) -> impl Iterator + '_ { + self.vps.iter().map(|vp| vp.vp_info.base.vp_index) + } +} + +impl TimeAbiBackend for WhpPartitionInner { + fn native_tsc_hz(&self) -> Result { + let hz = whp::capabilities::processor_clock_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("cannot query WHvCapabilityCodeProcessorClockFrequency: {err}"), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "WHvCapabilityCodeProcessorClockFrequency is zero", + )); + } + Ok(hz) + } + + fn lapic_hz(&self) -> Result { + let hz = self.vtl0.whp.apic_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("cannot read the InterruptClockFrequency partition property: {err}"), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the InterruptClockFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn preflight(&self) -> Result { + let started = std::time::Instant::now(); + let state = self.time_abi_state()?; + + // Partition creation set up the exits, and the build checked that + // every programmed CPUID result exits. Check that the exits are in + // place and that WHP answers the leaves that do not exit with zeros. + let exits = + self.vtl0.whp.extended_vm_exits().map_err(|err| { + routing_error(format!("cannot read the extended VM exits: {err}")) + })?; + let required = WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit; + let msr_exits = self + .vtl0 + .whp + .x64_msr_exit_bitmap() + .map_err(|err| routing_error(format!("cannot read the MSR exit bitmap: {err}")))?; + if exits.0 & required.0 != required.0 + || !msr_exits.is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) + { + return Err(routing_error(format!( + "the partition lacks CPUID, MSR, or unhandled-MSR exits (extended VM exits {:#x}, MSR exits {:#x})", + exits.0, msr_exits.0 + ))); + } + let mut native = NativeCpuid::new(&self.vtl0.whp); + check_native_sentinels(|function| native.get(function, 0))?; + check_feature_banks(|function, index| native.get(function, index))?; + + // The guest-visible identity and time bits. + let effective = CpuidLeafSet::new(self.effective_record(state, &mut native)?); + let mut lookup = |function, index| effective.result(function, index, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, self.vps.len() as u32)?; + let invariant_tsc = + state + .cpuid + .result(CpuidFunction::ExtendedPowerManagement.0, 0, &[0; 4])[3] + & virt::time_abi::identity::INVARIANT_TSC_EDX + != 0; + virt::time_abi::identity::check_time_bits(&mut lookup, invariant_tsc)?; + + // The synchronized set suspends partition time. A partition whose + // VPs never ran keeps its time frozen until the first VP run, so the + // probe leaves the partition as it was. + self.vtl0.whp.suspend_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot suspend partition time: {err}"), + ) + })?; + + let partition_hz = self.vtl0.whp.tsc_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("cannot read the ProcessorClockFrequency partition property: {err}"), + ) + })?; + check_unscaled(partition_hz, self.native_tsc_hz()?)?; + + tracing::info!( + tsc_hz = partition_hz, + invariant_tsc, + tsc_invariant_feature = WHV_PROCESSOR_FEATURES1(state.features_bank1) + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), + cpuid_reads = native.results.len(), + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP preflight" + ); + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + let state = self.time_abi_state()?; + self.effective_record(state, &mut NativeCpuid::new(&self.vtl0.whp)) + } + + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + let started = std::time::Instant::now(); + let unsupported = |what: &str, err: whp::WHvError| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query {what}: {err}"), + ) + }; + let banks = whp::capabilities::processor_features() + .map_err(|err| unsupported("the processor features", err))?; + let xsave = whp::capabilities::processor_xsave_features() + .map_err(|err| unsupported("the XSAVE features", err))?; + let physical_address_width = self + .vtl0 + .whp + .physical_address_width() + .map_err(|err| unsupported("the physical address width", err))?; + let surface = supported_surface( + host_cpuid(), + WhpFeatures { + banks: [banks.bank0.0, banks.bank1.0], + xsave: xsave.0, + }, + physical_address_width.try_into().unwrap_or(u8::MAX), + ); + tracing::info!( + cpuid = surface.cpuid.len(), + physical_address_width = surface.physical_address_width, + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP supported CPU surface" + ); + Ok(Some(surface)) + } + + fn capture_anchor(&self) -> Result { + self.time_abi_state()?; + let bsp = self.vtl0.whp.vp(VpIndex::BSP.index()); + let mut candidates = Vec::with_capacity(ANCHOR_ATTEMPTS); + for _ in 0..ANCHOR_ATTEMPTS { + let before = sample_host_time()?; + let tsc = bsp.get_register(whp::Register64::Tsc).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read the TSC of VP 0: {err}"), + ) + })?; + let after = sample_host_time()?; + let candidate = AnchorCandidate { tsc, before, after }; + candidates.push(candidate); + if candidate.anchor().pairing_ns <= ANCHOR_TARGET_PAIRING_NS { + break; + } + } + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS)?; + tracing::info!( + tsc = anchor.tsc, + pairing_ns = anchor.pairing_ns, + attempts = candidates.len(), + "time ABI: capture anchor" + ); + Ok(anchor) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let started = std::time::Instant::now(); + self.time_abi_state()?; + let partition = &self.vtl0.whp; + + // While partition time is suspended, every VP's TSC holds its last + // value, so the frozen read-back is exact. Writes while time runs + // would skew the VPs by the 10 to 25 µs each register write takes. + partition.suspend_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot suspend partition time: {err}"), + ) + })?; + let suspended = started.elapsed(); + + let sample = sample_host_time()?; + let target = target(&sample)?; + + let failed = |vp: VpIndex, what: &str, err: whp::WHvError| { + TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!("cannot {what} the TSC of VP {}: {err}", vp.index()), + ) + }; + for vp in self.time_abi_vps() { + partition + .vp(vp.index()) + .set_register(whp::Register64::Tsc, target) + .map_err(|err| failed(vp, "write", err))?; + } + let written = started.elapsed(); + let readback = self + .time_abi_vps() + .map(|vp| { + partition + .vp(vp.index()) + .get_register(whp::Register64::Tsc) + .map(|value| (vp, value)) + .map_err(|err| failed(vp, "read back", err)) + }) + .collect::, _>>()?; + let read = started.elapsed(); + let result = check_readback(target, &readback); + + // Resume before any VP runs, so the guest TSC runs from the restore + // anchor rather than from the first VP run. A failed set leaves time + // suspended: the restore is rejected and no VP runs. + if result.is_ok() { + partition.resume_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot resume partition time: {err}"), + ) + })?; + } + tracing::info!( + vps = readback.len(), + target, + readback_equal = result.is_ok(), + suspend_us = suspended.as_micros() as u64, + write_us = (written - suspended).as_micros() as u64, + readback_us = (read - written).as_micros() as u64, + total_us = started.elapsed().as_micros() as u64, + "time ABI: synchronized TSC set with partition time suspended" + ); + result?; + Ok(TscSetReport { + target, + sample, + readback, + method: TscSyncMethod::FrozenWrite, + }) + } +} + +impl WhpProcessor<'_> { + /// Serves an MSR exit that the time ABI owns (see [`serve_msr`]), ahead + /// of every other MSR handler. Returns `false` on a partition without the + /// time ABI and for every other MSR. + pub(crate) fn handle_time_abi_msr( + &self, + info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result { + let Some(time_abi) = &self.vp.partition.time_abi else { + return Ok(false); + }; + let access = if info.AccessInfo.IsWrite() { + MsrAccess::Write((info.Rax & 0xffff_ffff) | (info.Rdx << 32)) + } else { + MsrAccess::Read + }; + let Some(outcome) = serve_msr( + &time_abi.msrs, + time_abi.cpu_profile.cpu_vendor(), + self.vp.index, + info.MsrNumber, + access, + ) else { + return Ok(false); + }; + tracing::trace!( + vp = self.vp.index.index(), + msr = info.MsrNumber, + ?access, + ?outcome, + "time ABI MSR access" + ); + // RDMSR and WRMSR are two bytes long. A faulting access does not + // retire, so RIP stays. + let rip = exit.vp_context.Rip.wrapping_add(2); + match outcome { + MsrOutcome::Read(value) => whp::set_registers!( + self.current_whp(), + [ + (whp::Register64::Rax, value & 0xffff_ffff), + (whp::Register64::Rdx, value >> 32), + (whp::Register64::Rip, rip), + ] + ) + .for_op("complete a time ABI MSR read")?, + MsrOutcome::Written => self + .current_whp() + .set_register(whp::Register64::Rip, rip) + .for_op("complete a time ABI MSR write")?, + MsrOutcome::Fault => { + let event = hvdef::HvX64PendingExceptionEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) + .with_deliver_error_code(true) + .with_vector(x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into()); + self.current_whp() + .set_register(whp::Register128::PendingEvent, event.into()) + .for_op("inject #GP for a time ABI MSR access")?; + } + } + Ok(true) + } +} + +/// Test support: topologies of the pinned CPU profiles, and core's check of +/// the CPUID that VP 0 presents. `virt::time_abi::cpuid` builds the effective +/// CPUID and the [`TimeAbiConfig::cpuid`] table. +#[cfg(test)] +pub(crate) mod test_cpuid { + use cpu_profile::CpuProfile; + use cpu_profile::CpuVendor; + use cpu_profile::EffectiveCpuid; + use virt::CpuidLeaf; + use virt::CpuidLeafSet; + use vm_topology::processor::ProcessorTopology; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::ApicMode; + use vm_topology::processor::x86::X2ApicState; + + /// Pinned CPU profiles of each vendor. + pub const PROFILES: [&str; 6] = [ + "intel.skylake-sp.v1", + "intel.icelake-sp.v1", + "intel.emeraldrapids.v1", + "amd.milan.v1", + "amd.genoa.v1", + "amd.turin.v1", + ]; + + /// Returns the topology of a VM with `vp_count` VPs in one socket. + pub fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// Returns whether `effective` lists `function`/`index` as an extended + /// topology subleaf past the core level: the subleaf that ends the + /// enumeration, which the profile programming of `de39f6aab` left zero. + pub fn is_listed_terminator(effective: &EffectiveCpuid, function: u32, index: u32) -> bool { + matches!(function, 0xb | 0x1f) + && index >= 2 + && effective + .results() + .any(|result| result.function == function && result.index == Some(index)) + } + + /// Returns the number of `profile`'s entries with a nonzero value, each + /// of which VP 0 of a partition of `profile` presents nonzero. The + /// profile's explicit zero entries, whose number grows with its maximum + /// leaves, present zero. + pub fn nonzero_entries(profile: &CpuProfile) -> usize { + profile + .cpuid() + .iter() + .filter(|entry| entry.values() != [0; 4]) + .count() + } + + /// Returns `value`, a result of `function` that VP 0 of a partition of + /// `profile` presents, without the bits in which core's table and the + /// profile programming of `de39f6aab` deliberately differ: that + /// programming predates AMD profiles, so it presents AMD's core count + /// (`0x80000008` ECX) and cache sharing (`0x8000001D` EAX) as zero. + pub fn without_amd_topology(profile: &CpuProfile, function: u32, value: [u32; 4]) -> [u32; 4] { + let ignored = match function { + 0x8000_0008 | 0x8000_001d if profile.cpu_vendor() == CpuVendor::Amd => { + cpu_profile::vm_owned_bits(CpuVendor::Amd, function, value) + } + _ => [0; 4], + }; + [0, 1, 2, 3].map(|register| value[register] & !ignored[register]) + } + + /// Lists every result of `effective` that `presented` does not match + /// under the result's masks, as core's `check_presented_cpuid` does. + pub fn differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { + effective + .results() + .filter_map(|expected| { + let actual = + presented.result(expected.function, expected.index.unwrap_or(0), &[0; 4]); + let registers: Vec = (0..4) + .filter(|®ister| { + (actual[register] ^ expected.result[register]) & expected.mask[register] + != 0 + }) + .map(|register| { + format!( + "register {register} is {:#010x}, not {:#010x} under mask {:#010x}", + actual[register], expected.result[register], expected.mask[register] + ) + }) + .collect(); + (!registers.is_empty()).then(|| { + format!( + "CPUID {:#x}/{:?}: {}", + expected.function, + expected.index, + registers.join(", ") + ) + }) + }) + .collect() + } + + /// Returns the backend's own CPUID results, as the partition composes + /// them before [`super::WhpTimeAbi::partition_cpuid`]: the x2APIC bit and + /// OpenVMM's topology leaves, computed over `native`. + pub fn own_cpuid( + topology: &ProcessorTopology, + native: &dyn Fn(u32, u32) -> [u32; 4], + ) -> Vec { + let mask = [0, 0, 1 << 21, 0]; + let value = match topology.apic_mode() { + ApicMode::XApic => [0; 4], + ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => mask, + }; + let mut own = vec![CpuidLeaf::new(1, value).masked(mask)]; + virt::x86::topology::topology_cpuid(topology, native, &mut own).unwrap(); + own + } + + /// The CPUID programming of `de39f6aab`, before core's complete table: + /// the profile's leaves other than the VM-owned topology leaves as + /// `CpuidResultList2` results with the profile's masks, and exits for the + /// topology leaves and for every leaf of the identity and time-bit + /// overlay (`time_abi_cpuid`), which the exit handler applied last. + pub struct ProfileProgramming { + /// The `CpuidResultList2` results. + pub results: Vec, + /// The CPUID exit list. + pub exits: Vec, + /// The overlay that the exit handler applied. + pub overlay: CpuidLeafSet, + } + + /// Returns [`ProfileProgramming`] for `profile` and `vp_count` VPs. + pub fn profile_programming(profile: &CpuProfile, vp_count: u32) -> ProfileProgramming { + let overlay = virt::time_abi::identity::time_abi_cpuid(vp_count, true); + let mut exits = vec![1, 4, 0xb, 0x1f]; + exits.extend( + overlay + .leaves() + .iter() + .filter(|leaf| !super::is_zero_fill(leaf)) + .map(|leaf| leaf.function), + ); + exits.sort_unstable(); + exits.dedup(); + let leaves = profile + .cpuid() + .iter() + .filter(|entry| !cpu_profile::VM_OWNED_LEAVES.contains(&entry.key().0)) + .map(|entry| { + let (function, index) = entry.key(); + CpuidLeaf { + function, + index, + result: entry.values(), + mask: entry.masks(), + } + }) + .collect(); + ProfileProgramming { + results: super::cpuid_results(&CpuidLeafSet::new(leaves)), + exits, + overlay, + } + } + + /// Returns the leaves and subleaves on which to compare two CPUID views: + /// every result of `effective` and of `profile`, subleaves 0 to 63 of + /// every indexed leaf, and the four leaves past each maximum leaf. + pub fn probes(effective: &EffectiveCpuid, profile: &CpuProfile) -> Vec<(u32, u32)> { + let mut probes: Vec<(u32, u32)> = effective + .results() + .map(|result| (result.function, result.index.unwrap_or(0))) + .chain(profile.cpuid().iter().map(|entry| { + let (leaf, subleaf) = entry.key(); + (leaf, subleaf.unwrap_or(0)) + })) + .collect(); + let indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + for function in indexed { + probes.extend((0..64).map(|index| (function, index))); + } + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + probes.extend((max_basic + 1..=max_basic + 4).map(|function| (function, 0))); + probes.extend((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + probes.sort_unstable(); + probes.dedup(); + probes + } +} + +#[cfg(test)] +mod tests { + use super::test_cpuid; + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::msr::MSR_AMD_DE_CFG; + use virt::time_abi::msr::MSR_AMD_HWCR; + use virt::time_abi::msr::MSR_APIC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use virt::time_abi::msr::MSR_VP_INDEX; + use vm_topology::processor::x86::X2ApicState; + + const VP_COUNT: u32 = 8; + + /// WHP's own results on an Intel host without synthetic features: no + /// hypervisor leaves, and every time bit wrong. + fn whp_cpuid(function: u32, index: u32) -> [u32; 4] { + match (function, index) { + (0, _) => [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + (1, _) => [ + 0x0005_0654, + 0x0010_0800, + 0x7ffa_fbff | (1 << 24) | ECX1_OSXSAVE, + 0xbfeb_fbff, + ], + (6, _) => [0x77, 2, 9, 0], + (7, 0) => [0, 0xd19f_4fbb, ECX7_OSPKE, 0], + (0xa, _) => [0x0740_4f04, 0, 0, 0x603], + (0x15, _) => [2, 0xd4, 0x017d_7840, 0], + (0x16, _) => [0x0bb8, 0x0e74, 0x64, 0], + (0x8000_0000, _) => [0x8000_0008, 0, 0, 0], + (0x8000_0001, _) => [0, 0, 0x121, 0x2c10_0800], + _ => [0; 4], + } + } + + /// A simulated WHP partition: noise in every bit of every leaf, with the + /// `CpuidResultList2` results applied, as the CPUID output and the + /// default result of a CPUID exit report it. Only programmed bits can + /// match the effective CPUID. + struct FakeWhp { + results: Vec, + /// Whether unprogrammed bits read noise rather than zero. + noise: bool, + } + + impl FakeWhp { + fn new(config: &CpuidLeafSet) -> Self { + Self { + results: cpuid_results(config), + noise: true, + } + } + + fn native(&self, function: u32, index: u32) -> [u32; 4] { + let seed = function.wrapping_mul(0x9e37_79b9) ^ index.wrapping_mul(0x85eb_ca6b); + let mut result = if self.noise { + [ + seed, + seed.rotate_left(8) ^ 0xa5a5_a5a5, + seed.rotate_left(16) ^ 0x5a5a_5a5a, + !seed, + ] + } else { + [0; 4] + }; + for programmed in self.results.iter().filter(|result| { + result.Function == function + && (!result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) + || result.Index == index) + }) { + let output = programmed.Output; + let mask = programmed.Mask; + let output = [output.Eax, output.Ebx, output.Ecx, output.Edx]; + let mask = [mask.Eax, mask.Ebx, mask.Ecx, mask.Edx]; + for register in 0..4 { + result[register] = + (result[register] & !mask[register]) | (output[register] & mask[register]); + } + } + result + } + } + + /// A time ABI partition's CPUID, as `configure` and the partition build + /// compose it for `profile` and `vp_count` VPs. + struct Partition { + topology: vm_topology::processor::ProcessorTopology, + effective: cpu_profile::EffectiveCpuid, + config: CpuidLeafSet, + exits: Vec, + whp: FakeWhp, + cpuid: CpuidLeafSet, + cpuid_topology: CpuidTopology, + } + + impl Partition { + fn new(profile: &str, vp_count: u32, x2apic: X2ApicState) -> Self { + let topology = test_cpuid::topology(vp_count, x2apic); + let effective = virt::time_abi::cpuid::effective_cpuid( + cpu_profile::pinned(profile).unwrap(), + &topology, + ) + .unwrap(); + let config = virt::time_abi::cpuid::backend_cpuid(&effective); + let exits = cpuid_exits(&config); + let whp = FakeWhp::new(&config); + let own = + test_cpuid::own_cpuid(&topology, &|function, index| whp.native(function, index)); + let cpuid = partition_cpuid(own, &config, &exits).unwrap(); + check_cpuid_delivery(&cpuid, &exits).unwrap(); + let cpuid_topology = + CpuidTopology::new(&topology).with_listed_extended_topology(&config); + Self { + topology, + effective, + config, + exits, + whp, + cpuid, + cpuid_topology, + } + } + + /// A partition with the profile programming of `de39f6aab` + /// ([`test_cpuid::profile_programming`]) on a WHP whose unprogrammed + /// bits read zero if `noise` is false. + fn profile_programmed(profile: &str, vp_count: u32, noise: bool) -> Self { + let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); + let pinned = cpu_profile::pinned(profile).unwrap(); + let effective = virt::time_abi::cpuid::effective_cpuid(pinned, &topology).unwrap(); + let programming = test_cpuid::profile_programming(pinned, vp_count); + let whp = FakeWhp { + results: programming.results, + noise, + }; + let own = + test_cpuid::own_cpuid(&topology, &|function, index| whp.native(function, index)); + let cpuid = partition_cpuid(own, &programming.overlay, &programming.exits).unwrap(); + let cpuid_topology = CpuidTopology::new(&topology); + Self { + topology, + effective, + config: programming.overlay, + exits: programming.exits, + whp, + cpuid, + cpuid_topology, + } + } + + fn vp(&self, apic_id: u32) -> VpCpuid<'_> { + VpCpuid { + cpuid: &self.cpuid, + exits: &self.exits, + topology: &self.cpuid_topology, + apic_id, + } + } + + /// The CPUID that the VP with `apic_id` observes. + fn observe(&self, apic_id: u32, function: u32, index: u32) -> [u32; 4] { + self.vp(apic_id) + .result(function, index, || { + Ok::<_, std::convert::Infallible>(self.whp.native(function, index)) + }) + .unwrap() + } + + /// VP 0's effective CPUID record, as the backend reports it. + fn record(&self) -> Vec { + effective_cpuid(&self.cpuid, |function, index| { + Ok::<_, std::convert::Infallible>(self.observe(0, function, index)) + }) + .unwrap() + } + } + + fn key(leaf: &CpuidLeaf) -> (u32, Option, [u32; 4], [u32; 4]) { + (leaf.function, leaf.index, leaf.result, leaf.mask) + } + + fn declared_msrs() -> TimeAbiMsrs { + let msrs = TimeAbiMsrs::new(); + msrs.declare(DeclaredRates::new(2_194_843_000, 200_000_000).unwrap()) + .unwrap(); + msrs + } + + #[test] + fn partition_validation_rejects_unsupported_configurations() { + validate_partition(IsolationType::None, false, false, false).unwrap(); + for (isolation, hv, nested, user_mode_apic, code) in [ + ( + IsolationType::Vbs, + false, + false, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + true, + false, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + false, + true, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + false, + false, + true, + TimeAbiCode::LapicRateUnavailable, + ), + ] { + assert_eq!( + validate_partition(isolation, hv, nested, user_mode_apic) + .unwrap_err() + .code, + code + ); + } + } + + #[test] + fn exit_list_covers_the_per_vp_and_identity_leaves() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + let mut expected = vec![1, 0xb]; + if partition + .config + .leaves() + .iter() + .any(|leaf| leaf.function == 0x1f) + { + expected.push(0x1f); + } + expected.extend(0x4000_0000..=0x4000_0005); + // AMD's processor topology leaf carries each VP's APIC identity. + if partition + .config + .leaves() + .iter() + .any(|leaf| leaf.function == 0x8000_001e) + { + expected.push(0x8000_001e); + } + // WHP answers the explicit zero leaves natively, and every other + // leaf through CpuidResultList2. + assert_eq!(partition.exits, expected, "{profile}"); + } + } + + #[test] + fn cpuid_results_present_every_leaf_outside_the_hypervisor_range() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + let results = &partition.whp.results; + let leaves: Vec<_> = partition + .config + .leaves() + .iter() + .filter(|leaf| { + !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) && leaf.mask != [0; 4] + }) + .collect(); + assert_eq!(results.len(), leaves.len(), "{profile}"); + for (result, leaf) in results.iter().zip(leaves) { + let output = [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ]; + let mask = [ + result.Mask.Eax, + result.Mask.Ebx, + result.Mask.Ecx, + result.Mask.Edx, + ]; + assert_eq!( + ( + result.Function, + result.Index, + result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) + ), + (leaf.function, leaf.index.unwrap_or(0), leaf.index.is_some()), + "{profile}" + ); + assert_eq!(mask, leaf.mask, "{profile} {:#x}", leaf.function); + assert_eq!( + output, + [0, 1, 2, 3].map(|register| leaf.result[register] & leaf.mask[register]), + "{profile} {:#x}", + leaf.function + ); + } + // The per-VP APIC identity and the runtime state (OSXSAVE) keep + // WHP's value for each VP. + let leaf = |function, index| { + results + .iter() + .find(|result| result.Function == function && result.Index == index) + .unwrap() + }; + assert_eq!(leaf(1, 0).Mask.Ebx & 0xff00_0000, 0, "{profile}"); + assert_eq!(leaf(1, 0).Mask.Ecx & ECX1_OSXSAVE, 0, "{profile}"); + assert_eq!(leaf(0xb, 0).Mask.Edx, 0, "{profile}"); + // The brand string is the profile's generic one, not the host's. + let generation = &cpu_profile::pinned(profile).unwrap().generation().name; + let generic = cpu_profile::derive::known_generation(generation) + .unwrap() + .brand; + let brand: String = [0x8000_0002, 0x8000_0003, 0x8000_0004] + .iter() + .flat_map(|&function| { + let result = leaf(function, 0); + [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ] + }) + .flat_map(u32::to_le_bytes) + .take_while(|&byte| byte != 0) + .map(char::from) + .collect(); + assert_eq!(brand, generic, "{profile}"); + } + } + + #[test] + fn feature_banks_hide_exactly_the_time_bits() { + let mut available = whp::ProcessorFeatures::default(); + available.bank0 = WHV_PROCESSOR_FEATURES(0x1001_f9ff_e7f7_859f); + available.bank1 = WHV_PROCESSOR_FEATURES1(!0); + let features = processor_features(available); + assert_eq!(features.bank0, available.bank0); + assert_eq!(features.bank1.0, !((1 << 0) | (1 << 17) | (1 << 18))); + assert!( + features + .bank1 + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport) + ); + // Azure's 8573C runners offer no invariant TSC; the banks add none. + available.bank1 = WHV_PROCESSOR_FEATURES1(0xc_0000_0051); + assert_eq!(processor_features(available).bank1.0, 0xc_0000_0050); + } + + /// The check core runs before any VP runs (`E_CPU_SURFACE`): VP 0's + /// record equals the effective CPUID under its masks, on every pinned + /// profile, VP count, and APIC mode. + #[test] + fn vp0_presents_the_effective_cpuid() { + for profile in test_cpuid::PROFILES { + for vp_count in [1, 2, 4, 8, 64] { + for x2apic in [ + X2ApicState::Supported, + X2ApicState::Enabled, + X2ApicState::Unsupported, + ] { + if x2apic == X2ApicState::Unsupported && vp_count > 8 { + continue; + } + let partition = Partition::new(profile, vp_count, x2apic); + let record = CpuidLeafSet::new(partition.record()); + let differences = test_cpuid::differences(&record, &partition.effective); + assert!( + differences.is_empty(), + "{profile}, {vp_count} VPs, {x2apic:?}: {differences:#?}" + ); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, vp_count).unwrap(); + virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); + } + } + } + } + + /// Every VP observes the effective CPUID with its own APIC identity in + /// the per-VP fields. + #[test] + fn each_vp_observes_its_own_apic_identity() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + for vp in partition.topology.vps_arch() { + let apic_id = vp.apic_id; + assert_eq!( + partition.observe(apic_id, 1, 0)[1] >> 24, + apic_id, + "{profile}" + ); + for index in [0, 1] { + assert_eq!(partition.observe(apic_id, 0xb, index)[3], apic_id); + } + for leaf in partition.config.leaves() { + let index = leaf.index.unwrap_or(0); + let mut theirs = partition.observe(apic_id, leaf.function, index); + let mut ours = partition.observe(0, leaf.function, index); + let per_vp = per_vp_cpuid_bits(leaf.function); + for register in 0..4 { + theirs[register] &= !per_vp[register]; + ours[register] &= !per_vp[register]; + } + assert_eq!(theirs, ours, "{profile} VP {apic_id} {:#x}", leaf.function); + } + } + } + } + + /// Since step B, WHP programs core's complete table as the single source + /// of the CPUID. VP 0 observes exactly the CPUID of the profile + /// programming of `de39f6aab` on every leaf and subleaf that either + /// lists, on their unlisted subleaves, and past the maximum leaves, on a + /// WHP whose unprogrammed bits read zero. The deliberate changes are the + /// subleaf that ends each extended topology leaf: core's table lists it, + /// and VP 0 presents it as listed, where the profile programming read + /// zero; and AMD's topology fields, which that programming predates + /// ([`test_cpuid::without_amd_topology`]). + #[test] + fn core_table_presents_what_the_profile_programming_did() { + for profile in test_cpuid::PROFILES { + let pinned = cpu_profile::pinned(profile).unwrap(); + for vp_count in [1, 8] { + let mut new = Partition::new(profile, vp_count, X2ApicState::Supported); + new.whp.noise = false; + let old = Partition::profile_programmed(profile, vp_count, false); + let probes = test_cpuid::probes(&new.effective, pinned); + let differences: Vec = probes + .iter() + .filter_map(|&(function, index)| { + let ours = normalize_cpuid(function, index, new.observe(0, function, index)); + if test_cpuid::is_listed_terminator(&new.effective, function, index) { + let expected = new.effective.lookup(function, index); + return (ours != expected).then(|| { + format!("{function:#x}.{index}: terminator {ours:08x?}, not {expected:08x?}") + }); + } + let ours = test_cpuid::without_amd_topology(pinned, function, ours); + let theirs = test_cpuid::without_amd_topology( + pinned, + function, + normalize_cpuid(function, index, old.observe(0, function, index)), + ); + (ours != theirs).then(|| { + format!("{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}") + }) + }) + .collect(); + assert!( + differences.is_empty(), + "{profile}, {vp_count} VPs, {} probes: {differences:#?}", + probes.len() + ); + // The views carry the profile: the comparison is not vacuous. + let populated = probes + .iter() + .filter(|&&(function, index)| new.observe(0, function, index) != [0; 4]) + .count(); + let nonzero = test_cpuid::nonzero_entries(pinned); + assert!( + populated >= nonzero, + "{profile}: {populated} populated results of {nonzero} nonzero profile entries" + ); + } + } + } + + /// Core's table lists the subleaf that ends leaf 0Bh, with VP 0's x2APIC + /// ID and `EDX` unmasked: every VP presents it with its own ID, and the + /// subleaves past it still read zero. Before core listed it, the exit + /// handler zeroed it, and VP 0's report failed `E_CPU_SURFACE`. + #[test] + fn extended_topology_terminator_carries_each_vps_x2apic_id() { + for profile in test_cpuid::PROFILES { + for vp_count in [1, 2, 8] { + let partition = Partition::new(profile, vp_count, X2ApicState::Supported); + assert!(test_cpuid::is_listed_terminator( + &partition.effective, + 0xb, + 2 + )); + for vp in partition.topology.vps_arch() { + assert_eq!( + partition.observe(vp.apic_id, 0xb, 2), + [0, 0, 2, vp.apic_id], + "{profile}, {vp_count} VPs" + ); + assert_eq!(partition.observe(vp.apic_id, 0xb, 3), [0; 4]); + } + let presented = CpuidLeafSet::new(partition.record()); + let differences = test_cpuid::differences(&presented, &partition.effective); + assert!(differences.is_empty(), "{profile}: {differences:#?}"); + } + } + } + + /// The report holds VP 0's view at the host's entries outside the + /// profile, which core requires to read zero (`E_CPU_UNLISTED`): zeros + /// pass, and a WHP that showed its own values there fails. + #[test] + fn report_reads_vp0_at_host_entries_outside_the_profile() { + for profile_id in test_cpuid::PROFILES { + let profile = cpu_profile::pinned(profile_id).unwrap(); + let past_max = profile.lookup(0, 0)[0] + 1; + let mut host: Vec<_> = profile + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let unlisted = [(0xf, Some(1)), (0x14, Some(1)), (past_max, None)]; + host.extend(unlisted.iter().map(|&(leaf, subleaf)| { + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, [1, 2, 3, 4]) + })); + for noise in [false, true] { + let mut partition = Partition::new(profile_id, 2, X2ApicState::Supported); + partition.whp.noise = noise; + let report = unlisted_cpuid(profile, &host, |function, index| { + Ok::<_, std::convert::Infallible>(partition.observe(0, function, index)) + }) + .unwrap(); + let keys: Vec<_> = report + .iter() + .map(|leaf| (leaf.function, leaf.index)) + .collect(); + assert_eq!(keys, unlisted, "{profile_id}"); + let entries: Vec<_> = report + .iter() + .map(|leaf| { + cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result) + }) + .collect(); + match cpu_profile::check_unlisted_cpuid(profile, &entries) { + Ok(()) => assert!(!noise, "{profile_id}: noise passed"), + Err(err) => { + assert!(noise, "{profile_id}: {err}"); + assert!(matches!( + err.code, + cpu_profile::ProfileErrorCode::CpuUnlisted + )); + } + } + } + } + } + + #[test] + fn partition_cpuid_keeps_own_results_only_for_exiting_leaves() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let own = vec![ + CpuidLeaf::new(4, [0x1c00_4121, 0, 0, 0]).indexed(0), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(1, [0, 0x0008_0000, 0, 0]).masked([0, 0x00ff_0000, 0, 0]), + ]; + let cpuid = partition_cpuid(own, &partition.config, &partition.exits).unwrap(); + for leaf in cpuid.leaves() { + let programmed = partition + .config + .leaves() + .iter() + .find(|c| (c.function, c.index) == (leaf.function, leaf.index)); + match programmed { + // Leaves that do not exit come from the effective CPUID alone. + Some(c) if partition.exits.binary_search(&c.function).is_err() => { + assert_eq!(key(leaf), key(c), "{:#x}", leaf.function); + } + Some(_) => {} + None => assert!( + partition.exits.binary_search(&leaf.function).is_ok(), + "{:#x}", + leaf.function + ), + } + } + assert!( + !cpuid + .leaves() + .iter() + .any(|leaf| leaf.function == 0x4000_0100) + ); + } + + #[test] + fn partition_cpuid_rejects_shadowed_subleaves() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + // A subleaf-less leaf 0xB would hide the effective CPUID's 0xB.0. + let own = vec![CpuidLeaf::new(0xb, [0; 4]).masked([0, !0, 0, 0])]; + assert_eq!( + partition_cpuid(own, &partition.config, &partition.exits) + .unwrap_err() + .code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn hypervisor_leaves_must_exit() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let without: Vec = partition + .exits + .iter() + .copied() + .filter(|&leaf| leaf != 0x4000_0003) + .collect(); + let error = check_cpuid_delivery(&partition.cpuid, &without).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + assert!(error.message.contains("0x40000003"), "{error}"); + // A leaf outside the hypervisor range reaches the guest through + // CpuidResultList2 without an exit. + let only_identity: Vec = partition + .exits + .iter() + .copied() + .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(leaf)) + .collect(); + check_cpuid_delivery(&partition.config, &only_identity).unwrap(); + } + + #[test] + fn effective_cpuid_ignores_guest_cr4_state() { + let partition = Partition::new(test_cpuid::PROFILES[1], VP_COUNT, X2ApicState::Supported); + let record = |cr4_bits: bool| { + effective_cpuid(&partition.cpuid, |function, index| { + let mut result = partition.observe(0, function, index); + let (register, bit) = match (function, index) { + (1, _) => (2, ECX1_OSXSAVE), + (7, 0) => (2, ECX7_OSPKE), + _ => (0, 0), + }; + if cr4_bits { + result[register] |= bit; + } else { + result[register] &= !bit; + } + Ok::<_, std::convert::Infallible>(result) + }) + .unwrap() + .iter() + .map(key) + .collect::>() + }; + assert_eq!(record(true), record(false)); + } + + #[test] + fn normalization_clears_only_runtime_state() { + let all = [!0; 4]; + assert_eq!(normalize_cpuid(1, 0, all), [!0, !0, !ECX1_OSXSAVE, !0]); + assert_eq!(normalize_cpuid(7, 0, all), [!0, !0, !ECX7_OSPKE, !0]); + assert_eq!(normalize_cpuid(7, 1, all), all); + assert_eq!(normalize_cpuid(0xd, 0, all), [!0, 0, !0, !0]); + assert_eq!(normalize_cpuid(0xd, 1, all), [!0, 0, !0, !0]); + assert_eq!(normalize_cpuid(0xd, 2, all), all); + assert_eq!(normalize_cpuid(0x8000_0001, 0, all), all); + } + + #[test] + fn effective_cpuid_reports_read_failures() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let error = effective_cpuid(&partition.cpuid, |function, _| { + if function == 0x8000_0007 { + Err(function) + } else { + Ok(whp_cpuid(function, 0)) + } + }) + .unwrap_err(); + assert_eq!(error, 0x8000_0007); + } + + #[test] + fn native_sentinels_must_read_zero() { + let mut read = Vec::new(); + check_native_sentinels(|function| { + read.push(function); + Ok::<_, String>(whp_cpuid(function, 0)) + }) + .unwrap(); + assert_eq!(read, PREFLIGHT_SENTINEL_LEAVES); + let config = + Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported).config; + for leaf in [0x4000_0006, 0x4000_0081] { + assert!( + config + .leaves() + .iter() + .any(|c| c.function == leaf && is_zero_fill(c)) + ); + } + + for (leaf, result) in [ + (0x4000_0081, [0x3123_5356, 0, 0, 0]), + (0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + ] { + let error = check_native_sentinels(|function| { + Ok::<_, String>(if function == leaf { result } else { [0; 4] }) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + } + assert_eq!( + check_native_sentinels(|_| Err("no VP")).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn feature_banks_must_hide_the_timer_msrs() { + assert_eq!( + check_feature_banks(|function, index| Ok::<_, String>(whp_cpuid(function, index))) + .unwrap_err() + .code, + TimeAbiCode::ProfileUnsupported + ); + let hidden = |function, index| { + let mut result = whp_cpuid(function, index); + match function { + 1 => result[2] &= !(1 << 24), + 6 => result[2] &= !1, + 7 => result[1] &= !(1 << 1), + _ => {} + } + Ok::<_, String>(result) + }; + check_feature_banks(hidden).unwrap(); + for (leaf, register, bit) in [(1, 2, 1 << 24), (6, 2, 1), (7, 1, 1 << 1)] { + let error = check_feature_banks(|function, index| { + let mut result = hidden(function, index)?; + if function == leaf { + result[register] |= bit; + } + Ok::<_, String>(result) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported, "{leaf:#x}"); + } + } + + #[test] + fn scaling_is_detected() { + check_unscaled(2_194_843_000, 2_194_843_000).unwrap(); + assert_eq!( + check_unscaled(1_000_000_000, 2_194_843_000) + .unwrap_err() + .code, + TimeAbiCode::TscScalingActive + ); + } + + #[test] + fn msr_exits_serve_the_identity_and_hide_the_timer_msrs() { + let msrs = declared_msrs(); + let intel = CpuVendor::Intel; + let read = |vp, msr| serve_msr(&msrs, intel, VpIndex::new(vp), msr, MsrAccess::Read); + let write = + |msr, value| serve_msr(&msrs, intel, VpIndex::BSP, msr, MsrAccess::Write(value)); + assert_eq!(read(5, MSR_VP_INDEX), Some(MsrOutcome::Read(5))); + assert_eq!( + read(0, MSR_TSC_FREQUENCY), + Some(MsrOutcome::Read(2_194_843_000)) + ); + assert_eq!( + read(1, MSR_APIC_FREQUENCY), + Some(MsrOutcome::Read(200_000_000)) + ); + assert_eq!( + read(2, MSR_TSC_INVARIANT_CONTROL), + Some(MsrOutcome::Read(0)) + ); + assert_eq!( + write(MSR_TSC_INVARIANT_CONTROL, 1), + Some(MsrOutcome::Written) + ); + assert_eq!( + read(7, MSR_TSC_INVARIANT_CONTROL), + Some(MsrOutcome::Read(1)) + ); + assert_eq!( + write(MSR_TSC_INVARIANT_CONTROL, 0), + Some(MsrOutcome::Written) + ); + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 2), Some(MsrOutcome::Fault)); + for msr in [MSR_VP_INDEX, MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // Undefined identity MSRs, such as the hypercall MSRs, fault. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0073, + 0x4000_01ff, + ] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 1), Some(MsrOutcome::Fault), "{msr:#x}"); + } + for msr in [MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_DEADLINE] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // The legacy L2-cache MSRs that the backend otherwise stubs fault. + for msr in [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // Every other MSR keeps its existing handling, AMD's configuration + // MSRs included under an Intel profile. + for msr in [ + 0x10, + 0x1b, + 0x3a, + 0x6df, + 0x6e1, + 0x3fff_ffff, + 0x4000_0200, + MSR_AMD_HWCR, + MSR_AMD_DE_CFG, + ] { + assert_eq!(read(0, msr), None, "{msr:#x}"); + assert_eq!(write(msr, 0), None, "{msr:#x}"); + } + } + + /// Under an AMD profile, OpenVMM serves HWCR and DE_CFG with the time + /// ABI's fixed values, before the backend's legacy stubs, which read HWCR + /// as 0. + #[test] + fn msr_exits_serve_amd_config_msrs_under_an_amd_profile() { + let msrs = declared_msrs(); + let amd = CpuVendor::Amd; + let read = |msr| serve_msr(&msrs, amd, VpIndex::new(3), msr, MsrAccess::Read); + let write = |msr, value| serve_msr(&msrs, amd, VpIndex::BSP, msr, MsrAccess::Write(value)); + assert_eq!(read(MSR_AMD_HWCR), Some(MsrOutcome::Read(1 << 24))); + assert_eq!(read(MSR_AMD_DE_CFG), Some(MsrOutcome::Read(1 << 1))); + assert_eq!(write(MSR_AMD_HWCR, 1 << 24), Some(MsrOutcome::Written)); + assert_eq!(write(MSR_AMD_HWCR, 1 << 30), Some(MsrOutcome::Fault)); + assert_eq!(write(MSR_AMD_DE_CFG, 0), Some(MsrOutcome::Fault)); + // The identity and the hidden MSRs are served as under any profile. + assert_eq!(read(MSR_VP_INDEX), Some(MsrOutcome::Read(3))); + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(MsrOutcome::Fault)); + // Other AMD MSRs keep the backend's handling. + for msr in [0xc001_0010, 0xc001_0114, 0xc001_0140] { + assert_eq!(read(msr), None, "{msr:#x}"); + } + } + + #[test] + fn frequency_reads_fault_before_the_rates_are_declared() { + let msrs = TimeAbiMsrs::new(); + for msr in [MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_msr(&msrs, CpuVendor::Intel, VpIndex::BSP, msr, MsrAccess::Read), + Some(MsrOutcome::Fault) + ); + } + } + + fn sample(monotonic_ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns: 1_800_000_000_000_000_000 + monotonic_ns, + monotonic_ns, + } + } + + #[test] + fn anchors_pair_with_the_tightest_bracket_midpoint() { + let candidates = [ + AnchorCandidate { + tsc: 100, + before: sample(1_000), + after: sample(22_000), + }, + AnchorCandidate { + tsc: 200, + before: sample(50_000), + after: sample(59_001), + }, + AnchorCandidate { + tsc: 300, + before: sample(90_000), + after: sample(108_000), + }, + ]; + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS).unwrap(); + assert_eq!(anchor.tsc, 200); + assert_eq!(anchor.sample, sample(54_500)); + assert_eq!(anchor.pairing_ns, 4_501); + + let error = select_anchor(&candidates[..1], 10_000).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!(error.message.contains("10500 ns"), "{error}"); + assert_eq!( + select_anchor(&[], MAX_ANCHOR_PAIRING_NS).unwrap_err().code, + TimeAbiCode::TscAnchor + ); + } + + #[test] + fn readback_requires_every_vp() { + let readback: Vec<_> = (0..4).map(|vp| (VpIndex::new(vp), 0x1234)).collect(); + check_readback(0x1234, &readback).unwrap(); + let mut skewed = readback; + skewed[2].1 = 0x1235; + let error = check_readback(0x1234, &skewed).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 2"), "{error}"); + } + + #[test] + fn capabilities_exclude_hv1() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, 1, X2ApicState::Supported); + let mut lookup = |function, index| partition.observe(0, function, index); + let caps = virt::x86::X86PartitionCapabilities::from_cpuid( + &partition.topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + .unwrap(); + assert!(!caps.hv1, "{profile}"); + assert!(!caps.tsc_deadline, "{profile}"); + } + } +} + +/// Tests against WHP itself. Run them with `--ignored` on a host with the +/// Windows Hypervisor Platform. +#[cfg(test)] +mod whp_tests { + use super::test_cpuid; + use super::*; + use virt::time_abi::identity::check_identity; + use virt::time_abi::identity::check_time_bits; + use virt::time_abi::rate::LAPIC_HZ_HYPERV; + use vm_topology::processor::x86::X2ApicState; + + /// A time ABI partition, its time ABI state, and its effective CPUID. + struct Partition { + partition: whp::Partition, + time_abi: WhpTimeAbi, + topology: vm_topology::processor::ProcessorTopology, + effective: cpu_profile::EffectiveCpuid, + } + + /// Returns a time ABI partition with `vp_count` VPs and this host's CPU + /// profile, configured as `VtlPartition::new` configures one, or `None` + /// on a host outside the pinned generations, where the tests skip. + fn partition(vp_count: u32) -> Option { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => PartitionProfile::pinned(profile.id()).unwrap(), + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return None; + } + }; + let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); + let effective = virt::time_abi::cpuid::effective_cpuid(&profile, &topology).unwrap(); + let config = TimeAbiConfig { + cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(&effective)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: profile, + }; + let mut whp_config = whp::PartitionConfig::new().unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) + .unwrap(); + let mut exits = WHV_EXTENDED_VM_EXITS(0); + let time_abi = WhpTimeAbi::configure(&config, &mut whp_config, &mut exits).unwrap(); + whp_config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ExtendedVmExits(exits)) + .unwrap(); + let partition = whp_config.create().unwrap(); + for vp in 0..vp_count { + partition.create_vp(vp).create().unwrap(); + } + Some(Partition { + partition, + time_abi, + topology, + effective, + }) + } + + fn native_vp( + partition: &whp::Partition, + vp: u32, + function: u32, + index: u32, + ) -> Result<[u32; 4], whp::WHvError> { + let output = partition.vp(vp).get_cpuid_output(function, index)?; + Ok([output.Eax, output.Ebx, output.Ecx, output.Edx]) + } + + fn native( + partition: &whp::Partition, + function: u32, + index: u32, + ) -> Result<[u32; 4], whp::WHvError> { + native_vp(partition, 0, function, index) + } + + fn tsc(partition: &whp::Partition, vp: u32) -> u64 { + partition.vp(vp).get_register(whp::Register64::Tsc).unwrap() + } + + #[test] + #[ignore = "requires WHP"] + fn partition_meets_the_static_obligations() { + let Some(Partition { + partition, + time_abi, + .. + }) = partition(4) + else { + return; + }; + let started = std::time::Instant::now(); + check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); + check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); + println!("native checks: {} us", started.elapsed().as_micros()); + // WHP answers the whole identity range past the exits, and every + // hypervisor signature base, with zeros. + let bases = (0x4000_0100..=0x4000_ff00).step_by(0x100); + for function in (IDENTITY_MAX_LEAF + 1..=0x4000_00ff).chain(bases) { + assert_eq!( + native(&partition, function, 0).unwrap(), + [0; 4], + "{function:#x}" + ); + } + check_unscaled( + partition.tsc_frequency().unwrap(), + whp::capabilities::processor_clock_frequency().unwrap(), + ) + .unwrap(); + assert_eq!(partition.apic_frequency().unwrap(), LAPIC_HZ_HYPERV); + let exits = partition.extended_vm_exits().unwrap(); + assert!(exits.is_set(WHV_EXTENDED_VM_EXITS::X64CpuidExit)); + assert!(exits.is_set(WHV_EXTENDED_VM_EXITS::X64MsrExit)); + assert!( + partition + .x64_msr_exit_bitmap() + .unwrap() + .is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) + ); + println!( + "tsc_hz {} bank1 {:#x} native 0x80000007 {:#x?}", + partition.tsc_frequency().unwrap(), + time_abi.features_bank1, + native(&partition, 0x8000_0007, 0).unwrap() + ); + } + + /// Configures a partition from this host's CPU profile and runs the + /// check core runs before any VP runs (`E_CPU_SURFACE`) on every VP: the + /// CPUID it observes, through `CpuidResultList2` or the exit handler, + /// equals the effective CPUID under its masks, with its own APIC + /// identity in the per-VP fields. + #[test] + #[ignore = "requires WHP"] + fn host_profile_configures_the_partition() { + const VP_COUNT: u32 = 4; + let Some(Partition { + partition, + time_abi, + topology, + effective, + }) = partition(VP_COUNT) + else { + return; + }; + check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); + check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); + + // The backend's own results, as the partition build composes them. + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = time_abi.cpuid_topology(&topology); + let started = std::time::Instant::now(); + let mut reads = 0; + for vp in topology.vps_arch() { + let index = vp.base.vp_index.index(); + let view = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: vp.apic_id, + }; + let record = effective_cpuid(&cpuid, |function, subleaf| { + view.result(function, subleaf, || { + reads += 1; + native_vp(&partition, index, function, subleaf) + }) + }) + .unwrap(); + let record = CpuidLeafSet::new(record); + // Each VP ends leaf 0Bh with the listed terminator and its own + // x2APIC ID. + assert_eq!( + record.result(0xb, 2, &[0; 4]), + [0, 0, 2, vp.apic_id], + "VP {index}" + ); + if index == 0 { + println!( + "VP 0 record: {} results, {reads} native reads in {} us", + record.leaves().len(), + started.elapsed().as_micros() + ); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + check_identity(&mut lookup, VP_COUNT).unwrap(); + check_time_bits(&mut lookup, true).unwrap(); + let differences = test_cpuid::differences(&record, &effective); + assert!(differences.is_empty(), "VP 0: {differences:#?}"); + } else { + // Each VP observes VP 0's CPUID outside the per-VP fields, + // and its own APIC ID in them. + assert_eq!(record.result(1, 0, &[0; 4])[1] >> 24, vp.apic_id); + assert_eq!(record.result(0xb, 0, &[0; 4])[3], vp.apic_id); + for leaf in effective.results() { + let subleaf = leaf.index.unwrap_or(0); + let per_vp = per_vp_cpuid_bits(leaf.function); + let actual = record.result(leaf.function, subleaf, &[0; 4]); + for register in 0..4 { + let mask = leaf.mask[register] & !per_vp[register]; + assert_eq!( + (actual[register] ^ leaf.result[register]) & mask, + 0, + "VP {index} CPUID {:#x}/{subleaf} register {register}", + leaf.function + ); + } + } + } + } + println!( + "{}: exits {:#x?} results {} bank0 {:#x} bank1 {:#x} xsave {:#x}", + time_abi.cpu_profile.id(), + time_abi.cpuid_exits, + time_abi.cpuid_results, + time_abi.features_bank0, + time_abi.features_bank1, + time_abi.features_xsave + ); + } + + /// Sweeps subleaves 0 to 63 of every indexed leaf of the effective CPUID, + /// and the four leaves past the maximum basic and extended leaves, and + /// checks that VP 0 observes the effective CPUID there. Listed results + /// must match under their masks, as `host_profile_configures_the_partition` + /// checks too. The time ABI reserves the unlisted entries: a backend may + /// return zeros or the hypervisor's own values there, and WHP serves its + /// own value for anything not programmed. WHP reads zero there on + /// bare-metal, 8370C, and 8573C hosts, which the fingerprint verification of + /// pass-through backends (`E_CPU_UNLISTED`) expects, so the backend + /// programs no explicit zero results. + #[test] + #[ignore = "requires WHP"] + fn unlisted_subleaves_read_zero() { + let Some(Partition { + partition, + time_abi, + topology, + effective, + }) = partition(1) + else { + return; + }; + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = time_abi.cpuid_topology(&topology); + let vp0 = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: 0, + }; + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let probes = indexed + .iter() + .flat_map(|&function| (0..64).map(move |index| (function, index))) + .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + let (mut count, mut listed_failures, mut unlisted) = (0, Vec::new(), Vec::new()); + for (function, index) in probes { + count += 1; + let listed = effective.results().find(|result| { + result.function == function && result.index.is_none_or(|i| i == index) + }); + let (expected, mask) = + listed.map_or(([0; 4], [!0; 4]), |result| (result.result, result.mask)); + let actual = normalize_cpuid( + function, + index, + vp0.result(function, index, || native(&partition, function, index)) + .unwrap(), + ); + if (0..4).all(|register| (actual[register] ^ expected[register]) & mask[register] == 0) + { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if listed.is_some() { + listed_failures.push(format!("{line} expected {expected:08x?} mask {mask:08x?}")); + } else { + unlisted.push(line); + } + } + println!( + "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} unlisted results are not zero:", + time_abi.cpu_profile.id(), + indexed.len(), + unlisted.len() + ); + for line in &unlisted { + println!(" {line}"); + } + assert!(listed_failures.is_empty(), "{listed_failures:#?}"); + assert!(unlisted.is_empty(), "{unlisted:#?}"); + } + + /// Reads VP 0's view at every entry this host's CPUID enumerates outside + /// the profile's tables, as the backend's report does, and checks core's + /// verdict on them (`E_CPU_UNLISTED`): each must read zero. + #[test] + #[ignore = "requires WHP"] + fn host_entries_outside_the_profile_read_zero() { + let Some(Partition { + partition, + time_abi, + topology, + .. + }) = partition(1) + else { + return; + }; + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = time_abi.cpuid_topology(&topology); + let vp0 = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: 0, + }; + let profile = &*time_abi.cpu_profile; + let host = host_cpuid(); + let started = std::time::Instant::now(); + let report = unlisted_cpuid(profile, host, |function, index| { + vp0.result(function, index, || native(&partition, function, index)) + }) + .unwrap(); + let elapsed = started.elapsed(); + let keys: Vec = report + .iter() + .map(|leaf| match leaf.index { + Some(index) => format!("{:#x}.{index}", leaf.function), + None => format!("{:#x}", leaf.function), + }) + .collect(); + println!( + "{}: {} host entries, {} outside the profile, read in {} us: {}", + profile.id(), + host.len(), + report.len(), + elapsed.as_micros(), + keys.join(" ") + ); + let entries: Vec<_> = report + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap(); + } + + /// The comparison of `tests::core_table_presents_what_the_profile_programming_did` + /// on WHP: two partitions with this host's profile and the same features, + /// one programmed from core's complete table and one with the profile + /// programming of `de39f6aab`, present the same CPUID to VP 0, except the + /// listed subleaf that ends leaf 0Bh, which the profile programming left + /// zero, and AMD's topology fields, which it predates. + #[test] + #[ignore = "requires WHP"] + fn core_table_presents_what_the_profile_programming_did_on_whp() { + const VP_COUNT: u32 = 2; + let Some(new) = partition(VP_COUNT) else { + return; + }; + let profile = &*new.time_abi.cpu_profile; + let programming = test_cpuid::profile_programming(profile, VP_COUNT); + let mut whp_config = whp::PartitionConfig::new().unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorCount(VP_COUNT)) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::CpuidExitList(&programming.exits)) + .unwrap(); + let mut features = whp::capabilities::processor_features().unwrap(); + features.bank0 = WHV_PROCESSOR_FEATURES(new.time_abi.features_bank0); + features.bank1 = WHV_PROCESSOR_FEATURES1(new.time_abi.features_bank1); + whp_config + .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(new.time_abi.features_xsave), + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::CpuidResultList2( + &programming.results, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ExtendedVmExits( + WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit, + )) + .unwrap(); + let old = whp_config.create().unwrap(); + for vp in 0..VP_COUNT { + old.create_vp(vp).create().unwrap(); + } + + let new_topology = new.time_abi.cpuid_topology(&new.topology); + let old_topology = CpuidTopology::new(&new.topology); + let new_cpuid = new + .time_abi + .partition_cpuid(test_cpuid::own_cpuid(&new.topology, &|function, index| { + native(&new.partition, function, index).unwrap() + })) + .unwrap(); + let old_cpuid = partition_cpuid( + test_cpuid::own_cpuid(&new.topology, &|function, index| { + native(&old, function, index).unwrap() + }), + &programming.overlay, + &programming.exits, + ) + .unwrap(); + let view = |partition: &whp::Partition, + cpuid: &CpuidLeafSet, + exits: &[u32], + topology: &CpuidTopology, + function, + index| { + let vp0 = VpCpuid { + cpuid, + exits, + topology, + apic_id: 0, + }; + let result = vp0 + .result(function, index, || native(partition, function, index)) + .unwrap(); + normalize_cpuid(function, index, result) + }; + let probes = test_cpuid::probes(&new.effective, profile); + let mut populated = 0; + let mut differences = Vec::new(); + for &(function, index) in &probes { + let ours = view( + &new.partition, + &new_cpuid, + &new.time_abi.cpuid_exits, + &new_topology, + function, + index, + ); + populated += usize::from(ours != [0; 4]); + if test_cpuid::is_listed_terminator(&new.effective, function, index) { + let expected = new.effective.lookup(function, index); + if ours != expected { + differences.push(format!( + "{function:#x}.{index}: terminator {ours:08x?}, not {expected:08x?}" + )); + } + continue; + } + let theirs = view( + &old, + &old_cpuid, + &programming.exits, + &old_topology, + function, + index, + ); + let ours = test_cpuid::without_amd_topology(profile, function, ours); + let theirs = test_cpuid::without_amd_topology(profile, function, theirs); + if ours != theirs { + differences.push(format!( + "{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}" + )); + } + } + println!( + "{}: {} probes, {populated} populated, {} differences; exits {} against {}, results {} against {}", + profile.id(), + probes.len(), + differences.len(), + new.time_abi.cpuid_exits.len(), + programming.exits.len(), + new.time_abi.cpuid_results, + programming.results.len() + ); + assert!(differences.is_empty(), "{differences:#?}"); + assert!(populated >= test_cpuid::nonzero_entries(profile)); + } + + #[test] + #[ignore = "requires WHP"] + fn time_suspension_is_idempotent() { + // The preflight probe and the synchronized set both suspend time, and + // a fresh partition's time is frozen until a VP first runs. + let Some(Partition { partition, .. }) = partition(2) else { + return; + }; + partition.suspend_time().unwrap(); + partition.suspend_time().unwrap(); + let frozen = tsc(&partition, 0); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!(tsc(&partition, 0), frozen); + partition.resume_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert!(tsc(&partition, 0) > frozen); + println!("second resume: {:?}", partition.resume_time()); + } + + #[test] + #[ignore = "requires WHP"] + fn synchronized_set_is_exact_while_suspended() { + for vp_count in [1, 2, 4, 8] { + let Some(Partition { partition, .. }) = partition(vp_count) else { + return; + }; + let hz = partition.tsc_frequency().unwrap(); + partition.resume_time().unwrap(); + for vp in 0..vp_count { + partition + .vp(vp) + .set_register(whp::Register64::Tsc, 1_000_000 * u64::from(vp + 1)) + .unwrap(); + } + + let started = std::time::Instant::now(); + partition.suspend_time().unwrap(); + let target = 0x1234_5678_9abc; + for vp in 0..vp_count { + partition + .vp(vp) + .set_register(whp::Register64::Tsc, target) + .unwrap(); + } + let readback: Vec<_> = (0..vp_count) + .map(|vp| (VpIndex::new(vp), tsc(&partition, vp))) + .collect(); + check_readback(target, &readback).unwrap(); + let elapsed = started.elapsed(); + std::thread::sleep(std::time::Duration::from_millis(10)); + for vp in 0..vp_count { + assert_eq!(tsc(&partition, vp), target, "VP {vp} moved while suspended"); + } + partition.resume_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + // Time runs again without any VP run, from the target. + let advanced = tsc(&partition, 0) - target; + assert!(advanced >= hz / 100 && advanced < hz, "advanced {advanced}"); + println!( + "{vp_count} VPs: suspend, write, and read back in {} us", + elapsed.as_micros() + ); + } + } + + #[test] + #[ignore = "requires WHP"] + fn capture_anchor_pairs_within_the_bound() { + let Some(Partition { partition, .. }) = partition(1) else { + return; + }; + partition.resume_time().unwrap(); + let bsp = partition.vp(0); + let candidates: Vec<_> = (0..ANCHOR_ATTEMPTS) + .map(|_| { + let before = sample_host_time().unwrap(); + let tsc = bsp.get_register(whp::Register64::Tsc).unwrap(); + let after = sample_host_time().unwrap(); + AnchorCandidate { tsc, before, after } + }) + .collect(); + let mut widths: Vec = candidates.iter().map(|c| c.width_ns()).collect(); + widths.sort_unstable(); + println!("bracket widths (ns): {widths:?}"); + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS).unwrap(); + println!("pairing {} ns", anchor.pairing_ns); + } +} diff --git a/vmm_core/virt_whp/src/tsc.rs b/vmm_core/virt_whp/src/tsc.rs new file mode 100644 index 00000000000..1bfcc9f6d8d --- /dev/null +++ b/vmm_core/virt_whp/src/tsc.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPUID leaves that expose the exact TSC frequency to partitions without +//! the time ABI. + +use crate::Error; +use crate::VtlPartition; +use x86defs::cpuid::CpuidFunction; + +/// Adds the CPUID leaves that expose the exact TSC frequency. +pub(crate) fn add_frequency_leaves( + cpuid: Vec, + tsc_frequency_hz: u64, + vtl0: &VtlPartition, +) -> Result, Error> { + let cpuid = virt::CpuidLeafSet::new(cpuid); + let current_max_basic_leaf = + cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &vtl0.cpuid(0, 0))[0]; + let mut cpuid = cpuid.into_leaves(); + cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( + tsc_frequency_hz, + current_max_basic_leaf, + )?); + Ok(cpuid) +} diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index d713c5ee86a..1c6b413d8f9 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -231,6 +231,17 @@ impl<'a> WhpProcessor<'a> { dev: &impl CpuIo, ) -> Result { tracing::trace!(vtl = ?self.state.active_vtl, "current vtl"); + // A LAPIC state written since the last run may hold a pending vector + // that the hypervisor has not delivered, and partition time may have + // been suspended and resumed since then. Assert it again now that the + // VP is about to run. + #[cfg(guest_arch = "x86_64")] + if let Err(err) = self.reassert_written_lapic() { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to assert a pending LAPIC vector" + ); + } let mut last_waker = None; loop { self.inner.interrupt.maybe_yield().await; @@ -502,6 +513,7 @@ impl<'a> WhpProcessor<'a> { mod x86 { use crate::Hv1State; use crate::WhpProcessor; + use crate::WhpResultExt; use crate::emu; use crate::emu::WhpVpRefEmulation; use crate::memory::x86::GpaBackingType; @@ -566,7 +578,7 @@ mod x86 { &mut self.state.exits.apic_eoi } ExitReason::MsrAccess(info) => { - self.handle_msr(dev, info, exit); + self.handle_msr(dev, info, exit)?; &mut self.state.exits.msr } ExitReason::InterruptWindow(info) => { @@ -711,6 +723,10 @@ mod x86 { | GpaBackingType::Unaccepted => false, }; + if self.map_deferred_ram(access, &backing_type) { + return Ok(()); + } + if !access.AccessInfo.GpaUnmapped() && should_populate { // This is a mapped GPA that wasn't mapped in the SLAT. Tell the // kernel to populate the SLAT. @@ -1135,6 +1151,7 @@ mod x86 { _ => {} } + self.fixup_topology_cpuid(function, index, &mut default); let [eax, ebx, ecx, edx] = default; if self.vp.partition.vtl2_emulation.is_some() && self.state.active_vtl == Vtl::Vtl0 { @@ -1222,7 +1239,13 @@ mod x86 { dev: &impl CpuIo, info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, exit: whp::Exit<'_>, - ) { + ) -> Result<(), VpHaltReason> { + if self + .handle_time_abi_msr(info, exit) + .map_err(|error| dev.fatal_error(error.into()))? + { + return Ok(()); + } let handled = if info.AccessInfo.IsWrite() { self.msr_write(dev, exit, info.MsrNumber, info.Rax, info.Rdx) } else { @@ -1238,8 +1261,10 @@ mod x86 { self.current_whp() .set_register(whp::Register128::PendingEvent, event.into()) - .unwrap(); + .for_op("inject general protection fault") + .map_err(|error| dev.fatal_error(error.into()))?; } + Ok(()) } fn msr_write( diff --git a/vmm_core/virt_whp/src/vp_state.rs b/vmm_core/virt_whp/src/vp_state.rs index 418747d7abe..b80adf4906d 100644 --- a/vmm_core/virt_whp/src/vp_state.rs +++ b/vmm_core/virt_whp/src/vp_state.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(guest_arch = "x86_64")] +mod tsc; + use super::Error; use crate::WhpProcessor; use crate::WhpResultExt; @@ -16,11 +19,18 @@ use zerocopy::FromZeros; pub struct WhpVpStateAccess<'a, 'b> { run: &'a mut WhpProcessor<'b>, vtl: Vtl, + #[cfg(guest_arch = "x86_64")] + tsc_deadline: tsc::TscDeadlineRead, } impl<'a> WhpProcessor<'a> { pub(crate) fn access_state(&mut self, vtl: Vtl) -> WhpVpStateAccess<'_, 'a> { - WhpVpStateAccess { run: self, vtl } + WhpVpStateAccess { + run: self, + vtl, + #[cfg(guest_arch = "x86_64")] + tsc_deadline: Default::default(), + } } } @@ -124,6 +134,7 @@ mod x86 { } fn apic(&mut self) -> Result { + self.read_tsc_deadline_before_apic()?; self.run.save_apic(self.vtl) } @@ -187,6 +198,14 @@ mod x86 { self.run.vp.set_register_state(self.vtl, value) } + fn tsc_deadline(&mut self) -> Result { + self.saved_tsc_deadline() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.run.vp.set_register_state(self.vtl, value) + } + fn cet(&mut self) -> Result { self.run.vp.get_register_state(self.vtl) } diff --git a/vmm_core/virt_whp/src/vp_state/tsc.rs b/vmm_core/virt_whp/src/vp_state/tsc.rs new file mode 100644 index 00000000000..e1507c5ce44 --- /dev/null +++ b/vmm_core/virt_whp/src/vp_state/tsc.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! IA32_TSC_DEADLINE state access ordering. + +use super::WhpVpStateAccess; +use crate::Error; +use virt::x86::vp; +use virt::x86::vp::AccessVpState; + +/// IA32_TSC_DEADLINE read ordering for one state access. +#[derive(Default)] +pub(super) struct TscDeadlineRead { + before_apic: Option, + read: bool, +} + +impl WhpVpStateAccess<'_, '_> { + /// State serialization reads APIC before IA32_TSC_DEADLINE. Read the + /// deadline first so an expiry cannot fall between those snapshots. + pub(super) fn read_tsc_deadline_before_apic(&mut self) -> Result<(), Error> { + if self.caps().tsc_deadline && !self.tsc_deadline.read { + self.tsc_deadline.before_apic = Some(self.run.vp.get_register_state(self.vtl)?); + } + Ok(()) + } + + /// Returns the IA32_TSC_DEADLINE value read before the APIC state, or + /// reads it now. + pub(super) fn saved_tsc_deadline(&mut self) -> Result { + self.tsc_deadline.read = true; + if let Some(value) = self.tsc_deadline.before_apic.take() { + Ok(value) + } else { + self.run.vp.get_register_state(self.vtl) + } + } +} diff --git a/vmm_core/vm_manifest_builder/src/lib.rs b/vmm_core/vm_manifest_builder/src/lib.rs index 7bb56f8042b..f420e14bfb3 100644 --- a/vmm_core/vm_manifest_builder/src/lib.rs +++ b/vmm_core/vm_manifest_builder/src/lib.rs @@ -16,6 +16,8 @@ #![forbid(unsafe_code)] +mod microvm; + use chipset_resources::LEGACY_CHIPSET_PCI_BUS_NAME; use chipset_resources::battery::BatteryDeviceHandleAArch64; use chipset_resources::battery::BatteryDeviceHandleX64; @@ -173,6 +175,8 @@ pub enum BaseChipsetType { UnenlightenedLinuxDirect, /// Enlightened Linux VM with a minimal emulated chipset for direct boot. EnlightenedLinuxDirect, + /// microVM with only its allowlisted architectural devices. + Microvm, } /// The machine architecture of the VM. @@ -282,6 +286,7 @@ impl VmManifestBuilder { /// architecture. pub fn new(ty: BaseChipsetType, arch: MachineArch) -> Self { let vmbus = !matches!(ty, BaseChipsetType::UnenlightenedLinuxDirect); + let vmbus = microvm::vmbus_enabled(&ty, vmbus); VmManifestBuilder { ty, arch, @@ -597,6 +602,9 @@ impl VmManifestBuilder { result.attach_guest_watchdog(); } } + BaseChipsetType::Microvm => { + microvm::build(self.arch, &mut result)?; + } BaseChipsetType::HypervGen2Uefi | BaseChipsetType::HyperVGen2LinuxDirect => { result.chipset = BaseChipsetManifest { with_generic_cmos_rtc: is_x86, @@ -688,6 +696,7 @@ impl VmManifestBuilder { chipset_high_mmio_size: if self.vmbus { default_high } else { 0 }, vtl2_chipset_mmio_size: 0, }, + BaseChipsetType::Microvm => microvm::layout_config(), BaseChipsetType::HclHost => LayoutConfig { chipset_low_mmio_size: default_low, chipset_high_mmio_size: if self.vmbus { default_high } else { 0 }, @@ -727,7 +736,7 @@ impl VmChipsetResult { fn attach_pit(&mut self) -> &mut Self { self.chipset_devices.push(ChipsetDeviceHandle { name: PitDeviceHandle::ID.to_owned(), - resource: PitDeviceHandle.into_resource(), + resource: PitDeviceHandle::default().into_resource(), }); self.capabilities.with_pit = true; self diff --git a/vmm_core/vm_manifest_builder/src/microvm.rs b/vmm_core/vm_manifest_builder/src/microvm.rs new file mode 100644 index 00000000000..3c22cef5042 --- /dev/null +++ b/vmm_core/vm_manifest_builder/src/microvm.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM chipset manifest construction. + +use super::BaseChipsetManifest; +use super::BaseChipsetType; +use super::Error; +use super::ErrorInner; +use super::LayoutConfig; +use super::MachineArch; +use super::VmChipsetResult; + +pub(super) fn vmbus_enabled(ty: &BaseChipsetType, default: bool) -> bool { + default && !matches!(ty, BaseChipsetType::Microvm) +} + +pub(super) fn build(arch: MachineArch, result: &mut VmChipsetResult) -> Result<(), Error> { + if arch != MachineArch::X86_64 { + return Err(Error(ErrorInner::UnsupportedArch)); + } + result.chipset = BaseChipsetManifest { + with_generic_cmos_rtc: true, + ..BaseChipsetManifest::empty() + }; + result.attach_generic_ioapic(); + result.attach_pic(); + result.attach_pit(); + Ok(()) +} + +pub(super) fn layout_config() -> LayoutConfig { + LayoutConfig { + chipset_low_mmio_size: 1024 * 1024 * 1024, + chipset_high_mmio_size: 0, + vtl2_chipset_mmio_size: 0, + } +} + +#[cfg(test)] +mod tests { + use super::super::BaseChipsetType; + use super::super::MachineArch; + use super::super::VmManifestBuilder; + use chipset_resources::pic::PicDeviceHandle; + use chipset_resources::pit::PitDeviceHandle; + use test_with_tracing::test; + use vm_resource::ResourceId; + + #[test] + fn microvm_microvm_has_only_allowlisted_base_devices() { + let builder = VmManifestBuilder::new(BaseChipsetType::Microvm, MachineArch::X86_64); + assert!(!builder.vmbus); + assert_eq!( + builder.layout_config().chipset_low_mmio_size, + 1024 * 1024 * 1024 + ); + + let result = builder.build().unwrap(); + assert!(result.chipset.with_generic_cmos_rtc); + assert_eq!( + result + .chipset_devices + .iter() + .map(|device| device.name.as_str()) + .collect::>(), + ["ioapic", PicDeviceHandle::ID, PitDeviceHandle::ID] + ); + assert!(result.capabilities.with_ioapic); + assert!(result.capabilities.with_pic); + assert!(result.capabilities.with_pit); + assert!(result.pci_chipset_devices.is_empty()); + assert!(result.isa_dma_controller.is_none()); + } +} diff --git a/vmm_core/vmm_core_defs/src/lib.rs b/vmm_core/vmm_core_defs/src/lib.rs index 87d8f5c2ec5..d4ed8ff9e5d 100644 --- a/vmm_core/vmm_core_defs/src/lib.rs +++ b/vmm_core/vmm_core_defs/src/lib.rs @@ -62,4 +62,20 @@ pub enum HaltReason { }, /// The guest watchdog timer expired without being petted. Watchdog, + PowerOffWithStatus { + code: u8, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_off_status_survives_mesh_round_trip() { + let encoded = mesh::payload::encode(HaltReason::PowerOffWithStatus { code: 37 }); + let decoded: HaltReason = mesh::payload::decode(&encoded).unwrap(); + assert_eq!(decoded, HaltReason::PowerOffWithStatus { code: 37 }); + assert_eq!(format!("{decoded:?}"), "PowerOffWithStatus { code: 37 }"); + } } diff --git a/vmm_core/vmotherboard/src/base_chipset.rs b/vmm_core/vmotherboard/src/base_chipset.rs index d5d7afd97b5..55f3670c158 100644 --- a/vmm_core/vmotherboard/src/base_chipset.rs +++ b/vmm_core/vmotherboard/src/base_chipset.rs @@ -438,6 +438,7 @@ impl<'a> BaseChipsetBuilder<'a> { time_source, century_reg_idx, initial_cmos, + mode, }) = deps_generic_cmos_rtc { let resolved = resolver @@ -445,13 +446,16 @@ impl<'a> BaseChipsetBuilder<'a> { .await .map_err(BaseChipsetBuilderError::ResolveResource)?; builder.arc_mutex_device("rtc").add(|services| { - cmos_rtc::Rtc::new( + cmos_rtc::Rtc::new_with_mode( resolved.0, services.new_line(IRQ_LINE_SET, "interrupt", irq), services.register_vmtime(), century_reg_idx, initial_cmos, false, + match mode { + options::dev::GenericCmosRtcMode::Standard => cmos_rtc::RtcMode::Standard, + }, ) })?; } @@ -1168,6 +1172,13 @@ pub mod options { } } + /// Guest-visible generic CMOS RTC behavior. + #[derive(Clone, Copy)] + pub enum GenericCmosRtcMode { + /// Standard programmable MC146818-compatible behavior. + Standard, + } + /// Generic MC146818A compatible RTC + CMOS device pub struct GenericCmosRtcDeps { /// IRQ line to signal RTC device events @@ -1178,6 +1189,8 @@ pub mod options { pub century_reg_idx: u8, /// Initial state of CMOS RAM pub initial_cmos: Option<[u8; 256]>, + /// Guest-visible RTC behavior. + pub mode: GenericCmosRtcMode, } /// PIIX4 "flavored" MC146818A compatible RTC + CMOS device diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs index d5d05ff27d0..26026c63387 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs @@ -79,6 +79,9 @@ impl ArcMutexChipsetServicesFinalize for ArcMutexChipset .map(|d| d.valid_lines().to_vec()); let mut builder = self.builder.units.add(dev_name.clone()); + if device.advances_time() { + builder = builder.advances_time(); + } // Before stopping the device, the chipset interface must stop // running so that it stops issuing MMIO/PIO requests. if device.supports_mmio().is_some() diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs index 5b0ebb0772c..a25df16ddff 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs @@ -35,9 +35,12 @@ impl InspectMut for ArcMutexChipsetDeviceUnit { /// Object-safe trait for the subset of [`VmmChipsetDevice`] that we use here. #[async_trait] trait DynDevice: InspectMut + Send { - fn start(&mut self); + async fn start(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); + async fn quiesce_input(&mut self) -> anyhow::Result<()>; + async fn resume_input(&mut self) -> anyhow::Result<()>; async fn reset(&mut self); + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()>; fn poll_device(&mut self, cx: &mut Context<'_>); fn save(&mut self) -> Result; fn restore(&mut self, state: SavedStateBlob) -> Result<(), RestoreError>; @@ -45,18 +48,28 @@ trait DynDevice: InspectMut + Send { #[async_trait] impl DynDevice for T { - fn start(&mut self) { - self.start() + async fn start(&mut self) -> anyhow::Result<()> { + self.start_fallible().await } async fn stop(&mut self) { self.stop().await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + vmcore::device_state::ChangeDeviceState::quiesce_input(self).await + } + async fn resume_input(&mut self) -> anyhow::Result<()> { + vmcore::device_state::ChangeDeviceState::resume_input(self).await + } async fn reset(&mut self) { self.reset().await } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.advance_time(duration).await + } + fn poll_device(&mut self, cx: &mut Context<'_>) { if let Some(poll) = self.supports_poll_device() { poll.poll_device(cx); @@ -139,13 +152,14 @@ impl ArcMutexChipsetDeviceUnit { } impl StateUnit for ArcMutexChipsetDeviceUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { + self.device.clone().close().start().await?; self.running = true; - // Poll the device at least once. + // Poll the device at least once after startup completes. let mut device = self.device.lock(); - device.start(); device.poll_device(&mut Context::from_waker(&waker_ref(&self.poll_event))); + Ok(()) } async fn stop(&mut self) { @@ -162,11 +176,23 @@ impl StateUnit for ArcMutexChipsetDeviceUnit { // These are currently not true. } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.device.clone().close().quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.device.clone().close().resume_input().await + } + async fn reset(&mut self) -> anyhow::Result<()> { self.device.clone().close().reset().await; Ok(()) } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.device.clone().close().advance_time(duration).await + } + async fn save(&mut self) -> Result, SaveError> { if self.omit_saved_state { return Ok(None); diff --git a/vmm_core/vmotherboard/src/chipset/builder/mod.rs b/vmm_core/vmotherboard/src/chipset/builder/mod.rs index 788e80f98b1..899caa972e3 100644 --- a/vmm_core/vmotherboard/src/chipset/builder/mod.rs +++ b/vmm_core/vmotherboard/src/chipset/builder/mod.rs @@ -82,8 +82,12 @@ impl ChipsetDevices { ) .await?; let device = arc_builder.build(CloseableMutex::new(device)); + let advances_time = device.lock().advances_time(); let device_unit = ArcMutexChipsetDeviceUnit::new(device.clone(), false); - let builder = units.add(name).dependency_of(self.chipset_unit()); + let mut builder = units.add(name).dependency_of(self.chipset_unit()); + if advances_time { + builder = builder.advances_time(); + } let unit = builder .spawn(driver_source.simple(), |recv| device_unit.run(recv)) .context("name in use")?; @@ -379,7 +383,9 @@ mod chipset_unit { pub struct ChipsetUnit<'a>(pub &'a Chipset); impl StateUnit for ChipsetUnit<'_> { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} diff --git a/vmm_core/vmotherboard/src/chipset/line_sets.rs b/vmm_core/vmotherboard/src/chipset/line_sets.rs index dd6d73f7d7b..9d8517a8176 100644 --- a/vmm_core/vmotherboard/src/chipset/line_sets.rs +++ b/vmm_core/vmotherboard/src/chipset/line_sets.rs @@ -60,7 +60,9 @@ impl LineSets { struct LineSetUnit<'a>(&'a LineSet); impl StateUnit for LineSetUnit<'_> { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc.rs index bd6a6da4ac8..67f50c52999 100644 --- a/vmm_tests/vmm_tests/tests/tests/ttrpc.rs +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc.rs @@ -8,12 +8,14 @@ // Linux. #[cfg(target_os = "linux")] mod fd_passing; +mod microvm; use anyhow::Context; use futures::AsyncBufReadExt; use futures::AsyncReadExt; use guid::Guid; use mesh::CancelContext; +use microvm::test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot; use openvmm_ttrpc_vmservice as vmservice; use pal_async::DefaultDriver; use pal_async::pipe::PolledPipe; @@ -34,6 +36,13 @@ use std::time::Duration; use unix_socket::UnixListener; use unix_socket::UnixStream; +// Registered here, rather than in the `microvm` module, to keep the test named +// `ttrpc::test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot`. +petri::test!( + test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot, + microvm::lifecycle_and_snapshot_artifacts +); + petri::test!(test_ttrpc_interface, |resolver| { let openvmm = resolver.require(artifacts::OPENVMM_NATIVE); let kernel = resolver.require(artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_NATIVE); @@ -164,6 +173,7 @@ async fn test_ttrpc_no_vmbus( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -311,6 +321,7 @@ async fn test_ttrpc_interface( vmservice::CreateVmRequest { config: Some(vmservice::VmConfig::default()), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -616,6 +627,7 @@ async fn test_ttrpc_interface( tag: "testfs".to_string(), root_path: virtiofs_root.to_string_lossy().into(), read_only: i == 0, + ..Default::default() }], // A SCSI controller keeps a request channel // alive for the lifetime of the VM, which used @@ -637,6 +649,7 @@ async fn test_ttrpc_interface( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -769,6 +782,7 @@ async fn test_ttrpc_interface( tag: "hotplugfs".to_string(), root_path: hotplug_virtiofs_root.to_string_lossy().into(), read_only: true, + ..Default::default() }, ))), }, @@ -1103,6 +1117,7 @@ async fn test_ttrpc_uefi_boot( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -1296,6 +1311,7 @@ fn virtio_fs_vpci_request( tag: tag.to_string(), root_path: root_path.to_string_lossy().into_owned(), read_only, + ..Default::default() }, ))), } diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs index addf32758a7..5c190f85988 100644 --- a/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs @@ -288,5 +288,6 @@ fn create_vm_request( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, } } diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs new file mode 100644 index 00000000000..9a899ff49d1 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs @@ -0,0 +1,710 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM lifecycle and snapshot tests for the TTRPC interface. + +use super::launch_openvmm; +use anyhow::Context; +use futures::AsyncBufReadExt; +use futures::AsyncReadExt; +use futures::AsyncWriteExt; +#[cfg(windows)] +use guid::Guid; +use mesh::CancelContext; +use openvmm_ttrpc_vmservice as vmservice; +use pal_async::DefaultDriver; +#[cfg(windows)] +use pal_async::pipe::PolledPipe; +use pal_async::socket::PolledSocket; +#[cfg(windows)] +use pal_async::windows::pipe::ListeningPipe; +#[cfg(windows)] +use pal_async::windows::pipe::NamedPipeServer; +use petri::ResolvedArtifact; +use petri_artifacts_vmm_test::artifacts; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +#[cfg(unix)] +use unix_socket::UnixListener; +use unix_socket::UnixStream; + +/// Resolves the artifacts of +/// [`test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot`], which is +/// registered by the parent module to keep its `ttrpc::` test name. +pub(super) fn lifecycle_and_snapshot_artifacts( + resolver: &petri::ArtifactResolver<'_>, +) -> Option<[ResolvedArtifact; 3]> { + Some([ + resolver.require(artifacts::OPENVMM_NATIVE).erase(), + resolver + .require(artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_X64) + .erase(), + resolver + .require(artifacts::loadable::LINUX_DIRECT_TEST_INITRD_X64) + .erase(), + ]) +} + +fn microvm_portb_config(path: &Path) -> vmservice::SerialConfig { + vmservice::SerialConfig { + ports: vec![vmservice::serial_config::Config { + port: 0, + socket_path: path.to_string_lossy().into_owned(), + connect: false, + }], + } +} + +async fn read_restore_ready_event( + reader: impl futures::AsyncRead + Unpin, +) -> anyhow::Result> { + let mut reader = futures::io::BufReader::new(reader); + let mut bytes = Vec::new(); + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(reader.read_until(b'\n', &mut bytes)) + .await + .with_context(|| { + format!( + "timed out after {} bytes of the restore readiness event", + bytes.len() + ) + })??; + Ok(bytes) +} + +#[cfg(unix)] +struct RestoreReadyListener { + path: PathBuf, + listener: UnixListener, +} + +#[cfg(windows)] +struct RestoreReadyListener { + path: PathBuf, + _server: NamedPipeServer, + listener: ListeningPipe, +} + +#[cfg(unix)] +impl RestoreReadyListener { + fn bind(_driver: &DefaultDriver, path: PathBuf) -> anyhow::Result { + let listener = UnixListener::bind(&path)?; + Ok(Self { path, listener }) + } + + async fn read_event(self, driver: &DefaultDriver) -> anyhow::Result> { + let mut listener = PolledSocket::new(driver, self.listener)?; + let (connection, _) = listener.accept().await?; + read_restore_ready_event(PolledSocket::new(driver, connection)?).await + } +} + +#[cfg(windows)] +impl RestoreReadyListener { + fn bind(driver: &DefaultDriver, _path: PathBuf) -> anyhow::Result { + let path = PathBuf::from(format!( + "//./pipe/openvmm-restore-ready-{}-{}", + std::process::id(), + Guid::new_random() + )); + let server = NamedPipeServer::create(&path)?; + let listener = server.accept(driver)?; + Ok(Self { + path, + _server: server, + listener, + }) + } + + async fn read_event(self, driver: &DefaultDriver) -> anyhow::Result> { + let connection = CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(self.listener) + .await + .context("timed out accepting restore readiness pipe")??; + read_restore_ready_event(PolledPipe::new(driver, connection)?).await + } +} + +impl RestoreReadyListener { + fn path(&self) -> &Path { + &self.path + } +} + +fn microvm_restore_request( + snapshot_path: &Path, + portb_path: &Path, + restore_ready_path: &Path, + restore_memory_bytes: u64, +) -> vmservice::CreateVmRequest { + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + serial_config: Some(microvm_portb_config(portb_path)), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + restore_path: snapshot_path.to_string_lossy().into_owned(), + restore_entropy: true, + restore_processor_count: 1, + restore_memory_bytes, + restore_ready_path: restore_ready_path.to_string_lossy().into_owned(), + ..Default::default() + }), + } +} + +pub(super) async fn test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot( + params: petri::PetriTestParams<'_>, + driver: DefaultDriver, + [openvmm, kernel, initrd]: [ResolvedArtifact; 3], +) -> anyhow::Result<()> { + use std::hash::Hash; + use std::hash::Hasher; + + const MEMORY_MB: u64 = 128; + const READY_MARKER: &[u8] = b"OPENVMM-LINUX-MPTABLE-SNAPSHOT-READY"; + const SNAPSHOT_CONTINUED_MARKER: &[u8] = b"SNAPSHOT-CONTINUED=1"; + const STATE_MARKER: &[u8] = b"STATE=1"; + const COMMAND_STATE: u8 = 5; + const COMMAND_SHUTDOWN: u8 = 6; + const KERNEL_OVERRIDE: &str = "OPENVMM_MICROVM_TEST_KERNEL"; + const INITRD_OVERRIDE: &str = "OPENVMM_MICROVM_TEST_INITRD"; + + let portb_output = |bytes: &[u8]| -> anyhow::Result { + use std::fmt::Write as _; + let mut output = String::new(); + for byte in bytes { + writeln!( + output, + "printf '\\x{:02x}' | dd of=/dev/port bs=1 seek=233 count=1 conv=notrunc 2>/dev/null", + byte + )?; + } + Ok(output) + }; + + let kernel_override = std::env::var_os(KERNEL_OVERRIDE).map(PathBuf::from); + let initrd_override = std::env::var_os(INITRD_OVERRIDE).map(PathBuf::from); + anyhow::ensure!( + kernel_override.is_some() == initrd_override.is_some(), + "{KERNEL_OVERRIDE} and {INITRD_OVERRIDE} must be set together" + ); + let kernel_path = kernel_override.as_deref().unwrap_or_else(|| kernel.get()); + let initrd_path = initrd_override.as_deref().unwrap_or_else(|| initrd.get()); + anyhow::ensure!( + kernel_path.is_file(), + "microVM test kernel does not exist: {}", + kernel_path.display() + ); + anyhow::ensure!( + initrd_path.is_file(), + "microVM test initrd does not exist: {}", + initrd_path.display() + ); + let processor_counts: &[u32] = if kernel_override.is_some() { + &[1, 2, 4, 8] + } else { + // The packaged OpenVMM dependency kernel has CONFIG_X86_MPPARSE disabled. + &[1] + }; + + let base_initrd = std::fs::read(initrd_path) + .with_context(|| format!("failed to read initrd {}", initrd_path.display()))?; + for &processor_count in processor_counts { + let ready_marker = format!("OPENVMM-LINUX-MPTABLE-READY-{processor_count}"); + let cpu_failure_marker = format!("OPENVMM-LINUX-MPTABLE-CPU-FAILED-{processor_count}"); + let ioapic_failure_marker = + format!("OPENVMM-LINUX-MPTABLE-IOAPIC-FAILED-{processor_count}"); + let acpi_failure_marker = format!("OPENVMM-LINUX-MPTABLE-ACPI-FAILED-{processor_count}"); + let smbios_failure_marker = + format!("OPENVMM-LINUX-MPTABLE-SMBIOS-FAILED-{processor_count}"); + let script = format!( + "#!/bin/busybox sh\n\ + mount -t devtmpfs devtmpfs /dev 2>/dev/null || true\n\ + mount -t proc proc /proc 2>/dev/null || true\n\ + mount -t sysfs sysfs /sys 2>/dev/null || true\n\ + cpus=$(grep -c '^processor' /proc/cpuinfo)\n\ + ioapic=0\n\ + grep -Eqi 'IO-?APIC' /proc/iomem && ioapic=1\n\ + grep -Eqi 'IO-?APIC' /proc/interrupts && ioapic=1\n\ + dmesg | grep -Eqi 'IO-?APIC' && ioapic=1\n\ + if [ \"$cpus\" != \"{processor_count}\" ]; then\n\ + {}\ + elif [ \"$ioapic\" != 1 ]; then\n\ + {}\ + elif [ -d /sys/firmware/acpi ]; then\n\ + {}\ + elif [ -e /sys/firmware/dmi/tables/DMI ] \\\n\ + || [ -e /sys/firmware/dmi/tables/smbios_entry_point ]; then\n\ + {}\ + else\n\ + {}\ + fi\n\ + printf '\\x00' | dd of=/dev/port bs=1 seek=1540 count=1 conv=notrunc 2>/dev/null\n\ + while :; do sleep 1; done\n", + portb_output(cpu_failure_marker.as_bytes())?, + portb_output(ioapic_failure_marker.as_bytes())?, + portb_output(acpi_failure_marker.as_bytes())?, + portb_output(smbios_failure_marker.as_bytes())?, + portb_output(ready_marker.as_bytes())?, + ); + let probe_dir = if cfg!(target_os = "linux") { + tempfile::Builder::new() + .prefix("openvmm-ttrpc-linux-mptable-probe-") + .tempdir_in("/tmp") + } else { + tempfile::tempdir() + }?; + let probe_initrd = initrd_cpio::inject_into_initrd( + &base_initrd, + "microvm-test", + script.as_bytes(), + 0o100755, + ) + .context("failed to inject the microVM lifecycle script")?; + let probe_initrd_path = probe_dir.path().join("microvm-initrd.cpio.gz"); + std::fs::write(&probe_initrd_path, probe_initrd) + .context("failed to write the microVM lifecycle initrd")?; + let rpc_path = probe_dir.path().join("rpc.sock"); + let pidfile_path = probe_dir.path().join("openvmm.pid"); + let portb_path = probe_dir.path().join("portb.sock"); + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + memory_config: Some(vmservice::MemoryConfig { + memory_mb: MEMORY_MB, + ..Default::default() + }), + processor_config: Some(vmservice::ProcessorConfig { + processor_count, + ..Default::default() + }), + serial_config: Some(microvm_portb_config(&portb_path)), + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + vmservice::DirectBoot { + kernel_path: kernel_path.to_string_lossy().into_owned(), + initrd_path: probe_initrd_path.to_string_lossy().into_owned(), + kernel_cmdline: "rdinit=/microvm-test".to_owned(), + }, + )), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: None, + }, + ) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct {processor_count}-vCPU CreateVM failed: {}", + status.message + ) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, _portb_write) = portb.split(); + client + .call() + .start(vmservice::Vm::ResumeVm, ()) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct {processor_count}-vCPU ResumeVM failed: {}", + status.message + ) + })?; + let mut output = Vec::new(); + wait_for_bytes(&mut portb_read, &mut output, ready_marker.as_bytes()).await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut output)) + .await + .with_context(|| { + format!("timed out draining Linux direct {processor_count}-vCPU server") + })??; + anyhow::ensure!( + child.wait().await?.success(), + "Linux direct {processor_count}-vCPU server failed" + ); + } + + let script = format!( + r#"#!/bin/busybox sh +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true +read_port() {{ + dd if=/dev/port bs=1 skip="$1" count=1 2>/dev/null | od -An -tu1 | tr -d ' ' +}} +write_port() {{ + octal=$(printf '%03o' "$2") + printf "\\$octal" | dd of=/dev/port bs=1 seek="$1" count=1 conv=notrunc 2>/dev/null +}} +stty -F /dev/hvc0 raw -echo +next_byte() {{ + dd if=/dev/hvc0 bs=1 count=1 2>/dev/null | od -An -tu1 | tr -d ' ' +}} +generation=0 +{} +write_port 1541 0 +generation=$((generation + 1)) +{} +status=$(read_port 234) +if [ $((status & 8)) -eq 0 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +write_port 234 165 +magic="$(read_port 233) $(read_port 233) $(read_port 233) $(read_port 233)" +flags=$(read_port 233) +target=$(read_port 233) +range_count=$(read_port 233) +if [ "$magic" != "79 86 82 4" ] || [ $((flags & 4)) -eq 0 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +remaining=$((25 + range_count * 16 + 64)) +index=0 +while [ "$index" -lt "$remaining" ]; do + read_port 233 >/dev/null + index=$((index + 1)) +done +write_port 1541 2 +if [ "$target" != 1 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +case "$range_count" in + 0) +{} + ;; + 1) +{} + ;; + *) +{} + write_port 1540 255 + while :; do sleep 1; done + ;; +esac +while :; do + command=$(next_byte) + case "$command" in + {COMMAND_STATE}) + if [ "$generation" = 1 ]; then +{} + else +{} + fi + ;; + {COMMAND_SHUTDOWN}) + exit_status=$(next_byte) + write_port 1540 "$exit_status" + while :; do sleep 1; done + ;; + *) +{} + ;; + esac +done +"#, + portb_output(READY_MARKER)?, + portb_output(SNAPSHOT_CONTINUED_MARKER)?, + portb_output(b"RESTORE-PACKET-MISSING")?, + portb_output(b"RESTORE-PACKET-INVALID")?, + portb_output(b"RESTORE-TARGET-INVALID")?, + portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=0")?, + portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=1")?, + portb_output(b"RESTORE-RANGE-COUNT-INVALID")?, + portb_output(STATE_MARKER)?, + portb_output(b"STATE-INVALID")?, + portb_output(b"UNKNOWN-COMMAND")?, + ); + + let tempdir = if cfg!(target_os = "linux") { + tempfile::Builder::new() + .prefix("openvmm-ttrpc-linux-mptable-") + .tempdir_in("/tmp") + } else { + tempfile::tempdir() + }?; + let snapshot_path = tempdir.path().join("snapshot"); + let fingerprint = |path: &Path| -> anyhow::Result { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::fs::read(path)?.hash(&mut hasher); + Ok(hasher.finish()) + }; + let initrd_data = + initrd_cpio::inject_into_initrd(&base_initrd, "microvm-test", script.as_bytes(), 0o100755) + .context("failed to inject the microVM snapshot script")?; + let initrd = tempdir.path().join("microvm-initrd.cpio.gz"); + std::fs::write(&initrd, initrd_data).context("failed to write the microVM test initrd")?; + + let rpc_path = tempdir.path().join("capture-rpc.sock"); + let pidfile_path = tempdir.path().join("capture.pid"); + let portb_path = tempdir.path().join("capture-portb.sock"); + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + memory_config: Some(vmservice::MemoryConfig { + memory_mb: MEMORY_MB, + ..Default::default() + }), + processor_config: Some(vmservice::ProcessorConfig { + processor_count: 2, + ..Default::default() + }), + serial_config: Some(microvm_portb_config(&portb_path)), + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + vmservice::DirectBoot { + kernel_path: kernel_path.to_string_lossy().into_owned(), + initrd_path: initrd.to_string_lossy().into_owned(), + kernel_cmdline: "maxcpus=1 rdinit=/microvm-test".to_owned(), + }, + )), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + destination_path: snapshot_path.to_string_lossy().into_owned(), + quiesce_timeout_ms: 5_000, + memory_capacity_bytes: 512 * 1024 * 1024, + ..Default::default() + }), + }, + ) + .await + .map_err(|status| { + anyhow::anyhow!("Linux direct capture CreateVM failed: {}", status.message) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, _portb_write) = portb.split(); + client + .call() + .start(vmservice::Vm::ResumeVm, ()) + .await + .map_err(|status| { + anyhow::anyhow!("Linux direct capture ResumeVM failed: {}", status.message) + })?; + let mut source_output = Vec::new(); + wait_for_bytes(&mut portb_read, &mut source_output, READY_MARKER).await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut source_output)) + .await + .context("timed out draining Linux direct capture source")??; + anyhow::ensure!( + child.wait().await?.success(), + "Linux direct capture server failed" + ); + let (manifest, _) = + openvmm_helpers::snapshot::restore::read_snapshot(&snapshot_path, MEMORY_MB * 1024 * 1024)?; + let contract = manifest + .machine_contract + .context("captured microVM snapshot is missing its machine contract")?; + assert_eq!( + contract.boot_layout_version, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION + ); + anyhow::ensure!( + !pidfile_path.exists(), + "Linux direct capture source PID remained after snapshot commit" + ); + + let before = ["manifest.bin", "state.bin", "memory.bin"] + .map(|name| fingerprint(&snapshot_path.join(name))) + .into_iter() + .collect::>>()?; + + for restore_index in 0..2 { + let restore_memory_bytes = if restore_index == 0 { + MEMORY_MB * 1024 * 1024 + } else { + 256 * 1024 * 1024 + }; + let restore_target_marker = if restore_index == 0 { + b"RESTORE-TARGET=1 MEMORY-RANGES=0".as_slice() + } else { + b"RESTORE-TARGET=1 MEMORY-RANGES=1".as_slice() + }; + let rpc_path = tempdir + .path() + .join(format!("restore-{restore_index}-rpc.sock")); + let pidfile_path = tempdir.path().join(format!("restore-{restore_index}.pid")); + let portb_path = tempdir + .path() + .join(format!("restore-{restore_index}-portb.sock")); + let restore_ready = RestoreReadyListener::bind( + &driver, + tempdir + .path() + .join(format!("restore-{restore_index}-ready.sock")), + )?; + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + microvm_restore_request( + &snapshot_path, + &portb_path, + restore_ready.path(), + restore_memory_bytes, + ), + ) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct restore {restore_index} CreateVM failed: {}", + status.message + ) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, mut portb_write) = portb.split(); + let mut resume_cancel = CancelContext::new().with_timeout(Duration::from_secs(15)); + let (resume, readiness) = futures::join!( + resume_cancel.until_cancelled(client.call().start(vmservice::Vm::ResumeVm, ())), + restore_ready.read_event(&driver) + ); + resume + .with_context(|| format!("timed out resuming Linux direct restore {restore_index}"))? + .map_err(|status| { + anyhow::anyhow!( + "Linux direct restore {restore_index} ResumeVM failed: {}", + status.message + ) + })?; + anyhow::ensure!( + readiness? == openvmm_defs::worker::RESTORE_READY_EVENT_V1, + "Linux direct restore {restore_index} did not publish readiness" + ); + + let mut restore_output = Vec::new(); + wait_for_bytes( + &mut portb_read, + &mut restore_output, + SNAPSHOT_CONTINUED_MARKER, + ) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not continue after snapshot") + })?; + wait_for_bytes(&mut portb_read, &mut restore_output, restore_target_marker) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not report restore targets") + })?; + portb_write.write_all(&[COMMAND_STATE]).await?; + portb_write.flush().await?; + wait_for_bytes(&mut portb_read, &mut restore_output, STATE_MARKER) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not answer state query") + })?; + portb_write.write_all(&[COMMAND_SHUTDOWN, 0]).await?; + portb_write.flush().await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut restore_output)) + .await + .with_context(|| { + let tail = restore_output.len().saturating_sub(256); + format!( + "timed out draining Linux direct restore {restore_index} after output {:?}", + String::from_utf8_lossy(&restore_output[tail..]) + ) + })??; + let status = CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(child.wait()) + .await + .with_context(|| { + format!("timed out waiting for Linux direct restore {restore_index} server") + })??; + anyhow::ensure!( + status.success(), + "Linux direct restore {restore_index} server failed" + ); + anyhow::ensure!( + !pidfile_path.exists(), + "Linux direct restore {restore_index} PID remained after shutdown" + ); + let after = ["manifest.bin", "state.bin", "memory.bin"] + .map(|name| fingerprint(&snapshot_path.join(name))) + .into_iter() + .collect::>>()?; + anyhow::ensure!( + before == after, + "Linux direct restore {restore_index} modified snapshot artifacts" + ); + } + Ok(()) +} + +async fn wait_for_bytes( + reader: &mut (impl futures::AsyncRead + Unpin), + output: &mut Vec, + marker: &[u8], +) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(Duration::from_secs(60)) + .until_cancelled(async { + let mut buffer = [0_u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + anyhow::ensure!( + count != 0, + "portb closed before the expected marker after bytes {:?}", + String::from_utf8_lossy(&output[..output.len().min(256)]) + ); + output.extend_from_slice(&buffer[..count]); + if output.windows(marker.len()).any(|window| window == marker) { + return Ok(()); + } + } + }) + .await + .context("timed out waiting for portb output")? +} + +async fn drain_until_closed( + reader: &mut (impl futures::AsyncRead + Unpin), + output: &mut Vec, +) -> std::io::Result<()> { + let mut buffer = [0_u8; 4096]; + loop { + match reader.read(&mut buffer).await { + Ok(0) => return Ok(()), + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } +} diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 6a1af29d6c1..99ba91d5d5d 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -4,6 +4,7 @@ //! Integration tests for x86_64 guests. mod ipmi; +mod microvm; mod openhcl_linux_direct; mod openhcl_uefi; mod storage; @@ -252,6 +253,10 @@ async fn vpci_filter(config: PetriVmBuilder) -> anyhow::Res max_queues: None, mac_address: MacAddress::new([0x00, 0x15, 0x5D, 0x12, 0x12, 0x12]), endpoint: NullHandle.into_resource(), + egress_policy: None, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(), ) @@ -335,10 +340,7 @@ async fn vpci_relay_tdisp_device( } /// Boot with a virtio-blk disk via virtio-mmio and verify the device appears in the guest. -#[openvmm_test(unstable( - reason = "virtio-blk over virtio-mmio boot test fails frequently in CI; root cause unknown", - linux_direct_x64 -))] +#[openvmm_test(linux_direct_x64)] async fn virtio_blk_device(config: PetriVmBuilder) -> anyhow::Result<()> { use disk_backend_resources::LayeredDiskHandle; use disk_backend_resources::layer::RamDiskLayerHandle; @@ -445,7 +447,12 @@ async fn virtio_blk_device(config: PetriVmBuilder) -> anyho "post-restore write/read mismatch: {readback}" ); - agent.power_off().await?; + drop(sh); + drop(agent); + // Avoid the pipette shutdown delay after the pulse, since that delay + // depends on the guest timer state the test just restored. + vm.send_enlightened_shutdown(petri::ShutdownKind::Shutdown) + .await?; vm.wait_for_clean_teardown().await?; Ok(()) } @@ -608,6 +615,7 @@ async fn snapshot_save_to_disk( vp_count: 2, page_size: 4096, architecture: "x86_64".to_string(), + ..Default::default() }; openvmm_helpers::snapshot::write_snapshot(&snap_dir, &manifest, &saved_state_bytes, &mem_path)?; @@ -615,7 +623,8 @@ async fn snapshot_save_to_disk( assert!(snap_dir.join("manifest.bin").exists()); assert!(snap_dir.join("state.bin").exists()); assert!(snap_dir.join("memory.bin").exists()); - let (read_manifest, read_state) = openvmm_helpers::snapshot::read_snapshot(&snap_dir)?; + let (read_manifest, read_state) = + openvmm_helpers::snapshot::restore::read_snapshot(&snap_dir, mem_size)?; assert_eq!( read_state, saved_state_bytes, "state roundtrip through disk should match" diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S new file mode 100644 index 00000000000..b4ad3e1ea59 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Credits a 64-byte /tmp/entropy payload, then immediately reseeds the CRNG. +# Regenerate the adjacent fixture with: +# as --64 -o openvmm-reseed.o openvmm-reseed.S +# ld -N -s -o openvmm-reseed openvmm-reseed.o +# base64 -w0 openvmm-reseed > openvmm-reseed.b64 +.global _start +.section .text +_start: + mov $2, %rax + lea entropy_path(%rip), %rdi + xor %rsi, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + mov %rax, %rdi + xor %rax, %rax + lea entropy_buf+8(%rip), %rsi + mov $64, %rdx + syscall + cmp $64, %rax + jne fail + mov $2, %rax + lea random_path(%rip), %rdi + mov $1, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + mov %rax, %rdi + mov $16, %rax + mov $0x40085203, %rsi + lea entropy_buf(%rip), %rdx + syscall + test %rax, %rax + js fail + mov $16, %rax + mov $0x5207, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + xor %rdi, %rdi + jmp exit +fail: + mov $1, %rdi +exit: + mov $60, %rax + syscall +.section .rodata +entropy_path: .asciz "/tmp/entropy" +random_path: .asciz "/dev/random" +.section .data +.align 8 +entropy_buf: + .long 512 + .long 64 + .zero 64 diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 new file mode 100644 index 00000000000..00c8f210a82 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 @@ -0,0 +1 @@ +f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAKABAAAAAAAAAAAAAEAAOAABAEAABQAEAAEAAAAHAAAAeAAAAAAAAAB4AEAAAAAAAHgAQAAAAAAACAEAAAAAAAAIAQAAAAAAAAgAAAAAAAAASMfAAgAAAEiNPZQAAABIMfZIMdIPBUiFwHh3SInHSDHASI01oAAAAEjHwkAAAAAPBUiD+EB1W0jHwAIAAABIjT1qAAAASMfGAQAAAEgx0g8FSIXAeDxIicdIx8AQAAAASMfGA1IIQEiNFVIAAAAPBUiFwHgdSMfAEAAAAEjHxgdSAABIMdIPBUiFwHgFSDH/6wdIx8cBAAAASMfAPAAAAA8FL3RtcC9lbnRyb3B5AC9kZXYvcmFuZG9tAAAAAAAAAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC5zaHN0cnRhYgAudGV4dAAucm9kYXRhAC5kYXRhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAEAAAAHAAAAAAAAAHgAQAAAAAAAeAAAAAAAAACiAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAABEAAAABAAAAAgAAAAAAAAAaAUAAAAAAABoBAAAAAAAAGQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAZAAAAAQAAAAMAAAAAAAAAOAFAAAAAAAA4AQAAAAAAAEgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAQAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAgAEAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAA== diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs new file mode 100644 index 00000000000..6600c3bd756 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM lifecycle integration tests. + +use anyhow::Context; +use mesh::CancelContext; +use petri::PetriHaltReason; +use petri::PetriVmBuilder; +use petri::openvmm::OpenVmmPetriBackend; +use std::time::Duration; +use vmm_test_macros::openvmm_test_no_agent; + +fn portb_output(bytes: &[u8]) -> anyhow::Result { + use std::fmt::Write as _; + + let mut output = String::new(); + for byte in bytes { + writeln!( + output, + "printf '\\x{byte:02x}' | dd of=/dev/port bs=1 seek=233 count=1 conv=notrunc 2>/dev/null" + )?; + } + Ok(output) +} + +#[openvmm_test_no_agent(linux_direct_x64)] +async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyhow::Result<()> { + const TIMEOUT: Duration = Duration::from_secs(30); + const BOOT_MARKER: &[u8] = b"OPENVMM-LINUX-DIRECT-TEST-READY\n"; + const RAW_MARKER: &[u8] = b"\0\r\n\x7f\xffLINUX-DIRECT-ECHO\n"; + const COMMAND_PING: u8 = 1; + const COMMAND_ECHO: u8 = 2; + const COMMAND_SNAPSHOT: u8 = 3; + const COMMAND_STATE: u8 = 5; + const COMMAND_SHUTDOWN: u8 = 6; + + let script = format!( + "#!/bin/busybox sh\n\ + mount -t devtmpfs devtmpfs /dev 2>/dev/null || true\n\ + read_port() {{\n\ + dd if=/dev/port bs=1 skip=\"$1\" count=1 2>/dev/null | od -An -tu1 | tr -d ' '\n\ + }}\n\ + next_byte() {{\n\ + while :; do\n\ + status=$(read_port 234)\n\ + [ -n \"$status\" ] || status=0\n\ + if [ $((status & 1)) -ne 0 ]; then\n\ + read_port 233\n\ + return\n\ + fi\n\ + done\n\ + }}\n\ + generation=0\n\ + {}\ + while :; do\n\ + command=$(next_byte)\n\ + case \"$command\" in\n\ + {COMMAND_PING})\n\ + {}\ + ;;\n\ + {COMMAND_ECHO})\n\ + {}\ + ;;\n\ + {COMMAND_SNAPSHOT})\n\ + {}\ + printf '\\x00' | dd of=/dev/port bs=1 seek=1541 count=1 conv=notrunc 2>/dev/null\n\ + generation=$((generation + 1))\n\ + {}\ + ;;\n\ + {COMMAND_STATE})\n\ + if [ \"$generation\" = 1 ]; then\n\ + {}\ + else\n\ + {}\ + fi\n\ + ;;\n\ + {COMMAND_SHUTDOWN})\n\ + status=$(next_byte)\n\ + octal=$(printf '%03o' \"$status\")\n\ + printf \"\\\\$octal\" | dd of=/dev/port bs=1 seek=1540 count=1 conv=notrunc 2>/dev/null\n\ + while :; do sleep 1; done\n\ + ;;\n\ + *)\n\ + {}\ + ;;\n\ + esac\n\ + done\n", + portb_output(BOOT_MARKER)?, + portb_output(b"PONG\n")?, + portb_output(RAW_MARKER)?, + portb_output(b"SNAPSHOT-REQUESTED\n")?, + portb_output(b"SNAPSHOT-CONTINUED=1\n")?, + portb_output(b"STATE=1\n")?, + portb_output(b"STATE-INVALID\n")?, + portb_output(b"UNKNOWN-COMMAND\n")?, + ); + let config = config.with_microvm_machine(1); + let initrd = config.prepare_initrd_with_file("microvm-test", script.as_bytes(), 0o100755)?; + let mut vm = config + .with_prebuilt_initrd(initrd) + .modify_backend(|config| { + config.with_linux_command_line(|cmdline| { + assert!(cmdline.contains("rdinit=/microvm-test")); + }) + }) + .run_without_agent() + .await?; + + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_bytes(BOOT_MARKER)) + .await + .context("timed out waiting for microVM Linux direct boot marker")??; + + let save_error = vm + .backend() + .save_state() + .await + .expect_err("microVM host save unexpectedly succeeded"); + assert!( + format!("{save_error:#}").contains("save is unavailable for microVM"), + "unexpected microVM host-save error: {save_error:#}" + ); + let pulse_error = vm + .backend() + .pulse_save_restore() + .await + .expect_err("microVM pulse save/restore unexpectedly succeeded"); + assert!( + format!("{pulse_error:#}") + .contains("save and restore are unavailable for this machine profile"), + "unexpected microVM pulse-save error: {pulse_error:#}" + ); + + vm.backend() + .write_microvm_portb_input(&[COMMAND_PING]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_output("PONG")) + .await + .context("microVM Linux direct guest did not answer ping")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_ECHO]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_bytes(RAW_MARKER)) + .await + .context("microVM raw binary echo was not observed")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_SNAPSHOT]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled( + vm.backend() + .wait_for_microvm_portb_output("SNAPSHOT-CONTINUED=1"), + ) + .await + .context("microVM snapshot request without a destination did not continue")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_STATE]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_output("STATE=1")) + .await + .context("microVM guest state did not persist after the snapshot request")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_SHUTDOWN, 37]) + .await?; + + let halt = CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.wait_for_halt()) + .await + .context("timed out waiting for microVM status shutdown")??; + assert_eq!(halt.reason, PetriHaltReason::PowerOff); + assert!( + halt.detail.contains("code: 37"), + "expected microVM shutdown status 37, got {}", + halt.detail + ); + + vm.teardown().await +} diff --git a/workers/chipset_device_worker/src/protocol.rs b/workers/chipset_device_worker/src/protocol.rs index 1039257b60a..241a2294d66 100644 --- a/workers/chipset_device_worker/src/protocol.rs +++ b/workers/chipset_device_worker/src/protocol.rs @@ -60,7 +60,11 @@ pub(crate) enum DeviceRequest { /// Perform a PCI config space write. PciConfigWrite(WriteRequest, PciConfigByteEnable), /// Start the device - Start, + Start(mesh::rpc::FailableRpc<(), ()>), + /// Stop accepting new host input before snapshot. + QuiesceInput(mesh::rpc::FailableRpc<(), ()>), + /// Resume host input after a failed snapshot. + ResumeInput(mesh::rpc::FailableRpc<(), ()>), /// Stop the device Stop(Rpc<(), ()>), /// Reset the device diff --git a/workers/chipset_device_worker/src/proxy.rs b/workers/chipset_device_worker/src/proxy.rs index fbec1f63ce0..0bdad4819dd 100644 --- a/workers/chipset_device_worker/src/proxy.rs +++ b/workers/chipset_device_worker/src/proxy.rs @@ -267,7 +267,29 @@ impl PollDevice for ChipsetDeviceProxy { impl ChangeDeviceState for ChipsetDeviceProxy { fn start(&mut self) { - self.req_send.send(DeviceRequest::Start) + self.req_send + .send(DeviceRequest::Start(mesh::rpc::Rpc::detached(()))) + } + + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::Start, ()) + .await?; + Ok(()) + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::QuiesceInput, ()) + .await?; + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::ResumeInput, ()) + .await?; + Ok(()) } async fn stop(&mut self) { diff --git a/workers/chipset_device_worker/src/worker.rs b/workers/chipset_device_worker/src/worker.rs index 49d445bffae..fdc7ec8d7fc 100644 --- a/workers/chipset_device_worker/src/worker.rs +++ b/workers/chipset_device_worker/src/worker.rs @@ -252,7 +252,18 @@ impl Worker for RemoteChipsetDeviceWorker { } }, WorkerEvent::DeviceRequest(req) => match req { - DeviceRequest::Start => self.device.start(), + DeviceRequest::Start(rpc) => { + rpc.handle_failable(async |()| self.device.start_fallible().await) + .await + } + DeviceRequest::QuiesceInput(rpc) => { + rpc.handle_failable(async |()| self.device.quiesce_input().await) + .await + } + DeviceRequest::ResumeInput(rpc) => { + rpc.handle_failable(async |()| self.device.resume_input().await) + .await + } DeviceRequest::Stop(rpc) => { rpc.handle(async |()| self.device.stop().await).await }