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 8e4a9ec31..3e3c0bde7 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -1132,6 +1132,26 @@ if discriminator.phase.is_some() { } ``` +`parse_rolling_state_aware_wire_input` is the rolling source adapter. It +extracts and validates the raw experimental block, masks that block for base +wire deserialization, and produces: + +```rust +StateAwareWireInput { + config, // common wire fields; experimental is always None + experimental_raw, // lossless backend payload + source_text, // exact decoded request text +} +``` + +`convert_wire_state_aware` composes that source adapter with the shared +`normalize_state_aware` seam, which owns request-kind and containment +validation, one-shot-section rejection, common policy conversion, +post-provision Network presence handling, and typed telemetry population. The +exact development adapter produces the same neutral representation so the +private exact router can enter this seam without duplicating rolling +normalization. + The trailing-command path keeps the exact phase probe separate because it owns error routing. Backend selection and command editing share one duplicate-preserving raw root parse, and the resulting effective document then @@ -1140,9 +1160,13 @@ enters `parse_mxc_request_json` directly. `wire::MxcConfig` is closed (`deny_unknown_fields`) on its stable surface, so unknown fields are rejected at the trust boundary. `phase` maps to the -`wire::Phase` enum. The `experimental` block stays permissive and is captured as -a raw `serde_json::Value` on the state-aware path so the dispatcher can type each -backend's per-phase config from it (`experimental..`). +`wire::Phase` enum. The rolling `experimental` block stays permissive and is +captured as a raw `serde_json::Value`; the exact contract closes the same shape +before its adapter preserves the source value. In both cases +`config.experimental` is cleared, and the dispatcher types each backend's +per-phase config from `experimental_raw` +(`experimental..`). This raw value is the temporary transport +between structural parsing and backend phase deserialization. Per-phase requirements (`containment` for `provision`, `sandboxId` for the others) are enforced in the conversion step, not at the deserializer. The @@ -1150,12 +1174,14 @@ one-shot path rejects `phase` / `sandboxId`, and the state-aware path rejects one-shot-only sections (`seatbelt` / `processContainer` / `lxc` / `lifecycle`), so each mode only accepts its valid fields. -Conversion populates the cross-cutting wire fields (`filesystem`, `network`, +Normalization populates the cross-cutting wire fields (`filesystem`, `network`, `ui`) into `ExecutionRequest.policy` (a `ContainerPolicy`) exactly as the one-shot path does, and `process` populates `ExecutionRequest`'s flat -`script_code` / `working_directory` / `script_timeout` / `env` fields. The -state-aware-only fields (`phase`, `sandboxId`, `experimental..`) -are extracted alongside the `ExecutionRequest` and bundled into a +`script_code` / `working_directory` / `script_timeout` / `env` fields. Typed +telemetry is populated from the neutral config's top-level `config.telemetry`; +`experimental_raw` remains available for backend phase configuration. The state-aware-only fields +(`phase`, `sandboxId`, `experimental..`) are bundled with the +`ExecutionRequest` in a `ParsedStateAwareRequest` domain model — `{ request: ExecutionRequest, phase: Phase, containment: Option, sandbox_id: Option, experimental_raw: Option, source_text: Option> }` — diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs b/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs index 9d70556e3..021cf74f2 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs @@ -147,6 +147,7 @@ mod tests { assert_eq!(adapted.config.version, Some("0.9.0-alpha".to_string())); assert!(matches!(adapted.config.phase, Some(wire::Phase::Exec))); assert_eq!(adapted.config.sandbox_id, Some("sandbox-id".to_string())); + assert!(adapted.config.experimental.is_none()); assert!(adapted.config.process.is_some()); let process = adapted.config.process.unwrap(); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 2ea035cc8..9a1c58106 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -432,9 +432,14 @@ pub(super) fn deprovision_into_wire(request: contract::DeprovisionRequest) -> wi } pub(super) fn into_state_aware_wire_input( - config: wire::MxcConfig, + mut config: wire::MxcConfig, source_text: &str, ) -> Result { + // State-aware backend data is carried losslessly in `experimental_raw`. + // Clear the redundant rolling-wire copy before the shared normalizer sees + // the input, matching the rolling parser's canonical representation. + config.experimental = None; + Ok(StateAwareWireInput { config, experimental_raw: extract_experimental_value(source_text)?, diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs index a526cabe2..93a09ac5c 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs @@ -4,7 +4,31 @@ use super::super::{ contract, extract_experimental_value, into_state_aware_wire_input, start_into_wire, wire, }; +use crate::config_parser::parse_rolling_state_aware_wire_input; use crate::state_aware_wire::StateAwareWireInput; +use serde_json::value::RawValue; + +#[derive(serde::Deserialize)] +struct ExperimentalProbe<'a> { + #[serde(borrow, default)] + experimental: Option<&'a RawValue>, +} + +pub(super) fn assert_config_matches_rolling_state_aware_wire_input( + source: &str, + adapted_config: wire::MxcConfig, +) { + let probe: ExperimentalProbe<'_> = serde_json::from_str(source).unwrap(); + let rolling = parse_rolling_state_aware_wire_input(source, probe.experimental).unwrap(); + let adapted = into_state_aware_wire_input(adapted_config, source).unwrap(); + + assert_eq!( + serde_json::to_value(adapted.config).unwrap(), + serde_json::to_value(rolling.config).unwrap() + ); + assert_eq!(adapted.experimental_raw, rolling.experimental_raw); + assert_eq!(adapted.source_text, rolling.source_text); +} #[test] fn extract_experimental_value_returns_none_when_absent() { diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs index c7037f82a..40b8967e9 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::super::{contract, deprovision_into_wire, wire}; +use super::common::assert_config_matches_rolling_state_aware_wire_input; const MINIMAL_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", @@ -125,32 +126,27 @@ fn null_comment_maps_expected_wire_field() { } // Deserialization match tests -pub(super) fn assert_matches_current_wire_deserialization(json: &str) { - let current: wire::MxcConfig = crate::config_deserialize::from_str(json).unwrap(); +pub(super) fn assert_matches_rolling_state_aware_wire_input(json: &str) { let adapted = adapt(json); - - assert_eq!( - serde_json::to_value(adapted).unwrap(), - serde_json::to_value(current).unwrap() - ); + assert_config_matches_rolling_state_aware_wire_input(json, adapted); } #[test] -fn minimal_request_matches_current_wire_deserialization() { +fn minimal_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn request_with_all_fields_matches_current_wire_deserialization() { +fn request_with_all_fields_matches_rolling_state_aware_wire_input() { let json = ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] fn empty_experimental_sections_match_current_wire_deserialization() { for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { - assert_matches_current_wire_deserialization(&request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&request_with_fields(fields)); } } @@ -162,5 +158,5 @@ fn empty_identifier_strings_match_current_wire_deserialization() { "sandboxId": "" }"#; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs index e9ea02b38..fd2feb35b 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::super::{contract, exec_into_wire, wire}; +use super::common::assert_config_matches_rolling_state_aware_wire_input; const MINIMAL_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", @@ -203,38 +204,33 @@ fn null_comment_maps_expected_wire_field() { } // Deserialization match tests -pub(super) fn assert_matches_current_wire_deserialization(json: &str) { - let current: wire::MxcConfig = crate::config_deserialize::from_str(json).unwrap(); +pub(super) fn assert_matches_rolling_state_aware_wire_input(json: &str) { let adapted = adapt(json); - - assert_eq!( - serde_json::to_value(adapted).unwrap(), - serde_json::to_value(current).unwrap() - ); + assert_config_matches_rolling_state_aware_wire_input(json, adapted); } #[test] -fn minimal_request_matches_current_wire_deserialization() { +fn minimal_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn request_with_all_fields_matches_current_wire_deserialization() { +fn request_with_all_fields_matches_rolling_state_aware_wire_input() { let json = ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] fn empty_experimental_sections_match_current_wire_deserialization() { for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { - assert_matches_current_wire_deserialization(&request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&request_with_fields(fields)); } } #[test] -fn empty_network_section_matches_current_wire_deserialization() { - assert_matches_current_wire_deserialization(&request_with_fields(r#""network": {}"#)); +fn empty_network_section_matches_rolling_state_aware_wire_input() { + assert_matches_rolling_state_aware_wire_input(&request_with_fields(r#""network": {}"#)); } #[test] @@ -249,5 +245,5 @@ fn empty_identifier_strings_match_current_wire_deserialization() { } }"#; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs index 57d43e694..90e1d72a3 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -2,6 +2,8 @@ // Licensed under the MIT License. use super::super::{contract, provision_into_wire, wire}; +use super::common::assert_config_matches_rolling_state_aware_wire_input; +use crate::models::IsolationSessionProvisionConfig; const MINIMAL_ISOLATION_SESSION_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", @@ -222,7 +224,6 @@ fn isolation_session_request_maps_expected_wire_fields() { let telemetry = wire.telemetry.expect("telemetry should be present"); assert_eq!(telemetry.enabled, Some(false)); - assert!(experimental.test.is_none()); assert!(experimental.wslc.is_none()); assert!(experimental.windows_sandbox.is_none()); @@ -400,7 +401,6 @@ fn wslc_request_maps_expected_wire_fields() { ); let experimental = wire.experimental.expect("experimental should be populated"); - let wslc = experimental.wslc.expect("wslc should be populated"); let provision = wslc.provision.expect("provision should be populated"); assert_eq!(provision.image.as_deref(), Some("someImage")); @@ -419,7 +419,6 @@ fn wslc_request_maps_expected_wire_fields() { let telemetry = wire.telemetry.expect("telemetry should be populated"); assert_eq!(telemetry.enabled, Some(false)); - assert!(experimental.test.is_none()); assert!(experimental.isolation_session.is_none()); assert!(experimental.windows_sandbox.is_none()); @@ -573,51 +572,126 @@ fn null_provision_comments_map_expected_wire_fields() { } } -// Deserialization match tests -pub(super) fn assert_matches_current_wire_deserialization(json: &str) { - let current: wire::MxcConfig = crate::config_deserialize::from_str(json).unwrap(); - let adapted = adapt(json); +#[test] +fn isolation_session_provision_config_matches_the_contract_for_valid_values() { + for json in [r#"{}"#, r#"{"appId":""}"#, r#"{"appId":"Contoso.App"}"#] { + let contract::IsolationSessionProvision { app_id } = serde_json::from_str(json).unwrap(); + let backend: IsolationSessionProvisionConfig = serde_json::from_str(json).unwrap(); - assert_eq!( - serde_json::to_value(adapted).unwrap(), - serde_json::to_value(current).unwrap() + assert_eq!(app_id.into_option(), backend.app_id, "{json}"); + } +} + +#[test] +fn isolation_session_backend_accepts_known_shapes_stricter_contract_rejects() { + for json in [r#"{"appId":null}"#, r#"{"unknown":true}"#] { + assert!( + serde_json::from_str::(json).is_err(), + "contract should reject {json}" + ); + assert!( + serde_json::from_str::(json).is_ok(), + "backend config should retain its current compatibility for {json}" + ); + } +} + +#[test] +fn wslc_provision_config_matches_the_contract_for_valid_values() { + for json in [ + r#"{}"#, + r#"{"image":""}"#, + r#"{"imageTarPath":""}"#, + r#"{"image":"alpine:latest","imageTarPath":"C:\\images\\alpine.tar"}"#, + ] { + let contract::WslcProvision { + image, + image_tar_path, + } = serde_json::from_str(json).unwrap(); + let backend: wire::WslcProvisionPhase = serde_json::from_str(json).unwrap(); + + assert_eq!(image.into_option(), backend.image, "{json}: image"); + assert_eq!( + image_tar_path.into_option(), + backend.image_tar_path, + "{json}: imageTarPath" + ); + } +} + +#[test] +fn wslc_backend_accepts_known_shapes_stricter_contract_rejects() { + for json in [r#"{"image":null}"#, r#"{"unknown":true}"#] { + assert!( + serde_json::from_str::(json).is_err(), + "contract should reject {json}" + ); + assert!( + serde_json::from_str::(json).is_ok(), + "backend config should retain its current compatibility for {json}" + ); + } +} + +#[test] +fn windows_sandbox_contract_has_no_backend_provision_payload() { + let json = r#"{ + "version": "0.9.0-alpha", + "phase": "provision", + "containment": "windows_sandbox", + "experimental": { + "windows_sandbox": { + "provision": {} + } + } + }"#; + + assert!( + serde_json::from_str::(json).is_err(), + "Windows Sandbox provision config is the unit type and has no payload" ); } +// Deserialization match tests +pub(super) fn assert_matches_rolling_state_aware_wire_input(json: &str) { + let adapted = adapt(json); + assert_config_matches_rolling_state_aware_wire_input(json, adapted); +} + #[test] -fn minimal_isolation_session_request_matches_current_wire_deserialization() { +fn minimal_isolation_session_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_ISOLATION_SESSION_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn isolation_session_request_matches_current_wire_deserialization() { +fn isolation_session_request_matches_rolling_state_aware_wire_input() { let json = ISOLATION_SESSION_ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn minimal_windows_sandbox_request_matches_current_wire_deserialization() { +fn minimal_windows_sandbox_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_WINDOWS_SANDBOX_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn windows_sandbox_request_matches_current_wire_deserialization() { +fn windows_sandbox_request_matches_rolling_state_aware_wire_input() { let json = WINDOWS_SANDBOX_ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn minimal_wslc_request_matches_current_wire_deserialization() { +fn minimal_wslc_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_WSLC_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn wslc_request_matches_current_wire_deserialization() { +fn wslc_request_matches_rolling_state_aware_wire_input() { let json = WSLC_ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] @@ -628,7 +702,9 @@ fn empty_isolation_session_sections_match_current_wire_deserialization() { r#""experimental": {"isolation_session": {}}"#, r#""experimental": {"isolation_session": {"provision": {}}}"#, ] { - assert_matches_current_wire_deserialization(&isolation_session_request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&isolation_session_request_with_fields( + fields, + )); } } @@ -639,7 +715,7 @@ fn empty_windows_sandbox_sections_match_current_wire_deserialization() { r#""experimental": {}"#, r#""telemetry": {}"#, ] { - assert_matches_current_wire_deserialization(&windows_sandbox_request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&windows_sandbox_request_with_fields(fields)); } } @@ -653,7 +729,7 @@ fn empty_wslc_sections_match_current_wire_deserialization() { r#""experimental": {"wslc": {}}"#, r#""experimental": {"wslc": {"provision": {}}}"#, ] { - assert_matches_current_wire_deserialization(&wslc_request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&wslc_request_with_fields(fields)); } } @@ -662,10 +738,10 @@ fn empty_backend_strings_match_current_wire_deserialization() { let isolation_session = isolation_session_request_with_fields( r#""experimental": {"isolation_session": {"provision": {"appId": ""}}}"#, ); - assert_matches_current_wire_deserialization(&isolation_session); + assert_matches_rolling_state_aware_wire_input(&isolation_session); let wslc = wslc_request_with_fields( r#""experimental": {"wslc": {"provision": {"image": "", "imageTarPath": ""}}}"#, ); - assert_matches_current_wire_deserialization(&wslc); + assert_matches_rolling_state_aware_wire_input(&wslc); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs index bacd8e0e1..227ece567 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::super::{contract, start_into_wire, wire}; +use super::common::assert_config_matches_rolling_state_aware_wire_input; const MINIMAL_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", @@ -125,32 +126,27 @@ fn null_comment_maps_expected_wire_field() { } // Deserialization match tests -pub(super) fn assert_matches_current_wire_deserialization(json: &str) { - let current: wire::MxcConfig = crate::config_deserialize::from_str(json).unwrap(); +pub(super) fn assert_matches_rolling_state_aware_wire_input(json: &str) { let adapted = adapt(json); - - assert_eq!( - serde_json::to_value(adapted).unwrap(), - serde_json::to_value(current).unwrap() - ); + assert_config_matches_rolling_state_aware_wire_input(json, adapted); } #[test] -fn minimal_request_matches_current_wire_deserialization() { +fn minimal_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn request_with_all_fields_matches_current_wire_deserialization() { +fn request_with_all_fields_matches_rolling_state_aware_wire_input() { let json = ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] fn empty_experimental_sections_match_current_wire_deserialization() { for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { - assert_matches_current_wire_deserialization(&request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&request_with_fields(fields)); } } @@ -162,5 +158,5 @@ fn empty_identifier_strings_match_current_wire_deserialization() { "sandboxId": "" }"#; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs index 2b96134d6..7f1e79108 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::super::{contract, stop_into_wire, wire}; +use super::common::assert_config_matches_rolling_state_aware_wire_input; const MINIMAL_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", @@ -125,32 +126,27 @@ fn null_comment_maps_expected_wire_field() { } // Deserialization match tests -pub(super) fn assert_matches_current_wire_deserialization(json: &str) { - let current: wire::MxcConfig = crate::config_deserialize::from_str(json).unwrap(); +pub(super) fn assert_matches_rolling_state_aware_wire_input(json: &str) { let adapted = adapt(json); - - assert_eq!( - serde_json::to_value(adapted).unwrap(), - serde_json::to_value(current).unwrap() - ); + assert_config_matches_rolling_state_aware_wire_input(json, adapted); } #[test] -fn minimal_request_matches_current_wire_deserialization() { +fn minimal_request_matches_rolling_state_aware_wire_input() { let json = MINIMAL_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] -fn request_with_all_fields_matches_current_wire_deserialization() { +fn request_with_all_fields_matches_rolling_state_aware_wire_input() { let json = ALL_FIELDS_REQUEST_JSON; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } #[test] fn empty_experimental_sections_match_current_wire_deserialization() { for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { - assert_matches_current_wire_deserialization(&request_with_fields(fields)); + assert_matches_rolling_state_aware_wire_input(&request_with_fields(fields)); } } @@ -162,5 +158,5 @@ fn empty_identifier_strings_match_current_wire_deserialization() { "sandboxId": "" }"#; - assert_matches_current_wire_deserialization(json); + assert_matches_rolling_state_aware_wire_input(json); } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 1895eb281..5b9583720 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -18,6 +18,7 @@ use crate::network_parser::{ supports_directional_network, NetworkSections, }; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use crate::state_aware_wire::StateAwareWireInput; use crate::wire; use mxc_config_contract::dev::{probe_phase, Phase as ContractPhase}; use serde::{Deserialize, Deserializer}; @@ -414,17 +415,12 @@ fn parse_mxc_request_json(json_str: &str, logger: &mut Logger) -> Result = config_deserialize::from_str(json_str) .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; if discriminator.phase.is_some() { - let experimental = discriminator.experimental.map(|raw| raw.get()); - let experimental_span = experimental - .map(|raw| experimental_source_span(json_str, raw)) - .transpose() - .map_err(ParseError::Decode)?; let raw: serde_json::Value = config_deserialize::from_str(json_str) .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; validate_versioned_fields(&raw).map_err(|error| { ParseError::StateAware(MxcError::malformed_request(error.to_string())) })?; - convert_wire_state_aware(json_str, experimental, experimental_span, logger) + convert_wire_state_aware(json_str, discriminator.experimental, logger) .map(MxcRequest::StateAware) .map_err(|e| ParseError::StateAware(MxcError::malformed_request(e.to_string()))) } else { @@ -1589,12 +1585,14 @@ fn convert_wire_config( }) } -fn convert_wire_state_aware( +pub(crate) fn parse_rolling_state_aware_wire_input( json: &str, - experimental: Option<&str>, - experimental_span: Option<(usize, usize)>, - logger: &mut Logger, -) -> Result { + experimental: Option<&RawValue>, +) -> Result { + let experimental = experimental.map(RawValue::get); + let experimental_span = experimental + .map(|raw| experimental_source_span(json, raw)) + .transpose()?; let experimental_raw = experimental .map(|raw| { config_deserialize::from_str::(raw) @@ -1602,15 +1600,8 @@ fn convert_wire_state_aware( }) .transpose()?; - // Peeling `experimental` off above also removes it from the typed - // deserialize, so a non-object value (e.g. `"experimental": 42`) would slip - // through unchecked here and be silently ignored — unlike the one-shot path, - // where `experimental` is a typed `Option` and a non-object is - // a hard parse error. Reject a present, non-null, non-object value up front - // so both paths fail malformed configs consistently. (`null` maps to "absent" - // on both paths and is accepted.) - if let Some(exp) = experimental_raw.as_ref() { - if !exp.is_null() && !exp.is_object() { + if let Some(experimental) = experimental_raw.as_ref() { + if !experimental.is_null() && !experimental.is_object() { return Err(WxcError::ConfigParse( "Invalid configuration at `experimental`: expected an object".to_string(), )); @@ -1618,9 +1609,40 @@ fn convert_wire_state_aware( } let base_json = mask_state_aware_experimental_with_span(json, experimental, experimental_span)?; - let mut cfg: wire::MxcConfig = config_deserialize::from_str(&base_json) + let mut config: wire::MxcConfig = config_deserialize::from_str(&base_json) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + // The raw value above is authoritative for state-aware experimental data. + config.experimental = None; + + Ok(StateAwareWireInput { + config, + experimental_raw, + source_text: json.into(), + }) +} + +fn convert_wire_state_aware( + json: &str, + experimental: Option<&RawValue>, + logger: &mut Logger, +) -> Result { + let input = parse_rolling_state_aware_wire_input(json, experimental)?; + normalize_state_aware(input, logger) +} + +/// Apply the state-aware validation and runtime normalization shared by the +/// rolling and exact-contract parser paths. +fn normalize_state_aware( + input: StateAwareWireInput, + logger: &mut Logger, +) -> Result { + let StateAwareWireInput { + config: mut cfg, + experimental_raw, + source_text, + } = input; + // `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() { @@ -1746,7 +1768,7 @@ fn convert_wire_state_aware( // Retain the decoded request text so the dispatcher can deserialize each // `experimental..` sub-slice positionally and report // typed errors with whole-file line/column (parity with base config). - source_text: Some(json.to_owned().into_boxed_str()), + source_text: Some(source_text), }) } @@ -1844,6 +1866,165 @@ mod tests { Logger::new(Mode::Buffer) } + fn neutral_state_aware_input( + config_json: &str, + experimental_raw: Option, + source_text: &str, + ) -> StateAwareWireInput { + StateAwareWireInput { + config: config_deserialize::from_str(config_json).unwrap(), + experimental_raw, + source_text: source_text.into(), + } + } + + #[test] + fn normalize_state_aware_owns_config_and_raw_validations() { + let config_input = neutral_state_aware_input( + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "containment": "wslc" + }"#, + None, + "config validation source", + ); + let config_error = match normalize_state_aware(config_input, &mut test_logger()) { + Ok(_) => panic!("containment on start should be rejected"), + Err(error) => error, + }; + assert!( + config_error + .to_string() + .contains("requests must not carry 'containment'"), + "unexpected config validation error: {config_error}" + ); + + let raw_input = neutral_state_aware_input( + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234" + }"#, + Some(serde_json::json!({"seatbelt": {}})), + "raw validation source", + ); + let raw_error = match normalize_state_aware(raw_input, &mut test_logger()) { + Ok(_) => panic!("moved experimental Seatbelt config should be rejected"), + Err(error) => error, + }; + assert!( + raw_error + .to_string() + .contains("'experimental.seatbelt' has moved"), + "unexpected raw validation error: {raw_error}" + ); + } + + #[test] + fn normalize_state_aware_populates_telemetry_from_neutral_input() { + let input = neutral_state_aware_input( + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "telemetry": {"enabled": true} + }"#, + None, + "telemetry source", + ); + + let parsed = normalize_state_aware(input, &mut test_logger()).unwrap(); + + assert_eq!(parsed.phase, Phase::Start); + assert_eq!( + parsed + .request + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(true) + ); + assert!(parsed.experimental_raw.is_none()); + assert_eq!(parsed.source_text.as_deref(), Some("telemetry source")); + } + + #[test] + fn normalize_state_aware_rejects_moved_experimental_telemetry() { + let input = neutral_state_aware_input( + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234" + }"#, + Some(serde_json::json!({"telemetry": 42})), + "malformed telemetry source", + ); + + let error = match normalize_state_aware(input, &mut test_logger()) { + Ok(_) => panic!("moved experimental telemetry should be rejected"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved"), + "unexpected telemetry error: {error}" + ); + } + + #[test] + fn phase_7_2_preserves_rolling_only_validation_diagnostics() { + for (case, json, expected) in [ + ( + "containment on a non-provision phase", + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "containment": "wslc" + }"#, + "requests must not carry 'containment'", + ), + ( + "moved experimental Seatbelt section", + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "experimental": {"seatbelt": {}} + }"#, + "'experimental.seatbelt' has moved", + ), + ( + "one-shot lifecycle section", + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "lifecycle": {} + }"#, + "do not accept one-shot section(s): lifecycle", + ), + ( + "multiple experimental backend sections", + r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "experimental": { + "isolation_session": {}, + "wslc": {} + } + }"#, + "Multiple containment backends configured", + ), + ] { + let error = match load_mxc(json) { + Err(ParseError::StateAware(error)) => error, + other => panic!("{case}: expected state-aware error, got {other:?}"), + }; + assert!( + error.message.contains(expected), + "{case}: unexpected error: {}", + error.message + ); + } + } + #[test] fn reused_phase_probe_classifies_one_shot_and_state_aware_phases() { assert_eq!( @@ -1916,6 +2097,191 @@ mod tests { ) } + fn load_state_aware(json: &str) -> ParsedStateAwareRequest { + match load_mxc(json).expect("state-aware request should parse") { + MxcRequest::StateAware(parsed) => parsed, + MxcRequest::OneShot(_) => panic!("expected state-aware request"), + } + } + + #[test] + fn phase_7_2_characterizes_every_state_aware_phase_and_provision_backend() { + for ( + case, + json, + expected_phase, + expected_declared_containment, + expected_runtime_containment, + expected_sandbox_id, + expected_script, + ) in [ + ( + "windows sandbox provision", + r#"{"phase":"provision","containment":"windows_sandbox"}"#, + Phase::Provision, + Some(ContainmentBackend::WindowsSandbox), + ContainmentBackend::WindowsSandbox, + None, + "", + ), + ( + "isolation session provision", + r#"{"phase":"provision","containment":"isolation_session"}"#, + Phase::Provision, + Some(ContainmentBackend::IsolationSession), + ContainmentBackend::IsolationSession, + None, + "", + ), + ( + "wslc provision", + r#"{"phase":"provision","containment":"wslc"}"#, + Phase::Provision, + Some(ContainmentBackend::Wslc), + ContainmentBackend::Wslc, + None, + "", + ), + ( + "start", + r#"{"phase":"start","sandboxId":"wsb:abcd1234"}"#, + Phase::Start, + None, + ContainmentBackend::WindowsSandbox, + Some("wsb:abcd1234"), + "", + ), + ( + "exec", + r#"{"phase":"exec","sandboxId":"wslc:abcd1234","process":{"commandLine":"echo hi"}}"#, + Phase::Exec, + None, + ContainmentBackend::Wslc, + Some("wslc:abcd1234"), + "echo hi", + ), + ( + "stop", + r#"{"phase":"stop","sandboxId":"iso:abcd1234"}"#, + Phase::Stop, + None, + ContainmentBackend::IsolationSession, + Some("iso:abcd1234"), + "", + ), + ( + "deprovision", + r#"{"phase":"deprovision","sandboxId":"wslc:abcd1234"}"#, + Phase::Deprovision, + None, + ContainmentBackend::Wslc, + Some("wslc:abcd1234"), + "", + ), + ] { + let parsed = load_state_aware(json); + + assert_eq!(parsed.phase, expected_phase, "{case}"); + assert_eq!( + parsed.containment, expected_declared_containment, + "{case}: declared containment" + ); + assert_eq!( + parsed.request.containment, expected_runtime_containment, + "{case}: runtime containment" + ); + assert_eq!( + parsed.sandbox_id.as_deref(), + expected_sandbox_id, + "{case}: sandbox id" + ); + assert_eq!(parsed.request.script_code, expected_script, "{case}"); + assert!(parsed.experimental_raw.is_none(), "{case}"); + assert_eq!(parsed.source_text.as_deref(), Some(json), "{case}"); + } + } + + #[test] + fn phase_7_2_characterizes_raw_experimental_and_stable_telemetry() { + let json = r#"{ + "phase": "provision", + "containment": "isolation_session", + "telemetry": { + "enabled": true + }, + "experimental": { + "isolation_session": { + "provision": { + "appId": "Contoso.App" + } + } + } + }"#; + + let parsed = load_state_aware(json); + + assert_eq!( + parsed.experimental_raw, + Some(serde_json::json!({ + "isolation_session": { + "provision": { + "appId": "Contoso.App" + } + } + })) + ); + assert_eq!( + parsed + .request + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(true) + ); + assert!(parsed.request.experimental.test.is_none()); + assert!(parsed.request.experimental.windows_sandbox.is_none()); + assert!(parsed.request.experimental.wslc.is_none()); + assert_eq!(parsed.source_text.as_deref(), Some(json)); + } + + #[test] + fn phase_7_2_characterizes_post_provision_network_presence() { + let omitted = load_state_aware(r#"{"phase":"start","sandboxId":"wslc:abcd1234"}"#); + assert!(!omitted.request.policy.network_specified); + assert!(!omitted.request.policy.network_mode_specified); + assert!(omitted.request.policy.network_egress.is_none()); + assert!(omitted.request.policy.network_ingress.is_none()); + assert!(!omitted.request.policy.network_proxy.is_enabled()); + + let proxy_only = load_state_aware( + r#"{ + "phase": "exec", + "sandboxId": "wslc:abcd1234", + "process": {"commandLine": "echo hi"}, + "network": {"proxy": {"url": "http://proxy.example:8080"}} + }"#, + ); + assert!(proxy_only.request.policy.network_specified); + assert!(!proxy_only.request.policy.network_mode_specified); + assert!(proxy_only.request.policy.network_proxy.is_enabled()); + + let mode = load_state_aware( + r#"{ + "phase": "exec", + "sandboxId": "wslc:abcd1234", + "process": {"commandLine": "echo hi"}, + "network": {"defaultPolicy": "allow"} + }"#, + ); + assert!(mode.request.policy.network_specified); + assert!(mode.request.policy.network_mode_specified); + assert_eq!( + mode.request.policy.default_network_policy, + NetworkPolicy::Allow + ); + assert!(!mode.request.policy.network_proxy.is_enabled()); + } + #[test] fn cli_command_supplies_a_missing_one_shot_command_line() { let json = r#"{"process": {"cwd": "C:\\tmp"}}"#; @@ -2685,6 +3051,74 @@ mod tests { } } + #[test] + fn rolling_state_aware_wire_input_preserves_config_raw_experimental_and_source() { + let json = r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "experimental": { + "isolation_session": { + "start": {"opaqueFutureField": true} + } + } + }"#; + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); + + let input = parse_rolling_state_aware_wire_input(json, discriminator.experimental).unwrap(); + + assert!(matches!(input.config.phase, Some(wire::Phase::Start))); + assert_eq!(input.config.sandbox_id.as_deref(), Some("iso:abcd1234")); + assert!(input.config.experimental.is_none()); + assert_eq!( + input.experimental_raw, + Some(serde_json::json!({ + "isolation_session": { + "start": {"opaqueFutureField": true} + } + })) + ); + assert_eq!(input.source_text.as_ref(), json); + } + + #[test] + fn rolling_state_aware_wire_input_preserves_absent_experimental() { + let json = r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234" + }"#; + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); + + let input = parse_rolling_state_aware_wire_input(json, discriminator.experimental).unwrap(); + + assert!(matches!(input.config.phase, Some(wire::Phase::Start))); + assert_eq!(input.config.sandbox_id.as_deref(), Some("iso:abcd1234")); + assert!(input.config.experimental.is_none()); + assert!(input.experimental_raw.is_none()); + assert_eq!(input.source_text.as_ref(), json); + } + + #[test] + fn rolling_state_aware_wire_input_rejects_non_object_experimental() { + let json = r#"{ + "phase": "start", + "sandboxId": "iso:abcd1234", + "experimental": 42 + }"#; + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); + + let error = match parse_rolling_state_aware_wire_input(json, discriminator.experimental) { + Ok(_) => panic!("non-object experimental should be rejected"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("Invalid configuration at `experimental`: expected an object"), + "unexpected error: {error}" + ); + } + #[test] fn state_aware_exec_request_requires_command_line() { let json = r#"{