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..a5269666e 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; /** 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..22580947b 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,86 @@ 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, + 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); + 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")] @@ -1346,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, @@ -1467,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": { @@ -1507,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()), 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))