From 937a959e65b812963a782109e5a1b711286fe1a6 Mon Sep 17 00:00:00 2001 From: Gudge <26722142+MGudgin@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:18:44 -0700 Subject: [PATCH 1/2] Enforce per-field schema version availability at parse time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds per-field schema version availability: a wire field can declare the range of config schema versions it is valid in, and the parser rejects any use outside that range. It is what makes shape-only support for older schema versions real — until now such an annotation would have been documentation that nothing honoured. Details * New `mxc_version_derive` proc-macro crate. `#[derive(VersionAvailability)]` lifts `#[mxc_version(since = "0.8")]` / `until` off `wire.rs` into metadata normal builds carry, so one declaration feeds both the parser and schema generation (published as `x-mxc-since` / `x-mxc-until`). A derive rather than `#[schemars(extend(...))]`, which sits behind the `schema-gen` feature and so can never be consulted by the parser. * The mechanism fails **open** if a derived JSON name ever disagrees with what serde accepts — the range simply never fires — so that case is guarded twice: the macro compile-errors on every serde construct it cannot model exactly (`flatten`, split `rename`/`rename_all`, unknown `rename_all` rules, data-carrying variants), and a conformance test cross-checks all 32 wire types against the names `schemars` independently derives. * The gate runs immediately after deserialisation in every entry point, because `convert_wire_config` moves fields out of the config; state-aware requests are gated on the original document, not the experimental-masked copy. * `version` is now **required** — it selects the legal field surface, so an absent one would silently opt out of every range. * New `version_incompatible` code across all five surfaces (Rust `MxcErrorCode`, engine `ErrorCode`, TS, C#, `MXC_STATUS_VERSION_INCOMPATIBLE = 13`) carrying `details: { field, declaredVersion, since, until }`. The supported-range error migrates onto it. NOTE: this changes an existing error's observable shape — a consumer string-matching the old range message is affected. * Three annotations, each checked against real corpus usage first: `seatbelt` since 0.7, `processContainer.captureDenials` / `learningMode` since 0.8. The central subtlety is what is deliberately **not** annotated: schema-first- appearance is only a lower bound on accepted surface. `experimental` was an open block before 0.8, and state-aware requests declare 0.6 while carrying `phase` / `sandboxId` / `correlationVector` — annotating those from schema data would reject configs that have always worked. (Measured: 66 properties are unannotated yet absent from the 0.6 schema; 8 are covered transitively by an annotated ancestor, and the rest legitimately carry no range.) * New `check-version-availability.js` oracle gate derives each field's true first appearance from the frozen 0.6/0.7 and dev schemas and fails on disagreement. It is fail-closed on the surfaces above, which also catches a range that would leak onto the permissive `experimental` surface via a shared type. * Corpus and callers migrated: 61 configs versioned (state-aware to 0.6.0-alpha, matching what the SDK emits; one-shot to 0.8.0-alpha), ~200 Rust test literals, the PowerShell lifecycle helpers (stamped centrally), and the SDK builders, which no longer synthesise a top-level `seatbelt` marker below 0.7. Tests * On the final tip: `cargo fmt --all -- --check`, `cargo check --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, and the per-package test suites (`wxc_common` incl. the corpus test, `mxc_schema_gen`, `mxc_version_derive`, `mxc_engine`, `mxc-sdk`, `mxc_ffi`, `wxc`, `wxc_e2e_tests::e2e_state_aware`) all clean. * Feature-gated builds covering every flag this diff can reach, all clean: `wxc_common` {schema-gen, microvm}, `mxc_ffi` {dotnetsdk}, `mxc_engine` {isolation_session}, `wxc` {isolation_session, microvm, tier2_bfs, wslc, hyperlight}. * `wxc_common` 594 unit tests plus a new corpus test asserting all 195 configs declare a version and still parse, with the out-of-range fixture pinned as a negative case by exact code and bounds. Versioning gate tests 71 → 94. * Node SDK build + 223 tests; C# SDK 35 tests; ErrorCode parity 17 codes; bindings codegen OK. All 11 CI gates pass. * Non-regression: the PR #676 replay still yields exactly 6 findings naming `allowLocalNetwork`, `allowedHosts`, `blockedHosts`, `defaultPolicy`, `enforcementMode`, `proxy`; detector baselines hold (dev vs dev = 0, 0.6→0.7 = 6, 0.7→dev = 12); the dev-schema gate passes; `SUPPORTED_VERSION` unchanged at `>=0.6, <=0.8`. * Converged through a 2-round adversarial review (14 findings; 12 fixed, 1 pushback accepted, 1 pre-existing). Two blockers were genuine test failures an earlier verification pass had masked with a faulty grep. * **Not executed on this host** (Windows): the macOS Seatbelt paths, the Windows Sandbox and IsolationSession PowerShell lifecycle suites, and the host-gated MicroVM / Hyperlight E2E configs. The macOS code does **cross-compile** — `cargo check --target aarch64-apple-darwin --all-targets` is clean for `mxc_engine`, `wxc_common` and `mxc-sdk`, including the new `cfg(target_os = "macos")` regression tests — but it has not been run. (`mxc_darwin` cannot be cross-checked at all: pre-existing issue #735.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 21bb36ae-131a-4ab6-b062-a830ba488428 Generated-with: claude-opus-5 --- .github/copilot-instructions.md | 5 +- .github/workflows/Versioning.Checks.Job.yml | 7 + docs/schema.md | 49 +- .../mxc-state-aware-sandbox-api-overview.md | 1 + .../mxc-state-aware-sandbox-api.md | 23 +- docs/versioning.md | 144 ++- schemas/dev/mxc-config.schema.0.8.0-dev.json | 11 +- scripts/versioning/check-dev-schema-compat.js | 4 +- .../versioning/check-version-availability.js | 120 +++ .../versioning/lib/version-availability.js | 254 ++++++ scripts/versioning/package.json | 1 + .../tests/version-availability.test.js | 320 +++++++ .../MxcSandboxProcessTests.cs | 9 +- .../MxcSandboxTests.cs | 6 +- sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs | 8 +- sdk/node/src/errors.ts | 10 +- sdk/node/src/generated/wire.ts | 7 +- sdk/node/src/sandbox.ts | 23 +- sdk/node/tests/unit/sandbox.test.ts | 27 +- src/Cargo.lock | 11 + src/Cargo.toml | 5 + src/core/mxc-sdk/README.md | 4 +- src/core/mxc-sdk/src/lib.rs | 1 + src/core/mxc-sdk/tests/sandbox.rs | 31 +- src/core/mxc-sdk/tests/sdk_helpers.rs | 5 +- src/core/mxc-sdk/tests/state_aware.rs | 6 +- src/core/mxc_engine/src/error.rs | 9 + src/core/mxc_engine/src/policy.rs | 126 ++- src/core/mxc_engine/src/state_aware.rs | 5 +- src/core/mxc_version_derive/Cargo.toml | 16 + src/core/mxc_version_derive/src/lib.rs | 546 +++++++++++ src/core/wxc/src/main.rs | 7 +- src/core/wxc_common/Cargo.toml | 1 + src/core/wxc_common/src/config_deserialize.rs | 18 +- src/core/wxc_common/src/config_parser.rs | 852 +++++++++++++----- src/core/wxc_common/src/error.rs | 29 + src/core/wxc_common/src/lib.rs | 9 + src/core/wxc_common/src/mxc_error.rs | 13 + src/core/wxc_common/src/telemetry/mod.rs | 4 + .../wxc_common/src/version_availability.rs | 685 ++++++++++++++ src/core/wxc_common/src/wire.rs | 120 ++- src/core/wxc_common/tests/corpus_parses.rs | 151 ++++ src/ffi/mxc_ffi/src/lib.rs | 6 +- src/ffi/mxc_ffi/src/state_aware.rs | 17 +- .../wxc_e2e_tests/tests/e2e_state_aware.rs | 2 + .../wxc_e2e_tests/tests/e2e_windows.rs | 4 + src/tools/mxc_schema_gen/Cargo.toml | 5 + .../tests/version_availability_conformance.rs | 193 ++++ tests/configs/hyperlight_exit_code.json | 1 + tests/configs/hyperlight_fs.json | 1 + tests/configs/hyperlight_hello.json | 1 + tests/configs/hyperlight_networking.json | 1 + .../hyperlight_networking_blocked.json | 1 + tests/configs/hyperlight_pandas.json | 1 + tests/configs/hyperlight_timeout.json | 1 + ...ation_session_state_aware_deprovision.json | 1 + ...lation_session_state_aware_exec_basic.json | 1 + ...solation_session_state_aware_exec_cwd.json | 1 + ...n_session_state_aware_exec_env_absent.json | 1 + ..._session_state_aware_exec_env_initial.json | 1 + ...session_state_aware_exec_env_modified.json | 1 + ...ation_session_state_aware_exec_exit_0.json | 1 + ...ation_session_state_aware_exec_exit_1.json | 1 + ...ation_session_state_aware_exec_exit_2.json | 1 + ..._session_state_aware_exec_read_marker.json | 1 + ...session_state_aware_exec_read_persist.json | 1 + ...ession_state_aware_exec_read_readonly.json | 1 + ...sion_state_aware_exec_read_restricted.json | 1 + ..._session_state_aware_exec_read_shared.json | 1 + ...session_state_aware_exec_setx_initial.json | 1 + ...ession_state_aware_exec_setx_modified.json | 1 + ...session_state_aware_exec_write_marker.json | 1 + ...tate_aware_exec_write_readonly_denied.json | 1 + ...session_state_aware_exec_write_shared.json | 1 + ...olation_session_state_aware_provision.json | 1 + ...state_aware_provision_rejected_denied.json | 1 + ...e_aware_provision_user_empty_wamtoken.json | 1 + ...te_aware_provision_user_malformed_upn.json | 1 + ...state_aware_provision_with_filesystem.json | 1 + ...ion_state_aware_provision_with_filter.json | 1 + .../isolation_session_state_aware_start.json | 1 + ..._state_aware_start_entra_missing_user.json | 1 + ...ion_state_aware_start_local_with_user.json | 1 + ...tion_session_state_aware_start_medium.json | 1 + ...ession_state_aware_start_upn_mismatch.json | 1 + .../isolation_session_state_aware_stop.json | 1 + tests/configs/microvm_error.json | 1 + tests/configs/microvm_error_linux.json | 1 + tests/configs/microvm_exit_code.json | 1 + tests/configs/microvm_exit_code_linux.json | 1 + tests/configs/microvm_hello.json | 1 + tests/configs/microvm_hello_linux.json | 1 + tests/configs/microvm_large_output.json | 1 + tests/configs/microvm_large_output_linux.json | 1 + tests/configs/microvm_multiline.json | 1 + tests/configs/microvm_multiline_linux.json | 1 + tests/configs/microvm_network.json | 1 + tests/configs/microvm_network_linux.json | 1 + tests/configs/microvm_stdlib.json | 1 + tests/configs/microvm_stdlib_linux.json | 1 + tests/configs/microvm_timeout.json | 1 + tests/configs/microvm_timeout_linux.json | 1 + tests/configs/windows_sandbox_echo.json | 1 + tests/configs/windows_sandbox_exit_code.json | 1 + tests/configs/windows_sandbox_powershell.json | 1 + .../windows_sandbox_powershell_env.json | 1 + tests/configs/windows_sandbox_stderr.json | 1 + tests/configs/windows_sandbox_timeout.json | 1 + tests/examples/28_telemetry_enabled.json | 1 + ...un_isolation_session_state_aware_tests.ps1 | 14 + tests/scripts/run_isolation_session_tests.ps1 | 13 + .../run_windows_sandbox_state_aware_tests.ps1 | 15 + 112 files changed, 3673 insertions(+), 340 deletions(-) create mode 100644 scripts/versioning/check-version-availability.js create mode 100644 scripts/versioning/lib/version-availability.js create mode 100644 scripts/versioning/tests/version-availability.test.js create mode 100644 src/core/mxc_version_derive/Cargo.toml create mode 100644 src/core/mxc_version_derive/src/lib.rs create mode 100644 src/core/wxc_common/src/version_availability.rs create mode 100644 src/core/wxc_common/tests/corpus_parses.rs create mode 100644 src/tools/mxc_schema_gen/tests/version_availability_conformance.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a7c3078bc..22f58d5f6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -140,7 +140,8 @@ The SDK auto-discovers native binaries by checking `sdk/node/bin/ - **Dev schema**: the in-progress schema lives in [`schemas/dev/`](../schemas/dev). It is **generated** from the Rust wire model (`src/core/wxc_common/src/wire.rs`) by the `mxc_schema_gen` tool — **do not hand-edit it**. To change the dev schema, edit the wire model and regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema..json`. `scripts/versioning/check-schema-codegen.js` is a CI gate that regenerates and fails if the committed schema drifts. See [`docs/schema-codegen.md`](../docs/schema-codegen.md). - **Generated SDK wire types**: `sdk/node/src/generated/wire.ts` is **generated** from the same wire model by the `mxc_schema_gen --ts` TypeScript emitter (`wxc_common::ts_emit`, no third-party generator) — **do not hand-edit it**. It is a drift oracle (not public API); the SDK unit test `sdk/node/tests/unit/wire-conformance.test.ts` asserts the hand-written public types in `sdk/node/src/types.ts` conform to it, and `scripts/versioning/check-sdk-types-codegen.js` is a CI gate that fails if the committed file drifts. Regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/node/src/generated/wire.ts`. - **Canonical schema-version source**: `schemas/schema-version.json` — the single source of truth for the schema-version constants (min/maxSupported/state-aware/stable/dev). `scripts/versioning/check-schema-versions.js` enforces that the Rust parser, SDK, and schema filenames all agree with it; do not hand-edit a schema-version constant without updating the canonical file. See [`docs/versioning.md`](../docs/versioning.md) for the full design. -- **Dev schema compatibility**: `scripts/versioning/check-dev-schema-compat.js` is a CI gate that compares the dev schema at the pull-request base against the dev schema at HEAD and **fails on any structural restriction** — a removed property, a new `required`, a narrowed `type`, a tightened bound. There is no per-field escape hatch. Because one dev schema validates configs declaring every supported version, surface a supported version can use has to stay in it. Make a breaking change **additively**: keep the old fields, add the new shape alongside them, and let the supported-version window govern which may be used. Deleting is legitimate only once `min` in `schemas/schema-version.json` rises past the surface being dropped. +- **Version availability**: a wire field may declare the schema-version range it is valid in with `#[mxc_version(since = "0.8")]` / `#[mxc_version(until = "0.7")]` on the `wire.rs` field (see `src/core/mxc_version_derive/`). One declaration feeds both the parser (which enforces it right after deserialisation, before backend dispatch) and schema generation (which publishes `x-mxc-since` / `x-mxc-until`). `version` is **required** in every config. Annotation is opt-in — an unannotated field is valid across the whole supported range — and adding one is a behavioural change, so it is checked against the frozen schemas by `scripts/versioning/check-version-availability.js`. Put a range on the **containing field**, never inside a struct shared with `experimental` (e.g. `Seatbelt`), and never under `experimental` itself (that block was open before 0.8, so schema presence understates what has always been accepted). See [`docs/versioning.md`](../docs/versioning.md#version-availability). +- **Dev schema compatibility**: `scripts/versioning/check-dev-schema-compat.js` is a CI gate that compares the dev schema at the pull-request base against the dev schema at HEAD and **fails on any structural restriction** — a removed property, a new `required`, a narrowed `type`, a tightened bound. There is no per-field escape hatch. Because one dev schema validates configs declaring every supported version, surface a supported version can use has to stay in it. Make a breaking change **additively**: keep the old fields, add the new shape alongside them, and let the supported-availability range govern which may be used. Deleting is legitimate only once `min` in `schemas/schema-version.json` rises past the surface being dropped. - Config files can reference schemas via `"$schema"` for editor validation. `scripts/versioning/validate-configs.js` validates the `tests/examples` + `tests/configs` corpus against the dev schema in CI. ### Key documentation (`docs/`) @@ -192,7 +193,7 @@ The workspace is organized into six top-level directories under `src/`: | Directory | Purpose | Examples | |-----------|---------|----------| -| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `learning_mode_core/`, `generated/` | +| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `mxc_version_derive/`, `learning_mode_core/`, `generated/` | | `backends/` | Backend-specific code (one subfolder per containment backend or backend support component) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `learning_mode/windows`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | | `ffi/` | Foreign-function-interface crates (C ABI for language bindings) | `mxc_ffi/` | | `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index e03418686..666f74b27 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -50,6 +50,13 @@ jobs: - name: Check SDK wire types are in sync with the Rust wire model (codegen) run: node scripts/versioning/check-sdk-types-codegen.js + # After the codegen gates, which guarantee the committed schema still + # matches the wire model the ranges are declared in, and before the + # compatibility gate: a wrong `since` is a correctness bug in its own + # right, independent of whether the diff is breaking. + - name: Check availability ranges against the frozen schemas (oracle) + run: node scripts/versioning/check-version-availability.js + # Ahead of corpus validation: a pull request that removes a field also # migrates the corpus, so validation passes and the removal is what needs # reporting. diff --git a/docs/schema.md b/docs/schema.md index 63eaccfe4..4bff30a77 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -232,10 +232,14 @@ Full lifecycle API: [`docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md` ### Schema Versioning -MXC config files include an optional `version` field using +MXC config files declare a `version` field using [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH). The parser uses -this to detect incompatible configs and provide clear upgrade guidance. If -`version` is absent, the config is assumed compatible with the current version. +this to detect incompatible configs, to decide which fields are legal (see +[availability ranges](#version-availability)), and to provide clear upgrade guidance. + +`version` is **required**. It is not merely metadata: it selects the accepted +field surface, so a config without one would silently opt out of every range +rather than defaulting to something safe. Versions with a pre-release suffix (e.g., `-alpha`) indicate the schema is not yet stable — breaking changes may occur in any release. Once the schema is @@ -247,7 +251,7 @@ The parser compares the config's major.minor against its supported version | Config `version` | Parser supports | Result | |---|---|---| -| absent | >=0.6, <=0.8 | Accepted (assumed compatible) | +| absent or `""` | >=0.6, <=0.8 | **Rejected** — `version_incompatible`, "Missing required field: version" | | `"0.5.0-alpha"` | >=0.6, <=0.8 | **Rejected** — "older than supported" | | `"0.6.0-alpha"` | >=0.6, <=0.8 | Accepted (0.6 in range) | | `"0.7.0-alpha"` | >=0.6, <=0.8 | Accepted (0.7 in range) | @@ -255,6 +259,43 @@ The parser compares the config's major.minor against its supported version | `"0.9.0"` | >=0.6, <=0.8 | **Rejected** — "newer than supported" | | `"1.0.0"` | >=0.6, <=0.8 | **Rejected** — "newer than supported" | +#### Version availability + +A field may declare the range of schema versions it is valid in. The generated +schema publishes these as `x-mxc-since` / `x-mxc-until` on the property: + +```jsonc +"seatbelt": { + "anyOf": [{ "$ref": "#/definitions/Seatbelt" }, { "type": "null" }], + "x-mxc-since": "0.7" // rejected in a config declaring 0.6 +} +``` + +Both bounds are **inclusive** and compare `major.minor` only. Using a field +outside its range is rejected at parse time with `version_incompatible` and +structured `details`: + +```json +{ + "error": { + "code": "version_incompatible", + "message": "Config field 'seatbelt' was introduced in schema version 0.7 but the config declares '0.6.0-alpha'. Raise the config's 'version' to 0.7 or newer, or remove the field.", + "details": { + "field": "seatbelt", + "declaredVersion": "0.6.0-alpha", + "since": "0.7", + "until": null + } + } +} +``` + +Annotation is **opt-in**: an unannotated field is valid across the whole +supported range. Annotating one is a deliberate behavioural change — it starts +rejecting configs that were previously accepted — and is checked against history +by a CI gate. See [Version availability](versioning.md#version-availability) in the +versioning design for how to add one. + #### When to bump | Change type | Version bump | Example | diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md index ea00cf164..198286d98 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md @@ -385,6 +385,7 @@ structured `details`. Reference §8 has the full list and the `MxcError` mapping | Id problems | `malformed_id`, `stale_id` | | State-machine violations | `not_provisioned`, `not_started`, `already_started`, `already_stopped` | | Config / policy | `policy_validation` | +| Schema version | `version_incompatible` (unsupported version, or a field used outside its availability range) | | Catch-all | `backend_error` (with structured `details`) | Process-runtime kill conditions (timeouts, backend-initiated termination) surface as diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 963047e91..e3c15884c 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -422,11 +422,20 @@ Phases with no backend-specific or cross-cutting fields declare a Config carryin change: extend `StateAwareContainmentBackend`, define five new `*Config` interfaces, and add an arm to `ConfigsForBackend`. -Each Config carries an optional `version?: string`. When omitted, the SDK fills in its -own `SUPPORTED_VERSION`; an explicit value is range-validated against the SDK's -`MIN_VERSION` and `SUPPORTED_VERSION` (same convention as today's -`validatePolicyVersion`). The override exists so consumers can target a specific wire -version when debugging or testing version negotiation. +Each Config carries an optional `version?: string`. It is optional **to the SDK +caller only**: the wire format *requires* `version` (it selects which config fields are +legal — see [versioning](../versioning.md#version-availability)), and the SDK always emits +one, filling in `STATE_AWARE_VERSION` when the caller omits it. An explicit value is +range-validated against the SDK's `MIN_VERSION` and `SUPPORTED_VERSION` (same convention +as today's `validatePolicyVersion`). The override exists so consumers can target a +specific wire version when debugging or testing version negotiation. + +A request that reaches the executor without a `version` is rejected with +`version_incompatible`. Note that the generated JSON Schema does **not** list `version` +under `required`: the schema is an editor/CI convenience, not the trust boundary (the +parser is), and adding a `required` entry would be a structural restriction that the +dev-schema compatibility gate correctly refuses. Cross-field and presence invariants +live in the parser for exactly this reason. ### 6.2 Method signatures @@ -991,6 +1000,7 @@ other state-aware backend, so caller error-handling code is portable across back | `already_stopped` | `stop` called on an already-stopped sandbox | | `policy_validation` | Per-stage config or cross-cutting policy contents do not satisfy the backend's expected shape or values | | `backend_error` | Catch-all for backend-specific failures; `details` carries structured information | +| `version_incompatible` | The config omitted `version`, declared one outside the supported range, or used a field outside its availability range. `details` carries `{ field, declaredVersion, since, until }`, where `field` is the offending field's dotted path or `"version"` for a range failure. See [versioning](../versioning.md#version-availability) | ```typescript type ErrorCode = @@ -1005,7 +1015,8 @@ type ErrorCode = | 'already_started' | 'already_stopped' | 'policy_validation' - | 'backend_error'; + | 'backend_error' + | 'version_incompatible'; ``` The set is closed at the MXC layer. Backend-specific failures that don't fit one of the diff --git a/docs/versioning.md b/docs/versioning.md index 3477b5f1a..5b075e691 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -365,12 +365,18 @@ only the first; it does **not** influence stages 2 or 3 (Phase 3a removed that coupling). ``` -Stage 1 — Schema-range check (the trust boundary, `config_parser`) +Stage 1 — Schema-range check + availability ranges (the trust boundary, + `config_parser`) + Is config.version present? (required — it selects the legal field surface) + absent / empty → error: "Missing required field: version" Is config.version within [floor, dev-ceiling]? (major.minor; pre-release labels ignored) below floor → error: "older than supported" (update your config) above ceiling → error: "newer than supported" (upgrade wxc-exec) - in range / absent → continue + in range → continue + Does every populated field fall inside its availability range? + no → error naming the field and its bounds + yes → continue Stage 2 — Containment resolve (independent of schema version) Map the `containment` intent to a concrete backend: @@ -413,6 +419,123 @@ backend cannot honor the requested filesystem/network policy, execution fails with a typed, actionable error rather than silently weakening enforcement (see [Error Contract](#error-contract)). +## Version availability + +### The requirement + +> Breaking config-schema changes must be possible **without removing support for +> an earlier version.** + +"Support" here means **shape only**: an older config keeps parsing, and is then +enforced with *today's* semantics. Reproducing an old version's *behaviour* is +explicitly a non-goal, for two reasons: + +* Most behavioural changes are **bug fixes**. Supporting 0.6 must not mean + reproducing 0.6's bugs. +* At least one change tightened a sandbox default (Bubblewrap moved from + whole-host-root-readable to deny-by-default). Honouring old behaviour would be + a **downgrade attack**: an attacker-influenced config could select a weaker + sandbox merely by declaring an older version. + +### The consequence: breaking changes are additive + +Because one dev schema has to validate configs declaring *every* supported +version, the schema cannot be the thing that retires a field — any surface a +supported version can use has to stay in it. So a breaking change is made +**additively**: + +1. Keep the old field, and mark it `until` the last version it was valid in. +2. Add the new shape alongside it, marked `since` the version that introduced it. +3. Let the declared version decide which one a given config may use. + +This is also why the dev-schema compatibility gate +(`check-dev-schema-compat.js`) has no per-field escape hatch: a removal is +always the wrong shape for a change, not an exception to be waived. Deleting an +`until`-marked field is legitimate only once the supported floor rises past its +`until` value. + +### Declaring an availability range + +Availability ranges are declared **once**, on the wire model, with +`#[derive(VersionAvailability)]` (crate `mxc_version_derive`): + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Network { + // Unannotated: valid across the whole supported range. + pub enforcement_mode: Option, + // Introduced at 0.8: rejected in a config declaring 0.6 or 0.7. + #[mxc_version(since = "0.8")] + pub egress: Option, + // Retired after 0.7: rejected in a config declaring 0.8. + #[mxc_version(until = "0.7")] + pub default_policy: Option, +} +``` + +Both bounds are inclusive and compare `major.minor` only, matching the +supported-range check. + +One declaration feeds **both** consumers: + +* the **parser** enforces it (`wxc_common::version_availability::validate_document`), + immediately after deserialisation and before the wire→domain mapping moves + fields out of the config; +* **schema generation** publishes it as `x-mxc-since` / `x-mxc-until`, so the + committed schema documents the same thing the parser enforces. Adding an + annotation without regenerating fails the existing codegen gate. + +A derive is required rather than `#[schemars(extend(...))]`: the schemars +attributes sit behind the `schema-gen` feature, which only `mxc_schema_gen` +enables, so they are invisible to the parser. An annotation nothing enforces is +documentation, not a contract. + +### Rules and hazards + +**Annotation is opt-in.** An unannotated field is valid across the whole +supported range. There is no obligation to annotate every field, and annotating +one is a behavioural change: it starts rejecting configs that were previously +accepted. + +**Put the range on the containing field, not inside a shared struct.** A +struct reached from two places is *one* node, so a range on its fields applies +to every path that reaches it. `Seatbelt`, for example, is both the top-level +`seatbelt` section and `experimental.seatbelt`; the range therefore lives on +`MxcConfig::seatbelt`. The oracle gate refuses any range that would leak onto +the `experimental` surface this way. + +**Schema presence is not the same as accepted surface.** A field's first +appearance in the JSON Schema is a *lower* bound on how long it has been +accepted, not the truth: + +* `experimental` declared no properties before 0.8, so anything under it + validated vacuously and has always been accepted. The oracle gate **refuses** + ranges under this subtree rather than deriving a bound it cannot justify. +* State-aware requests declare `0.6.0-alpha` while carrying `phase` / + `sandboxId`, which the schema only described from 0.8. Annotating those from + schema data would break every state-aware request. + +The behavioural counter-check is the corpus parse test +(`src/core/wxc_common/tests/corpus_parses.rs`): every config in +`tests/examples` + `tests/configs` must still parse. + +**Aliases share their field's range.** `#[serde(alias = "…")]` is another +spelling of the same field, so it is checked with the same bounds — otherwise +the alias would be a bypass — and the diagnostic names the spelling the config +actually used. This is why the deprecated `appContainer` alias needs no range +of its own: it inherits `processContainer`'s. (It is a serde alias, never a +schema property, so it has no independent history to annotate.) + +### Gates + +| Gate | What it protects | +|---|---| +| `check-version-availability.js` | Each `since` matches the field's true first appearance across the frozen 0.6 / 0.7 and dev schemas; each `until` names a version the field really existed in. Fail-closed under `experimental`. | +| `check-schema-codegen.js` | The committed schema still matches the wire model, so the published `x-mxc-*` cannot drift from what the parser enforces. | +| `mxc_schema_gen` conformance tests | The JSON field names the derive computes match the ones `schemars` derives. A name that disagrees with `serde` would make the range unreachable — a silent **fail-open**. | +| `corpus_parses.rs` | Every corpus config still parses, catching an annotation that is schema-correct but behaviourally wrong. | + ## OS APIs The BaseContainer tier calls the OS sandbox API to launch the child: @@ -451,6 +574,23 @@ Negotiation failures are **typed and actionable** — never a silent fallback: - **Schema-range failures** (Stage 1) carry a clear "older than supported" / "newer than supported" message telling the caller whether to update the config or upgrade `wxc-exec`. +- **Version failures** (Stage 1) — a missing `version`, a version outside the + supported range, or a field used outside its + [availability range](#version-availability) — all surface under the single + `version_incompatible` code with structured `details`: + + ```json + { "field": "processContainer.captureDenials", + "declaredVersion": "0.7.0-alpha", + "since": "0.8", + "until": null } + ``` + + `field` is the dotted path of the offending field, or `"version"` when the + declared version itself is the problem (in which case `since` / `until` carry + the supported range). One code plus structured details keeps the closed error + union from growing a variant per direction, and lets a caller act on the + failure without re-parsing the message. - **Capability failures** (Stage 3) surface on the runner's `ScriptResponse` (and the SDK `spawn` path's `MxcError`) as a `BackendUnavailable` failure phase when the requested backend's API is absent (e.g. the BaseContainer OS diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index 504446da6..d14a5193d 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -699,14 +699,16 @@ "type": "null" } ], - "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability." + "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability.\n\nIntroduced at 0.8.", + "x-mxc-since": "0.8" }, "learningMode": { - "description": "AppContainer learning mode (deny-and-record): failed access checks are logged for diagnostics while the accesses stay denied; containment is unchanged. Distinct from the allow-all `permissiveLearningMode` capability, which is injected internally by the `--audit` CLI flag or dedicated denial-capture configuration.", + "description": "AppContainer learning mode (deny-and-record): failed access checks are logged for diagnostics while the accesses stay denied; containment is unchanged. Distinct from the allow-all `permissiveLearningMode` capability, which is injected internally by the `--audit` CLI flag or dedicated denial-capture configuration.\n\nIntroduced at 0.8.", "type": [ "boolean", "null" - ] + ], + "x-mxc-since": "0.8" }, "leastPrivilege": { "description": "Enforce least-privilege mode.", @@ -1138,7 +1140,8 @@ "type": "null" } ], - "description": "macOS Seatbelt backend configuration. Used when containment is `seatbelt`." + "description": "macOS Seatbelt backend configuration. Used when containment is `seatbelt`.\n\nIntroduced at 0.7. The range is on this field, not inside [`Seatbelt`], which is shared with the unconstrained `experimental.seatbelt`.", + "x-mxc-since": "0.7" }, "ui": { "anyOf": [ diff --git a/scripts/versioning/check-dev-schema-compat.js b/scripts/versioning/check-dev-schema-compat.js index d3b4bc803..dc6752866 100644 --- a/scripts/versioning/check-dev-schema-compat.js +++ b/scripts/versioning/check-dev-schema-compat.js @@ -15,7 +15,7 @@ // of the accepted instance set. // // There is deliberately no per-field escape hatch. A field may not simply -// disappear: the supported-version window is what allows surface to end, so +// disappear: the supported-availability range is what allows surface to end, so // until a change moves that window the only correct answer is to keep accepting // what the base accepted. // @@ -95,7 +95,7 @@ if (findings.length > 0) { `${base.commit.slice(0, 8)}${moved}:`, ...findings, `Configs declaring an already-supported version must keep parsing. Add ` + - `surface instead of removing it, or move the supported-version window ` + + `surface instead of removing it, or move the supported-availability range ` + `in the same change.`, ]); } diff --git a/scripts/versioning/check-version-availability.js b/scripts/versioning/check-version-availability.js new file mode 100644 index 000000000..8e8a7ebd5 --- /dev/null +++ b/scripts/versioning/check-version-availability.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Version-availability oracle gate. +// +// A `since` annotation is a claim about history, and the frozen stable schemas +// record which fields existed when — so it is checked rather than trusted. A +// mistyped bound would otherwise silently start rejecting configs that were +// always valid. +// +// * `x-mxc-since: S` — the field's first appearance must be exactly S. +// * `x-mxc-until: U` — the field must exist at U. (An upper bound is not +// derivable from presence: a retired field stays in the dev schema on +// purpose, since one dev schema validates every supported version.) +// +// Ranges are read from the generated dev schema, not `wire.rs`: they are +// emitted there from the same derive the parser enforces, and +// `check-schema-codegen.js` fails if the two drift. +// +// Run from anywhere (paths are resolved relative to the repo root): +// node scripts/versioning/check-version-availability.js + +const { readFileSync, readdirSync } = require("fs"); +const { join, resolve } = require("path"); +const { parseMajorMinor, compareMajorMinor, majorMinor } = require("./lib/version.js"); +const { + SINCE_KEY, + UNTIL_KEY, + collectPropertyPaths, + collectDeclaredAvailability, + checkAvailability, +} = require("./lib/version-availability.js"); + +const repoRoot = resolve(__dirname, "..", ".."); + +function readJson(...parts) { + return JSON.parse(readFileSync(join(repoRoot, ...parts), "utf8")); +} + +const schemaVer = readJson("schemas", "schema-version.json"); + +// Derived from the canonical version file plus the stable schemas on disk: a +// hard-coded timeline would attach a stale label to a bumped dev schema. +function discoverTimeline() { + const stableDir = join(repoRoot, "schemas", "stable"); + const floor = parseMajorMinor(majorMinor(schemaVer.min)); + if (!floor) { + throw new Error(`schema-version.json: 'min' (${schemaVer.min}) is not a version`); + } + + const stable = readdirSync(stableDir) + .map((name) => /^mxc-config\.schema\.(.+)\.json$/.exec(name)) + .filter(Boolean) + .map((m) => ({ file: join("schemas", "stable", m[0]), label: majorMinor(m[1]) })) + .filter((entry) => entry.label !== null) + .map((entry) => ({ ...entry, version: parseMajorMinor(entry.label) })) + // Below the floor a schema can no longer be used by any config. + .filter((entry) => entry.version && compareMajorMinor(entry.version, floor) >= 0); + + const devLabel = majorMinor(schemaVer.maxSupported); + const devVersion = parseMajorMinor(devLabel); + if (!devVersion) { + throw new Error( + `schema-version.json: 'maxSupported' (${schemaVer.maxSupported}) is not a version` + ); + } + const dev = { + file: join("schemas", "dev", `mxc-config.schema.${schemaVer.devSchemaFile}.json`), + label: devLabel, + version: devVersion, + }; + + // One entry per version line, preferring the in-progress dev schema. + const entries = [...stable.filter((e) => e.label !== dev.label), dev]; + entries.sort((a, b) => compareMajorMinor(a.version, b.version)); + return entries; +} + +const timeline = discoverTimeline().map((entry) => ({ + ...entry, + paths: collectPropertyPaths(readJson(entry.file)), +})); + +if (timeline.length < 2) { + console.error( + "ERROR: need at least two schemas to derive a first appearance, found " + + timeline.map((t) => t.label).join(", ") + ); + process.exit(1); +} + +const devSchema = readJson(timeline[timeline.length - 1].file); +const declared = collectDeclaredAvailability(devSchema); + +let result; +try { + result = checkAvailability({ declared, timeline, compareMajorMinor }); +} catch (e) { + console.error("Version-availability oracle check FAILED:"); + console.error(` - ${e.message}`); + process.exit(1); +} +const { errors, checked } = result; + +if (errors.length > 0) { + console.error("Version-availability oracle check FAILED:"); + for (const e of errors) console.error(` - ${e}`); + console.error( + `\n${errors.length} problem(s). Availability ranges are declared with #[mxc_version(...)] in ` + + `src/core/wxc_common/src/wire.rs and published into the dev schema as ` + + `${SINCE_KEY} / ${UNTIL_KEY}.` + ); + process.exit(1); +} + +console.log( + `Version-availability oracle OK: ${checked} declared availability range(s) agree with the ` + + `${timeline.map((t) => t.label).join(" / ")} schemas.` +); diff --git a/scripts/versioning/lib/version-availability.js b/scripts/versioning/lib/version-availability.js new file mode 100644 index 000000000..b7d901421 --- /dev/null +++ b/scripts/versioning/lib/version-availability.js @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Shared logic for the version-availability oracle gate, kept separate from the CLI +// entry point so each piece is unit-testable on its own. + +const SINCE_KEY = "x-mxc-since"; +const UNTIL_KEY = "x-mxc-until"; +const { parseMajorMinor } = require("./version.js"); + +// Surfaces whose accepted history is NOT derivable from schema presence, so a +// range here is refused outright rather than checked against a bound that would +// reject configs that have always worked: +// * `experimental` was an open block before 0.8, so anything under it +// validated vacuously and has long been accepted. +// * the state-aware discriminators ride on 0.6 envelopes but only entered the +// schema at 0.8. +const UNDERIVABLE_ROOTS = [ + { path: "experimental", why: "declared no properties before 0.8, so anything under it validated vacuously" }, + { path: "phase", why: "carried by state-aware requests declaring 0.6 since before the schema described it" }, + { path: "sandboxId", why: "carried by state-aware requests declaring 0.6 since before the schema described it" }, + { path: "correlationVector", why: "carried by state-aware requests declaring 0.6 since before the schema described it" }, +]; + +const MAX_DEPTH = 64; + +/// Thrown when traversal hits its depth budget: returning silently would let +/// the gate pass by not looking. +class DepthExceeded extends Error { + constructor(path) { + super( + `schema traversal exceeded ${MAX_DEPTH} levels at '${path || ""}'. ` + + `Availability ranges below that depth would not be checked, so this fails rather ` + + `than silently skipping them.` + ); + this.name = "DepthExceeded"; + } +} + +function has(node, key) { + return node && typeof node === "object" && Object.prototype.hasOwnProperty.call(node, key); +} + +function resolvePointer(root, ref) { + if (typeof ref !== "string" || !ref.startsWith("#/")) return null; + let target = root; + for (const encoded of ref.slice(2).split("/")) { + const part = encoded.replace(/~1/g, "/").replace(/~0/g, "~"); + if (!has(target, part)) return null; + target = target[part]; + } + return target; +} + +/** + * Walk every property reachable from the schema root, returning + * `{ path, ownerType, property, node }`. Array nesting adds no path segment, + * matching the wire model's own listing. + */ +function collectPropertyPaths(schema, { entries } = {}) { + const out = []; + const rootType = "#root"; + + const walk = (node, path, ownerType, seenRefs, depth) => { + if (!node || typeof node !== "object") return; + if (depth > MAX_DEPTH) throw new DepthExceeded(path); + + if (typeof node.$ref === "string") { + if (seenRefs.has(node.$ref)) return; + const target = resolvePointer(schema, node.$ref); + if (!target) return; + const name = node.$ref.startsWith("#/definitions/") + ? node.$ref.slice("#/definitions/".length) + : ownerType; + walk(target, path, name, new Set(seenRefs).add(node.$ref), depth + 1); + return; + } + + for (const key of ["allOf", "anyOf", "oneOf"]) { + if (Array.isArray(node[key])) { + for (const branch of node[key]) walk(branch, path, ownerType, seenRefs, depth + 1); + } + } + + // An array contributes no path segment: a range on `Vec`'s element field + // is keyed the same as a range on a directly nested struct's field. + if (node.items) walk(node.items, path, ownerType, seenRefs, depth + 1); + + if (node.properties && typeof node.properties === "object") { + for (const [name, sub] of Object.entries(node.properties)) { + const childPath = path ? `${path}.${name}` : name; + out.push({ path: childPath, ownerType, property: name, node: sub }); + walk(sub, childPath, ownerType, seenRefs, depth + 1); + } + } + }; + + walk(schema, "", rootType, new Set(), 0); + return entries ? out : new Set(out.map((e) => e.path)); +} + +/** + * Every declared range, keyed by owning type + property (where the annotation + * lives), with every document path that reaches it. A type reachable from two + * places yields two paths — which is how a range leaking onto `experimental` + * gets caught. + */ +function collectDeclaredAvailability(schema) { + const entries = collectPropertyPaths(schema, { entries: true }); + const byKey = new Map(); + + for (const entry of entries) { + const since = entry.node?.[SINCE_KEY]; + const until = entry.node?.[UNTIL_KEY]; + if (since === undefined && until === undefined) continue; + + const key = `${entry.ownerType}.${entry.property}`; + if (!byKey.has(key)) { + byKey.set(key, { + ownerType: entry.ownerType, + property: entry.property, + since, + until, + paths: [], + }); + } + const record = byKey.get(key); + if (!record.paths.includes(entry.path)) record.paths.push(entry.path); + } + + return [...byKey.values()].sort((a, b) => + `${a.ownerType}.${a.property}`.localeCompare(`${b.ownerType}.${b.property}`) + ); +} + +/** + * Check declared ranges against the schema timeline. + * + * `timeline` is ordered oldest-first: `[{ label, version, paths }]`. + */ +function checkAvailability({ declared, timeline, compareMajorMinor }) { + const errors = []; + let checked = 0; + + const firstAppearance = (path) => timeline.find((t) => t.paths.has(path)); + const atLabel = (label) => timeline.find((t) => t.label === label); + + for (const record of declared) { + const name = `${record.ownerType}.${record.property}`; + + if (record.paths.length === 0) { + errors.push( + `${name}: declares a availability range but is not reachable from the schema root, ` + + `so no config could ever use it` + ); + continue; + } + + const underivable = record.paths + .map((p) => ({ + path: p, + root: UNDERIVABLE_ROOTS.find((r) => p === r.path || p.startsWith(`${r.path}.`)), + })) + .filter((entry) => entry.root); + if (underivable.length > 0) { + const { path, root } = underivable[0]; + errors.push( + `${name}: a availability range cannot be declared at '${underivable + .map((e) => e.path) + .join("', '")}'. The '${root.path}' surface ${root.why}, so its first appearance ` + + `is not derivable from the schemas — a bound taken from schema presence would start ` + + `rejecting configs that have always worked. If this availability range is on a shared type, move ` + + `it to the containing field instead.` + ); + continue; + } + + let malformed = false; + for (const key of [SINCE_KEY, UNTIL_KEY]) { + const raw = key === SINCE_KEY ? record.since : record.until; + if (raw === undefined) continue; + if (typeof raw !== "string" || !parseMajorMinor(raw)) { + errors.push(`${name}: ${key} '${raw}' is not a major.minor version`); + malformed = true; + } + } + if (malformed) continue; + + if (record.since !== undefined) { + for (const path of record.paths) { + const first = firstAppearance(path); + if (!first) { + errors.push( + `${name}: declares ${SINCE_KEY} '${record.since}' but '${path}' appears in none ` + + `of the ${timeline.map((t) => t.label).join(" / ")} schemas` + ); + continue; + } + if (first.label !== record.since) { + errors.push( + `${name}: declares ${SINCE_KEY} '${record.since}' but '${path}' first appears ` + + `in the ${first.label} schema. Correct the annotation in wire.rs (and ` + + `regenerate the schema), or confirm the field really did exist earlier.` + ); + } + } + checked++; + } + + if (record.until !== undefined) { + const at = atLabel(record.until); + if (!at) { + errors.push( + `${name}: declares ${UNTIL_KEY} '${record.until}', which is not a version this ` + + `gate has a schema for (${timeline.map((t) => t.label).join(", ")})` + ); + } else { + for (const path of record.paths) { + if (!at.paths.has(path)) { + errors.push( + `${name}: declares ${UNTIL_KEY} '${record.until}' but '${path}' does not exist ` + + `in the ${record.until} schema, so that is not a version it was available in` + ); + } + } + } + checked++; + } + + if (record.since !== undefined && record.until !== undefined) { + const since = parseMajorMinor(record.since); + const until = parseMajorMinor(record.until); + if (since && until && compareMajorMinor(since, until) > 0) { + errors.push( + `${name}: empty availability range — ${SINCE_KEY} '${record.since}' is newer than ` + + `${UNTIL_KEY} '${record.until}', so the field could never be used` + ); + } + } + } + + return { errors, checked }; +} + +module.exports = { + DepthExceeded, + MAX_DEPTH, + SINCE_KEY, + UNDERIVABLE_ROOTS, + UNTIL_KEY, + checkAvailability, + collectDeclaredAvailability, + collectPropertyPaths, +}; diff --git a/scripts/versioning/package.json b/scripts/versioning/package.json index 275dd2db7..d0850dca7 100644 --- a/scripts/versioning/package.json +++ b/scripts/versioning/package.json @@ -8,6 +8,7 @@ "test": "node --test tests/*.test.js", "check-schema-versions": "node check-schema-versions.js", "check-dev-schema-compat": "node check-dev-schema-compat.js", + "check-version-availability": "node check-version-availability.js", "validate-configs": "node validate-configs.js" }, "dependencies": { diff --git a/scripts/versioning/tests/version-availability.test.js b/scripts/versioning/tests/version-availability.test.js new file mode 100644 index 000000000..a372807eb --- /dev/null +++ b/scripts/versioning/tests/version-availability.test.js @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const test = require("node:test"); +const assert = require("node:assert"); + +const { + DepthExceeded, + MAX_DEPTH, + SINCE_KEY, + UNDERIVABLE_ROOTS, + UNTIL_KEY, + checkAvailability, + collectDeclaredAvailability, + collectPropertyPaths, +} = require("../lib/version-availability.js"); +const { compareMajorMinor, parseMajorMinor } = require("../lib/version.js"); + +// --- helpers --------------------------------------------------------------- + +function schema(properties, definitions = {}) { + return { type: "object", properties, definitions }; +} + +function timelineOf(...schemas) { + const labels = ["0.6", "0.7", "0.8"]; + return schemas.map((s, i) => ({ + label: labels[i], + version: parseMajorMinor(labels[i]), + paths: collectPropertyPaths(s), + })); +} + +function run(devSchema, timeline) { + return checkAvailability({ + declared: collectDeclaredAvailability(devSchema), + timeline, + compareMajorMinor, + }); +} + +// --- collectPropertyPaths -------------------------------------------------- + +test("collectPropertyPaths walks nested objects into dotted paths", () => { + const s = schema( + { network: { $ref: "#/definitions/Network" } }, + { Network: { type: "object", properties: { defaultPolicy: { type: "string" } } } } + ); + const paths = collectPropertyPaths(s); + assert.ok(paths.has("network")); + assert.ok(paths.has("network.defaultPolicy")); +}); + +test("collectPropertyPaths adds no segment for array nesting", () => { + // A range on an element field is keyed the same as one on a directly nested + // struct, matching how the wire model lists paths. + const s = schema( + { wslc: { $ref: "#/definitions/Wslc" } }, + { + Wslc: { + type: "object", + properties: { portMappings: { type: "array", items: { $ref: "#/definitions/Port" } } }, + }, + Port: { type: "object", properties: { protocol: { type: "string" } } }, + } + ); + const paths = collectPropertyPaths(s); + assert.ok(paths.has("wslc.portMappings")); + assert.ok(paths.has("wslc.portMappings.protocol")); + assert.ok(!paths.has("wslc.portMappings[].protocol")); +}); + +test("collectPropertyPaths follows anyOf branches (the Option shape)", () => { + const s = schema( + { seatbelt: { anyOf: [{ $ref: "#/definitions/Seatbelt" }, { type: "null" }] } }, + { Seatbelt: { type: "object", properties: { guiAccess: { type: "boolean" } } } } + ); + const paths = collectPropertyPaths(s); + assert.ok(paths.has("seatbelt.guiAccess")); +}); + +test("collectPropertyPaths terminates on a self-referential schema", () => { + const s = schema( + { node: { $ref: "#/definitions/Node" } }, + { + Node: { + type: "object", + properties: { child: { $ref: "#/definitions/Node" }, name: { type: "string" } }, + }, + } + ); + const paths = collectPropertyPaths(s); + assert.ok(paths.has("node.name")); +}); + +test("collectPropertyPaths ignores an unresolvable $ref", () => { + const s = schema({ a: { $ref: "#/definitions/Missing" } }); + assert.ok(collectPropertyPaths(s).has("a")); +}); + +// --- collectDeclaredAvailability ------------------------------------------------ + +test("collectDeclaredAvailability records every path a shared type is reachable from", () => { + const s = schema( + { + seatbelt: { $ref: "#/definitions/Seatbelt" }, + experimental: { $ref: "#/definitions/Experimental" }, + }, + { + Seatbelt: { + type: "object", + properties: { guiAccess: { type: "boolean", [SINCE_KEY]: "0.7" } }, + }, + Experimental: { + type: "object", + properties: { seatbelt: { $ref: "#/definitions/Seatbelt" } }, + }, + } + ); + const declared = collectDeclaredAvailability(s); + assert.strictEqual(declared.length, 1); + assert.deepStrictEqual(declared[0].paths.sort(), [ + "experimental.seatbelt.guiAccess", + "seatbelt.guiAccess", + ]); +}); + +test("collectDeclaredAvailability ignores unannotated properties", () => { + const s = schema({ a: { type: "string" }, b: { type: "string", [SINCE_KEY]: "0.7" } }); + const declared = collectDeclaredAvailability(s); + assert.deepStrictEqual( + declared.map((d) => d.property), + ["b"] + ); +}); + +// --- since ----------------------------------------------------------------- + +test("a since that matches the derived first appearance passes", () => { + const v06 = schema({ old: { type: "string" } }); + const v07 = schema({ old: { type: "string" }, seatbelt: { type: "string" } }); + const dev = schema({ + old: { type: "string" }, + seatbelt: { type: "string", [SINCE_KEY]: "0.7" }, + }); + const { errors, checked } = run(dev, timelineOf(v06, v07, dev)); + assert.deepStrictEqual(errors, []); + assert.strictEqual(checked, 1); +}); + +test("a since that disagrees with the derived first appearance fails", () => { + const v06 = schema({ seatbelt: { type: "string" } }); + const v07 = schema({ seatbelt: { type: "string" } }); + const dev = schema({ seatbelt: { type: "string", [SINCE_KEY]: "0.7" } }); + const { errors } = run(dev, timelineOf(v06, v07, dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /first appears in the 0\.6 schema/); +}); + +test("a since on a field absent from every schema fails", () => { + const v06 = schema({}); + const v07 = schema({}); + const dev = schema({ ghost: { type: "string", [SINCE_KEY]: "0.7" } }); + // Deliberately hand the gate a timeline whose dev entry lacks the field, to + // model a schema that advertises a range for something unreachable. + const timeline = timelineOf(v06, v07, schema({})); + const { errors } = run(dev, timeline); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /appears in none of the/); +}); + +test("a nested since is checked at its full path", () => { + const bare = schema( + { processContainer: { $ref: "#/definitions/PC" } }, + { PC: { type: "object", properties: { leastPrivilege: { type: "boolean" } } } } + ); + const dev = schema( + { processContainer: { $ref: "#/definitions/PC" } }, + { + PC: { + type: "object", + properties: { + leastPrivilege: { type: "boolean" }, + captureDenials: { type: "object", [SINCE_KEY]: "0.8" }, + }, + }, + } + ); + const { errors } = run(dev, timelineOf(bare, bare, dev)); + assert.deepStrictEqual(errors, []); +}); + +// --- fail-closed on the open experimental block ---------------------------- + +test(`an availability range under 'experimental' is refused rather than silently skipped`, () => { + const dev = schema( + { experimental: { $ref: "#/definitions/Exp" } }, + { Exp: { type: "object", properties: { wslc: { type: "object", [SINCE_KEY]: "0.8" } } } } + ); + const { errors } = run(dev, timelineOf(schema({}), schema({}), dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /validated vacuously/); +}); + +test("an availability range on a state-aware discriminator is refused", () => { + // `phase` / `sandboxId` / `correlationVector` are carried by 0.6 state-aware + // requests but only entered the schema at 0.8, so schema presence would + // approve `since: 0.8` and reject every lifecycle request ever sent. + for (const field of ["phase", "sandboxId", "correlationVector"]) { + const dev = schema({ [field]: { type: "string", [SINCE_KEY]: "0.8" } }); + const { errors } = run(dev, timelineOf(schema({}), schema({}), dev)); + assert.strictEqual(errors.length, 1, `${field}: ${errors.join("; ")}`); + assert.match(errors[0], /state-aware requests declaring 0\.6/); + } +}); + +test("every underivable root carries an explanation", () => { + assert.ok(UNDERIVABLE_ROOTS.length >= 4); + for (const root of UNDERIVABLE_ROOTS) { + assert.ok(typeof root.path === "string" && root.path.length > 0); + assert.ok(typeof root.why === "string" && root.why.length > 0); + } +}); + +test("an availability range on a type shared with the experimental subtree is refused", () => { + // This is the leak the rule exists for: annotating a field inside a struct + // reachable from `experimental` would constrain the permissive surface too. + const dev = schema( + { + seatbelt: { $ref: "#/definitions/Seatbelt" }, + experimental: { $ref: "#/definitions/Exp" }, + }, + { + Seatbelt: { + type: "object", + properties: { guiAccess: { type: "boolean", [SINCE_KEY]: "0.7" } }, + }, + Exp: { type: "object", properties: { seatbelt: { $ref: "#/definitions/Seatbelt" } } }, + } + ); + const { errors } = run(dev, timelineOf(schema({}), schema({}), dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /move it to the containing field/); +}); + +// --- until ----------------------------------------------------------------- + +test("an until naming a version the field existed in passes", () => { + const v06 = schema({ defaultPolicy: { type: "string" } }); + const v07 = schema({ defaultPolicy: { type: "string" } }); + // Retired fields deliberately stay in the dev schema: one dev schema has to + // validate configs declaring every supported version. + const dev = schema({ defaultPolicy: { type: "string", [UNTIL_KEY]: "0.7" } }); + const { errors, checked } = run(dev, timelineOf(v06, v07, dev)); + assert.deepStrictEqual(errors, []); + assert.strictEqual(checked, 1); +}); + +test("an until naming a version the field never existed in fails", () => { + const v06 = schema({}); + const v07 = schema({}); + const dev = schema({ egress: { type: "string", [UNTIL_KEY]: "0.7" } }); + const { errors } = run(dev, timelineOf(v06, v07, dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /does not exist in the 0\.7 schema/); +}); + +test("an until naming a version outside the timeline fails", () => { + const dev = schema({ a: { type: "string", [UNTIL_KEY]: "1.4" } }); + const { errors } = run(dev, timelineOf(schema({ a: {} }), schema({ a: {} }), dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /not a version this gate has a schema for/); +}); + +// --- combined / malformed -------------------------------------------------- + +test("an empty availability range (since newer than until) fails", () => { + const v = schema({ a: { type: "string" } }); + const dev = schema({ a: { type: "string", [SINCE_KEY]: "0.6", [UNTIL_KEY]: "0.6" } }); + const ok = run(dev, timelineOf(v, v, dev)); + assert.deepStrictEqual(ok.errors, []); + + const bad = schema({ a: { type: "string", [SINCE_KEY]: "0.8", [UNTIL_KEY]: "0.6" } }); + const { errors } = run(bad, timelineOf(schema({}), schema({}), bad)); + assert.ok(errors.some((e) => /empty availability range/.test(e)), errors.join("\n")); +}); + +test("a malformed bound is reported and stops further checks for that field", () => { + const dev = schema({ a: { type: "string", [SINCE_KEY]: "0.8.0-alpha" } }); + const { errors } = run(dev, timelineOf(schema({}), schema({}), dev)); + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /not a major\.minor version/); +}); + +test("no declared ranges is not an error", () => { + const dev = schema({ a: { type: "string" } }); + const { errors, checked } = run(dev, timelineOf(dev, dev, dev)); + assert.deepStrictEqual(errors, []); + assert.strictEqual(checked, 0); +}); + +test("exceeding the traversal depth budget fails loudly instead of skipping", () => { + // Returning silently at the depth cut-off would drop every declaration below + // it from the checked set — the gate would pass by not looking. + const definitions = {}; + const depth = MAX_DEPTH + 5; + for (let i = 0; i < depth; i++) { + definitions[`N${i}`] = { + type: "object", + properties: { next: { $ref: `#/definitions/N${i + 1}` } }, + }; + } + definitions[`N${depth}`] = { + type: "object", + properties: { leaf: { type: "string", [SINCE_KEY]: "0.8" } }, + }; + const deep = schema({ root: { $ref: "#/definitions/N0" } }, definitions); + + assert.throws(() => collectPropertyPaths(deep), DepthExceeded); +}); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxProcessTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxProcessTests.cs index ff24b2c95..b72cb3fd2 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxProcessTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxProcessTests.cs @@ -31,14 +31,17 @@ public void Spawn_NullCommand_Throws() } [Fact] - public void Spawn_MalformedPolicy_ThrowsMalformedRequest() + public void Spawn_VersionlessPolicy_ThrowsVersionIncompatible() { // A version-less policy is rejected by the native parser before any - // sandbox is spawned, so this runs on any host. + // sandbox is spawned, so this runs on any host. `version` selects which + // config fields are legal, so it surfaces as its own typed code rather + // than the generic MalformedRequest — this is the C# end of that + // contract reaching across the FFI. var policy = new SandboxPolicy { Version = string.Empty }; var ex = Assert.Throws(() => MxcSandbox.Spawn(policy, "echo hi")); - Assert.Equal(ErrorCode.MalformedRequest, ex.Code); + Assert.Equal(ErrorCode.VersionIncompatible, ex.Code); Assert.False(string.IsNullOrEmpty(ex.Message)); } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 100da3cc6..b28f34cf8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -17,14 +17,16 @@ public void NativeVersion_IsNotEmpty() } [Fact] - public void Run_MalformedPolicy_ThrowsMalformedRequest() + public void Run_VersionlessPolicy_ThrowsVersionIncompatible() { // A version-less policy is rejected by the native parser before any // sandbox is spawned, so this runs on any host (no host-prep needed). + // `version` selects which config fields are legal, so it surfaces as its + // own typed code rather than the generic MalformedRequest. var policy = new SandboxPolicy { Version = string.Empty }; var ex = Assert.Throws(() => MxcSandbox.Run(policy, "echo hi")); - Assert.Equal(ErrorCode.MalformedRequest, ex.Code); + Assert.Equal(ErrorCode.VersionIncompatible, ex.Code); Assert.False(string.IsNullOrEmpty(ex.Message)); } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs index 6bbde7798..3eca45434 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs @@ -4,7 +4,7 @@ namespace Microsoft.Mxc.Sdk; /// -/// Error codes returned across the native FFI boundary. Values 0–12 mirror the +/// Error codes returned across the native FFI boundary. Values 0–13 mirror the /// Rust MxcErrorCode / mxc_sdk::ErrorCode one-for-one; values 100+ /// are FFI-local conditions with no Rust equivalent. Kept in lockstep with the /// native MXC_STATUS_* constants by a CI drift gate. @@ -50,6 +50,12 @@ public enum ErrorCode /// A generic backend error. BackendError = 12, + /// + /// The config declared an unsupported schema version, or used a field + /// outside the version window it is valid in. + /// + VersionIncompatible = 13, + /// A required pointer argument was null (FFI-local). NullArgument = 100, diff --git a/sdk/node/src/errors.ts b/sdk/node/src/errors.ts index 6f08332cd..f3645d07c 100644 --- a/sdk/node/src/errors.ts +++ b/sdk/node/src/errors.ts @@ -20,7 +20,15 @@ export type ErrorCode = | 'already_started' | 'already_stopped' | 'policy_validation' - | 'backend_error'; + | 'backend_error' + /** + * The config declared an unsupported schema version, or used a field outside + * the version window it is valid in. `details` carries + * `{ field, declaredVersion, since, until }` — `field` is the dotted path of + * the offending field, or `"version"` when the declared version itself is + * outside the supported range. + */ + | 'version_incompatible'; /** * Typed error thrown by the MXC SDK in response to a wire-format error diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 4d18d61ea..e2dbfdb4f 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -320,10 +320,14 @@ export interface ProcessContainer { capabilities?: string[] | null; /** * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability. + * + * Introduced at 0.8. */ captureDenials?: CaptureDenials | null; /** * AppContainer learning mode (deny-and-record): failed access checks are logged for diagnostics while the accesses stay denied; containment is unchanged. Distinct from the allow-all `permissiveLearningMode` capability, which is injected internally by the `--audit` CLI flag or dedicated denial-capture configuration. + * + * Introduced at 0.8. */ learningMode?: boolean | null; /** @@ -558,6 +562,8 @@ export interface MXCConfiguration { sandboxId?: string | null; /** * macOS Seatbelt backend configuration. Used when containment is `seatbelt`. + * + * Introduced at 0.7. The range is on this field, not inside [`Seatbelt`], which is shared with the unconstrained `experimental.seatbelt`. */ seatbelt?: Seatbelt | null; /** @@ -569,4 +575,3 @@ export interface MXCConfiguration { */ version?: string | null; } - diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index e8b6b74ca..601472498 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -114,6 +114,25 @@ function buildLinuxProcessConfig( return config; } +/** + * Whether a policy declaring `version` may carry a top-level `seatbelt` + * section, which was introduced at 0.7 and carries an availability range natively. + * + * The block is a pure marker — `containment` already selects the backend, and + * an absent section behaves identically to `{}` — so below 0.7 it is omitted. + * + * Exported so it stays unit-testable on any host; `buildDarwinProcessConfig` + * only runs on darwin. + */ +export function emitsTopLevelSeatbeltSection(version: string): boolean { + const parsed = semverParse(version); + if (!parsed) { + // validatePolicyVersion owns that diagnostic. + return true; + } + return parsed.major > 0 || parsed.minor >= 7; +} + /** * Builds the macOS process container (seatbelt) portion of a ContainerConfig. * @@ -127,7 +146,9 @@ function buildDarwinProcessConfig( config: ContainerConfig, ): ContainerConfig { config.containment = 'seatbelt'; - config.seatbelt = config.seatbelt ?? {}; + if (config.seatbelt === undefined && emitsTopLevelSeatbeltSection(config.version)) { + config.seatbelt = {}; + } return config; } diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 054346cba..cae155d57 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { buildSandboxPayload, createConfigFromPolicy, spawnSandbox, spawnSandboxFromConfig } from '../../src/sandbox.js'; +import { buildSandboxPayload, createConfigFromPolicy, emitsTopLevelSeatbeltSection, spawnSandbox, spawnSandboxFromConfig } from '../../src/sandbox.js'; import { resolveExecutableAndArgs } from '../../src/helper.js'; import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; import { platformSkip } from './test-helpers.js'; @@ -1198,3 +1198,28 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk }); }); }); + +describe('seatbelt section availability range', () => { + // Regression (review F2): the darwin builder synthesises a top-level + // `seatbelt` block purely as a marker. That block carries a `since: 0.7` + // range natively, so emitting it for a 0.6 policy made the executor reject a + // field the caller never supplied — every 0.6 macOS policy failed. + // + // Asserted through the exported predicate because buildDarwinProcessConfig + // only runs on darwin; testing it only there is what let the bug through. + it('omits the block below 0.7', () => { + assert.strictEqual(emitsTopLevelSeatbeltSection('0.6.0-alpha'), false); + }); + + it('emits the block at and above 0.7', () => { + assert.strictEqual(emitsTopLevelSeatbeltSection('0.7.0-alpha'), true); + assert.strictEqual(emitsTopLevelSeatbeltSection('0.8.0-alpha'), true); + assert.strictEqual(emitsTopLevelSeatbeltSection('1.0.0'), true); + }); + + it('leaves an unparseable version to the version validator', () => { + // validatePolicyVersion owns that diagnostic; this predicate must not add a + // second, differently-worded failure path. + assert.strictEqual(emitsTopLevelSeatbeltSection('nonsense'), true); + }); +}); diff --git a/src/Cargo.lock b/src/Cargo.lock index bd80afa2c..e4eadc9dd 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1549,6 +1549,7 @@ dependencies = [ name = "mxc_schema_gen" version = "0.7.0" dependencies = [ + "serde_json", "wxc_common", ] @@ -1560,6 +1561,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "mxc_version_derive" +version = "0.7.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nanvix_binaries" version = "0.7.0" @@ -3151,6 +3161,7 @@ dependencies = [ "getrandom 0.2.17", "libc", "mxc_telemetry", + "mxc_version_derive", "nanvix_common", "schemars", "semver", diff --git a/src/Cargo.toml b/src/Cargo.toml index 767e5e4c4..571fba590 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -9,6 +9,7 @@ members = [ "core/mxc_engine", "core/mxc_pty", "core/mxc_build_common", + "core/mxc_version_derive", "host/plm", "core/generated/base_container_specification", "core/generated/process_security_environment_specification", @@ -106,7 +107,11 @@ base64 = "0.22" clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } quick-xml = "0.41" +proc-macro2 = "1" +quote = "1" +syn = "2" wxc_common = { path = "core/wxc_common" } +mxc_version_derive = { path = "core/mxc_version_derive" } mxc_engine = { path = "core/mxc_engine" } mxc-sdk = { path = "core/mxc-sdk" } appcontainer_common = { path = "backends/appcontainer/common" } diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 9fde579d2..5800054dc 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -232,13 +232,13 @@ use mxc_sdk::{run_state_aware_json, exec_sandbox}; // Envelope phase: provision returns { "result": { "sandboxId": ... } }. let provisioned = run_state_aware_json( - r#"{"phase":"provision","containment":"isolation_session"}"#, + r#"{"version":"0.6.0-alpha","phase":"provision","containment":"isolation_session"}"#, false, )?; // Exec phase: a live streaming handle. let mut proc = exec_sandbox( - r#"{"phase":"exec","sandboxId":"iso:...","process":{"commandLine":"echo hi"}}"#, + r#"{"version":"0.6.0-alpha","phase":"exec","sandboxId":"iso:...","process":{"commandLine":"echo hi"}}"#, )?; let _ = proc.wait(); # Ok::<(), Box>(()) diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 5fa533ff0..3204705b5 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -152,6 +152,7 @@ pub fn run(request: SandboxRequest) -> Result { sandbox.wait_with_output().map_err(|e| Error { code: ErrorCode::BackendError, message: format!("waiting for the sandbox to complete failed: {e}"), + details: None, }) } diff --git a/src/core/mxc-sdk/tests/sandbox.rs b/src/core/mxc-sdk/tests/sandbox.rs index a89b37103..90de56edd 100644 --- a/src/core/mxc-sdk/tests/sandbox.rs +++ b/src/core/mxc-sdk/tests/sandbox.rs @@ -117,8 +117,8 @@ fn spawn_and_wait(request: SandboxRequest) -> Result #[test] fn version_older_than_supported_is_rejected() { - // Schema version below the supported floor (>=0.4) must be rejected by the - // parser before any backend selection happens. + // Schema version below the supported floor must be rejected by the parser + // before any backend selection happens. let policy = SandboxPolicy { version: "0.3.0-alpha".to_string(), filesystem: None, @@ -130,7 +130,32 @@ fn version_older_than_supported_is_rejected() { let err = build_request(&policy, None).expect_err("an out-of-range schema version must be rejected"); - assert_eq!(err.code, ErrorCode::MalformedRequest); + // Previously this surfaced as the generic `MalformedRequest`, because + // `build_request` wrapped every loader error. Version problems now carry + // their own code and structured details so a caller can act on them without + // parsing the message. + assert_eq!(err.code, ErrorCode::VersionIncompatible); + let details = err.details.expect("a version failure carries details"); + assert_eq!(details["field"], "version"); + assert_eq!(details["declaredVersion"], "0.3.0-alpha"); + assert_eq!(details["since"], "0.6"); + assert_eq!(details["until"], "0.8"); +} + +#[test] +fn missing_version_is_rejected_with_the_version_code() { + // `version` is required: it selects which fields are legal, so an absent one + // must not fall through as "compatible". + let policy = SandboxPolicy { + version: String::new(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + + let err = build_request(&policy, None).expect_err("a missing version must be rejected"); + assert_eq!(err.code, ErrorCode::VersionIncompatible); } #[cfg(target_os = "macos")] diff --git a/src/core/mxc-sdk/tests/sdk_helpers.rs b/src/core/mxc-sdk/tests/sdk_helpers.rs index 8bc8c3bdd..23573ff81 100644 --- a/src/core/mxc-sdk/tests/sdk_helpers.rs +++ b/src/core/mxc-sdk/tests/sdk_helpers.rs @@ -131,7 +131,10 @@ fn build_request_rejects_empty_version() { }; let err = build_request(&policy, None).expect_err("an empty policy version must be rejected"); - assert_eq!(err.code, mxc_sdk::ErrorCode::MalformedRequest); + // A missing version is a version problem, not a generic malformed request: + // `version` selects which config fields are legal, so it reports the same + // typed code as any other version failure. + assert_eq!(err.code, mxc_sdk::ErrorCode::VersionIncompatible); } #[test] diff --git a/src/core/mxc-sdk/tests/state_aware.rs b/src/core/mxc-sdk/tests/state_aware.rs index 7acde69b0..c3869660a 100644 --- a/src/core/mxc-sdk/tests/state_aware.rs +++ b/src/core/mxc-sdk/tests/state_aware.rs @@ -25,7 +25,7 @@ fn run_state_aware_json_rejects_one_shot_config() { fn run_state_aware_json_rejects_non_dry_run_exec() { // A non-dry-run exec streams; it must be routed through exec_sandbox, not // the envelope entry point. - let json = r#"{"phase":"exec","sandboxId":"isolationsession:abc","process":{"commandLine":"echo hi"}}"#; + let json = r#"{"version":"0.6.0-alpha","phase":"exec","sandboxId":"isolationsession:abc","process":{"commandLine":"echo hi"}}"#; let err = run_state_aware_json(json, false).expect_err("non-dry-run exec must be rejected"); assert_eq!(err.code, ErrorCode::MalformedRequest); assert!(err.message.contains("exec")); @@ -39,7 +39,7 @@ fn run_state_aware_json_malformed_json_is_malformed_request() { #[test] fn exec_sandbox_rejects_non_exec_phase() { - let json = r#"{"phase":"provision","containment":"isolation_session"}"#; + let json = r#"{"version":"0.6.0-alpha","phase":"provision","containment":"isolation_session"}"#; // `Sandbox` is not `Debug`, so match rather than `expect_err`. match exec_sandbox(json) { Ok(_) => panic!("a provision request is not an exec"), @@ -66,7 +66,7 @@ fn exec_sandbox_rejects_one_shot_config() { // covered by the host-gated executor E2E suites.) #[test] fn unregistered_backend_prefix_is_unsupported_containment() { - let json = r#"{"phase":"start","sandboxId":"nosuchbackend:abc123"}"#; + let json = r#"{"version":"0.6.0-alpha","phase":"start","sandboxId":"nosuchbackend:abc123"}"#; let err = run_state_aware_json(json, false) .expect_err("an unregistered sandbox-id prefix has no backend"); assert_eq!(err.code, ErrorCode::UnsupportedContainment); diff --git a/src/core/mxc_engine/src/error.rs b/src/core/mxc_engine/src/error.rs index 02a3ffddc..0796b3219 100644 --- a/src/core/mxc_engine/src/error.rs +++ b/src/core/mxc_engine/src/error.rs @@ -22,6 +22,9 @@ pub enum ErrorCode { AlreadyStopped, PolicyValidation, BackendError, + /// The config declared an unsupported schema version, or used a field + /// outside the version window it is valid in. + VersionIncompatible, } impl ErrorCode { @@ -40,6 +43,7 @@ impl ErrorCode { Self::AlreadyStopped => "already_stopped", Self::PolicyValidation => "policy_validation", Self::BackendError => "backend_error", + Self::VersionIncompatible => "version_incompatible", } } } @@ -65,6 +69,7 @@ impl From for ErrorCode { MxcErrorCode::AlreadyStopped => Self::AlreadyStopped, MxcErrorCode::PolicyValidation => Self::PolicyValidation, MxcErrorCode::BackendError => Self::BackendError, + MxcErrorCode::VersionIncompatible => Self::VersionIncompatible, } } } @@ -77,6 +82,9 @@ pub struct Error { pub code: ErrorCode, /// A human-readable message. pub message: String, + /// Structured failure information, so a caller can act without re-parsing + /// `message`. `VersionIncompatible` reports the field and its bounds. + pub details: Option, } impl std::fmt::Display for Error { @@ -92,6 +100,7 @@ impl From for Error { Self { code: error.code.into(), message: error.message, + details: error.details, } } } diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index a2703c67b..3b53df09a 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -19,6 +19,7 @@ use std::path::{Path, PathBuf}; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ExecutionRequest; use wxc_common::mxc_error::MxcError; +use wxc_common::version_availability::MajorMinor; // --------------------------------------------------------------------------- // Filesystem policy discovery @@ -831,10 +832,17 @@ pub fn build_request_with_containment( containment: &Containment, container_name: Option<&str>, ) -> Result { - // The shared parser tolerates an empty schema version (treats it as - // "unset"), but the SDK requires it; reject it here for parity. + // Reject here too, so the SDK reports the same typed failure without + // building a config first. if policy.version.is_empty() { - return Err(MxcError::malformed_request("Policy version is required").into()); + return Err(MxcError::version_incompatible("Policy version is required") + .with_details(serde_json::json!({ + "field": "version", + "declaredVersion": "", + "since": null, + "until": null, + })) + .into()); } let config = build_wire_config(policy, containment, container_name)?; @@ -842,8 +850,11 @@ pub fn build_request_with_containment( // Map the wire config straight to a request — no base64/file round-trip. // The command line is intentionally empty here (the caller fills // `script_code` before running), so tolerate a missing command. + // + // Preserve the loader's typed error: flattening to `malformed_request` + // would deny the code and details to every one-shot SDK/FFI/C# caller. let inner = wxc_common::config_parser::load_request_from_value(config, &mut logger, true) - .map_err(|e| MxcError::malformed_request(format!("failed to build request: {e}")))?; + .map_err(|e| e.to_mxc_error())?; Ok(SandboxRequest { inner }) } @@ -931,6 +942,27 @@ fn proxy_to_wire(proxy: &ProxySpec) -> serde_json::Value { } } +/// Whether a policy declaring `version` may carry a top-level `seatbelt` +/// section, which was introduced at 0.7 and carries an availability range. +/// +/// The block is a pure marker — `containment` already selects the backend, and +/// an absent section behaves identically to `{}` (every field defaults the same +/// either way, including `nestedPty`) — so below 0.7 it is omitted. +/// +/// A free function, not inlined into the `cfg(macos)` arm, so it stays +/// unit-testable on every host. +/// Kept compiled on every host (not just macOS) so the decision stays +/// unit-testable everywhere; only the macOS arm of [`apply_backend`] calls it. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn emits_top_level_seatbelt_section(version: &str) -> bool { + const SEATBELT_SECTION_SINCE: MajorMinor = MajorMinor::new(0, 7); + match MajorMinor::parse_semver(version) { + Some(declared) => declared >= SEATBELT_SECTION_SINCE, + // `validate_schema_version` owns that diagnostic downstream. + None => true, + } +} + /// Apply backend-specific fields, resolving the abstract `Process` intent the /// same way the SDK does (Bubblewrap on Linux, Seatbelt on macOS, /// ProcessContainer on Windows — which itself resolves to BaseContainer or @@ -953,9 +985,9 @@ fn apply_host_process_backend( #[cfg(target_os = "macos")] { - let _ = (policy, container_id); + let _ = container_id; config["containment"] = json!("seatbelt"); - if config.get("seatbelt").is_none() { + if config.get("seatbelt").is_none() && emits_top_level_seatbelt_section(&policy.version) { config["seatbelt"] = json!({}); } } @@ -1144,7 +1176,8 @@ mod tests { } use super::{ - build_request, CaptureDenialsMode, CaptureDenialsSection, NetworkSection, SandboxPolicy, + build_request, emits_top_level_seatbelt_section, CaptureDenialsMode, CaptureDenialsSection, + NetworkSection, SandboxPolicy, }; use wxc_common::wire; @@ -1159,6 +1192,85 @@ mod tests { } } + #[test] + fn seatbelt_section_is_omitted_below_its_version_availability() { + // Regression (review F2): the macOS builder synthesises a top-level + // `seatbelt` block purely as a marker. That block carries a + // `since = "0.7"` range, so emitting it for a 0.6 policy made the + // parser reject a field the caller never supplied — every 0.6 macOS + // policy built by this crate or the TypeScript SDK failed. + // + // Tested through the pure predicate rather than `apply_backend`, whose + // macOS arm is compiled out on other hosts — which is precisely why the + // bug survived a full green run on Windows. + assert!(!emits_top_level_seatbelt_section("0.6.0-alpha")); + assert!(emits_top_level_seatbelt_section("0.7.0-alpha")); + assert!(emits_top_level_seatbelt_section("0.8.0-alpha")); + assert!(emits_top_level_seatbelt_section("1.0.0")); + // Omitting is safe only because absent behaves exactly like `{}`: every + // Seatbelt field defaults identically either way. + } + + #[test] + fn unparseable_version_does_not_change_seatbelt_emission() { + // The loader owns the "not valid semver" diagnostic; this predicate must + // not introduce a second, differently-worded failure path. + assert!(emits_top_level_seatbelt_section("nonsense")); + assert!(emits_top_level_seatbelt_section("")); + } + + #[test] + fn build_request_preserves_the_version_incompatible_code_and_details() { + // Regression (review F3): `build_request` used to wrap every loader + // error as `malformed_request`, so one-shot Rust SDK / FFI / C# callers + // could never observe the code this change introduced. + let policy = SandboxPolicy { + version: "0.3.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + let err = build_request(&policy, None).expect_err("0.3 is below the supported floor"); + assert_eq!(err.code, crate::ErrorCode::VersionIncompatible); + let details = err.details.expect("a version failure carries details"); + assert_eq!(details["field"], "version"); + assert_eq!(details["declaredVersion"], "0.3.0-alpha"); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_zero_six_policy_builds_without_a_seatbelt_section() { + // The end-to-end form of F2, on the only host where the macOS arm of + // `apply_backend` is compiled in. + let policy = SandboxPolicy { + version: "0.6.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + let request = build_request(&policy, None).expect("a 0.6 macOS policy must still build"); + assert!( + request.inner.seatbelt.is_none(), + "no seatbelt section is emitted below 0.7" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_zero_seven_policy_still_carries_a_seatbelt_section() { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + let request = build_request(&policy, None).expect("build_request"); + assert!(request.inner.seatbelt.is_some()); + } + // Mirror the TypeScript SDK by accepting `allowedHosts` with or without // `allowOutbound`, even though Seatbelt cannot enforce the host list. #[cfg(target_os = "macos")] diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 1c7f33fd6..ceb7f50bd 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -110,9 +110,8 @@ fn parse_error_to_mxc(e: wxc_common::config_parser::ParseError) -> MxcError { use wxc_common::config_parser::ParseError; match e { ParseError::StateAware(err) => err, - ParseError::Decode(err) | ParseError::OneShot(err) => { - MxcError::malformed_request(err.to_string()) - } + // `to_mxc_error` keeps a version incompatibility's code and details. + ParseError::Decode(err) | ParseError::OneShot(err) => err.to_mxc_error(), } } diff --git a/src/core/mxc_version_derive/Cargo.toml b/src/core/mxc_version_derive/Cargo.toml new file mode 100644 index 000000000..83e4f3a37 --- /dev/null +++ b/src/core/mxc_version_derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mxc_version_derive" +version.workspace = true +edition.workspace = true +license.workspace = true + +# Derive macro that lifts per-field schema-version windows out of the wire model +# so the parser and the schema generator read the same declaration. See +# `wxc_common::version_availability` for the runtime side. +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } diff --git a/src/core/mxc_version_derive/src/lib.rs b/src/core/mxc_version_derive/src/lib.rs new file mode 100644 index 000000000..85504c527 --- /dev/null +++ b/src/core/mxc_version_derive/src/lib.rs @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `#[derive(VersionAvailability)]` — lifts per-field schema-availability ranges out of +//! the wire model so the config parser and the JSON-Schema generator read the +//! same declaration. +//! +//! A derive rather than `#[schemars(extend(...))]`, because the schemars +//! attributes sit behind the `schema-gen` feature and so can never be consulted +//! by the parser — an annotation nothing enforces is documentation, not a +//! contract. +//! +//! ```ignore +//! #[derive(Serialize, Deserialize, VersionAvailability)] +//! #[serde(rename_all = "camelCase", deny_unknown_fields)] +//! pub struct Network { +//! pub default_policy: Option, // valid everywhere +//! #[mxc_version(since = "0.8")] +//! pub egress: Option, +//! #[mxc_version(until = "0.7")] +//! pub allowed_hosts: Option>, +//! } +//! ``` +//! +//! Anything this macro cannot model exactly is a compile error, never a silent +//! approximation: a derived name that disagrees with what serde accepts would +//! fail *open*, since the availability range is enforced by matching that name against a +//! JSON key. Hence the rejections of `flatten`, split `rename`/`rename_all`, +//! unknown `rename_all` rules, data-carrying variants and malformed literals. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::punctuated::Punctuated; +use syn::{ + Attribute, Data, DeriveInput, Error, Expr, ExprLit, Fields, Lit, Meta, Result, Token, Type, +}; + +/// Derives `wxc_common::version_availability::VersionAvailability`. +/// +/// Structs emit a node describing every deserialisable field. Enums are leaves: +/// an availability range constrains where a *field* may appear, not which values it may take. +#[proc_macro_derive(VersionAvailability, attributes(mxc_version))] +pub fn derive_version_availabilitys(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + expand(input) + .unwrap_or_else(Error::into_compile_error) + .into() +} + +fn expand(input: DeriveInput) -> Result { + let ident = &input.ident; + let type_name = ident.to_string(); + + // `static NODE` cannot depend on a type parameter, and no wire type is generic. + if !input.generics.params.is_empty() { + return Err(Error::new_spanned( + &input.generics, + "VersionAvailability does not support generic types; the wire model is concrete", + )); + } + + let body = match &input.data { + Data::Struct(data) => { + let rename_all = container_rename_all(&input.attrs)?; + let fields = match &data.fields { + Fields::Named(named) => &named.named, + Fields::Unit => { + return Err(Error::new_spanned( + ident, + "VersionAvailability requires a struct with named fields", + )) + } + Fields::Unnamed(unnamed) => { + return Err(Error::new_spanned( + unnamed, + "VersionAvailability does not support tuple structs; \ + a wire field needs a name to key its availability range on", + )) + } + }; + + let mut entries = Vec::new(); + for field in fields { + let Some(field_ident) = field.ident.as_ref() else { + continue; + }; + let attrs = FieldAttrs::parse(&field.attrs)?; + if attrs.skipped { + // Not deserialisable, so an availability range on it could never fire. + if attrs.availability.is_annotated() { + return Err(Error::new_spanned( + field_ident, + "#[mxc_version] on a #[serde(skip)] field can never fire: \ + the field is not deserialisable", + )); + } + continue; + } + + let rust_name = field_ident.to_string(); + let json_name = attrs + .rename + .clone() + .unwrap_or_else(|| rename_all.apply_to_field(&rust_name)); + let aliases = &attrs.aliases; + let availability = attrs.availability.to_tokens(); + let ty = strip_leading_underscore_type(&field.ty); + + entries.push(quote! { + ::wxc_common::version_availability::FieldAvailability { + rust_name: #rust_name, + name: #json_name, + aliases: &[#(#aliases),*], + availability: #availability, + nested: <#ty as ::wxc_common::version_availability::VersionAvailability>::availability, + } + }); + } + + quote! { + static NODE: ::wxc_common::version_availability::NodeAvailability = + ::wxc_common::version_availability::NodeAvailability { + type_name: #type_name, + fields: &[#(#entries),*], + }; + ::core::option::Option::Some(&NODE) + } + } + Data::Enum(data) => { + for variant in &data.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(Error::new_spanned( + variant, + "VersionAvailability only supports data-less enum variants; \ + a variant carrying data has inner fields that would need their own ranges", + )); + } + if FieldAttrs::parse(&variant.attrs)? + .availability + .is_annotated() + { + return Err(Error::new_spanned( + variant, + "#[mxc_version] cannot be applied to an enum variant: an availability range \ + governs where a field may appear, not which values it may take. \ + Narrowing a value set is an ordinary schema restriction.", + )); + } + } + quote! { ::core::option::Option::None } + } + Data::Union(data) => { + return Err(Error::new_spanned( + data.union_token, + "VersionAvailability does not support unions", + )) + } + }; + + Ok(quote! { + #[automatically_derived] + impl ::wxc_common::version_availability::VersionAvailability for #ident { + fn availability() -> ::core::option::Option<&'static ::wxc_common::version_availability::NodeAvailability> { + #body + } + } + }) +} + +/// Field types are used verbatim. +fn strip_leading_underscore_type(ty: &Type) -> &Type { + ty +} + +// --------------------------------------------------------------------------- +// Attribute parsing +// --------------------------------------------------------------------------- + +/// Mirrors `serde_derive`'s `RenameRule` for the field case. Any rule this does +/// not model is a compile error rather than a guess. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RenameAll { + None, + Lower, + Upper, + Pascal, + Camel, + Snake, + ScreamingSnake, + Kebab, + ScreamingKebab, +} + +impl RenameAll { + fn from_str(value: &str) -> Option { + Some(match value { + "lowercase" => Self::Lower, + "UPPERCASE" => Self::Upper, + "PascalCase" => Self::Pascal, + "camelCase" => Self::Camel, + "snake_case" => Self::Snake, + "SCREAMING_SNAKE_CASE" => Self::ScreamingSnake, + "kebab-case" => Self::Kebab, + "SCREAMING-KEBAB-CASE" => Self::ScreamingKebab, + _ => return None, + }) + } + + /// Replicates `RenameRule::apply_to_field`. Field names are already + /// `snake_case`, hence `lowercase`/`snake_case` being identity. + fn apply_to_field(self, field: &str) -> String { + match self { + Self::None | Self::Lower | Self::Snake => field.to_owned(), + Self::Upper | Self::ScreamingSnake => field.to_ascii_uppercase(), + Self::Pascal => pascal_case(field), + Self::Camel => { + let pascal = pascal_case(field); + let mut chars = pascal.chars(); + match chars.next() { + Some(first) => first.to_ascii_lowercase().to_string() + chars.as_str(), + None => pascal, + } + } + Self::Kebab => field.replace('_', "-"), + Self::ScreamingKebab => field.to_ascii_uppercase().replace('_', "-"), + } + } +} + +fn pascal_case(field: &str) -> String { + let mut out = String::with_capacity(field.len()); + let mut capitalize = true; + for ch in field.chars() { + if ch == '_' { + capitalize = true; + } else if capitalize { + out.push(ch.to_ascii_uppercase()); + capitalize = false; + } else { + out.push(ch); + } + } + out +} + +fn container_rename_all(attrs: &[Attribute]) -> Result { + let mut rule = RenameAll::None; + for meta in serde_metas(attrs)? { + match &meta { + // The range is keyed on the deserialised name, so ignoring the split + // form would derive the wrong key and leave the availability range unreachable. + Meta::List(list) if list.path.is_ident("rename_all") => { + return Err(Error::new_spanned( + list, + "VersionAvailability does not support the \ + `rename_all(serialize = ..., deserialize = ...)` form; the availability range is keyed \ + on the deserialised name, so the split form would need an explicit choice", + )); + } + Meta::NameValue(nv) if nv.path.is_ident("rename_all") => { + let value = string_literal(&nv.value, "serde(rename_all)")?; + rule = RenameAll::from_str(&value).ok_or_else(|| { + Error::new_spanned( + &nv.value, + format!( + "VersionAvailability does not model the `{value}` rename_all rule; \ + add it to RenameAll (mirroring serde_derive) rather than letting \ + the derived JSON name silently disagree with serde" + ), + ) + })?; + } + _ => {} + } + } + Ok(rule) +} + +#[derive(Default)] +struct AvailabilityAttr { + since: Option<(u64, u64, proc_macro2::Span)>, + until: Option<(u64, u64, proc_macro2::Span)>, +} + +impl AvailabilityAttr { + fn is_annotated(&self) -> bool { + self.since.is_some() || self.until.is_some() + } + + fn to_tokens(&self) -> TokenStream2 { + let since = bound_tokens(self.since); + let until = bound_tokens(self.until); + quote! { + ::wxc_common::version_availability::Availability { since: #since, until: #until } + } + } +} + +fn bound_tokens(bound: Option<(u64, u64, proc_macro2::Span)>) -> TokenStream2 { + match bound { + Some((major, minor, _)) => quote! { + ::core::option::Option::Some( + ::wxc_common::version_availability::MajorMinor { major: #major, minor: #minor } + ) + }, + None => quote! { ::core::option::Option::None }, + } +} + +#[derive(Default)] +struct FieldAttrs { + rename: Option, + aliases: Vec, + skipped: bool, + availability: AvailabilityAttr, +} + +impl FieldAttrs { + fn parse(attrs: &[Attribute]) -> Result { + let mut out = FieldAttrs::default(); + + for meta in serde_metas(attrs)? { + match &meta { + Meta::Path(path) => { + if path.is_ident("skip") || path.is_ident("skip_deserializing") { + out.skipped = true; + } + if path.is_ident("flatten") { + return Err(Error::new_spanned( + path, + "VersionAvailability does not support #[serde(flatten)]: a flattened \ + field's keys appear at the parent level, so they cannot be keyed \ + by this field's name", + )); + } + } + Meta::NameValue(nv) => { + if nv.path.is_ident("rename") { + out.rename = Some(string_literal(&nv.value, "serde(rename)")?); + } else if nv.path.is_ident("alias") { + out.aliases.push(string_literal(&nv.value, "serde(alias)")?); + } + } + Meta::List(list) => { + if list.path.is_ident("rename") { + return Err(Error::new_spanned( + list, + "VersionAvailability does not support the \ + `rename(serialize = ..., deserialize = ...)` form; the availability range is \ + keyed on the deserialised name, so the split form would need an \ + explicit choice", + )); + } + } + } + } + + for attr in attrs { + if !attr.path().is_ident("mxc_version") { + continue; + } + let nested = attr.parse_args_with(Punctuated::::parse_terminated)?; + if nested.is_empty() { + return Err(Error::new_spanned( + attr, + "#[mxc_version] requires at least one of `since = \"X.Y\"` or `until = \"X.Y\"`", + )); + } + for meta in nested { + let Meta::NameValue(nv) = &meta else { + return Err(Error::new_spanned( + &meta, + "expected `since = \"X.Y\"` or `until = \"X.Y\"`", + )); + }; + let is_since = nv.path.is_ident("since"); + let is_until = nv.path.is_ident("until"); + if !is_since && !is_until { + return Err(Error::new_spanned( + &nv.path, + "unknown #[mxc_version] key; expected `since` or `until`", + )); + } + let literal = string_literal(&nv.value, "mxc_version")?; + let parsed = parse_major_minor(&literal).ok_or_else(|| { + Error::new_spanned( + &nv.value, + format!( + "`{literal}` is not a major.minor schema version (e.g. \"0.8\"). \ + Availability ranges compare major.minor only, matching the parser's \ + supported-range check, so a patch or pre-release component \ + would be misleading." + ), + ) + })?; + let span = proc_macro2::Span::call_site(); + let slot = if is_since { + &mut out.availability.since + } else { + &mut out.availability.until + }; + if slot.is_some() { + return Err(Error::new_spanned(&nv.path, "duplicate #[mxc_version] key")); + } + *slot = Some((parsed.0, parsed.1, span)); + } + } + + if let (Some(since), Some(until)) = (out.availability.since, out.availability.until) { + if (since.0, since.1) > (until.0, until.1) { + return Err(Error::new_spanned( + attrs + .iter() + .find(|a| a.path().is_ident("mxc_version")) + .expect("an availability range was parsed, so the attribute exists"), + format!( + "empty availability range: since {}.{} is newer than until {}.{}, \ + so the field could never be used", + since.0, since.1, until.0, until.1 + ), + )); + } + } + + Ok(out) + } +} + +/// Flattens every `#[serde(...)]` attribute into its entries. Parsing into +/// [`Meta`] lets unrecognised serde options be consumed and ignored. +fn serde_metas(attrs: &[Attribute]) -> Result> { + let mut out = Vec::new(); + for attr in attrs { + if !attr.path().is_ident("serde") { + continue; + } + let nested = attr.parse_args_with(Punctuated::::parse_terminated)?; + out.extend(nested); + } + Ok(out) +} + +fn string_literal(expr: &Expr, context: &str) -> Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Ok(s.value()), + other => Err(Error::new_spanned( + other, + format!("expected a string literal for {context}"), + )), + } +} + +fn parse_major_minor(value: &str) -> Option<(u64, u64)> { + let (major, minor) = value.split_once('.')?; + if major.is_empty() || minor.is_empty() { + return None; + } + // Reject leading zeros and non-digits so "0.08" / "0.8.0-alpha" cannot pass. + for part in [major, minor] { + if !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + if part.len() > 1 && part.starts_with('0') { + return None; + } + } + Some((major.parse().ok()?, minor.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn camel_case_matches_serde_for_wire_field_names() { + let camel = RenameAll::Camel; + assert_eq!(camel.apply_to_field("command_line"), "commandLine"); + assert_eq!(camel.apply_to_field("cwd"), "cwd"); + assert_eq!(camel.apply_to_field("memory_mb"), "memoryMb"); + assert_eq!(camel.apply_to_field("image_tar_path"), "imageTarPath"); + assert_eq!(camel.apply_to_field("wam_token"), "wamToken"); + // serde capitalises the first *letter*, so a leading underscore is + // dropped rather than yielding "Comment". + assert_eq!(camel.apply_to_field("_comment"), "comment"); + } + + #[test] + fn other_rules_match_serde() { + assert_eq!( + RenameAll::None.apply_to_field("command_line"), + "command_line" + ); + assert_eq!( + RenameAll::Snake.apply_to_field("command_line"), + "command_line" + ); + assert_eq!( + RenameAll::Lower.apply_to_field("command_line"), + "command_line" + ); + assert_eq!( + RenameAll::Pascal.apply_to_field("command_line"), + "CommandLine" + ); + assert_eq!( + RenameAll::Kebab.apply_to_field("command_line"), + "command-line" + ); + assert_eq!( + RenameAll::ScreamingSnake.apply_to_field("command_line"), + "COMMAND_LINE" + ); + assert_eq!( + RenameAll::ScreamingKebab.apply_to_field("command_line"), + "COMMAND-LINE" + ); + assert_eq!(RenameAll::Upper.apply_to_field("cwd"), "CWD"); + } + + #[test] + fn unknown_rename_all_is_rejected() { + assert!(RenameAll::from_str("camelCase").is_some()); + assert!(RenameAll::from_str("weirdCase").is_none()); + } + + #[test] + fn major_minor_parsing_is_strict() { + assert_eq!(parse_major_minor("0.8"), Some((0, 8))); + assert_eq!(parse_major_minor("1.12"), Some((1, 12))); + assert_eq!(parse_major_minor("0.0"), Some((0, 0))); + // Availability ranges compare major.minor only; a full SemVer would imply precision + // this does not honour. + assert_eq!(parse_major_minor("0.8.0"), None); + assert_eq!(parse_major_minor("0.8.0-alpha"), None); + assert_eq!(parse_major_minor("0.08"), None); + assert_eq!(parse_major_minor("08.1"), None); + assert_eq!(parse_major_minor("0"), None); + assert_eq!(parse_major_minor(""), None); + assert_eq!(parse_major_minor("0."), None); + assert_eq!(parse_major_minor(".8"), None); + assert_eq!(parse_major_minor("x.y"), None); + } +} diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index ccac609b5..6d453c33e 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -1596,7 +1596,9 @@ mod tests { #[test] fn cli_captures_command_after_config_base64() { - let encoded = encoded_policy(r#"{ "process": { "commandLine": "policy" } }"#); + let encoded = encoded_policy( + r#"{ "version": "0.8.0-alpha", "process": { "commandLine": "policy" } }"#, + ); let cli = parse_cli(&[ "wxc-exec", "--config-base64", @@ -1671,6 +1673,7 @@ mod tests { command_override_from_cli(&cli, CommandLineContext::WindowsCreateProcess).unwrap(); let mut logger = test_logger(); let policy = r#"{ + "version": "0.8.0-alpha", "process": { "commandLine": "policy-app.exe --from-policy", "cwd": "C:\\workspace" @@ -1721,12 +1724,14 @@ mod tests { let mut policy_logger = test_logger(); let mut cli_logger = test_logger(); let policy = r#"{ + "version": "0.8.0-alpha", "process": { "commandLine": "cli-app.exe --message \"hello world\"", "cwd": "C:\\workspace" } }"#; let cli_policy = r#"{ + "version": "0.8.0-alpha", "process": { "cwd": "C:\\workspace" } diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index 339674f13..c834b8bc4 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -21,6 +21,7 @@ url = { workspace = true } getrandom = { workspace = true } semver = "1" schemars = { version = "0.8", optional = true } +mxc_version_derive = { workspace = true } nanvix_common = { path = "../../backends/nanvix/common", optional = true } uuid = { workspace = true, optional = true } mxc_telemetry = { workspace = true } diff --git a/src/core/wxc_common/src/config_deserialize.rs b/src/core/wxc_common/src/config_deserialize.rs index 56e0ae97e..5a788b5c8 100644 --- a/src/core/wxc_common/src/config_deserialize.rs +++ b/src/core/wxc_common/src/config_deserialize.rs @@ -3,7 +3,7 @@ use std::fmt; -use serde::{de::DeserializeOwned, Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer}; use serde_json::{error::Category, Value}; use unicode_general_category::{get_general_category, GeneralCategory}; @@ -323,13 +323,11 @@ where Ok(value) } -pub(crate) fn from_value(value: Value) -> Result -where - T: DeserializeOwned, -{ - deserialize_with_path(value) -} - +/// Deserialise from a borrowed [`Value`]. +/// +/// There is deliberately no owned `from_value` counterpart: the version gate +/// needs the document *after* the typed pass, so every caller keeps ownership of +/// the value and lends it here. pub(crate) fn from_value_ref<'de, T>(value: &'de Value) -> Result where T: Deserialize<'de>, @@ -474,9 +472,9 @@ mod tests { } #[test] - fn from_value_reports_the_typed_error_path() { + fn from_value_ref_reports_the_typed_error_path() { let value = serde_json::json!({"inner": {"count": "many"}}); - let error = from_value::(value).unwrap_err(); + let error = from_value_ref::(&value).unwrap_err(); assert_eq!(error.path.as_deref(), Some("inner.count")); assert!(error.to_string().contains("expected u16")); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index a51dba6df..2ea6cb825 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -15,6 +15,9 @@ use crate::models::{ }; use crate::mxc_error::MxcError; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use crate::version_availability::{ + validate_document, MajorMinor, VersionAvailability, VersionIncompatibility, +}; use crate::wire; use serde::{Deserialize, Deserializer}; use serde_json::value::RawValue; @@ -122,8 +125,7 @@ pub fn load_request_with_options( let result = (|| { let json_str = decode_request_input_without_logging(input, opts.is_base64)?; - let cfg: wire::MxcConfig = config_deserialize::from_str(&json_str) - .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + let cfg = deserialize_and_gate_str(&json_str)?; convert_wire_config(cfg, logger, true, opts.allow_missing_command) })(); @@ -144,14 +146,35 @@ pub fn load_request_from_value( allow_missing_command: bool, ) -> Result { let result = (|| { - let cfg: wire::MxcConfig = config_deserialize::from_value(config) - .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + let cfg = deserialize_and_gate_value(config)?; convert_wire_config(cfg, logger, true, allow_missing_command) })(); log_one_shot_error(logger, &result); result } + +/// Deserialise a one-shot config from JSON text and run the version gate. +/// +/// Typed deserialisation runs first so a malformed config keeps its precise +/// path and source position. +fn deserialize_and_gate_str(json_str: &str) -> Result { + let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + // Infallible: the typed pass above already parsed this text as JSON. + let document: serde_json::Value = + serde_json::from_str(json_str).map_err(|error| WxcError::ConfigParse(error.to_string()))?; + gate_version(&document, cfg.version.as_deref())?; + Ok(cfg) +} + +/// As above, for in-process callers (the Rust SDK) that already hold a value. +fn deserialize_and_gate_value(config: serde_json::Value) -> Result { + let cfg: wire::MxcConfig = config_deserialize::from_value_ref(&config) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + gate_version(&config, cfg.version.as_deref())?; + Ok(cfg) +} /// driver can pick the right output convention per path (envelope on stdout /// for state-aware, diagnostic on stderr for one-shot and pre-discrimination /// failures). @@ -227,10 +250,9 @@ fn parse_mxc_request_json( allow_missing_command, ) .map(MxcRequest::StateAware) - .map_err(|e| ParseError::StateAware(MxcError::malformed_request(e.to_string()))) + .map_err(|e| ParseError::StateAware(e.to_mxc_error())) } else { - let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) - .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; + let cfg = deserialize_and_gate_str(json_str).map_err(ParseError::OneShot)?; convert_wire_config(cfg, logger, true, allow_missing_command) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot) @@ -283,6 +305,11 @@ fn decode_request_input_without_logging(input: &str, is_base64: bool) -> Result< /// Maximum supported schema version (major.minor). Configs with a higher major.minor are rejected. const SUPPORTED_VERSION: &str = ">=0.6, <=0.8"; +/// Comparable form of [`SUPPORTED_VERSION`], which the CI gate parses +/// textually. Pinned to it by `supported_bounds_match_the_range_string`. +const MIN_SUPPORTED: MajorMinor = MajorMinor::new(0, 6); +const MAX_SUPPORTED: MajorMinor = MajorMinor::new(0, 8); + /// Canonical "latest" schema version string used in samples and tests. Bump /// alongside `SUPPORTED_VERSION`'s upper bound when a new dev schema lands. #[cfg(test)] @@ -294,43 +321,78 @@ const CURRENT_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// section or graduating one from experimental. const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isolation_session"]; -/// Validate that the schema version (semver) is supported by this binary. -/// Compares major.minor only — patch and pre-release labels are ignored. -fn validate_schema_version(version: &str) -> Result<(), WxcError> { - if version.is_empty() { - return Ok(()); - } +/// Validate that the schema version is present and supported. +/// +/// `version` is required: it selects which fields are legal, so an absent one +/// would silently opt out of every range. Patch and pre-release are ignored. +fn validate_schema_version(version: Option<&str>) -> Result { + let Some(version) = version.filter(|v| !v.is_empty()) else { + return Err(WxcError::VersionIncompatible(Box::new( + VersionIncompatibility { + field: "version".to_string(), + declared_version: String::new(), + since: Some(MIN_SUPPORTED.to_string()), + until: Some(MAX_SUPPORTED.to_string()), + message: format!( + "Missing required field: version. Declare the config schema version \ + (supported: {SUPPORTED_VERSION}); it selects which fields are valid." + ), + }, + ))); + }; // Parse the version, stripping pre-release suffix for comparison - // (e.g., "0.4.0-alpha" is treated as "0.4.0") - let parsed = semver::Version::parse(version).map_err(|_| { + // Invalid SemVer is a malformed field, not an unsupported version. + let declared = MajorMinor::parse_semver(version).ok_or_else(|| { WxcError::ConfigParse(format!( "Invalid schema version '{}': must be semver (e.g., 'X.Y.Z' or 'X.Y.Z-alpha')", config_deserialize::escape_diagnostic_text(version) )) })?; - let req = semver::VersionReq::parse(SUPPORTED_VERSION).unwrap(); - - // semver crate treats pre-release as lower precedence, so we compare - // against a version without the pre-release label for major.minor check. - let comparable = semver::Version::new(parsed.major, parsed.minor, parsed.patch); - if !req.matches(&comparable) { - let min = semver::VersionReq::parse(">=0.6").unwrap(); + if declared < MIN_SUPPORTED || declared > MAX_SUPPORTED { let safe_version = config_deserialize::escape_diagnostic_text(version); - let msg = if !min.matches(&comparable) { + let message = if declared < MIN_SUPPORTED { format!( - "Config schema version '{}' is older than supported (supported: {}). Update your config.", - safe_version, SUPPORTED_VERSION + "Config schema version '{safe_version}' is older than supported \ + (supported: {SUPPORTED_VERSION}). Update your config." ) } else { format!( - "Config schema version '{}' is newer than supported (supported: {}). Upgrade wxc-exec.", - safe_version, SUPPORTED_VERSION + "Config schema version '{safe_version}' is newer than supported \ + (supported: {SUPPORTED_VERSION}). Upgrade wxc-exec." ) }; - return Err(WxcError::ConfigParse(msg)); + return Err(WxcError::VersionIncompatible(Box::new( + VersionIncompatibility { + field: "version".to_string(), + declared_version: safe_version, + since: Some(MIN_SUPPORTED.to_string()), + until: Some(MAX_SUPPORTED.to_string()), + message, + }, + ))); } + Ok(declared) +} + +/// Run the version gate over a freshly deserialised config document. +/// +/// Must run immediately after deserialisation: [`convert_wire_config`] moves +/// fields out of `cfg`, after which the document cannot be checked as a whole. +/// The supported-range check runs first, so a range violation is only reported +/// for a supported version. +fn gate_version(document: &serde_json::Value, version: Option<&str>) -> Result<(), WxcError> { + let declared = validate_schema_version(version)?; + let Some(root) = wire::MxcConfig::availability() else { + // Unreachable (`MxcConfig` derives it); fail closed rather than skip. + return Err(WxcError::ConfigParse( + "internal error: the wire model exposes no version-availability metadata".to_string(), + )); + }; + // Untrusted input echoed into diagnostics; escape before use. + let safe_version = config_deserialize::escape_diagnostic_text(version.unwrap_or_default()); + validate_document(document, declared, &safe_version, root)?; Ok(()) } @@ -746,11 +808,16 @@ fn convert_wire_config( // Backend sections present in the config (captured before fields move out). let present_backend_sections = present_backend_sections(&cfg); + // Belt-and-braces: the entry-point gate enforces ranges against the raw + // document, but this keeps "always carries a supported version" true for + // any future caller that bypasses it. + let declared = validate_schema_version(cfg.version.as_deref())?; + debug_assert!( + declared >= MIN_SUPPORTED && declared <= MAX_SUPPORTED, + "validate_schema_version returns only in-range versions" + ); let schema_version = cfg.version.unwrap_or_default(); - // Validate the schema version up front so an unsupported version fails fast. - validate_schema_version(&schema_version)?; - let container_id = cfg.container_id.unwrap_or_default(); // Process section: required for one-shot and state-aware exec; optional for @@ -1312,6 +1379,13 @@ fn convert_wire_state_aware( let mut cfg: wire::MxcConfig = config_deserialize::from_str(&base_json) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + // Gate the ORIGINAL document, not the experimental-masked copy: nothing + // under `experimental` is annotated today, but masking would silently make + // such a range unenforceable if one were added. + let document: serde_json::Value = + serde_json::from_str(json).map_err(|error| WxcError::ConfigParse(error.to_string()))?; + gate_version(&document, cfg.version.as_deref())?; + // `phase` is the state-aware discriminator and is constrained by the wire // enum; absence here would be a logic error in the caller's discrimination. let phase = match cfg.phase.take() { @@ -1532,7 +1606,7 @@ mod tests { // No process.commandLine in the policy — without the flag this would // be a parse error; with allow_missing_command set the parser yields // an empty script_code for the driver to fill in. - let json = r#"{"process": {"cwd": "C:\\tmp"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"cwd": "C:\\tmp"}}"#; let opts = LoadOptions { is_base64: true, allow_missing_command: true, @@ -1548,7 +1622,7 @@ mod tests { #[test] fn allow_missing_command_lets_one_shot_skip_process_block_entirely() { - let json = r#"{"containment": "processcontainer"}"#; + let json = r#"{"version": "0.8.0-alpha", "containment": "processcontainer"}"#; let opts = LoadOptions { is_base64: true, allow_missing_command: true, @@ -1561,7 +1635,7 @@ mod tests { #[test] fn allow_missing_command_lets_state_aware_exec_skip_command_line() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "exec", "sandboxId": "iso:abcd1234", "process": {"cwd": "C:\\tmp"} @@ -1583,7 +1657,7 @@ mod tests { fn default_options_still_reject_missing_command_line() { // Sanity: without the flag, the legacy contract holds — missing // commandLine is a hard parse error. - let json = r#"{"process": {"cwd": "C:\\tmp"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"cwd": "C:\\tmp"}}"#; let opts = LoadOptions { is_base64: true, allow_missing_command: false, @@ -1593,7 +1667,7 @@ mod tests { #[test] fn one_shot_routes_via_load_mxc_request() { - let json = r#"{"process": {"commandLine": "echo hello"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}}"#; match load_mxc(json).unwrap() { MxcRequest::OneShot(req) => assert_eq!(req.script_code, "echo hello"), MxcRequest::StateAware(_) => panic!("expected one-shot"), @@ -1602,7 +1676,7 @@ mod tests { #[test] fn state_aware_provision_request_routes_to_state_aware_arm() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "filesystem": {"readwritePaths": ["C:\\workspace"]} @@ -1623,7 +1697,7 @@ mod tests { #[test] fn state_aware_start_request_carries_sandbox_id_and_experimental() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "start", "sandboxId": "iso:abcd1234", "experimental": { @@ -1655,7 +1729,7 @@ mod tests { // populate the typed `experimental.telemetry` field (consumed the same // way as one-shot) while leaving the per-backend `experimental_raw` // block intact for dispatch. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": {"telemetry": {"enabled": true}} @@ -1677,7 +1751,7 @@ mod tests { #[test] fn state_aware_without_telemetry_leaves_typed_field_unset() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "start", "sandboxId": "iso:abcd1234", "experimental": {"isolation_session": {"start": {"configurationId": "small"}}} @@ -1692,7 +1766,7 @@ mod tests { fn state_aware_malformed_telemetry_is_rejected() { // A present-but-malformed telemetry block is a client error rejected at // parse time (surfaced as a state-aware envelope), not a silent disable. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": {"telemetry": 42} @@ -1707,7 +1781,7 @@ mod tests { // diagnostic sink exactly once (routed centrally by the outer // `load_mxc_request` wrapper), never duplicated, and must never touch the // primary buffer/stdout that the state-aware JSON envelope owns. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": {"telemetry": 42} @@ -1746,7 +1820,7 @@ mod tests { // path peels `experimental` off before typed deserialize, so it must // reject a non-object value explicitly to stay consistent rather than // silently ignoring it. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "start", "sandboxId": "iso:abcd1234", "experimental": 42 @@ -1759,7 +1833,7 @@ mod tests { fn state_aware_null_experimental_is_accepted() { // `null` maps to "absent" on both the one-shot and state-aware paths, so // it is accepted (leaving telemetry unset), unlike a non-object value. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "start", "sandboxId": "iso:abcd1234", "experimental": null @@ -1772,7 +1846,7 @@ mod tests { #[test] fn state_aware_exec_request_requires_command_line() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "exec", "sandboxId": "iso:abcd1234", "process": {"commandLine": "echo hello"} @@ -1789,14 +1863,14 @@ mod tests { #[test] fn state_aware_exec_without_process_is_rejected() { // Exec phase still requires the process.commandLine wire field. - let json = r#"{ "phase": "exec", "sandboxId": "iso:abcd1234" }"#; + let json = r#"{"version": "0.8.0-alpha", "phase": "exec", "sandboxId": "iso:abcd1234" }"#; let r = load_mxc(json); assert!(matches!(r, Err(ParseError::StateAware(_))), "got {:?}", r); } #[test] fn state_aware_unknown_phase_is_rejected() { - let json = r#"{"phase": "teleport"}"#; + let json = r#"{"version": "0.8.0-alpha", "phase": "teleport"}"#; let error = match load_mxc(json) { Err(ParseError::StateAware(error)) => error, other => panic!("expected state-aware error, got {other:?}"), @@ -1807,7 +1881,7 @@ mod tests { #[test] fn present_null_phase_is_still_discriminated_as_state_aware() { - let error = match load_mxc(r#"{"phase": null}"#) { + let error = match load_mxc(r#"{"version": "0.8.0-alpha", "phase": null}"#) { Err(ParseError::StateAware(error)) => error, other => panic!("expected state-aware error, got {other:?}"), }; @@ -1817,7 +1891,7 @@ mod tests { #[test] fn state_aware_unknown_containment_is_rejected() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "totally_made_up" }"#; @@ -1838,7 +1912,7 @@ mod tests { #[test] fn state_aware_mask_preserves_locations_after_multiline_experimental() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "experimental": { "future_backend": { @@ -1865,8 +1939,8 @@ mod tests { #[test] fn state_aware_mask_handles_empty_object_at_root_boundaries() { for json in [ - r#"{"experimental":{},"phase":"provision"}"#, - r#"{"phase":"provision","experimental":{}}"#, + r#"{"version": "0.8.0-alpha", "experimental":{},"phase":"provision"}"#, + r#"{"version": "0.8.0-alpha", "phase":"provision","experimental":{}}"#, ] { let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); @@ -1895,7 +1969,8 @@ mod tests { #[test] fn experimental_source_span_locates_the_borrowed_block() { - let json = r#"{"phase":"provision","experimental":{"a":{"b":1}}}"#; + let json = + r#"{"version": "0.8.0-alpha", "phase":"provision","experimental":{"a":{"b":1}}}"#; let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); let raw = discriminator.experimental.unwrap().get(); @@ -1909,7 +1984,7 @@ mod tests { fn experimental_source_span_rejects_a_foreign_slice() { // A `raw` not borrowed from `json` must fail closed rather than compute // an out-of-range offset — the invariant guard the masking relies on. - let json = r#"{"experimental":{}}"#; + let json = r#"{"version": "0.8.0-alpha", "experimental":{}}"#; let foreign = String::from("{}"); let error = experimental_source_span(json, foreign.as_str()).unwrap_err(); assert!(matches!(error, WxcError::ConfigParse(_))); @@ -1941,7 +2016,9 @@ mod tests { // `state_aware_null_experimental_is_accepted`); only non-null, // non-object values are rejected. for value in [r#""oops""#, "42", "[]"] { - let json = format!(r#"{{"phase":"provision","experimental":{value}}}"#); + let json = format!( + r#"{{"version": "0.8.0-alpha", "phase":"provision","experimental":{value}}}"# + ); let error = match load_mxc(&json) { Err(ParseError::StateAware(error)) => error, other => panic!("expected state-aware error for {value}, got {other:?}"), @@ -1960,7 +2037,7 @@ mod tests { fn state_aware_provision_works_with_no_containment() { // Containment is optional at parse time; the dispatcher enforces it // (provision needs containment, non-provision uses sandbox_id prefix). - let json = r#"{"phase": "provision"}"#; + let json = r#"{"version": "0.8.0-alpha", "phase": "provision"}"#; match load_mxc(json).unwrap() { MxcRequest::StateAware(p) => { assert_eq!(p.phase, Phase::Provision); @@ -1972,7 +2049,7 @@ mod tests { #[test] fn minimal_config() { - let json = r#"{"process": {"commandLine": "echo hello"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2007,7 +2084,7 @@ mod tests { #[test] fn missing_process_section() { - let json = r#"{"containment": "processcontainer"}"#; + let json = r#"{"version": "0.8.0-alpha", "containment": "processcontainer"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2017,7 +2094,7 @@ mod tests { #[test] fn missing_command_line() { - let json = r#"{"process": {"cwd": "/tmp"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"cwd": "/tmp"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2027,7 +2104,7 @@ mod tests { #[test] fn empty_command_line() { - let json = r#"{"process": {"commandLine": ""}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": ""}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2037,7 +2114,7 @@ mod tests { #[test] fn malicious_command_line() { - let json = r#"{"process": {"commandLine": "echo hello\0world"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello\0world"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2047,7 +2124,7 @@ mod tests { #[test] fn full_config() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "containerId": "TestProfile", "containment": "processcontainer", "process": { @@ -2098,8 +2175,7 @@ mod tests { #[test] fn invalid_network_policy() { - let json = - r#"{"process": {"commandLine": "echo x"}, "network": {"defaultPolicy": "invalid"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo x"}, "network": {"defaultPolicy": "invalid"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2117,7 +2193,7 @@ mod tests { #[test] fn wrong_value_type_reports_path_and_source_location() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": { "commandLine": "echo x", "timeout": "soon" @@ -2157,7 +2233,7 @@ mod tests { let log_path = directory.path().join("mxc.log"); let mut logger = test_logger(); logger.enable_file_sink(&log_path).unwrap(); - let encoded = base64_encode(br#"{"phase":"teleport"}"#); + let encoded = base64_encode(br#"{"version": "0.8.0-alpha", "phase":"teleport"}"#); let result = load_mxc_request(&encoded, &mut logger, true); assert!(matches!(result, Err(ParseError::StateAware(_)))); @@ -2174,8 +2250,7 @@ mod tests { #[test] fn out_of_range_value_reports_path() { - let json = - r#"{"process":{"commandLine":"echo x"},"network":{"proxy":{"localhost":70000}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo x"},"network":{"proxy":{"localhost":70000}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2193,7 +2268,7 @@ mod tests { #[test] fn malformed_json_is_reported_as_syntax_not_policy_data() { - let json = r#"{"process":{"commandLine":"echo x"}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo x"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2211,8 +2286,7 @@ mod tests { #[test] fn invalid_enforcement_mode() { - let json = - r#"{"process": {"commandLine": "echo x"}, "network": {"enforcementMode": "invalid"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo x"}, "network": {"enforcementMode": "invalid"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2228,7 +2302,11 @@ mod tests { fn load_from_file() { let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("config.json"); - std::fs::write(&file_path, r#"{"process": {"commandLine": "whoami"}}"#).unwrap(); + std::fs::write( + &file_path, + r#"{"version": "0.8.0-alpha", "process": {"commandLine": "whoami"}}"#, + ) + .unwrap(); let mut logger = test_logger(); let req = load_request(file_path.to_str().unwrap(), &mut logger, false).unwrap(); @@ -2336,7 +2414,9 @@ mod tests { let log_path = directory.path().join("mxc.log"); let mut logger = test_logger(); logger.enable_file_sink(&log_path).unwrap(); - let encoded = base64_encode(br#"{"phase":"provision","experimental":{"seatbelt":{}}}"#); + let encoded = base64_encode( + br#"{"version": "0.8.0-alpha", "phase":"provision","experimental":{"seatbelt":{}}}"#, + ); let result = load_mxc_request(&encoded, &mut logger, true); assert!(matches!(result, Err(ParseError::StateAware(_)))); @@ -2365,7 +2445,7 @@ mod tests { #[test] fn learning_mode_boolean_maps_to_deny_and_record_capability() { - let json = r#"{"process": {"commandLine": "echo x"}, "containment": "processcontainer", "processContainer": {"learningMode": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo x"}, "containment": "processcontainer", "processContainer": {"learningMode": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2390,7 +2470,7 @@ mod tests { "PERMISSIVELEARNINGMODE", ] { let json = format!( - r#"{{"process": {{"commandLine": "echo x"}}, "containment": "processcontainer", "processContainer": {{"capabilities": ["{capability}"]}}}}"# + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "echo x"}}, "containment": "processcontainer", "processContainer": {{"capabilities": ["{capability}"]}}}}"# ); let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2411,7 +2491,7 @@ mod tests { "internetClient,privateNetworkClientServer", ] { let json = format!( - r#"{{"process": {{"commandLine": "echo x"}}, "containment": "processcontainer", "processContainer": {{"capabilities": ["{capability}"]}}}}"# + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "echo x"}}, "containment": "processcontainer", "processContainer": {{"capabilities": ["{capability}"]}}}}"# ); let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2429,8 +2509,7 @@ mod tests { #[test] fn script_with_timeout() { - let json = - r#"{"process": {"commandLine": "import sys\nprint(sys.version)", "timeout": 60000}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "import sys\nprint(sys.version)", "timeout": 60000}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2440,7 +2519,7 @@ mod tests { #[test] fn process_container_capabilities() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": { @@ -2459,7 +2538,7 @@ mod tests { #[test] fn capture_denials_absent_leaves_policy_none() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {} @@ -2472,7 +2551,7 @@ mod tests { #[test] fn capture_denials_presence_enables_capture_without_path() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {}} @@ -2491,7 +2570,7 @@ mod tests { #[test] fn capture_denials_mode_block_is_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"mode": "block"}} @@ -2505,7 +2584,7 @@ mod tests { #[test] fn capture_denials_mode_allow_is_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"mode": "allow"}} @@ -2519,7 +2598,7 @@ mod tests { #[test] fn capture_denials_block_injects_learning_mode_logging_capability() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"mode": "block"}} @@ -2544,7 +2623,7 @@ mod tests { #[test] fn capture_denials_allow_injects_permissive_learning_mode_capability() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"mode": "allow"}} @@ -2563,7 +2642,7 @@ mod tests { #[test] fn capture_denials_default_injects_learning_mode_logging_capability() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {}} @@ -2582,7 +2661,7 @@ mod tests { #[test] fn capture_denials_allow_overrides_learning_mode_boolean() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": { @@ -2620,7 +2699,7 @@ mod tests { #[test] fn capture_denials_unknown_mode_rejected() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"mode": "audit"}} @@ -2644,7 +2723,7 @@ mod tests { let path = dir.path().join("denials.json"); let path_json = serde_json::to_string(&path.to_string_lossy()).unwrap(); let json = format!( - r#"{{ + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "print('test')"}}, "containment": "processcontainer", "processContainer": {{"captureDenials": {{"outputPath": {path_json}}}}} @@ -2662,7 +2741,7 @@ mod tests { #[test] fn capture_denials_relative_output_path_rejected() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"captureDenials": {"outputPath": "relative/denials.json"}} @@ -2684,7 +2763,7 @@ mod tests { let path = dir.path().join("nonexistent").join("denials.json"); let path_json = serde_json::to_string(&path.to_string_lossy()).unwrap(); let json = format!( - r#"{{ + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "print('test')"}}, "containment": "processcontainer", "processContainer": {{"captureDenials": {{"outputPath": {path_json}}}}} @@ -2707,7 +2786,7 @@ mod tests { let root = if cfg!(windows) { "C:\\" } else { "/" }; let root_json = serde_json::to_string(root).unwrap(); let json = format!( - r#"{{ + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "print('test')"}}, "containment": "processcontainer", "processContainer": {{"captureDenials": {{"outputPath": {root_json}}}}} @@ -2728,7 +2807,7 @@ mod tests { let dir = tempfile::tempdir().expect("temp dir"); let path_json = serde_json::to_string(&dir.path().to_string_lossy()).unwrap(); let json = format!( - r#"{{ + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "print('test')"}}, "containment": "processcontainer", "processContainer": {{"captureDenials": {{"outputPath": {path_json}}}}} @@ -2747,7 +2826,7 @@ mod tests { #[test] fn least_privilege_mode() { - let json = r#"{"process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"leastPrivilege": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "processContainer": {"leastPrivilege": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2757,7 +2836,7 @@ mod tests { #[test] fn network_default_policy_allow() { - let json = r#"{"process": {"commandLine": "print('test')"}, "network": {"defaultPolicy": "allow"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"defaultPolicy": "allow"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2767,7 +2846,7 @@ mod tests { #[test] fn network_default_policy_block() { - let json = r#"{"process": {"commandLine": "print('test')"}, "network": {"defaultPolicy": "block"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"defaultPolicy": "block"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2798,7 +2877,7 @@ mod tests { #[test] fn network_enforcement_mode_capabilities() { - let json = r#"{"process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "capabilities"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "capabilities"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2811,7 +2890,7 @@ mod tests { #[test] fn network_enforcement_mode_firewall() { - let json = r#"{"process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "firewall"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "firewall"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2824,7 +2903,7 @@ mod tests { #[test] fn network_enforcement_mode_both() { - let json = r#"{"process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "both"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"enforcementMode": "both"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2837,7 +2916,7 @@ mod tests { #[test] fn network_hosts() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": { "allowedHosts": ["example.com", "api.trusted.com"], @@ -2858,7 +2937,7 @@ mod tests { #[test] fn network_allow_local_network() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {"allowLocalNetwork": true} }"#; @@ -2871,7 +2950,7 @@ mod tests { #[test] fn network_allow_local_network_defaults_false() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "network": {} }"#; @@ -2884,7 +2963,7 @@ mod tests { #[test] fn filesystem_paths() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "filesystem": { "readwritePaths": ["C:\\Users\\Public", "C:\\Temp\\Data"], @@ -2905,7 +2984,7 @@ mod tests { #[test] fn block_evil_filesystem_paths() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "filesystem": { "readwritePaths": ["C:\\My \"Evil\\Path"] @@ -2920,7 +2999,7 @@ mod tests { #[test] fn base64_complex_config() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "containerId": "TestContainer", "containment": "processcontainer", "process": { @@ -2943,7 +3022,7 @@ mod tests { #[test] fn invalid_json_syntax() { - let json = r#"{"process": {"commandLine": "print('test')"}, INVALID_JSON}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, INVALID_JSON}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2953,7 +3032,7 @@ mod tests { #[test] fn default_timeout_is_zero() { - let json = r#"{"process": {"commandLine": "echo hello"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -2963,7 +3042,7 @@ mod tests { #[test] fn allow_dacl_mutation_default_true() { - let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); @@ -2972,7 +3051,7 @@ mod tests { #[test] fn allow_dacl_mutation_explicit_false() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "fallback": {"allowDaclMutation": false} }"#; @@ -2984,7 +3063,7 @@ mod tests { #[test] fn allow_dacl_mutation_explicit_true() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "fallback": {"allowDaclMutation": true} }"#; @@ -3000,7 +3079,7 @@ mod tests { fn default_containment_resolves_per_target() { // Omitted `containment` resolves to the OS-native process sandbox: // ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS. - let json = r#"{"process": {"commandLine": "echo hello"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3016,8 +3095,7 @@ mod tests { #[test] fn explicit_processcontainer_containment() { - let json = - r#"{"process": {"commandLine": "echo hello"}, "containment": "processcontainer"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "processcontainer"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3031,7 +3109,7 @@ mod tests { // ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS. // Callers who want LXC (a full container) must request it explicitly // via `"containment": "lxc"`. - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "process"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "process"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3050,7 +3128,7 @@ mod tests { // Regression guard: making bubblewrap the Linux default for the // abstract `"process"` intent must NOT change how explicit `"lxc"` // resolves. LXC remains available to any caller that asks for it. - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "lxc"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "lxc"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3064,7 +3142,7 @@ mod tests { // `"bubblewrap"` should parse to the concrete backend on every // target without error. (Host availability is checked at runtime by // the runner, not here.) - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "bubblewrap"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "bubblewrap"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3076,7 +3154,7 @@ mod tests { fn hyperlight_containment_value_parses() { // Lock in that `"hyperlight"` is accepted by the parser (the // `map_wire_containment` arm handles both one-shot and state-aware). - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "hyperlight"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "hyperlight"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3090,7 +3168,7 @@ mod tests { // other targets there is no concrete VM backend yet, so the parser // returns the historical `Vm` placeholder variant which the host // binaries surface as a "not implemented" error. - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "vm"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "vm"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3104,8 +3182,7 @@ mod tests { #[test] fn sandbox_containment() { - let json = - r#"{"process": {"commandLine": "echo hello"}, "containment": "windows_sandbox"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "windows_sandbox"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3115,7 +3192,7 @@ mod tests { #[test] fn invalid_containment_value() { - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "docker"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "docker"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3129,7 +3206,7 @@ mod tests { #[test] fn sandbox_config_defaults() { - let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3141,7 +3218,7 @@ mod tests { #[test] fn sandbox_config_custom_values() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hello"}, "containment": "windows_sandbox", "experimental": { @@ -3164,8 +3241,7 @@ mod tests { #[test] fn no_proxy_leaves_default() { - let json = - r#"{"process": {"commandLine": "echo test"}, "network": {"defaultPolicy": "block"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "network": {"defaultPolicy": "block"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3175,7 +3251,7 @@ mod tests { #[test] fn proxy_localhost_port() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3195,7 +3271,7 @@ mod tests { #[test] fn proxy_url_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3214,7 +3290,7 @@ mod tests { #[test] fn proxy_url_non_localhost() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3232,8 +3308,7 @@ mod tests { #[test] fn proxy_url_missing_port() { - let json = - r#"{"process":{"commandLine":"x"},"network":{"proxy":{"url":"http://localhost"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":{"url":"http://localhost"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3243,7 +3318,7 @@ mod tests { #[test] fn proxy_url_ipv6_loopback() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3261,7 +3336,7 @@ mod tests { #[test] fn proxy_with_firewall_fields() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3283,7 +3358,7 @@ mod tests { #[test] fn proxy_rejected_with_non_processcontainer() { - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"localhost":8080}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"localhost":8080}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3293,7 +3368,7 @@ mod tests { #[test] fn proxy_rejects_port_zero() { - let json = r#"{"process":{"commandLine":"x"},"network":{"proxy":{"localhost":0}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":{"localhost":0}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3303,7 +3378,8 @@ mod tests { #[test] fn proxy_rejects_missing_localhost() { - let json = r#"{"process":{"commandLine":"x"},"network":{"proxy":{}}}"#; + let json = + r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":{}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3313,7 +3389,8 @@ mod tests { #[test] fn proxy_rejects_non_object() { - let json = r#"{"process":{"commandLine":"x"},"network":{"proxy":true}}"#; + let json = + r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3323,7 +3400,7 @@ mod tests { #[test] fn proxy_builtin_test_server() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo test"}, "containment": "processcontainer", "network": { @@ -3341,7 +3418,7 @@ mod tests { #[test] fn proxy_builtin_test_server_rejects_extra_keys() { - let json = r#"{"process":{"commandLine":"x"},"network":{"proxy":{"builtinTestServer":true,"localhost":8080}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":{"builtinTestServer":true,"localhost":8080}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3351,8 +3428,7 @@ mod tests { #[test] fn proxy_builtin_test_server_rejects_false() { - let json = - r#"{"process":{"commandLine":"x"},"network":{"proxy":{"builtinTestServer":false}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"network":{"proxy":{"builtinTestServer":false}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3363,7 +3439,7 @@ mod tests { #[test] fn proxy_builtin_test_server_rejected_with_non_processcontainer() { // lxc is not allowed -- proxy is gated to processcontainer + bubblewrap. - let json = r#"{"process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"builtinTestServer":true}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"x"},"containment":"lxc","network":{"proxy":{"builtinTestServer":true}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3798,7 +3874,7 @@ mod tests { // as HTTP(S)_PROXY, which fails open. Reject at parse time. for url in ["socks5://proxy.example:1080", "ftp://proxy.example:21"] { let json = format!( - r#"{{ + r#"{{"version": "0.8.0-alpha", "process": {{"commandLine": "echo hi"}}, "containment": "processcontainer", "network": {{"proxy": {{"url": "{url}"}}}} @@ -3818,7 +3894,7 @@ mod tests { fn proxy_scheme_error_redacts_credentials() { // A rejected proxy URL must not echo embedded `user:password@` // userinfo into the error (which reaches the diagnostic/log stream). - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "processcontainer", "network": {"proxy": {"url": "socks5://alice:s3cr3t@proxy.example:1080"}} @@ -3898,18 +3974,20 @@ mod tests { #[test] fn new_toplevel_fields_default_when_absent() { - let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - assert_eq!(req.schema_version, ""); + // `version` is required, so it is never empty; only `containerId` still + // defaults. + assert_eq!(req.schema_version, "0.8.0-alpha"); assert_eq!(req.container_id, ""); } #[test] fn process_section_env_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": { "commandLine": "echo hi", "env": ["FOO=bar", "BAZ=qux"] @@ -3924,7 +4002,7 @@ mod tests { #[test] fn process_section_cwd_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": { "commandLine": "echo hi", "cwd": "/workspace" @@ -3939,7 +4017,7 @@ mod tests { #[test] fn process_section_timeout_parsed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": { "commandLine": "echo hi", "timeout": 9000 @@ -3954,7 +4032,7 @@ mod tests { #[test] fn containment_microvm_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "microvm"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "microvm"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3964,7 +4042,7 @@ mod tests { #[test] fn unknown_top_level_field_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "bogusField": true}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "bogusField": true}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3979,7 +4057,7 @@ mod tests { fn filesystem_typo_rejected() { // `fileSystem` (capital S) used to be silently dropped, so the policy // never applied. It must now be rejected as an unknown field. - let json = r#"{"process": {"commandLine": "echo hi"}, "fileSystem": {"readwritePaths": ["C:\\x"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "fileSystem": {"readwritePaths": ["C:\\x"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -3991,7 +4069,8 @@ mod tests { fn nested_unknown_field_rejected() { // The stable surface is closed at every level (deny_unknown_fields): // an unknown *nested* field must be rejected, not just top-level ones. - let json = r#"{"process": {"commandLine": "echo hi", "bogus": 1}}"#; + let json = + r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi", "bogus": 1}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4009,7 +4088,7 @@ mod tests { #[test] fn nested_proxy_unknown_field_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "processcontainer", "network": {"proxy": {"localhost": 8080, "unexpected": true}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "processcontainer", "network": {"proxy": {"localhost": 8080, "unexpected": true}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4024,7 +4103,7 @@ mod tests { #[test] fn invalid_clipboard_rejected() { // Strict enum: an out-of-range clipboard value is rejected at deserialize. - let json = r#"{"process": {"commandLine": "echo hi"}, "ui": {"clipboard": "bogus"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "ui": {"clipboard": "bogus"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4041,7 +4120,7 @@ mod tests { // The experimental surface is intentionally permissive (forward-compat): // an unknown field on a nested experimental struct must be tolerated and // the known fields preserved. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "futureField": "ignored"}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "futureField": "ignored"}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4054,7 +4133,7 @@ mod tests { #[test] fn experimental_isolation_user_unknown_field_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"user": {"upn": "alice@contoso.com", "wamToken": "tok", "futureField": true}}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"user": {"upn": "alice@contoso.com", "wamToken": "tok", "futureField": true}}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4072,7 +4151,7 @@ mod tests { fn one_shot_rejects_phase_field() { // A state-aware-shaped payload (carries `phase`) sent to a one-shot // entry point must be rejected, not silently run as a one-shot. - let json = r#"{"process": {"commandLine": "echo hi"}, "phase": "provision"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "phase": "provision"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4086,7 +4165,7 @@ mod tests { #[test] fn one_shot_rejects_sandbox_id_field() { - let json = r#"{"process": {"commandLine": "echo hi"}, "sandboxId": "abc"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "sandboxId": "abc"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4102,7 +4181,7 @@ mod tests { fn one_shot_rejects_correlation_vector_field() { // `correlationVector` is a state-aware-only relay field; a one-shot // payload carrying it must be rejected, mirroring `phase`/`sandboxId`. - let json = r#"{"process": {"commandLine": "echo hi"}, "correlationVector": "AAAAAAAAAAAAAAAAAAAAAA.0"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "correlationVector": "AAAAAAAAAAAAAAAAAAAAAA.0"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4118,7 +4197,7 @@ mod tests { fn top_level_macos_sandbox_alias_maps_to_seatbelt() { // The deprecated `macos_sandbox` section-key alias on the top-level // `seatbelt` field is still accepted and maps to `req.seatbelt`. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "macos_sandbox": {"guiAccess": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "macos_sandbox": {"guiAccess": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4148,7 +4227,7 @@ mod tests { #[test] fn state_aware_unknown_top_level_field_rejected() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "bogusField": true @@ -4165,7 +4244,7 @@ mod tests { // A state-aware request carrying a one-shot-only `seatbelt` policy must // be rejected, not silently discarded (the caller might believe the // hardening is in effect). - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "seatbelt", "seatbelt": {"guiAccess": true} @@ -4182,7 +4261,7 @@ mod tests { #[test] fn state_aware_rejects_one_shot_lifecycle_section() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "lifecycle": {"destroyOnExit": false} @@ -4199,7 +4278,7 @@ mod tests { #[test] fn state_aware_rejects_one_shot_processcontainer_section() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "processcontainer", "processContainer": {"leastPrivilege": true} @@ -4216,7 +4295,7 @@ mod tests { #[test] fn state_aware_rejects_one_shot_lxc_section() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "lxc", "lxc": {"distribution": "alpine"} @@ -4236,7 +4315,7 @@ mod tests { // `experimental.seatbelt` moved to the stable section; the state-aware // path must reject it with the migration message, not silently discard // it. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": {"seatbelt": {"guiAccess": true}} @@ -4253,7 +4332,7 @@ mod tests { #[test] fn state_aware_rejects_experimental_macos_sandbox_alias() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": {"macos_sandbox": {"guiAccess": true}} @@ -4270,7 +4349,7 @@ mod tests { #[test] fn state_aware_top_level_annotation_allowed() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "$schema": "../schemas/dev/mxc-config.schema.0.7.0-dev.json", "phase": "provision", "containment": "isolation_session" @@ -4285,7 +4364,7 @@ mod tests { fn state_aware_forwards_container_id() { // `containerId` is a documented top-level field and must be preserved // into the inner ExecutionRequest for state-aware requests, not dropped. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containerId": "sa-container-1", "containment": "isolation_session" @@ -4379,13 +4458,305 @@ mod tests { } #[test] - fn schema_version_absent_accepted() { + fn schema_version_absent_rejected() { + // `version` selects which fields are legal, so absence cannot default. let json = r#"{"process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - let req = load_request(&encoded, &mut logger, true).unwrap(); - assert_eq!(req.schema_version, ""); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + let details = match &error { + WxcError::VersionIncompatible(details) => details, + other => panic!("expected VersionIncompatible, got {other:?}"), + }; + assert_eq!(details.field, "version"); + assert_eq!(details.declared_version, ""); + assert!( + details.message.contains("Missing required field: version"), + "got: {}", + details.message + ); + } + + #[test] + fn schema_version_empty_string_rejected() { + // An empty string names no version, so it counts as absent. + let json = r#"{"version": "", "process": {"commandLine": "echo hi"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + matches!(error, WxcError::VersionIncompatible(_)), + "{error:?}" + ); + } + + // ---------- availability ranges ---------- + // + // These drive the real parser over the real wire model, proving the + // production annotations are enforced. The mechanism itself (both + // directions, nesting, arrays, aliases) is covered in + // `crate::version_availability`. + + fn version_error(json: &str) -> Box { + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + match load_request(&encoded, &mut logger, true) { + Err(WxcError::VersionIncompatible(details)) => details, + other => panic!("expected a version incompatibility, got {other:?}"), + } + } + + fn expect_accepted(json: &str) { + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + load_request(&encoded, &mut logger, true) + .unwrap_or_else(|err| panic!("expected accept for {json}, got {err:?}")); + } + + #[test] + fn supported_bounds_match_the_range_string() { + // The CI gate parses `SUPPORTED_VERSION` textually; the comparison uses + // the consts. Pin them so raising one without the other is caught. + let req = semver::VersionReq::parse(SUPPORTED_VERSION).expect("range parses"); + let inside = semver::Version::new(MIN_SUPPORTED.major, MIN_SUPPORTED.minor, 0); + let above = semver::Version::new(MAX_SUPPORTED.major, MAX_SUPPORTED.minor, 0); + assert!( + req.matches(&inside), + "min bound disagrees with {SUPPORTED_VERSION}" + ); + assert!( + req.matches(&above), + "max bound disagrees with {SUPPORTED_VERSION}" + ); + assert!(!req.matches(&semver::Version::new( + MIN_SUPPORTED.major, + MIN_SUPPORTED.minor - 1, + 0 + ))); + assert!(!req.matches(&semver::Version::new( + MAX_SUPPORTED.major, + MAX_SUPPORTED.minor + 1, + 0 + ))); + } + + #[test] + fn seatbelt_section_rejected_below_its_availability() { + let details = version_error( + r#"{"version": "0.6.0-alpha", "process": {"commandLine": "echo hi"}, + "seatbelt": {"guiAccess": true}}"#, + ); + assert_eq!(details.field, "seatbelt"); + assert_eq!(details.since.as_deref(), Some("0.7")); + assert_eq!(details.until, None); + assert_eq!(details.declared_version, "0.6.0-alpha"); + assert!( + details.message.contains("introduced in schema version 0.7"), + "got: {}", + details.message + ); + } + + #[test] + fn seatbelt_section_accepted_at_and_above_its_availability() { + for version in ["0.7.0-alpha", "0.8.0-alpha"] { + expect_accepted(&format!( + r#"{{"version": "{version}", "containment": "seatbelt", + "process": {{"commandLine": "echo hi"}}, + "seatbelt": {{"guiAccess": true}}}}"# + )); + } + } + + #[test] + fn a_serde_alias_is_availability_checked_like_the_field_it_spells() { + // Checking only the canonical spelling would let the alias bypass. + let details = version_error( + r#"{"version": "0.6.0-alpha", "process": {"commandLine": "echo hi"}, + "macos_sandbox": {"guiAccess": true}}"#, + ); + assert_eq!( + details.field, "macos_sandbox", + "the message should name the spelling the config actually used" + ); + assert_eq!(details.since.as_deref(), Some("0.7")); + } + + #[test] + fn nested_availability_violation_names_the_full_path() { + for version in ["0.6.0-alpha", "0.7.0-alpha"] { + let details = version_error(&format!( + r#"{{"version": "{version}", "containment": "processcontainer", + "process": {{"commandLine": "echo hi"}}, + "processContainer": {{"captureDenials": {{"mode": "block"}}}}}}"# + )); + assert_eq!(details.field, "processContainer.captureDenials"); + assert_eq!(details.since.as_deref(), Some("0.8")); + } + } + + #[test] + fn nested_availability_field_accepted_at_its_version() { + expect_accepted( + r#"{"version": "0.8.0-alpha", "containment": "processcontainer", + "process": {"commandLine": "echo hi"}, + "processContainer": {"learningMode": true}}"#, + ); + } + + #[test] + fn learning_mode_rejected_below_its_availability() { + let details = version_error( + r#"{"version": "0.7.0-alpha", "containment": "processcontainer", + "process": {"commandLine": "echo hi"}, + "processContainer": {"learningMode": true}}"#, + ); + assert_eq!(details.field, "processContainer.learningMode"); + assert_eq!(details.since.as_deref(), Some("0.8")); + } + + #[test] + fn unannotated_fields_are_valid_across_the_whole_supported_range() { + for version in ["0.6.0-alpha", "0.7.0-alpha", "0.8.0-alpha"] { + expect_accepted(&format!( + r#"{{"version": "{version}", "containerId": "abc", + "process": {{"commandLine": "echo hi", "timeout": 1000}}, + "filesystem": {{"readonlyPaths": ["C:\\tmp"]}}, + "ui": {{"disable": true}}}}"# + )); + } + } + + #[test] + fn the_experimental_block_carries_no_availability() { + // `experimental` was an open block before 0.8, so anything under it has + // always been accepted; a range derived from schema presence would + // reject configs that have always worked. + for version in ["0.6.0-alpha", "0.7.0-alpha", "0.8.0-alpha"] { + expect_accepted(&format!( + r#"{{"version": "{version}", "containment": "wslc", + "process": {{"commandLine": "echo hi"}}, + "experimental": {{"wslc": {{"image": "python"}}}}}}"# + )); + } + } + + #[test] + fn state_aware_requests_parse_at_the_version_the_sdk_emits() { + // The SDK stamps state-aware envelopes 0.6 while carrying + // `phase`/`sandboxId`, which only entered the schema at 0.8. + let json = r#"{ + "version": "0.6.0-alpha", + "phase": "exec", + "sandboxId": "iso:abcd1234", + "process": {"commandLine": "echo hello"} + }"#; + match load_mxc(json).unwrap() { + MxcRequest::StateAware(p) => assert_eq!(p.phase, Phase::Exec), + MxcRequest::OneShot(_) => panic!("expected state-aware"), + } + } + + #[test] + fn state_aware_availability_violations_surface_as_typed_envelope_errors() { + let json = r#"{ + "version": "0.6.0-alpha", + "phase": "provision", + "containment": "processcontainer", + "processContainer": {"learningMode": true} + }"#; + let error = match load_mxc(json) { + Err(ParseError::StateAware(error)) => error, + other => panic!("expected a state-aware error, got {other:?}"), + }; + assert_eq!( + error.code, + crate::mxc_error::MxcErrorCode::VersionIncompatible + ); + assert_eq!( + error.details, + Some(serde_json::json!({ + "field": "processContainer.learningMode", + "declaredVersion": "0.6.0-alpha", + "since": "0.8", + "until": null, + })) + ); + } + + #[test] + fn state_aware_missing_version_is_rejected() { + let json = r#"{"phase": "provision", "containment": "isolation_session"}"#; + let error = match load_mxc(json) { + Err(ParseError::StateAware(error)) => error, + other => panic!("expected a state-aware error, got {other:?}"), + }; + assert_eq!( + error.code, + crate::mxc_error::MxcErrorCode::VersionIncompatible + ); + } + + #[test] + fn the_supported_range_error_is_typed_and_carries_the_range() { + let details = + version_error(r#"{"version": "0.3.0-alpha", "process": {"commandLine": "echo hi"}}"#); + assert_eq!(details.field, "version"); + assert_eq!(details.declared_version, "0.3.0-alpha"); + assert_eq!(details.since.as_deref(), Some("0.6")); + assert_eq!(details.until.as_deref(), Some("0.8")); + assert!(details.message.contains("older than supported")); + + let details = + version_error(r#"{"version": "9.9.0-alpha", "process": {"commandLine": "echo hi"}}"#); + assert_eq!(details.field, "version"); + assert!(details.message.contains("newer than supported")); + } + + #[test] + fn an_availability_violation_is_reported_before_backend_dispatch_concerns() { + // The gate runs before backend selection, so the version problem is + // reported even when the config is otherwise wrong too. + let details = version_error( + r#"{"version": "0.6.0-alpha", "containment": "lxc", + "process": {"commandLine": "echo hi"}, + "seatbelt": {"guiAccess": true}}"#, + ); + assert_eq!(details.field, "seatbelt"); + } + + #[test] + fn version_availabilitys_apply_to_the_in_process_value_entry_point_too() { + // The Rust SDK's path in; it must gate identically or it is a bypass. + let mut logger = test_logger(); + let error = load_request_from_value( + serde_json::json!({ + "version": "0.6.0-alpha", + "process": {"commandLine": "echo hi"}, + "seatbelt": {"guiAccess": true}, + }), + &mut logger, + false, + ) + .unwrap_err(); + match error { + WxcError::VersionIncompatible(details) => assert_eq!(details.field, "seatbelt"), + other => panic!("expected VersionIncompatible, got {other:?}"), + } + + let mut logger = test_logger(); + let error = load_request_from_value( + serde_json::json!({"process": {"commandLine": "echo hi"}}), + &mut logger, + false, + ) + .unwrap_err(); + assert!( + matches!(error, WxcError::VersionIncompatible(_)), + "{error:?}" + ); } #[test] @@ -4412,7 +4783,7 @@ mod tests { fn schema_version_error_escapes_control_characters() { // The invalid version is free-form user input echoed into a manual // (non-serde) diagnostic; it must not carry raw ESC / newline bytes. - let error = validate_schema_version("1.\u{1b}[31m0\nX").unwrap_err(); + let error = validate_schema_version(Some("1.\u{1b}[31m0\nX")).unwrap_err(); let message = error.to_string(); assert!(!message.contains('\u{1b}'), "got: {message}"); assert!(!message.contains('\n'), "got: {message}"); @@ -4447,7 +4818,7 @@ mod tests { #[test] fn sandbox_idle_timeout_ms_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {"idleTimeoutMs": 60000}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {"idleTimeoutMs": 60000}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4460,7 +4831,7 @@ mod tests { #[test] fn sandbox_idle_timeout_ms_overrides_idle_timeout() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {"idleTimeout": 10000, "idleTimeoutMs": 60000}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "windows_sandbox", "experimental": {"windows_sandbox": {"idleTimeout": 10000, "idleTimeoutMs": 60000}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4473,7 +4844,7 @@ mod tests { #[test] fn container_id_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containerId": "my-container"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containerId": "my-container"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4483,8 +4854,7 @@ mod tests { #[test] fn lifecycle_destroy_on_exit_parsed() { - let json = - r#"{"process": {"commandLine": "echo hi"}, "lifecycle": {"destroyOnExit": false}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "lifecycle": {"destroyOnExit": false}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4494,8 +4864,7 @@ mod tests { #[test] fn lifecycle_preserve_policy_parsed() { - let json = - r#"{"process": {"commandLine": "echo hi"}, "lifecycle": {"preservePolicy": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "lifecycle": {"preservePolicy": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4505,7 +4874,7 @@ mod tests { #[test] fn lifecycle_defaults_when_absent() { - let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4516,7 +4885,7 @@ mod tests { #[test] fn wslc_section_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4528,7 +4897,7 @@ mod tests { #[test] fn wslc_image_tar_path_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "my-image:latest", "imageTarPath": "C:\\images\\alpine.tar"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "my-image:latest", "imageTarPath": "C:\\images\\alpine.tar"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4543,7 +4912,7 @@ mod tests { #[test] fn wslc_port_mapping_basic_tcp_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4557,7 +4926,7 @@ mod tests { #[test] fn wslc_port_mappings_default_protocol_is_tcp() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4571,7 +4940,7 @@ mod tests { // Strict enums are case-sensitive: "TCP" is not the lowercase wire // value "tcp", so it is rejected at deserialize as an unknown variant. // Only lowercase "tcp" is accepted (see wslc_port_mapping_basic_tcp_parsed). - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "TCP"}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "TCP"}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4588,7 +4957,7 @@ mod tests { // The wire model's TransportProtocol is tcp-only (the WSLC SDK runtime // returns E_NOTIMPL for UDP), so "udp" is rejected at // deserialize as an unknown enum variant. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4602,7 +4971,7 @@ mod tests { #[test] fn wslc_port_mapping_missing_windows_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"containerPort": 80}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"containerPort": 80}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4616,7 +4985,7 @@ mod tests { #[test] fn wslc_port_mapping_missing_container_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4630,7 +4999,7 @@ mod tests { #[test] fn wslc_port_mapping_zero_windows_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 0, "containerPort": 80}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 0, "containerPort": 80}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4644,7 +5013,7 @@ mod tests { #[test] fn wslc_port_mapping_zero_container_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 0}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 0}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4660,7 +5029,7 @@ mod tests { fn wslc_port_mapping_unsupported_protocol_rejected() { // An unknown protocol like "sctp" is rejected at deserialize: the // tcp-only TransportProtocol enum has no matching variant. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "sctp"}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "sctp"}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4674,7 +5043,7 @@ mod tests { #[test] fn wslc_port_mapping_duplicate_host_port_same_protocol_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}, {"windowsPort": 8080, "containerPort": 81}]}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}, {"windowsPort": 8080, "containerPort": 81}]}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4688,7 +5057,7 @@ mod tests { #[test] fn wslc_port_mapping_empty_list_default() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4701,7 +5070,7 @@ mod tests { #[test] fn experimental_section_parsed_when_present() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "world"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "world"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4712,7 +5081,7 @@ mod tests { #[test] fn experimental_section_absent_is_ok() { - let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4722,7 +5091,7 @@ mod tests { #[test] fn experimental_enabled_defaults_to_false() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "check"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "check"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4732,7 +5101,7 @@ mod tests { #[test] fn unknown_experimental_fields_ignored() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {"futureFeature": {"x": 1}, "test": {"message": "hi"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {"futureFeature": {"x": 1}, "test": {"message": "hi"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4742,7 +5111,7 @@ mod tests { #[test] fn experimental_test_message_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "greetings"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {"test": {"message": "greetings"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4753,7 +5122,7 @@ mod tests { #[test] fn experimental_test_default_message() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {"test": {}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {"test": {}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4764,7 +5133,7 @@ mod tests { #[test] fn ui_section_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "ui": {"disable": false, "clipboard": "read", "injection": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "ui": {"disable": false, "clipboard": "read", "injection": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4776,7 +5145,7 @@ mod tests { #[test] fn ui_section_defaults_when_omitted() { - let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4788,7 +5157,7 @@ mod tests { #[test] fn ui_clipboard_all_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "ui": {"clipboard": "all"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "ui": {"clipboard": "all"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4800,7 +5169,7 @@ mod tests { #[test] fn containment_isolation_session_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4810,7 +5179,7 @@ mod tests { #[test] fn isolation_session_config_defaults() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4824,7 +5193,7 @@ mod tests { #[test] fn isolation_session_config_small() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "small"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "small"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4838,7 +5207,7 @@ mod tests { #[test] fn isolation_session_config_medium() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "medium"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "medium"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4852,7 +5221,7 @@ mod tests { #[test] fn isolation_session_config_large() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "large"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "large"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4866,7 +5235,7 @@ mod tests { #[test] fn isolation_session_config_composable() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "composable"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "composable"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4882,7 +5251,7 @@ mod tests { fn isolation_session_config_unknown_is_rejected() { // Strict enums: an unrecognized configurationId is rejected at // deserialize time rather than silently defaulting to `composable`. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "xlarge"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "xlarge"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4896,7 +5265,7 @@ mod tests { #[test] fn isolation_session_absent_from_experimental() { - let json = r#"{"process": {"commandLine": "echo hi"}, "experimental": {}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "experimental": {}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4906,7 +5275,7 @@ mod tests { #[test] fn isolation_session_user_field_round_trips_through_one_shot_parser() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"user": {"upn": "alice@contoso.com", "wamToken": "tok"}}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"user": {"upn": "alice@contoso.com", "wamToken": "tok"}}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4921,7 +5290,7 @@ mod tests { #[test] fn isolation_session_user_absent_when_field_omitted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "medium"}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {"configurationId": "medium"}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4932,7 +5301,7 @@ mod tests { #[test] fn containment_seatbelt_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4943,7 +5312,7 @@ mod tests { #[test] fn seatbelt_config_defaults() { // When no seatbelt block is provided the parser leaves it unset. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4953,7 +5322,7 @@ mod tests { #[test] fn seatbelt_profile_override_passed_through() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(deny default)"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(deny default)"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4969,8 +5338,7 @@ mod tests { fn seatbelt_nested_pty_defaults_to_true_when_block_present_but_field_absent() { // seatbelt block is present but nestedPty is not specified; // the parser should fill in true to match the schema default. - let json = - r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4982,7 +5350,7 @@ mod tests { #[test] fn seatbelt_nested_pty_and_keychain_access_pass_through() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4994,7 +5362,7 @@ mod tests { #[test] fn top_level_seatbelt_config_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5007,7 +5375,7 @@ mod tests { #[test] fn experimental_seatbelt_errors_with_migration_message() { // After promotion, configs using experimental.seatbelt must error. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "experimental": {"seatbelt": {"nestedPty": true}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "experimental": {"seatbelt": {"nestedPty": true}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5029,7 +5397,7 @@ mod tests { #[test] fn legacy_appcontainer_wire_value_aliases_processcontainer() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "appcontainer"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "appcontainer"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5039,7 +5407,7 @@ mod tests { #[test] fn legacy_macos_sandbox_wire_value_aliases_seatbelt() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "macos_sandbox"}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "macos_sandbox"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5052,7 +5420,7 @@ mod tests { // The `appContainer` JSON key is a deprecated spelling; serde's alias // routes it to the same `processContainer` parsing path regardless of // the declared schema version. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "print('test')"}, "containment": "processcontainer", "appContainer": { @@ -5072,7 +5440,7 @@ mod tests { fn legacy_experimental_macos_sandbox_subblock_alias_rejected() { // `experimental.macos_sandbox` is the pre-rename key; after promotion // it should be rejected with a migration error. - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "macos_sandbox", "experimental": {"macos_sandbox": {"profileOverride": "(version 1)(allow default)"}} @@ -5093,7 +5461,7 @@ mod tests { fn make_multi_backend_config(containment: &str, extra_json: &str) -> String { let json = format!( - r#"{{ "containment": "{containment}", "process": {{"commandLine": "echo hi"}}, {extra_json} }}"# + r#"{{"version": "0.8.0-alpha", "containment": "{containment}", "process": {{"commandLine": "echo hi"}}, {extra_json} }}"# ); base64_encode(json.as_bytes()) } @@ -5204,7 +5572,7 @@ mod tests { // one-shot path. #[test] fn state_aware_foreign_experimental_backend_rejected() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": { @@ -5234,7 +5602,7 @@ mod tests { #[cfg(target_os = "windows")] #[test] fn abstract_process_with_process_container_accepted_on_windows() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "processContainer": {} @@ -5248,7 +5616,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn abstract_process_with_seatbelt_accepted_on_macos() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "seatbelt": {} @@ -5261,7 +5629,7 @@ mod tests { #[cfg(not(any(target_os = "windows", target_os = "macos")))] #[test] fn abstract_process_with_process_container_rejected_off_windows() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "processContainer": {} @@ -5275,7 +5643,7 @@ mod tests { #[cfg(target_os = "windows")] #[test] fn abstract_vm_with_windows_sandbox_accepted_on_windows() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "vm", "experimental": {"windows_sandbox": {}} @@ -5289,7 +5657,7 @@ mod tests { #[cfg(not(target_os = "windows"))] #[test] fn abstract_vm_with_windows_sandbox_rejected_off_windows() { - let json = r#"{ + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "vm", "experimental": {"windows_sandbox": {}} @@ -5303,7 +5671,7 @@ mod tests { #[test] fn same_path_in_readwrite_and_denied_becomes_denied() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "deniedPaths": ["C:\\workspace"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "deniedPaths": ["C:\\workspace"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5317,7 +5685,7 @@ mod tests { #[test] fn same_path_in_readwrite_and_readonly_becomes_readonly() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "readonlyPaths": ["C:\\workspace"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "readonlyPaths": ["C:\\workspace"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5331,7 +5699,7 @@ mod tests { #[test] fn same_path_in_readonly_and_denied_becomes_denied() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readonlyPaths": ["C:\\tools"], "deniedPaths": ["C:\\tools"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readonlyPaths": ["C:\\tools"], "deniedPaths": ["C:\\tools"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5345,7 +5713,7 @@ mod tests { #[test] fn same_path_in_all_three_lists_becomes_denied() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\x"], "readonlyPaths": ["C:\\x"], "deniedPaths": ["C:\\x"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\x"], "readonlyPaths": ["C:\\x"], "deniedPaths": ["C:\\x"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5357,7 +5725,7 @@ mod tests { #[test] fn distinct_paths_across_lists_preserved() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "readonlyPaths": ["C:\\tools"], "deniedPaths": ["C:\\secrets"]}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": ["C:\\workspace"], "readonlyPaths": ["C:\\tools"], "deniedPaths": ["C:\\secrets"]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5370,7 +5738,7 @@ mod tests { #[test] fn empty_filesystem_lists_accepted() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": [], "readonlyPaths": [], "deniedPaths": []}}"#; + let json = r#"{"version": "0.8.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "process", "filesystem": {"readwritePaths": [], "readonlyPaths": [], "deniedPaths": []}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5381,7 +5749,7 @@ mod tests { #[test] fn telemetry_not_set() { - let json = r#"{"process":{"commandLine":"echo hi"}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo hi"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); @@ -5390,7 +5758,7 @@ mod tests { #[test] fn telemetry_enabled_true() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":true}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":true}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); @@ -5400,7 +5768,7 @@ mod tests { #[test] fn telemetry_enabled_false() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":false}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":false}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); @@ -5410,7 +5778,7 @@ mod tests { #[test] fn telemetry_empty_object() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{}}}"#; + let json = r#"{"version": "0.8.0-alpha", "process":{"commandLine":"echo hi"},"experimental":{"telemetry":{}}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); diff --git a/src/core/wxc_common/src/error.rs b/src/core/wxc_common/src/error.rs index b3cdd3141..869e069da 100644 --- a/src/core/wxc_common/src/error.rs +++ b/src/core/wxc_common/src/error.rs @@ -3,11 +3,20 @@ use thiserror::Error; +use crate::version_availability::VersionIncompatibility; + #[derive(Debug, Error)] pub enum WxcError { #[error("Configuration parse error: {0}")] ConfigParse(String), + /// Unsupported schema version, or a field used outside its availability range. + /// + /// Distinct from [`WxcError::ConfigParse`] so the field name and bounds + /// survive to the wire envelope instead of being flattened into a string. + #[error("{0}")] + VersionIncompatible(Box), + #[error("Validation error: {0}")] Validation(String), @@ -42,6 +51,26 @@ impl From for WxcError { } } +impl From for WxcError { + fn from(err: VersionIncompatibility) -> Self { + WxcError::VersionIncompatible(Box::new(err)) + } +} + +impl WxcError { + /// The typed wire error this maps to. Every parse-time failure except a + /// version incompatibility is a malformed request. + pub fn to_mxc_error(&self) -> crate::mxc_error::MxcError { + match self { + WxcError::VersionIncompatible(details) => { + crate::mxc_error::MxcError::version_incompatible(details.message.clone()) + .with_details(details.details()) + } + other => crate::mxc_error::MxcError::malformed_request(other.to_string()), + } + } +} + impl From for WxcError { fn from(err: std::io::Error) -> Self { WxcError::Io(err.to_string()) diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index e80681957..181be68ea 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// `#[derive(VersionAvailability)]` emits `::wxc_common::…` paths so it works from any +// crate; this alias makes those paths resolve inside `wxc_common` itself, where +// the wire model that uses the derive lives. (Same trick serde uses.) +extern crate self as wxc_common; + // Platform-agnostic modules (shared by wxc-exec, lxc-exec, mxc-exec-mac // and every backend crate). pub mod cmdline; @@ -29,6 +34,10 @@ pub mod state_aware_request; pub mod telemetry; pub mod ui_policy; pub mod validator; +// Per-field schema-availability ranges: the runtime half of +// `#[derive(VersionAvailability)]`, consumed by the config parser and by schema +// generation so both read one declaration. +pub mod version_availability; // Dedicated well-typed wire model. It is the parser's deserialization target; // the JSON Schema is generated from it under the `schema-gen` feature. diff --git a/src/core/wxc_common/src/mxc_error.rs b/src/core/wxc_common/src/mxc_error.rs index 9221a2fe8..7f97c99b9 100644 --- a/src/core/wxc_common/src/mxc_error.rs +++ b/src/core/wxc_common/src/mxc_error.rs @@ -30,6 +30,10 @@ pub enum MxcErrorCode { AlreadyStopped, PolicyValidation, BackendError, + /// The config declared a schema version that is unsupported, or used a field + /// outside the version window it is valid in. Structured `details` name the + /// field and its bounds — see `wxc_common::version_availability`. + VersionIncompatible, } impl MxcErrorCode { @@ -47,6 +51,7 @@ impl MxcErrorCode { Self::AlreadyStopped => "already_stopped", Self::PolicyValidation => "policy_validation", Self::BackendError => "backend_error", + Self::VersionIncompatible => "version_incompatible", } } } @@ -131,6 +136,9 @@ impl MxcError { pub fn backend_error(message: impl Into) -> Self { Self::new(MxcErrorCode::BackendError, message) } + pub fn version_incompatible(message: impl Into) -> Self { + Self::new(MxcErrorCode::VersionIncompatible, message) + } } /// Wire shape of the `error` arm. `code` is a closed `MxcErrorCode` that @@ -183,6 +191,7 @@ mod tests { (MxcErrorCode::AlreadyStopped, "already_stopped"), (MxcErrorCode::PolicyValidation, "policy_validation"), (MxcErrorCode::BackendError, "backend_error"), + (MxcErrorCode::VersionIncompatible, "version_incompatible"), ]; for (code, wire) in cases { assert_eq!(code.as_str(), wire); @@ -235,6 +244,10 @@ mod tests { MxcError::backend_error("x").code, MxcErrorCode::BackendError ); + assert_eq!( + MxcError::version_incompatible("x").code, + MxcErrorCode::VersionIncompatible + ); } #[test] diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 15a85d4eb..45c804705 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -527,6 +527,10 @@ pub fn emit_cancellation() { fn classify_mxc_error(err: &MxcError) -> FailureReason { match err.code { MxcErrorCode::MalformedRequest | MxcErrorCode::MalformedId => FailureReason::ConfigError, + // A version incompatibility is a property of the submitted config (an + // unsupported version, or a field used outside its version window), so + // it classifies with the other config-authoring failures. + MxcErrorCode::VersionIncompatible => FailureReason::ConfigError, MxcErrorCode::PolicyValidation => FailureReason::PolicyError, MxcErrorCode::UnsupportedContainment | MxcErrorCode::UnsupportedPhase diff --git a/src/core/wxc_common/src/version_availability.rs b/src/core/wxc_common/src/version_availability.rs new file mode 100644 index 000000000..f79d68c21 --- /dev/null +++ b/src/core/wxc_common/src/version_availability.rs @@ -0,0 +1,685 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Per-field schema-availability ranges — the runtime half of +//! `#[derive(VersionAvailability)]`. +//! +//! An availability range is the inclusive span of config schema versions a wire +//! field may be used: `since = "0.8"` rejects it in an older config, +//! `until = "0.7"` rejects it in a newer one. Both compare `major.minor` only, +//! matching the parser's supported-range check. +//! +//! Annotation is opt-in — an unannotated field is valid across the whole +//! supported range — and adding one is a behavioural change, not bookkeeping. +//! See `docs/versioning.md#version-availability` for the design and for why a +//! field's first appearance in the JSON Schema is *not* the same as its first +//! appearance in the accepted surface. + +use std::fmt; + +use serde_json::Value; + +/// A `major.minor` schema-version bound. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct MajorMinor { + pub major: u64, + pub minor: u64, +} + +impl MajorMinor { + pub const fn new(major: u64, minor: u64) -> Self { + Self { major, minor } + } + + /// Parse the `major.minor` line of a full SemVer config version, discarding + /// patch and pre-release. Shared so the parser's gate and the SDK config + /// builders derive it one way. + pub fn parse_semver(version: &str) -> Option { + let parsed = semver::Version::parse(version).ok()?; + Some(Self::new(parsed.major, parsed.minor)) + } +} + +impl fmt::Display for MajorMinor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.major, self.minor) + } +} + +/// The inclusive version range a field may be used in. `None` on either side +/// means unbounded in that direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Availability { + pub since: Option, + pub until: Option, +} + +impl Availability { + /// The range of an unannotated field: valid everywhere. + pub const UNBOUNDED: Self = Self { + since: None, + until: None, + }; + + pub fn admits(&self, declared: MajorMinor) -> bool { + self.check(declared).is_ok() + } + + fn check(&self, declared: MajorMinor) -> Result<(), ViolationKind> { + if let Some(since) = self.since { + if declared < since { + return Err(ViolationKind::NotYetIntroduced); + } + } + if let Some(until) = self.until { + if declared > until { + return Err(ViolationKind::Retired); + } + } + Ok(()) + } + + fn is_annotated(&self) -> bool { + self.since.is_some() || self.until.is_some() + } +} + +/// One deserialisable wire field: how it is spelled on the wire, its range, +/// and how to reach the nested type's node. +#[derive(Debug, Clone, Copy)] +pub struct FieldAvailability { + pub rust_name: &'static str, + /// The primary JSON key, exactly as `serde` spells it. + pub name: &'static str, + /// Extra spellings from `#[serde(alias = ...)]`. An alias is the *same* + /// field, so it shares this availability range and is not separately annotatable. + pub aliases: &'static [&'static str], + pub availability: Availability, + /// The nested type's node, or `None` for a leaf. + pub nested: fn() -> Option<&'static NodeAvailability>, +} + +impl FieldAvailability { + fn matches(&self, key: &str) -> bool { + self.name == key || self.aliases.contains(&key) + } +} + +/// The availability metadata for one wire struct. +#[derive(Debug, Clone, Copy)] +pub struct NodeAvailability { + pub type_name: &'static str, + pub fields: &'static [FieldAvailability], +} + +impl NodeAvailability { + pub fn field(&self, key: &str) -> Option<&'static FieldAvailability> { + self.fields.iter().find(|f| f.matches(key)) + } +} + +/// Implemented for every wire type. Structs return their node; leaves return +/// `None`. +/// +/// The leaf impls are explicit rather than a blanket impl so a new wire field +/// whose type has no impl fails to compile, forcing an answer to "does it nest?". +pub trait VersionAvailability { + fn availability() -> Option<&'static NodeAvailability>; +} + +macro_rules! leaf { + ($($ty:ty),* $(,)?) => { + $(impl VersionAvailability for $ty { + fn availability() -> Option<&'static NodeAvailability> { None } + })* + }; +} + +leaf!( + bool, + char, + i8, + i16, + i32, + i64, + i128, + isize, + u8, + u16, + u32, + u64, + u128, + usize, + f32, + f64, + String, + serde_json::Value, +); + +impl VersionAvailability for Option { + fn availability() -> Option<&'static NodeAvailability> { + T::availability() + } +} + +impl VersionAvailability for Vec { + fn availability() -> Option<&'static NodeAvailability> { + T::availability() + } +} + +impl VersionAvailability for Box { + fn availability() -> Option<&'static NodeAvailability> { + T::availability() + } +} + +// --------------------------------------------------------------------------- +// Violations +// --------------------------------------------------------------------------- + +/// Which side of the range was breached. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViolationKind { + NotYetIntroduced, + Retired, +} + +/// A structured version incompatibility, carrying enough to render the wire +/// `details` object without re-parsing a message. Models the supported-range +/// failure too (`field: "version"`), so both classes share one error code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionIncompatibility { + /// Dotted JSON path of the offending field, or `"version"`. + pub field: String, + /// As written in the config; callers must escape it for diagnostics first. + pub declared_version: String, + pub since: Option, + pub until: Option, + pub message: String, +} + +impl VersionIncompatibility { + /// The wire `details` object. Absent bounds emit JSON `null` so the key set + /// is stable for consumers. + pub fn details(&self) -> Value { + serde_json::json!({ + "field": self.field, + "declaredVersion": self.declared_version, + "since": self.since, + "until": self.until, + }) + } +} + +impl fmt::Display for VersionIncompatibility { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +// --------------------------------------------------------------------------- +// Document validation +// --------------------------------------------------------------------------- + +/// Validate every populated field in `document` against its range. +/// +/// `declared_raw` is used verbatim in messages, so callers must escape it for +/// diagnostics first. +/// +/// Traversal is driven by the *document*, so it covers nested objects, arrays +/// and `experimental`. Keys with no matching field carry no range and are +/// skipped; closed structs reject them during typed deserialisation anyway. A +/// JSON `null` counts as **absent**, matching how serde maps it to `None`. +pub fn validate_document( + document: &Value, + declared: MajorMinor, + declared_raw: &str, + root: &'static NodeAvailability, +) -> Result<(), VersionIncompatibility> { + let mut path = String::new(); + walk(document, declared, declared_raw, root, &mut path) +} + +fn walk( + value: &Value, + declared: MajorMinor, + declared_raw: &str, + node: &'static NodeAvailability, + path: &mut String, +) -> Result<(), VersionIncompatibility> { + let Value::Object(map) = value else { + // Typed deserialisation reports a wrong-shaped value with full path. + return Ok(()); + }; + + for (key, child) in map { + let Some(field) = node.field(key) else { + continue; + }; + if child.is_null() { + continue; + } + + let restore = path.len(); + if !path.is_empty() { + path.push('.'); + } + // Report the spelling the config used, so an alias names itself. + path.push_str(key); + + if let Err(kind) = field.availability.check(declared) { + return Err(incompatibility( + kind, + path, + declared_raw, + &field.availability, + )); + } + + if let Some(nested) = (field.nested)() { + walk_value(child, declared, declared_raw, nested, path)?; + } + + path.truncate(restore); + } + + Ok(()) +} + +/// Descend into a field's value: objects walked directly, arrays per element. +/// +/// Arrays recurse through *any* depth — `Vec>` is a shape serde +/// accepts, and stopping at the outer array would leave `Inner`'s ranges +/// unchecked, i.e. silently fail open. +fn walk_value( + value: &Value, + declared: MajorMinor, + declared_raw: &str, + node: &'static NodeAvailability, + path: &mut String, +) -> Result<(), VersionIncompatibility> { + match value { + Value::Object(_) => walk(value, declared, declared_raw, node, path), + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + let restore = path.len(); + path.push_str(&format!("[{index}]")); + walk_value(item, declared, declared_raw, node, path)?; + path.truncate(restore); + } + Ok(()) + } + _ => Ok(()), + } +} + +fn incompatibility( + kind: ViolationKind, + field: &str, + declared_raw: &str, + availability: &Availability, +) -> VersionIncompatibility { + let message = match kind { + ViolationKind::NotYetIntroduced => { + let since = availability + .since + .expect("NotYetIntroduced implies a since bound"); + format!( + "Config field '{field}' was introduced in schema version {since} but the config \ + declares '{declared_raw}'. Raise the config's 'version' to {since} or newer, or \ + remove the field." + ) + } + ViolationKind::Retired => { + let until = availability.until.expect("Retired implies an until bound"); + format!( + "Config field '{field}' is not supported in schema version '{declared_raw}'; it \ + was retired after {until}. Use the replacement field for this version, or \ + declare a 'version' of {until} or older." + ) + } + }; + VersionIncompatibility { + field: field.to_string(), + declared_version: declared_raw.to_string(), + since: availability.since.map(|v| v.to_string()), + until: availability.until.map(|v| v.to_string()), + message, + } +} + +/// Every annotated field in `node` and its descendants, as dotted paths. +/// +/// Array nesting is not marked: a field inside `Vec` is `parent.child`, not +/// `parent[].child`. Consumers normalise the schema side the same way. +pub fn declared_availability(root: &'static NodeAvailability) -> Vec<(String, Availability)> { + let mut out = Vec::new(); + let mut path = String::new(); + let mut stack = Vec::new(); + collect(root, &mut path, &mut out, &mut stack); + out +} + +fn collect( + node: &'static NodeAvailability, + path: &mut String, + out: &mut Vec<(String, Availability)>, + stack: &mut Vec<&'static str>, +) { + // Acyclic today; guard so a future self-referential type truncates rather + // than hangs. + if stack.contains(&node.type_name) { + return; + } + stack.push(node.type_name); + for field in node.fields { + let restore = path.len(); + if !path.is_empty() { + path.push('.'); + } + path.push_str(field.name); + if field.availability.is_annotated() { + out.push((path.clone(), field.availability)); + } + if let Some(nested) = (field.nested)() { + collect(nested, path, out, stack); + } + path.truncate(restore); + } + stack.pop(); +} + +/// Every distinct node reachable from `root`, deduplicated by type name. +/// +/// A struct reached from two places (`Seatbelt` is both the top-level section +/// and `experimental.seatbelt`) appears once, because it *is* one node — which +/// is why a range belongs on the containing field, not inside a shared struct. +pub fn all_nodes(root: &'static NodeAvailability) -> Vec<&'static NodeAvailability> { + let mut out: Vec<&'static NodeAvailability> = Vec::new(); + let mut queue = vec![root]; + while let Some(node) = queue.pop() { + if out.iter().any(|seen| seen.type_name == node.type_name) { + continue; + } + out.push(node); + for field in node.fields { + if let Some(nested) = (field.nested)() { + queue.push(nested); + } + } + } + out.sort_by_key(|node| node.type_name); + out +} + +/// Every annotated field, as `(owning type name, JSON key, range)`. Keyed by +/// type because that is how the schema is organised +/// (`definitions..properties.`). +pub fn annotated_fields( + root: &'static NodeAvailability, +) -> Vec<(&'static str, &'static str, Availability)> { + let mut out = Vec::new(); + for node in all_nodes(root) { + for field in node.fields { + if field.availability.is_annotated() { + out.push((node.type_name, field.name, field.availability)); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use mxc_version_derive::VersionAvailability; + use serde::Deserialize; + use serde_json::json; + + // A miniature wire model exercising the machinery end to end: renamed keys, + // an alias, nesting, arrays, and both range directions. Fields exist to be + // deserialised, not read, hence `allow(dead_code)`. + #[derive(Debug, Deserialize, VersionAvailability)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + #[allow(dead_code)] + struct Root { + version: Option, + #[mxc_version(until = "0.7")] + default_policy: Option, + #[mxc_version(since = "0.8")] + egress: Option, + plain_field: Option, + #[serde(alias = "legacySection")] + section: Option
, + #[serde(rename = "$schema")] + schema: Option, + } + + #[derive(Debug, Deserialize, VersionAvailability)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + #[allow(dead_code)] + struct Egress { + rules: Option>, + } + + #[derive(Debug, Deserialize, VersionAvailability)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + #[allow(dead_code)] + struct Rule { + cidr: Option, + #[mxc_version(since = "0.9")] + except: Option>, + } + + #[derive(Debug, Deserialize, VersionAvailability)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + #[allow(dead_code)] + struct Section { + #[mxc_version(until = "0.7")] + retired_leaf: Option, + kept: Option, + } + + #[derive(Debug, Deserialize, VersionAvailability)] + #[serde(rename_all = "lowercase")] + #[allow(dead_code)] + enum Leaf { + A, + B, + } + + fn root() -> &'static NodeAvailability { + Root::availability().expect("a struct derives a node") + } + + fn check(doc: serde_json::Value, version: &str) -> Result<(), VersionIncompatibility> { + let (major, minor) = version + .split_once('.') + .map(|(a, b)| { + ( + a.parse().unwrap(), + b.split(['.', '-']).next().unwrap().parse().unwrap(), + ) + }) + .unwrap(); + validate_document(&doc, MajorMinor::new(major, minor), version, root()) + } + + #[test] + fn enums_are_leaves() { + assert!(Leaf::availability().is_none()); + } + + #[test] + fn derived_json_names_follow_serde() { + let names: Vec<_> = root().fields.iter().map(|f| f.name).collect(); + assert!(names.contains(&"defaultPolicy"), "{names:?}"); + assert!(names.contains(&"plainField"), "{names:?}"); + assert!(names.contains(&"$schema"), "{names:?}"); + } + + #[test] + fn unannotated_field_is_valid_across_the_whole_range() { + for version in ["0.6.0-alpha", "0.7.0-alpha", "0.8.0-alpha"] { + assert!( + check(json!({"plainField": "x"}), version).is_ok(), + "{version}" + ); + } + } + + #[test] + fn until_field_is_accepted_at_and_below_its_bound() { + for version in ["0.6.0-alpha", "0.7.0-alpha"] { + assert!( + check(json!({"defaultPolicy": "block"}), version).is_ok(), + "{version}" + ); + } + } + + #[test] + fn until_field_is_rejected_above_its_bound() { + let err = check(json!({"defaultPolicy": "block"}), "0.8.0-alpha").unwrap_err(); + assert_eq!(err.field, "defaultPolicy"); + assert_eq!(err.until.as_deref(), Some("0.7")); + assert_eq!(err.since, None); + assert_eq!(err.declared_version, "0.8.0-alpha"); + assert!(err.message.contains("retired after 0.7"), "{}", err.message); + } + + #[test] + fn since_field_is_rejected_below_its_bound() { + for version in ["0.6.0-alpha", "0.7.0-alpha"] { + let err = check(json!({"egress": {}}), version).unwrap_err(); + assert_eq!(err.field, "egress"); + assert_eq!(err.since.as_deref(), Some("0.8")); + assert!(err.message.contains("introduced in schema version 0.8")); + } + } + + #[test] + fn since_field_is_accepted_at_and_above_its_bound() { + assert!(check(json!({"egress": {}}), "0.8.0-alpha").is_ok()); + } + + #[test] + fn nested_fields_are_checked() { + let err = check(json!({"section": {"retiredLeaf": true}}), "0.8.0-alpha").unwrap_err(); + assert_eq!(err.field, "section.retiredLeaf"); + } + + #[test] + fn nested_field_under_an_alias_reports_the_spelling_used() { + let err = check( + json!({"legacySection": {"retiredLeaf": true}}), + "0.8.0-alpha", + ) + .unwrap_err(); + assert_eq!(err.field, "legacySection.retiredLeaf"); + } + + #[test] + fn an_alias_shares_its_field_availability() { + // `section` itself is unannotated, so both spellings are accepted. + assert!(check(json!({"legacySection": {"kept": true}}), "0.6.0-alpha").is_ok()); + } + + #[test] + fn array_elements_are_checked_and_indexed() { + let doc = + json!({"egress": {"rules": [{"cidr": "10.0.0.0/8"}, {"except": ["10.1.0.0/16"]}]}}); + let err = check(doc, "0.8.0-alpha").unwrap_err(); + assert_eq!(err.field, "egress.rules[1].except"); + assert_eq!(err.since.as_deref(), Some("0.9")); + } + + #[test] + fn null_counts_as_absent() { + // serde maps `null` to `None`, so treating it as use would reject a + // config that explicitly nulls a field it does not set. + assert!(check(json!({"defaultPolicy": null}), "0.8.0-alpha").is_ok()); + assert!(check(json!({"egress": null}), "0.6.0-alpha").is_ok()); + } + + #[test] + fn an_empty_object_still_counts_as_use() { + assert!(check(json!({"egress": {}}), "0.6.0-alpha").is_err()); + } + + #[test] + fn unknown_keys_carry_no_availability() { + assert!(check(json!({"totallyUnknown": {"whatever": 1}}), "0.6.0-alpha").is_ok()); + } + + #[test] + fn non_object_values_do_not_panic() { + assert!(check(json!({"section": 42}), "0.6.0-alpha").is_ok()); + assert!(check(json!({"egress": {"rules": "not-an-array"}}), "0.8.0-alpha").is_ok()); + assert!(check(json!({"egress": {"rules": [1, 2, 3]}}), "0.8.0-alpha").is_ok()); + } + + #[test] + fn nested_arrays_are_still_checked() { + // Regression: descending only into direct array elements skipped + // `Vec>` entirely — a silent fail-open. + let doc = json!({"egress": {"rules": [[{"except": ["10.0.0.0/8"]}]]}}); + let err = check(doc, "0.8.0-alpha").unwrap_err(); + assert_eq!(err.field, "egress.rules[0][0].except"); + assert_eq!(err.since.as_deref(), Some("0.9")); + } + + #[test] + fn deeply_nested_arrays_are_checked_at_every_level() { + let doc = json!({"egress": {"rules": [[[{"except": []}]]]}}); + let err = check(doc, "0.8.0-alpha").unwrap_err(); + assert_eq!(err.field, "egress.rules[0][0][0].except"); + } + + #[test] + fn availability_admits_matches_check() { + let w = Availability { + since: Some(MajorMinor::new(0, 7)), + until: Some(MajorMinor::new(0, 8)), + }; + assert!(!w.admits(MajorMinor::new(0, 6))); + assert!(w.admits(MajorMinor::new(0, 7))); + assert!(w.admits(MajorMinor::new(0, 8))); + assert!(!w.admits(MajorMinor::new(0, 9))); + assert!(Availability::UNBOUNDED.admits(MajorMinor::new(1, 0))); + } + + #[test] + fn bounds_are_inclusive_on_both_sides() { + assert!(check(json!({"defaultPolicy": "block"}), "0.7.0-alpha").is_ok()); + assert!(check(json!({"egress": {}}), "0.8.0-alpha").is_ok()); + } + + #[test] + fn details_carries_the_stable_key_set() { + let err = check(json!({"defaultPolicy": "block"}), "0.8.0-alpha").unwrap_err(); + assert_eq!( + err.details(), + json!({ + "field": "defaultPolicy", + "declaredVersion": "0.8.0-alpha", + "since": null, + "until": "0.7", + }) + ); + } + + #[test] + fn declared_availability_lists_annotated_paths_only() { + let listed: Vec = declared_availability(root()) + .into_iter() + .map(|(path, _)| path) + .collect(); + assert!(listed.contains(&"defaultPolicy".to_string())); + assert!(listed.contains(&"egress".to_string())); + assert!(listed.contains(&"egress.rules.except".to_string())); + assert!(listed.contains(&"section.retiredLeaf".to_string())); + assert!(!listed.contains(&"plainField".to_string())); + } +} diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 19e9028c9..861fd1c4d 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -27,12 +27,13 @@ //! expressed in the generated schema; they are enforced by the parser, which is //! the trust boundary. The schema is an editor/CI convenience, never the gate. +use mxc_version_derive::VersionAvailability; use serde::{Deserialize, Serialize}; /// MXC container execution configuration. Defines the recommended config format /// for both one-shot and state-aware sandbox lifecycle requests. A few /// deprecated field spellings not listed here are also accepted via serde aliases. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schema-gen", schemars(title = "MXC Configuration"))] #[serde( @@ -110,6 +111,10 @@ pub struct MxcConfig { /// macOS Seatbelt backend configuration. Used when containment is /// `seatbelt`. + /// + /// Introduced at 0.7. The range is on this field, not inside [`Seatbelt`], + /// which is shared with the unconstrained `experimental.seatbelt`. + #[mxc_version(since = "0.7")] #[serde(alias = "macos_sandbox")] pub seatbelt: Option, @@ -118,7 +123,7 @@ pub struct MxcConfig { } /// State-aware lifecycle phase. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum Phase { @@ -130,7 +135,7 @@ pub enum Phase { } /// Containment backend (abstract intent or concrete backend). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum Containment { @@ -161,7 +166,7 @@ pub enum Containment { } /// Process execution settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Process { @@ -176,7 +181,7 @@ pub struct Process { } /// Container lifecycle settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Lifecycle { @@ -187,7 +192,7 @@ pub struct Lifecycle { } /// ProcessContainer-specific settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ProcessContainer { @@ -198,6 +203,9 @@ pub struct ProcessContainer { /// unchanged. Distinct from the allow-all `permissiveLearningMode` /// capability, which is injected internally by the `--audit` CLI flag or /// dedicated denial-capture configuration. + /// + /// Introduced at 0.8. + #[mxc_version(since = "0.8")] pub learning_mode: Option, /// AppContainer capabilities (e.g. `internetClient`, `registryRead`). /// Each array entry must contain exactly one capability name; commas are @@ -212,6 +220,9 @@ pub struct ProcessContainer { /// Learning Mode and process security-environment API set. Cannot be /// combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` /// additionally requires the V2 deny-support capability. + /// + /// Introduced at 0.8. + #[mxc_version(since = "0.8")] pub capture_denials: Option, /// BaseProcessContainer UI settings (Windows). pub ui: Option, @@ -222,7 +233,7 @@ pub struct ProcessContainer { /// with `processContainer.leastPrivilege` and `network.proxy`. Explicit /// `filesystem.deniedPaths` requires the host's V2 process security-environment /// support query to advertise native deny enforcement. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CaptureDenials { @@ -246,7 +257,7 @@ pub struct CaptureDenials { } /// How `captureDenials` handles each ungranted access check while recording it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] pub enum CaptureDenialsMode { @@ -260,7 +271,7 @@ pub enum CaptureDenialsMode { } /// BaseProcessContainer UI isolation settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct BaseProcessUi { @@ -275,7 +286,7 @@ pub struct BaseProcessUi { } /// Desktop UI isolation level. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum UiIsolation { @@ -298,7 +309,7 @@ impl UiIsolation { } /// LXC container settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Lxc { @@ -309,7 +320,7 @@ pub struct Lxc { } /// Filesystem access policy. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Filesystem { @@ -322,7 +333,7 @@ pub struct Filesystem { } /// AppContainer DACL-mutation fallback policy. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Fallback { @@ -331,7 +342,7 @@ pub struct Fallback { } /// Network access policy. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Network { @@ -350,7 +361,7 @@ pub struct Network { } /// Default network policy. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum NetworkPolicy { @@ -359,7 +370,7 @@ pub enum NetworkPolicy { } /// Network enforcement mechanism. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum NetworkEnforcement { @@ -372,7 +383,7 @@ pub enum NetworkEnforcement { } /// Proxy configuration. Exactly one variant applies. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Proxy { @@ -386,7 +397,7 @@ pub struct Proxy { } /// Cross-platform UI isolation policy. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Ui { @@ -399,7 +410,7 @@ pub struct Ui { } /// Clipboard access level. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum ClipboardPolicy { @@ -410,7 +421,7 @@ pub enum ClipboardPolicy { } /// macOS Seatbelt backend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Seatbelt { @@ -429,7 +440,7 @@ pub struct Seatbelt { } /// Seatbelt inner-process launch method. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum LaunchMethod { @@ -445,7 +456,7 @@ pub enum LaunchMethod { /// backends are in flux, so the schema documents the known shapes for editor /// help without rejecting in-progress fields. The strict, closed contract is /// the stable (top-level) surface. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct Experimental { // Keep every direct field optional: state-aware parsing temporarily @@ -466,7 +477,7 @@ pub struct Experimental { } /// Telemetry configuration (`experimental.telemetry`). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct Telemetry { @@ -476,7 +487,7 @@ pub struct Telemetry { } /// Placeholder experimental feature. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct TestFeature { @@ -485,7 +496,7 @@ pub struct TestFeature { } /// Windows Sandbox backend config. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct WindowsSandbox { @@ -498,7 +509,7 @@ pub struct WindowsSandbox { } /// WSL container backend config. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct Wslc { @@ -524,7 +535,7 @@ pub struct Wslc { /// A single host → container port forward. Reachable only under the permissive /// `experimental` surface, so unknown fields are tolerated (forward-compat). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct PortMapping { @@ -540,7 +551,7 @@ pub struct PortMapping { /// Port-forward transport protocol. Only `tcp` is currently supported by the /// vendored WSLC SDK runtime; `udp` is rejected at parse time. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum TransportProtocol { @@ -550,7 +561,7 @@ pub enum TransportProtocol { /// IsolationSession backend config. Carries both the one-shot fields /// (`configurationId`, `user`) and the per-phase state-aware nesting /// (`provision` / `start` / `stop` / `deprovision`). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct IsolationSession { @@ -569,7 +580,7 @@ pub struct IsolationSession { } /// Per-phase IsolationSession configuration (state-aware lifecycle). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct IsolationSessionPhase { @@ -580,7 +591,7 @@ pub struct IsolationSessionPhase { } /// IsolationSession sizing profile. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum IsolationConfigurationId { @@ -592,7 +603,7 @@ pub enum IsolationConfigurationId { /// Entra cloud-agent user bundle. Reachable only under the permissive /// `experimental` surface, so unknown fields are tolerated (forward-compat). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, VersionAvailability)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct IsolationUser { @@ -639,6 +650,7 @@ mod schema_gen { let schema = schemars::schema_for!(MxcConfig); let mut value = serde_json::to_value(&schema).expect("schema serialises to JSON value"); normalize_integer_formats(&mut value); + inject_version_availabilitys(&mut value); if let serde_json::Value::Object(map) = &mut value { map.insert( "$id".to_string(), @@ -648,6 +660,50 @@ mod schema_gen { value } + /// Publish each field's availability range into the generated schema as + /// `x-mxc-since` / `x-mxc-until`. + /// + /// The `x-` prefix keeps standard validators and the compatibility detector + /// treating them as annotations: recording *when* a field is valid does not + /// change which instances the document accepts. + /// + /// Panics if a declared field has no matching schema property — the drift + /// guard, since a silent miss would leave a range documented nowhere. + fn inject_version_availabilitys(value: &mut serde_json::Value) { + use crate::version_availability::{annotated_fields, VersionAvailability}; + + let Some(root) = MxcConfig::availability() else { + panic!("MxcConfig derives VersionAvailability, so it has a node"); + }; + + for (type_name, field, availability) in annotated_fields(root) { + let target = if type_name == "MxcConfig" { + value.pointer_mut(&format!("/properties/{field}")) + } else { + value.pointer_mut(&format!("/definitions/{type_name}/properties/{field}")) + }; + let Some(serde_json::Value::Object(property)) = target else { + panic!( + "availability range declared on {type_name}.{field}, but the generated schema \ + has no such property — the wire model and the schema disagree about the \ + shape" + ); + }; + if let Some(since) = availability.since { + property.insert( + "x-mxc-since".to_string(), + serde_json::Value::String(since.to_string()), + ); + } + if let Some(until) = availability.until { + property.insert( + "x-mxc-until".to_string(), + serde_json::Value::String(until.to_string()), + ); + } + } + } + /// Emit the SDK's wire TypeScript types directly from the same generated schema /// model — no third-party generator. The output is a drift oracle /// (`sdk/node/src/generated/wire.ts`): the SDK's hand-written public types are diff --git a/src/core/wxc_common/tests/corpus_parses.rs b/src/core/wxc_common/tests/corpus_parses.rs new file mode 100644 index 000000000..8f6b606fa --- /dev/null +++ b/src/core/wxc_common/tests/corpus_parses.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Every config in the repository corpus must declare a schema version and +//! still parse. +//! +//! The behavioural counter-check to the first-appearance oracle, which can only +//! see when a field appeared *in the schema* — not that it was accepted for +//! longer than the schema described it (`experimental`, and the state-aware +//! `phase`/`sandboxId` pair). An annotation can look right against the schemas +//! and still break configs that have always worked. + +use std::fs; +use std::path::{Path, PathBuf}; + +use wxc_common::config_parser::{load_mxc_request_from_json, ParseError}; +use wxc_common::error::WxcError; +use wxc_common::logger::{Logger, Mode}; + +/// Fixtures that are deliberately rejected; one that starts passing is as much +/// a regression as a positive case that starts failing. +const EXPECTED_REJECTIONS: &[&str] = &["tests/configs/rejected_version_too_old.json"]; + +fn repo_root() -> PathBuf { + // .../src/core/wxc_common -> repo root + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("the crate lives three levels below the repo root") + .to_path_buf() +} + +fn collect(dir: &Path, root: &Path, out: &mut Vec<(String, PathBuf)>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect(&path, root, out); + } else if path.extension().is_some_and(|e| e == "json") { + let rel = path + .strip_prefix(root) + .expect("collected under the root") + .to_string_lossy() + .replace('\\', "/"); + out.push((rel, path)); + } + } +} + +fn corpus() -> Vec<(String, PathBuf)> { + let root = repo_root(); + let mut out = Vec::new(); + for dir in ["tests/examples", "tests/configs"] { + collect(&root.join(dir), &root, &mut out); + } + out.sort(); + assert!( + out.len() > 150, + "expected the full corpus, found only {} file(s) under {}", + out.len(), + root.display() + ); + out +} + +#[test] +fn every_corpus_config_declares_a_version() { + let mut missing = Vec::new(); + for (rel, path) in corpus() { + let text = fs::read_to_string(&path).expect("corpus file is readable"); + let value: serde_json::Value = + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{rel}: invalid JSON: {e}")); + match value.get("version").and_then(serde_json::Value::as_str) { + Some(v) if !v.is_empty() => {} + _ => missing.push(rel), + } + } + assert!( + missing.is_empty(), + "`version` is required, so every corpus config must declare one. Missing in:\n {}", + missing.join("\n ") + ); +} + +#[test] +fn every_corpus_config_parses() { + let files = corpus(); + let mut failures = Vec::new(); + let mut unexpectedly_accepted = Vec::new(); + + // A deleted or renamed negative fixture must fail rather than silently + // reduce this to a positive-only test. + for expected in EXPECTED_REJECTIONS { + assert!( + files.iter().any(|(rel, _)| rel == expected), + "negative fixture `{expected}` is missing from the corpus; it is what proves \ + an unsupported version is still rejected" + ); + } + + for (rel, path) in files { + let text = fs::read_to_string(&path).expect("corpus file is readable"); + let mut logger = Logger::new(Mode::Buffer); + let result = load_mxc_request_from_json(&text, &mut logger); + let expected_rejection = EXPECTED_REJECTIONS.contains(&rel.as_str()); + + match (result, expected_rejection) { + (Ok(_), false) => {} + (Err(_), true) => {} + (Ok(_), true) => unexpectedly_accepted.push(rel), + (Err(e), false) => failures.push(format!("{rel}: {e:?}")), + } + } + + assert!( + failures.is_empty(), + "corpus configs failed to parse:\n {}", + failures.join("\n ") + ); + assert!( + unexpectedly_accepted.is_empty(), + "these are negative fixtures and must keep being rejected:\n {}", + unexpectedly_accepted.join("\n ") + ); +} + +#[test] +fn the_out_of_range_fixture_is_rejected_for_the_right_reason() { + // Accepting any error would let a typo or unrelated failure pass. + let root = repo_root(); + let path = root.join("tests/configs/rejected_version_too_old.json"); + let text = fs::read_to_string(&path).expect("the negative fixture is readable"); + let mut logger = Logger::new(Mode::Buffer); + + let error = load_mxc_request_from_json(&text, &mut logger) + .expect_err("a version below the supported floor must be rejected"); + let details = match error { + ParseError::OneShot(WxcError::VersionIncompatible(details)) => details, + other => panic!("expected a one-shot VersionIncompatible, got {other:?}"), + }; + assert_eq!(details.field, "version"); + assert_eq!(details.since.as_deref(), Some("0.6")); + assert_eq!(details.until.as_deref(), Some("0.8")); + assert!( + details.message.contains("older than supported"), + "got: {}", + details.message + ); +} diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index f7fbb2c64..e9d64fa83 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -62,7 +62,7 @@ pub use streaming::*; /// Success. pub const MXC_STATUS_SUCCESS: i32 = 0; -// 1..=12 mirror `mxc_sdk::ErrorCode` (kept in lockstep with a CI drift gate). +// 1..=13 mirror `mxc_sdk::ErrorCode` (kept in lockstep with a CI drift gate). /// The request/policy was malformed. pub const MXC_STATUS_MALFORMED_REQUEST: i32 = 1; /// The requested containment backend is not supported by this library. @@ -87,6 +87,9 @@ pub const MXC_STATUS_ALREADY_STOPPED: i32 = 10; pub const MXC_STATUS_POLICY_VALIDATION: i32 = 11; /// A generic backend error. pub const MXC_STATUS_BACKEND_ERROR: i32 = 12; +/// The config declared an unsupported schema version, or used a field outside +/// the version window it is valid in. +pub const MXC_STATUS_VERSION_INCOMPATIBLE: i32 = 13; // 100+ are FFI-local statuses with no `ErrorCode` equivalent. /// A required pointer argument was null. @@ -111,6 +114,7 @@ pub(crate) fn status_from_error_code(code: ErrorCode) -> i32 { ErrorCode::AlreadyStopped => MXC_STATUS_ALREADY_STOPPED, ErrorCode::PolicyValidation => MXC_STATUS_POLICY_VALIDATION, ErrorCode::BackendError => MXC_STATUS_BACKEND_ERROR, + ErrorCode::VersionIncompatible => MXC_STATUS_VERSION_INCOMPATIBLE, } } diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 6e838e5e4..8fda23b62 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -241,7 +241,7 @@ mod tests { #[test] fn non_dry_run_exec_is_rejected() { let mut out = call( - r#"{"phase":"exec","sandboxId":"isolationsession:abc","process":{"commandLine":"echo hi"}}"#, + r#"{"version":"0.6.0-alpha","phase":"exec","sandboxId":"isolationsession:abc","process":{"commandLine":"echo hi"}}"#, false, ); assert_eq!(out.status, crate::MXC_STATUS_MALFORMED_REQUEST); @@ -257,7 +257,7 @@ mod tests { // (A real isolation_session provision is avoided: on a capable host it // would actually provision a sandbox. See the mxc-sdk state_aware test.) let mut out = call( - r#"{"phase":"start","sandboxId":"nosuchbackend:abc123"}"#, + r#"{"version":"0.6.0-alpha","phase":"start","sandboxId":"nosuchbackend:abc123"}"#, false, ); assert_eq!(out.status, crate::MXC_STATUS_UNSUPPORTED_CONTAINMENT); @@ -278,7 +278,10 @@ mod tests { #[test] fn null_out_reports_null_argument() { - let j = CString::new(r#"{"phase":"provision","containment":"isolation_session"}"#).unwrap(); + let j = CString::new( + r#"{"version":"0.6.0-alpha","phase":"provision","containment":"isolation_session"}"#, + ) + .unwrap(); // SAFETY: valid string, deliberately-null out. let status = unsafe { mxc_state_aware(j.as_ptr(), 0, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); @@ -286,7 +289,8 @@ mod tests { #[test] fn exec_null_out_handle_is_null_argument() { - let j = CString::new(r#"{"phase":"exec","sandboxId":"x:y"}"#).unwrap(); + let j = + CString::new(r#"{"version":"0.6.0-alpha","phase":"exec","sandboxId":"x:y"}"#).unwrap(); // SAFETY: valid string, deliberately-null out_handle. let status = unsafe { mxc_state_aware_exec(j.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); @@ -294,7 +298,10 @@ mod tests { #[test] fn exec_non_exec_phase_reports_error_and_null_handle() { - let j = CString::new(r#"{"phase":"provision","containment":"isolation_session"}"#).unwrap(); + let j = CString::new( + r#"{"version":"0.6.0-alpha","phase":"provision","containment":"isolation_session"}"#, + ) + .unwrap(); let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err: *mut c_char = ptr::null_mut(); // SAFETY: valid string and out pointers. diff --git a/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs b/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs index d0124de22..ad0f343e1 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs @@ -55,6 +55,7 @@ fn state_aware_unknown_containment_emits_error_envelope_on_stdout() { // a state-aware request — exercises the parser-level rejection branch of // the wire-format error contract. let request = json!({ + "version": "0.6.0-alpha", "containment": "totally_made_up", "phase": "provision" }); @@ -82,6 +83,7 @@ fn state_aware_recognized_but_non_state_aware_backend_emits_unsupported_phase() // I-commits land state-aware impls — the assertion will keep working // because `wslc` will remain a non-state-aware backend. let request = json!({ + "version": "0.6.0-alpha", "containment": "wslc", "phase": "provision" }); diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index b388fd363..69a5de41f 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -207,6 +207,7 @@ fn microvm_network_blocked() { // Negative case: host is on the blocklist -> guest egress denied (EACCES). let blocked = serde_json::json!({ + "version": "0.8.0-alpha", "process": { "commandLine": source, "timeout": 30000 }, "containment": "microvm", "network": { "blockedHosts": [host_ip.to_string()] } @@ -233,6 +234,7 @@ fn microvm_network_blocked() { // closed target yields a connection error other than EACCES (typically // ECONNREFUSED / errno 111), never the filter's EACCES. let allowed = serde_json::json!({ + "version": "0.8.0-alpha", "process": { "commandLine": source, "timeout": 30000 }, "containment": "microvm", "network": { "defaultPolicy": "allow" } @@ -950,6 +952,7 @@ fn hyperlight_suite() { ); let config = serde_json::json!({ + "version": "0.8.0-alpha", "process": { "commandLine": script, "timeout": 30000 }, "containment": "hyperlight", "filesystem": { "readwritePaths": [mount_dir.to_string_lossy()] } @@ -1014,6 +1017,7 @@ fn hyperlight_suite() { ); let config = serde_json::json!({ + "version": "0.8.0-alpha", "process": { "commandLine": script, "timeout": 30000 }, "containment": "hyperlight", "filesystem": { diff --git a/src/tools/mxc_schema_gen/Cargo.toml b/src/tools/mxc_schema_gen/Cargo.toml index f021a1941..2fd9ce603 100644 --- a/src/tools/mxc_schema_gen/Cargo.toml +++ b/src/tools/mxc_schema_gen/Cargo.toml @@ -8,3 +8,8 @@ edition.workspace = true # cargo run -p mxc_schema_gen -- [dependencies] wxc_common = { path = "../../core/wxc_common", features = ["schema-gen"] } + +# Test-only: the version-window conformance tests read the generated schema as +# JSON to cross-check it against the wire model's window metadata. +[dev-dependencies] +serde_json = { workspace = true } diff --git a/src/tools/mxc_schema_gen/tests/version_availability_conformance.rs b/src/tools/mxc_schema_gen/tests/version_availability_conformance.rs new file mode 100644 index 000000000..586edac47 --- /dev/null +++ b/src/tools/mxc_schema_gen/tests/version_availability_conformance.rs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pins the version-availability metadata against the generated JSON Schema. +//! +//! A range is enforced by matching a JSON key against the name the derive +//! computes; a name that disagrees with what `serde` accepts would fail *open*. +//! `schemars` derives the same names through separate code, so the generated +//! schema is an independent oracle. These live here because this crate enables +//! the `schema-gen` feature, so they run under a plain `cargo test --workspace`. + +use std::collections::BTreeSet; + +use serde_json::Value; +use wxc_common::version_availability::{ + all_nodes, annotated_fields, NodeAvailability, VersionAvailability, +}; +use wxc_common::wire::MxcConfig; + +fn schema() -> Value { + serde_json::from_str(&wxc_common::wire::generate_config_schema_json()) + .expect("the generator emits valid JSON") +} + +fn root_node() -> &'static NodeAvailability { + MxcConfig::availability().expect("MxcConfig is a struct, so it has a node") +} + +/// The schema object describing `type_name`: the document root for the root +/// type, else its entry under `definitions`. +fn schema_object_for<'a>(schema: &'a Value, type_name: &str) -> Option<&'a Value> { + if type_name == "MxcConfig" { + Some(schema) + } else { + schema.pointer(&format!("/definitions/{type_name}")) + } +} + +fn schema_property_names(object: &Value) -> BTreeSet { + object + .get("properties") + .and_then(Value::as_object) + .map(|props| props.keys().cloned().collect()) + .unwrap_or_default() +} + +#[test] +fn every_wire_node_has_a_schema_counterpart() { + let schema = schema(); + for node in all_nodes(root_node()) { + assert!( + schema_object_for(&schema, node.type_name).is_some(), + "wire type `{}` derives VersionAvailability but has no schema definition; \ + the availability metadata and the schema disagree about which types exist", + node.type_name + ); + } +} + +#[test] +fn derived_json_names_match_the_schema_exactly() { + let schema = schema(); + for node in all_nodes(root_node()) { + let object = schema_object_for(&schema, node.type_name) + .unwrap_or_else(|| panic!("no schema object for `{}`", node.type_name)); + + let from_schema = schema_property_names(object); + let from_derive: BTreeSet = + node.fields.iter().map(|f| f.name.to_string()).collect(); + + assert_eq!( + from_derive, + from_schema, + "`{}`: the JSON field names derived for availability ranges disagree with the \ + generated schema. A derived name that serde would not accept makes the \ + availability range unreachable (it fails OPEN), so these must match exactly.\n \ + only in derive: {:?}\n only in schema: {:?}", + node.type_name, + from_derive.difference(&from_schema).collect::>(), + from_schema.difference(&from_derive).collect::>(), + ); + } +} + +#[test] +fn serde_aliases_are_absent_from_the_schema_but_present_in_the_metadata() { + // An alias is a spelling, not a schema property, so schemars omits it — but + // the metadata must carry it or the alias bypasses the range. + let schema = schema(); + let root = schema_property_names(&schema); + + let process_container = root_node() + .field("processContainer") + .expect("processContainer is a wire field"); + assert!( + process_container.aliases.contains(&"appContainer"), + "the appContainer alias must be carried in the availability metadata so a config \ + using it is still checked; aliases: {:?}", + process_container.aliases + ); + assert!( + !root.contains("appContainer"), + "the schema advertises only the canonical spelling" + ); + + let seatbelt = root_node() + .field("seatbelt") + .expect("seatbelt is a wire field"); + assert!(seatbelt.aliases.contains(&"macos_sandbox")); + assert!(!root.contains("macos_sandbox")); +} + +#[test] +fn every_declared_availability_is_published_in_the_schema() { + let schema = schema(); + let declared = annotated_fields(root_node()); + assert!( + !declared.is_empty(), + "the wire model declares no availability ranges at all; this test would be vacuous" + ); + + for (type_name, field, availability) in declared { + let pointer = if type_name == "MxcConfig" { + format!("/properties/{field}") + } else { + format!("/definitions/{type_name}/properties/{field}") + }; + let property = schema + .pointer(&pointer) + .unwrap_or_else(|| panic!("schema has no property at {pointer}")); + + match availability.since { + Some(since) => assert_eq!( + property.get("x-mxc-since").and_then(Value::as_str), + Some(since.to_string().as_str()), + "{pointer}: x-mxc-since is missing or stale" + ), + None => assert!( + property.get("x-mxc-since").is_none(), + "{pointer}: schema advertises a lower bound the wire model does not declare" + ), + } + match availability.until { + Some(until) => assert_eq!( + property.get("x-mxc-until").and_then(Value::as_str), + Some(until.to_string().as_str()), + "{pointer}: x-mxc-until is missing or stale" + ), + None => assert!( + property.get("x-mxc-until").is_none(), + "{pointer}: schema advertises an upper bound the wire model does not declare" + ), + } + } +} + +#[test] +fn the_schema_declares_no_availability_the_wire_model_does_not() { + // Reverse direction: a hand-edited schema cannot add an unenforced range. + let schema = schema(); + let declared: BTreeSet<(String, String)> = annotated_fields(root_node()) + .into_iter() + .map(|(type_name, field, _)| (type_name.to_string(), field.to_string())) + .collect(); + + let mut found = BTreeSet::new(); + collect_availability_keys(&schema, "MxcConfig", &mut found); + if let Some(defs) = schema.get("definitions").and_then(Value::as_object) { + for (type_name, object) in defs { + collect_availability_keys(object, type_name, &mut found); + } + } + + assert_eq!( + found, declared, + "the schema's x-mxc-* annotations must come from the wire model and nowhere else" + ); +} + +fn collect_availability_keys( + object: &Value, + type_name: &str, + out: &mut BTreeSet<(String, String)>, +) { + let Some(props) = object.get("properties").and_then(Value::as_object) else { + return; + }; + for (name, property) in props { + if property.get("x-mxc-since").is_some() || property.get("x-mxc-until").is_some() { + out.insert((type_name.to_string(), name.clone())); + } + } +} diff --git a/tests/configs/hyperlight_exit_code.json b/tests/configs/hyperlight_exit_code.json index 50f4f0de6..d18344cfb 100644 --- a/tests/configs/hyperlight_exit_code.json +++ b/tests/configs/hyperlight_exit_code.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import sys; sys.exit(42)", "timeout": 30000 diff --git a/tests/configs/hyperlight_fs.json b/tests/configs/hyperlight_fs.json index b87bef9f3..1235a9bfa 100644 --- a/tests/configs/hyperlight_fs.json +++ b/tests/configs/hyperlight_fs.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "_comment": "End-to-end filesystem test. readwritePaths are exposed to the guest under /host/. Before running, create /tmp/hyperlight-fs-demo/ and drop a file in it (or let the script create one). After the run, /tmp/hyperlight-fs-demo/written.txt will exist with guest-written content.", "process": { diff --git a/tests/configs/hyperlight_hello.json b/tests/configs/hyperlight_hello.json index d31dd67cd..5e8950ebe 100644 --- a/tests/configs/hyperlight_hello.json +++ b/tests/configs/hyperlight_hello.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import sys\nprint(f'Hello from Hyperlight! Python {sys.version.split()[0]} on {sys.platform}')", "timeout": 30000 diff --git a/tests/configs/hyperlight_networking.json b/tests/configs/hyperlight_networking.json index b500913b2..ee06f7895 100644 --- a/tests/configs/hyperlight_networking.json +++ b/tests/configs/hyperlight_networking.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import urllib.request; r = urllib.request.urlopen('http://example.com/'); print(r.status)", "timeout": 30000 diff --git a/tests/configs/hyperlight_networking_blocked.json b/tests/configs/hyperlight_networking_blocked.json index 6a1c23331..c6120c331 100644 --- a/tests/configs/hyperlight_networking_blocked.json +++ b/tests/configs/hyperlight_networking_blocked.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import urllib.request\ntry:\n urllib.request.urlopen('http://httpbin.org/', timeout=5)\n print('FAIL: request should have been blocked')\nexcept Exception as e:\n print('BLOCKED:', type(e).__name__)", "timeout": 30000 diff --git a/tests/configs/hyperlight_pandas.json b/tests/configs/hyperlight_pandas.json index c09971846..365a7cd9f 100644 --- a/tests/configs/hyperlight_pandas.json +++ b/tests/configs/hyperlight_pandas.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import pandas as pd, numpy as np\ndf = pd.DataFrame({'x': np.arange(5), 'y': np.arange(5) ** 2})\nprint(df.sum().to_dict())", "timeout": 30000 diff --git a/tests/configs/hyperlight_timeout.json b/tests/configs/hyperlight_timeout.json index 6d6a31a10..bd1594de8 100644 --- a/tests/configs/hyperlight_timeout.json +++ b/tests/configs/hyperlight_timeout.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import time; time.sleep(120); print('should not reach here')", "timeout": 1000 diff --git a/tests/configs/isolation_session_state_aware_deprovision.json b/tests/configs/isolation_session_state_aware_deprovision.json index b8a1501c4..39e90e78f 100644 --- a/tests/configs/isolation_session_state_aware_deprovision.json +++ b/tests/configs/isolation_session_state_aware_deprovision.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "deprovision", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/isolation_session_state_aware_exec_basic.json b/tests/configs/isolation_session_state_aware_exec_basic.json index 8e89eeb5d..4db0dddbb 100644 --- a/tests/configs/isolation_session_state_aware_exec_basic.json +++ b/tests/configs/isolation_session_state_aware_exec_basic.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_cwd.json b/tests/configs/isolation_session_state_aware_exec_cwd.json index 26ea98adc..8dab74ca4 100644 --- a/tests/configs/isolation_session_state_aware_exec_cwd.json +++ b/tests/configs/isolation_session_state_aware_exec_cwd.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_env_absent.json b/tests/configs/isolation_session_state_aware_exec_env_absent.json index 373681432..c3793fe09 100644 --- a/tests/configs/isolation_session_state_aware_exec_env_absent.json +++ b/tests/configs/isolation_session_state_aware_exec_env_absent.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_env_initial.json b/tests/configs/isolation_session_state_aware_exec_env_initial.json index 544c3988b..f5078384f 100644 --- a/tests/configs/isolation_session_state_aware_exec_env_initial.json +++ b/tests/configs/isolation_session_state_aware_exec_env_initial.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_env_modified.json b/tests/configs/isolation_session_state_aware_exec_env_modified.json index 4edecbead..e041b4a5e 100644 --- a/tests/configs/isolation_session_state_aware_exec_env_modified.json +++ b/tests/configs/isolation_session_state_aware_exec_env_modified.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_exit_0.json b/tests/configs/isolation_session_state_aware_exec_exit_0.json index fc67fca23..4dcfe1eb6 100644 --- a/tests/configs/isolation_session_state_aware_exec_exit_0.json +++ b/tests/configs/isolation_session_state_aware_exec_exit_0.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_exit_1.json b/tests/configs/isolation_session_state_aware_exec_exit_1.json index b8dc7eabb..b9afd3e9f 100644 --- a/tests/configs/isolation_session_state_aware_exec_exit_1.json +++ b/tests/configs/isolation_session_state_aware_exec_exit_1.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_exit_2.json b/tests/configs/isolation_session_state_aware_exec_exit_2.json index 8fe9b11dd..bd217aa42 100644 --- a/tests/configs/isolation_session_state_aware_exec_exit_2.json +++ b/tests/configs/isolation_session_state_aware_exec_exit_2.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_read_marker.json b/tests/configs/isolation_session_state_aware_exec_read_marker.json index 51c195049..a947ac4ad 100644 --- a/tests/configs/isolation_session_state_aware_exec_read_marker.json +++ b/tests/configs/isolation_session_state_aware_exec_read_marker.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_read_persist.json b/tests/configs/isolation_session_state_aware_exec_read_persist.json index aac2830a8..607e953ad 100644 --- a/tests/configs/isolation_session_state_aware_exec_read_persist.json +++ b/tests/configs/isolation_session_state_aware_exec_read_persist.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_read_readonly.json b/tests/configs/isolation_session_state_aware_exec_read_readonly.json index 693575d64..9a4b9224a 100644 --- a/tests/configs/isolation_session_state_aware_exec_read_readonly.json +++ b/tests/configs/isolation_session_state_aware_exec_read_readonly.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_read_restricted.json b/tests/configs/isolation_session_state_aware_exec_read_restricted.json index 2e5c7c32e..07a4428b2 100644 --- a/tests/configs/isolation_session_state_aware_exec_read_restricted.json +++ b/tests/configs/isolation_session_state_aware_exec_read_restricted.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_read_shared.json b/tests/configs/isolation_session_state_aware_exec_read_shared.json index 3a40b66d0..569dd75ae 100644 --- a/tests/configs/isolation_session_state_aware_exec_read_shared.json +++ b/tests/configs/isolation_session_state_aware_exec_read_shared.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_setx_initial.json b/tests/configs/isolation_session_state_aware_exec_setx_initial.json index d135c1e25..f12970b33 100644 --- a/tests/configs/isolation_session_state_aware_exec_setx_initial.json +++ b/tests/configs/isolation_session_state_aware_exec_setx_initial.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_setx_modified.json b/tests/configs/isolation_session_state_aware_exec_setx_modified.json index 58d0644e4..7f3dd9548 100644 --- a/tests/configs/isolation_session_state_aware_exec_setx_modified.json +++ b/tests/configs/isolation_session_state_aware_exec_setx_modified.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_write_marker.json b/tests/configs/isolation_session_state_aware_exec_write_marker.json index 1dcc95628..93b477dd8 100644 --- a/tests/configs/isolation_session_state_aware_exec_write_marker.json +++ b/tests/configs/isolation_session_state_aware_exec_write_marker.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_write_readonly_denied.json b/tests/configs/isolation_session_state_aware_exec_write_readonly_denied.json index cf1e2e736..3f8033e8f 100644 --- a/tests/configs/isolation_session_state_aware_exec_write_readonly_denied.json +++ b/tests/configs/isolation_session_state_aware_exec_write_readonly_denied.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_exec_write_shared.json b/tests/configs/isolation_session_state_aware_exec_write_shared.json index 54f3860a7..a2bcdbcad 100644 --- a/tests/configs/isolation_session_state_aware_exec_write_shared.json +++ b/tests/configs/isolation_session_state_aware_exec_write_shared.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/isolation_session_state_aware_provision.json b/tests/configs/isolation_session_state_aware_provision.json index 84b99c93c..6fec972b4 100644 --- a/tests/configs/isolation_session_state_aware_provision.json +++ b/tests/configs/isolation_session_state_aware_provision.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session" } diff --git a/tests/configs/isolation_session_state_aware_provision_rejected_denied.json b/tests/configs/isolation_session_state_aware_provision_rejected_denied.json index 3a5ee43ac..ab068ffc3 100644 --- a/tests/configs/isolation_session_state_aware_provision_rejected_denied.json +++ b/tests/configs/isolation_session_state_aware_provision_rejected_denied.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session", "filesystem": { diff --git a/tests/configs/isolation_session_state_aware_provision_user_empty_wamtoken.json b/tests/configs/isolation_session_state_aware_provision_user_empty_wamtoken.json index 08cec9e9d..f5783dfc5 100644 --- a/tests/configs/isolation_session_state_aware_provision_user_empty_wamtoken.json +++ b/tests/configs/isolation_session_state_aware_provision_user_empty_wamtoken.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": { diff --git a/tests/configs/isolation_session_state_aware_provision_user_malformed_upn.json b/tests/configs/isolation_session_state_aware_provision_user_malformed_upn.json index 0c5c37343..e023129cc 100644 --- a/tests/configs/isolation_session_state_aware_provision_user_malformed_upn.json +++ b/tests/configs/isolation_session_state_aware_provision_user_malformed_upn.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session", "experimental": { diff --git a/tests/configs/isolation_session_state_aware_provision_with_filesystem.json b/tests/configs/isolation_session_state_aware_provision_with_filesystem.json index f3d5c7dee..2e9221541 100644 --- a/tests/configs/isolation_session_state_aware_provision_with_filesystem.json +++ b/tests/configs/isolation_session_state_aware_provision_with_filesystem.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session", "filesystem": { diff --git a/tests/configs/isolation_session_state_aware_provision_with_filter.json b/tests/configs/isolation_session_state_aware_provision_with_filter.json index 906498521..d5c4aa4e9 100644 --- a/tests/configs/isolation_session_state_aware_provision_with_filter.json +++ b/tests/configs/isolation_session_state_aware_provision_with_filter.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "provision", "containment": "isolation_session", "filesystem": { diff --git a/tests/configs/isolation_session_state_aware_start.json b/tests/configs/isolation_session_state_aware_start.json index b2c67519f..5f836a231 100644 --- a/tests/configs/isolation_session_state_aware_start.json +++ b/tests/configs/isolation_session_state_aware_start.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "start", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/isolation_session_state_aware_start_entra_missing_user.json b/tests/configs/isolation_session_state_aware_start_entra_missing_user.json index d2b2e2b66..0c16d3c27 100644 --- a/tests/configs/isolation_session_state_aware_start_entra_missing_user.json +++ b/tests/configs/isolation_session_state_aware_start_entra_missing_user.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "start", "sandboxId": "iso:alice@contoso.com" } diff --git a/tests/configs/isolation_session_state_aware_start_local_with_user.json b/tests/configs/isolation_session_state_aware_start_local_with_user.json index f674705d6..e117809f5 100644 --- a/tests/configs/isolation_session_state_aware_start_local_with_user.json +++ b/tests/configs/isolation_session_state_aware_start_local_with_user.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "start", "sandboxId": "iso:wxc-fake1234", "experimental": { diff --git a/tests/configs/isolation_session_state_aware_start_medium.json b/tests/configs/isolation_session_state_aware_start_medium.json index db39e2246..bcfcf4d10 100644 --- a/tests/configs/isolation_session_state_aware_start_medium.json +++ b/tests/configs/isolation_session_state_aware_start_medium.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "start", "sandboxId": "{{SANDBOX_ID}}", "experimental": { diff --git a/tests/configs/isolation_session_state_aware_start_upn_mismatch.json b/tests/configs/isolation_session_state_aware_start_upn_mismatch.json index 7f5242a4c..728b40355 100644 --- a/tests/configs/isolation_session_state_aware_start_upn_mismatch.json +++ b/tests/configs/isolation_session_state_aware_start_upn_mismatch.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "start", "sandboxId": "iso:alice@contoso.com", "experimental": { diff --git a/tests/configs/isolation_session_state_aware_stop.json b/tests/configs/isolation_session_state_aware_stop.json index 41c9349b8..27de41079 100644 --- a/tests/configs/isolation_session_state_aware_stop.json +++ b/tests/configs/isolation_session_state_aware_stop.json @@ -1,4 +1,5 @@ { + "version": "0.6.0-alpha", "phase": "stop", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/microvm_error.json b/tests/configs/microvm_error.json index 0217bc91c..7e504596f 100644 --- a/tests/configs/microvm_error.json +++ b/tests/configs/microvm_error.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "raise ValueError('intentional test error')", "timeout": 30000 diff --git a/tests/configs/microvm_error_linux.json b/tests/configs/microvm_error_linux.json index 0217bc91c..7e504596f 100644 --- a/tests/configs/microvm_error_linux.json +++ b/tests/configs/microvm_error_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "raise ValueError('intentional test error')", "timeout": 30000 diff --git a/tests/configs/microvm_exit_code.json b/tests/configs/microvm_exit_code.json index 0b4d61d08..fa0d1c2b7 100644 --- a/tests/configs/microvm_exit_code.json +++ b/tests/configs/microvm_exit_code.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import sys; sys.exit(42)", "timeout": 30000 diff --git a/tests/configs/microvm_exit_code_linux.json b/tests/configs/microvm_exit_code_linux.json index 0b4d61d08..fa0d1c2b7 100644 --- a/tests/configs/microvm_exit_code_linux.json +++ b/tests/configs/microvm_exit_code_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import sys; sys.exit(42)", "timeout": 30000 diff --git a/tests/configs/microvm_hello.json b/tests/configs/microvm_hello.json index a2bdaa59d..7dae3ba5b 100644 --- a/tests/configs/microvm_hello.json +++ b/tests/configs/microvm_hello.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "x = 42\ny = 58\nprint('Hello from MicroVM! sum=%d' % (x + y))", "timeout": 30000 diff --git a/tests/configs/microvm_hello_linux.json b/tests/configs/microvm_hello_linux.json index 21ed00595..60f1fd004 100644 --- a/tests/configs/microvm_hello_linux.json +++ b/tests/configs/microvm_hello_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "x = 42\ny = 58\nprint('Hello from NanVix/KVM on Linux! sum=%d' % (x + y))", "timeout": 30000 diff --git a/tests/configs/microvm_large_output.json b/tests/configs/microvm_large_output.json index 7efe25d5d..9a29c2e8d 100644 --- a/tests/configs/microvm_large_output.json +++ b/tests/configs/microvm_large_output.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)", "timeout": 30000 diff --git a/tests/configs/microvm_large_output_linux.json b/tests/configs/microvm_large_output_linux.json index 7efe25d5d..9a29c2e8d 100644 --- a/tests/configs/microvm_large_output_linux.json +++ b/tests/configs/microvm_large_output_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)", "timeout": 30000 diff --git a/tests/configs/microvm_multiline.json b/tests/configs/microvm_multiline.json index c11c1db31..8277454c2 100644 --- a/tests/configs/microvm_multiline.json +++ b/tests/configs/microvm_multiline.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')", "timeout": 30000 diff --git a/tests/configs/microvm_multiline_linux.json b/tests/configs/microvm_multiline_linux.json index c11c1db31..8277454c2 100644 --- a/tests/configs/microvm_multiline_linux.json +++ b/tests/configs/microvm_multiline_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')", "timeout": 30000 diff --git a/tests/configs/microvm_network.json b/tests/configs/microvm_network.json index 702eb151e..53e2a6299 100644 --- a/tests/configs/microvm_network.json +++ b/tests/configs/microvm_network.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)", "timeout": 30000 diff --git a/tests/configs/microvm_network_linux.json b/tests/configs/microvm_network_linux.json index 702eb151e..53e2a6299 100644 --- a/tests/configs/microvm_network_linux.json +++ b/tests/configs/microvm_network_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)", "timeout": 30000 diff --git a/tests/configs/microvm_stdlib.json b/tests/configs/microvm_stdlib.json index d159a896c..13835c601 100644 --- a/tests/configs/microvm_stdlib.json +++ b/tests/configs/microvm_stdlib.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))", "timeout": 30000 diff --git a/tests/configs/microvm_stdlib_linux.json b/tests/configs/microvm_stdlib_linux.json index d159a896c..13835c601 100644 --- a/tests/configs/microvm_stdlib_linux.json +++ b/tests/configs/microvm_stdlib_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "process": { "commandLine": "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))", "timeout": 30000 diff --git a/tests/configs/microvm_timeout.json b/tests/configs/microvm_timeout.json index b9c8c1c22..678059da3 100644 --- a/tests/configs/microvm_timeout.json +++ b/tests/configs/microvm_timeout.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "_comment": "Timeout test. Note: MicroVM adds a 60s boot grace to the 5s script timeout, so actual wall time is ~65s.", "process": { "commandLine": "import time; time.sleep(120); print('should not reach here')", diff --git a/tests/configs/microvm_timeout_linux.json b/tests/configs/microvm_timeout_linux.json index c7748e18b..e3a4749c8 100644 --- a/tests/configs/microvm_timeout_linux.json +++ b/tests/configs/microvm_timeout_linux.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "_comment": "Timeout test. NanVix adds a 60s boot grace to the 5s script timeout, so actual wall time is ~65s.", "process": { "commandLine": "import time; time.sleep(120); print('should not reach here')", diff --git a/tests/configs/windows_sandbox_echo.json b/tests/configs/windows_sandbox_echo.json index 46cb757ba..968c8d4fe 100644 --- a/tests/configs/windows_sandbox_echo.json +++ b/tests/configs/windows_sandbox_echo.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "echo Hello from sandbox!", diff --git a/tests/configs/windows_sandbox_exit_code.json b/tests/configs/windows_sandbox_exit_code.json index 92d1a2d94..94a079841 100644 --- a/tests/configs/windows_sandbox_exit_code.json +++ b/tests/configs/windows_sandbox_exit_code.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "exit /b 42", diff --git a/tests/configs/windows_sandbox_powershell.json b/tests/configs/windows_sandbox_powershell.json index bff4c281c..fe84ac2a4 100644 --- a/tests/configs/windows_sandbox_powershell.json +++ b/tests/configs/windows_sandbox_powershell.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "powershell -NoProfile -Command \"Write-Output 'PowerShell works'; $PSVersionTable.PSVersion.ToString()\"", diff --git a/tests/configs/windows_sandbox_powershell_env.json b/tests/configs/windows_sandbox_powershell_env.json index f11be8157..8553e4141 100644 --- a/tests/configs/windows_sandbox_powershell_env.json +++ b/tests/configs/windows_sandbox_powershell_env.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "powershell -NoProfile -Command \"Write-Output ('ComputerName=' + $env:COMPUTERNAME); Write-Output ('User=' + $env:USERNAME); Write-Output ('ProcessCount=' + (Get-Process | Measure-Object).Count)\"", diff --git a/tests/configs/windows_sandbox_stderr.json b/tests/configs/windows_sandbox_stderr.json index defee7768..40c8eccfc 100644 --- a/tests/configs/windows_sandbox_stderr.json +++ b/tests/configs/windows_sandbox_stderr.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "echo stdout-message && echo stderr-message 1>&2", diff --git a/tests/configs/windows_sandbox_timeout.json b/tests/configs/windows_sandbox_timeout.json index a629b0e22..87b756dd7 100644 --- a/tests/configs/windows_sandbox_timeout.json +++ b/tests/configs/windows_sandbox_timeout.json @@ -1,4 +1,5 @@ { + "version": "0.8.0-alpha", "containment": "windows_sandbox", "process": { "commandLine": "ping -n 30 127.0.0.1", diff --git a/tests/examples/28_telemetry_enabled.json b/tests/examples/28_telemetry_enabled.json index 7ac84537b..5ec0e326f 100644 --- a/tests/examples/28_telemetry_enabled.json +++ b/tests/examples/28_telemetry_enabled.json @@ -1,5 +1,6 @@ { "$schema": "../../schemas/dev/mxc-config.schema.0.8.0-dev.json", + "version": "0.8.0-alpha", "containment": "processcontainer", "process": { "commandLine": "cmd.exe /c echo Hello from telemetry-enabled sandbox" diff --git a/tests/scripts/run_isolation_session_state_aware_tests.ps1 b/tests/scripts/run_isolation_session_state_aware_tests.ps1 index 641623791..8b0818fa6 100644 --- a/tests/scripts/run_isolation_session_state_aware_tests.ps1 +++ b/tests/scripts/run_isolation_session_state_aware_tests.ps1 @@ -47,6 +47,13 @@ param( ) $ErrorActionPreference = "Stop" + +# Schema version stamped on every state-aware request built from a hashtable in +# this script. `version` is required by the parser and selects which config +# fields are legal, so it is applied centrally in the Invoke-* helper rather +# than repeated at each call site. Matches the SDK's STATE_AWARE_VERSION and +# `stateAware` in schemas/schema-version.json. +$StateAwareSchemaVersion = '0.6.0-alpha' $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ---------------- Locate wxc-exec.exe ---------------- @@ -137,6 +144,13 @@ function Invoke-StateAware { $json = $json -replace '\{\{SANDBOX_ID\}\}', $SandboxId } } elseif ($Request) { + # `version` is required by the parser and selects which fields are legal, + # so stamp it centrally rather than at every call site. (Fixture-driven + # requests carry their own version in the JSON file.) + if (-not $Request.ContainsKey('version')) { + $Request = $Request.Clone() + $Request['version'] = $StateAwareSchemaVersion + } $json = $Request | ConvertTo-Json -Compress -Depth 12 } else { throw "Invoke-StateAware requires either -Request or -ConfigFile" diff --git a/tests/scripts/run_isolation_session_tests.ps1 b/tests/scripts/run_isolation_session_tests.ps1 index 957edff3c..d188053e0 100644 --- a/tests/scripts/run_isolation_session_tests.ps1 +++ b/tests/scripts/run_isolation_session_tests.ps1 @@ -58,6 +58,13 @@ param( ) $ErrorActionPreference = "Stop" + +# Schema version stamped on every state-aware request built from a hashtable in +# this script. `version` is required by the parser and selects which config +# fields are legal, so it is applied centrally in the Invoke-* helper rather +# than repeated at each call site. Matches the SDK's STATE_AWARE_VERSION and +# `stateAware` in schemas/schema-version.json. +$StateAwareSchemaVersion = '0.6.0-alpha' $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) if (-not $ConfigDir) { @@ -131,6 +138,12 @@ if (-not (Test-Path $IsoSessionOpsKey)) { # { Stdout, ExitCode }. function Invoke-StateAwareProbe { param([hashtable]$Request) + # `version` is required by the parser; stamp it centrally so probes cannot + # fail the version gate before reaching the behaviour they are probing. + if ($Request -and -not $Request.ContainsKey('version')) { + $Request = $Request.Clone() + $Request['version'] = $StateAwareSchemaVersion + } $json = $Request | ConvertTo-Json -Compress -Depth 8 $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) $out = & $WxcExec --experimental --config-base64 $b64 2>&1 | Out-String diff --git a/tests/scripts/run_windows_sandbox_state_aware_tests.ps1 b/tests/scripts/run_windows_sandbox_state_aware_tests.ps1 index aaf8f0bc1..851f039a5 100644 --- a/tests/scripts/run_windows_sandbox_state_aware_tests.ps1 +++ b/tests/scripts/run_windows_sandbox_state_aware_tests.ps1 @@ -43,6 +43,13 @@ param( ) $ErrorActionPreference = "Stop" + +# Schema version stamped on every state-aware request built from a hashtable in +# this script. `version` is required by the parser and selects which config +# fields are legal, so it is applied centrally in the Invoke-* helper rather +# than repeated at each call site. Matches the SDK's STATE_AWARE_VERSION and +# `stateAware` in schemas/schema-version.json. +$StateAwareSchemaVersion = '0.6.0-alpha' $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ---------------- Locate wxc-exec.exe ---------------- @@ -131,6 +138,14 @@ function Invoke-StateAware { [int]$TimeoutSec = 120 ) + # `version` is required by the parser and selects which fields are legal, so + # stamp it centrally rather than at every call site. State-aware requests use + # the same version the SDK emits (STATE_AWARE_VERSION). + if ($Request -and -not $Request.ContainsKey('version')) { + $Request = $Request.Clone() + $Request['version'] = $StateAwareSchemaVersion + } + $json = $Request | ConvertTo-Json -Compress -Depth 12 $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) From 309bc30cbfdee9a1bda095762d9f8c4ccdb58429 Mon Sep 17 00:00:00 2001 From: Branden Bonaby Date: Fri, 7 Aug 2026 12:21:37 -0700 Subject: [PATCH 2/2] Resolve phase8a conflicts after stack rebase Regenerate the SDK wire types with the current emitter and update tests added below phase8a to use the newly required schema version and availability bounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43f038b4-b943-4a48-965f-ed8350b7a30a --- sdk/node/src/generated/wire.ts | 3 ++- src/core/mxc_engine/src/policy.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index e2dbfdb4f..a5269666e 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -320,7 +320,7 @@ export interface ProcessContainer { capabilities?: string[] | null; /** * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability. - * + * * Introduced at 0.8. */ captureDenials?: CaptureDenials | null; @@ -575,3 +575,4 @@ export interface MXCConfiguration { */ version?: string | null; } + diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 3b53df09a..22580947b 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -1230,6 +1230,7 @@ mod tests { network: None, ui: None, timeout_ms: None, + capture_denials: None, }; let err = build_request(&policy, None).expect_err("0.3 is below the supported floor"); assert_eq!(err.code, crate::ErrorCode::VersionIncompatible); @@ -1458,7 +1459,7 @@ mod tests { fn policy_with_capture_denials(section: CaptureDenialsSection) -> SandboxPolicy { SandboxPolicy { - version: "0.7.0-alpha".to_string(), + version: "0.8.0-alpha".to_string(), filesystem: None, network: None, ui: None, @@ -1579,6 +1580,7 @@ mod tests { #[test] fn wire_contract_accepts_capture_denials_together_with_a_network_proxy() { let config = serde_json::json!({ + "version": "0.8.0-alpha", "process": { "commandLine": "echo hello" }, "containment": "processcontainer", "network": { @@ -1619,6 +1621,7 @@ mod tests { proxy: Some(ProxySpec::Localhost(8080)), ..NetworkSection::default() }); + policy.version = "0.8.0-alpha".to_string(); policy.capture_denials = Some(CaptureDenialsSection { mode: CaptureDenialsMode::Allow, output_path: Some(expected.clone()),