diff --git a/docs/schema.md b/docs/schema.md index f4215fbf1..1de799a95 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -72,7 +72,10 @@ production configs and the dev schema when working on experimental features: "cpuCount": 4, // CPU count for WSLC session "memoryMb": 2048, // Memory in MB for WSLC session "gpu": false, // GPU passthrough - "storagePath": "C:\\wslc-storage" // Image store path + "storagePath": "C:\\wslc-storage", // Image store path + "portMappings": [ // Host<->container port forwarding. TCP only -- the vendored WSLC SDK 2.8.1 runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message. + { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } + ] }, "seatbelt": { // macOS sandbox settings (macOS only) "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) diff --git a/docs/wsl/wsl-container-support-plan.md b/docs/wsl/wsl-container-support-plan.md index b71a2dbcb..b8eb955d5 100644 --- a/docs/wsl/wsl-container-support-plan.md +++ b/docs/wsl/wsl-container-support-plan.md @@ -211,7 +211,7 @@ Container APIs: - `WslcSetContainerSettingsInitProcess()` — attach process settings before creation - `WslcSetContainerSettingsNetworkingMode()` — `NONE` (isolated) or `BRIDGED` (NAT) - `WslcSetContainerSettingsVolumes()` — mount Windows paths into Linux container -- `WslcSetContainerSettingsPortMappings()` — host↔container port forwarding (TCP only) +- `WslcSetContainerSettingsPortMappings()` — host↔container port forwarding (TCP only; UDP is declared in the header but returns `E_NOTIMPL` in the vendored SDK 2.8.1 runtime, so the MXC parser hard-rejects `"udp"` at config-validation time) - `WslcSetContainerSettingsFlags()` — `AUTO_REMOVE`, `ENABLE_GPU`, `PRIVILEGED` - `WslcGetContainerInitProcess()` — retrieve process handle after start - `WslcStopContainer()` / `WslcDeleteContainer()` / `WslcReleaseContainer()` — teardown @@ -309,7 +309,7 @@ Network mapping: Port mapping (new capability enabled by WSLC SDK): | Config field | WSLC SDK equivalent | |---|---| -| `portMappings: [{ windowsPort: 8080, containerPort: 80 }]` | `WslcSetContainerSettingsPortMappings()` with `WslcContainerPortMapping` structs (TCP only) | +| `portMappings: [{ windowsPort: 8080, containerPort: 80, protocol: "tcp" }]` | `WslcSetContainerSettingsPortMappings()` with `WslcContainerPortMapping` structs (TCP only; `protocol` defaults to `"tcp"`. UDP is declared by the SDK header but returns `E_NOTIMPL` at runtime in the vendored SDK 2.8.1 and is rejected by the parser.) | **WSLC SDK advantage:** The `WslcContainerVolume` struct directly models the Windows↔Linux path mapping with `windowsPath` (PCWSTR) and `containerPath` (PCSTR) fields. The runner applies the deterministic `/mnt//...` mapping rule and the SDK's 9P filesystem handles the cross-OS bridging internally. 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 c3614a8cb..c3dae00a3 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -418,7 +418,8 @@ }, "portMappings": { "type": "array", - "description": "Host-to-container port forwards. Each entry maps a Windows host port to a container port for a given protocol.", + "description": "Host-to-container port forwards. Each entry maps a Windows host port to a container port for a given protocol. Only TCP is currently supported by the vendored WSLC SDK runtime (Microsoft.WSL.Containers 2.8.1); the parser hard-rejects 'udp' because the shipped runtime returns E_NOTIMPL even though the SDK header declares it.", + "default": [], "items": { "type": "object", "properties": { @@ -437,11 +438,10 @@ "protocol": { "type": "string", "enum": [ - "tcp", - "udp" + "tcp" ], "default": "tcp", - "description": "Transport protocol for the mapping." + "description": "Transport protocol for the mapping. Only 'tcp' is currently supported (see description above)." } }, "required": [ diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 4023fac61..3370dc629 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -224,20 +224,33 @@ export interface WslcConfig { gpu?: boolean; /** Path to a local tar file to import as the container image */ imageTarPath?: string; - /** Host↔container port mappings (TCP only) */ + /** + * Host↔container port mappings. + * + * Only TCP is currently supported by the vendored WSLC SDK runtime + * (Microsoft.WSL.Containers 2.8.1). UDP is declared in the SDK header + * but the shipped runtime returns `E_NOTIMPL` when UDP is actually + * requested, so the parser hard-rejects `"udp"` with a clear message at + * spawn time. The `protocol` field defaults to `"tcp"` when omitted. + */ portMappings?: PortMapping[]; } /** - * Port mapping for host↔container port forwarding + * Port mapping for host↔container port forwarding. */ export interface PortMapping { /** Port on the Windows host */ windowsPort: number; /** Port inside the Linux container */ containerPort: number; - /** Protocol: "tcp" or "udp" (default: "tcp") */ - protocol?: string; + /** + * Transport protocol. Only `"tcp"` is currently supported; `"udp"` is + * rejected by the parser because the vendored WSLC SDK runtime + * (Microsoft.WSL.Containers 2.8.1) returns `E_NOTIMPL` for UDP even + * though the header declares it. Defaults to `"tcp"` when omitted. + */ + protocol?: 'tcp'; } /** diff --git a/sdk/tests/integration/wslc-e2e.test.ts b/sdk/tests/integration/wslc-e2e.test.ts index 7410ba53a..fe651f6b1 100644 --- a/sdk/tests/integration/wslc-e2e.test.ts +++ b/sdk/tests/integration/wslc-e2e.test.ts @@ -16,6 +16,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs'; +import net from 'node:net'; import path from 'node:path'; import os from 'os'; import { ChildProcess } from 'child_process'; @@ -25,17 +26,32 @@ import { sdk } from './test-helpers.js'; // Opt-in via MXC_ENABLE_WSLC_TESTS=1 since most CI agents lack the runtime. const isWslcAvailable = os.platform() === 'win32' && process.env.MXC_ENABLE_WSLC_TESTS === '1'; +// Probe a small range of host ports and return the first one we can +// successfully bind to. Avoids both the fixed-port collision risk (any +// other process on the dev box / runner may already own a hard-coded +// port) AND the TOCTOU race that `listen(0) → close → reuse` would +// introduce. Throws if every candidate in the range is busy. +async function pickAvailableHostPort(start = 40000, end = 40099): Promise { + for (let port = start; port <= end; port++) { + const ok = await new Promise((resolve) => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true))); + }); + if (ok) return port; + } + throw new Error(`pickAvailableHostPort: no free port in [${start}, ${end}]`); +} + describe('WSLC SDK E2E — createConfigFromPolicy → customize → spawn', { skip: !isWslcAvailable ? 'WSLC tests require MXC_ENABLE_WSLC_TESTS=1 on Windows with WSL2 and WSLC SDK' : undefined, }, () => { - it('should run with all WSLC-specific fields set', async () => { - // Create temp directories for volume mount and storage. + it('should run with all WSLC-specific fields set', { timeout: 120_000 }, async () => { + // Create temp directory for the volume mount. // Use short paths under os.tmpdir() — WSLC SDK can fail with very long paths. const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-e2e-')); - const storageDir = path.join(testDir, 'storage'); const mountDir = path.join(testDir, 'mount'); - fs.mkdirSync(storageDir); fs.mkdirSync(mountDir); try { @@ -54,9 +70,13 @@ describe('WSLC SDK E2E — createConfigFromPolicy → customize → spawn', { config.experimental!.wslc!.image = 'python:3.12-alpine'; config.experimental!.wslc!.cpuCount = 2; config.experimental!.wslc!.memoryMb = 1024; - config.experimental!.wslc!.storagePath = storageDir; + // Intentionally omit `storagePath` so this test reuses the default + // image store where `python:3.12-alpine` has already been pre-pulled + // (the docs require operators to pre-pull). Setting storagePath to a + // fresh temp directory would point WSLC at an empty image store and + // fail with "image not found" — MXC does not pull at runtime. - const { stdout, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => { + const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => { const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess; let stdout = ''; let stderr = ''; @@ -70,11 +90,150 @@ describe('WSLC SDK E2E — createConfigFromPolicy → customize → spawn', { }); }); - assert.strictEqual(exitCode, 0); - assert.ok(stdout.includes('Python 3.12')); - assert.ok(stdout.includes('All fields work')); + assert.strictEqual(exitCode, 0, `exit=${exitCode}\nstdout=${stdout}\nstderr=${stderr}`); + assert.ok(stdout.includes('Python 3.12'), `Python 3.12 not found in stdout=${stdout}`); + assert.ok(stdout.includes('All fields work'), `'All fields work' not found in stdout=${stdout}`); } finally { fs.rmSync(testDir, { recursive: true, force: true }); } }); + + it('should forward a TCP port from host to container', { timeout: 120_000 }, async () => { + const http = await import('node:http'); + // Pick an available host port to avoid collisions on busy dev/CI hosts. + // The container port can stay fixed because the container's network + // namespace is isolated from the host. + const HOST_PORT = await pickAvailableHostPort(); + const CONTAINER_PORT = 8080; + + const policy = { + version: '0.5.0-alpha', + network: { allowOutbound: true }, + filesystem: {}, + }; + const config = sdk.createConfigFromPolicy(policy, 'wslc'); + // The container runs `/bin/sh -c ""`. We base64-encode the + // Python source and run it via a single-argv `python3 -c "..."` call to + // avoid any embedded-newline / shell-pipeline ambiguity through the WSLC + // FFI. `handle_request()` serves exactly one request then returns, so + // the container exits cleanly once the host probe completes — no + // SIGTERM/SIGKILL dance is needed. + // + // We deliberately do NOT wait for an in-container "ready" marker before + // probing: WSLC's stdout pump may delay delivery of bytes from a + // long-running process. The host probe retries on ECONNREFUSED, so the + // retry loop naturally bridges the bind-then-accept window. + const pythonScript = `import http.server, socketserver +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'PORT_MAPPING_TCP_OK') + def log_message(self, *a, **k): + pass +srv = socketserver.TCPServer(('0.0.0.0', ${CONTAINER_PORT}), H) +srv.handle_request() +`; + const scriptB64 = Buffer.from(pythonScript, 'utf8').toString('base64'); + config.process!.commandLine = `python3 -c "import base64; exec(base64.b64decode('${scriptB64}'))"`; + config.experimental!.wslc!.image = 'python:3.12-alpine'; + config.experimental!.wslc!.portMappings = [ + { windowsPort: HOST_PORT, containerPort: CONTAINER_PORT, protocol: 'tcp' }, + ]; + + const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess; + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); + child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + const closed = new Promise((resolve) => { + if (child.exitCode !== null) { + resolve(child.exitCode); + } else { + child.on('close', (code: number | null) => resolve(code)); + } + }); + + let body = ''; + let lastErr: Error | undefined; + try { + // Probe from the Windows host with poll-retry. Retries cover both the + // container-start window and the NAT-rule settle window. Each attempt + // bails if the child has already exited (avoids 60s of pointless retry + // when the container crashed). + const probeDeadline = Date.now() + 60_000; + while (Date.now() < probeDeadline) { + if (child.exitCode !== null) { + throw new Error(`Container exited before probe could succeed (code=${child.exitCode}). stdout=${stdout} stderr=${stderr}`); + } + try { + body = await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: HOST_PORT, timeout: 2000 }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(new Error('http.get timeout')); }); + }); + break; + } catch (e) { + lastErr = e as Error; + await new Promise((r) => setTimeout(r, 500)); + } + } + assert.strictEqual(body, 'PORT_MAPPING_TCP_OK', `host probe failed; lastErr=${lastErr?.message} stdout=${stdout} stderr=${stderr}`); + } finally { + // After one served request, the Python server returns from handle_request + // and the container exits naturally. Wait up to 20s for clean exit, then + // force-kill so a stuck WSLC teardown can never hang the whole suite. + const cleanExit = await Promise.race([ + closed, + new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 20_000)), + ]); + if (cleanExit === 'timeout') { + child.kill('SIGKILL'); + await Promise.race([ + closed, + new Promise((r) => setTimeout(r, 5_000)), + ]); + } + } + }); + + it('should reject UDP port mapping with a clear SDK-limitation message', { timeout: 60_000 }, async () => { + // WSLC SDK 2.8.1 declares WSLC_PORT_PROTOCOL_UDP in its header but its + // runtime returns E_NOTIMPL (0x80004001) when UDP is actually requested. + // The parser rejects UDP up front so SDK consumers get a clear error at + // spawn time rather than a cryptic HRESULT at container-create time. The + // SDK type narrows `protocol` to `'tcp'`, so a cast is required here to + // exercise the parser path that rejects an out-of-type value at runtime. + const policy = { + version: '0.5.0-alpha', + network: { allowOutbound: true }, + filesystem: {}, + }; + const config = sdk.createConfigFromPolicy(policy, 'wslc'); + config.process!.commandLine = 'echo unreachable'; + config.experimental!.wslc!.image = 'python:3.12-alpine'; + config.experimental!.wslc!.portMappings = [ + { windowsPort: 39000, containerPort: 9000, protocol: 'udp' as unknown as 'tcp' }, + ]; + + const { exitCode, combined } = await new Promise<{ exitCode: number; combined: string }>((resolve, reject) => { + const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess; + let combined = ''; + const onData = (d: Buffer) => { combined += d.toString(); }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('error', reject); + child.on('close', (code: number | null) => resolve({ exitCode: code ?? -1, combined })); + }); + + assert.notStrictEqual(exitCode, 0, `expected non-zero exit when UDP is requested; output=${combined}`); + assert.ok( + /udp/i.test(combined) && /not supported|not implemented/i.test(combined), + `expected SDK-limitation message mentioning UDP; output=${combined}`, + ); + }); }); diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index 5b3d92150..4b6485d2a 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -972,9 +972,49 @@ impl WSLContainerRunner { return sdk_error("WslcInitContainerSettings failed", hr, ""); } - // TODO: Port mappings (WslcConfig.port_mappings) are parsed but not yet applied. - // Requires adding WslcSetContainerSettingsPortMappings and WslcContainerPortMapping - // bindings. See wslcsdk.h lines 120-128, 183-186. + // -- Port mappings (host<->container) -- + // Apply before networking mode so the SDK has the complete picture + // when the container is created. Empty list = no forwarding (default). + // The parser rejects `"udp"` up front: the C header declares + // `WSLC_PORT_PROTOCOL_UDP = 1` but the shipped runtime + // (Microsoft.WSL.Containers 2.8.1) returns `E_NOTIMPL` when UDP is + // actually requested. The protocol match below therefore only ever + // sees `"tcp"` today, but the explicit branch is retained so this + // code keeps compiling cleanly if/when the parser starts accepting + // UDP after an SDK update. + if !self.config.port_mappings.is_empty() { + let mappings: Vec = self + .config + .port_mappings + .iter() + .map(|pm| WslcContainerPortMapping { + windows_port: pm.windows_port, + container_port: pm.container_port, + protocol: if pm.protocol == "udp" { + WslcPortProtocol::Udp + } else { + WslcPortProtocol::Tcp + }, + // Default bind address (typically loopback/0.0.0.0 per + // SDK config). Not exposed in the MXC config today. + windows_address: ptr::null(), + }) + .collect(); + + let hr = (sdk.WslcSetContainerSettingsPortMappings)( + &mut container_settings, + mappings.as_ptr(), + mappings.len() as u32, + ); + if hr != S_OK { + return sdk_error("WslcSetContainerSettingsPortMappings failed", hr, ""); + } + let _ = writeln!( + logger, + "[WSLC] {} port mapping(s) configured", + mappings.len() + ); + } let mounts = match policy_mapping::build_volume_mounts( &request.policy.readwrite_paths, &request.policy.readonly_paths, diff --git a/src/backends/wslc/common/src/wslc_bindings.rs b/src/backends/wslc/common/src/wslc_bindings.rs index 324da71c9..b1eff94f5 100644 --- a/src/backends/wslc/common/src/wslc_bindings.rs +++ b/src/backends/wslc/common/src/wslc_bindings.rs @@ -189,6 +189,33 @@ pub struct WslcContainerVolume { pub read_only: BOOL, } +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WslcPortProtocol { + Tcp = 0, + Udp = 1, +} + +/// Host↔container port mapping passed to +/// `WslcSetContainerSettingsPortMappings`. +/// +/// Matches `WslcContainerPortMapping` in `wslcsdk.h`. The trailing +/// `windows_address` field is an optional override for the host bind address; +/// MXC always passes `null`, which lets the SDK select the default address. +/// +/// Note: although the C header declares `WSLC_PORT_PROTOCOL_UDP = 1`, the +/// shipped SDK runtime (Microsoft.WSL.Containers 2.8.1) returns `E_NOTIMPL` +/// when UDP is actually requested. The parser rejects `"udp"` up front; this +/// enum keeps the discriminant so it stays in sync with the C ABI if a future +/// SDK ships UDP support. +#[repr(C)] +pub struct WslcContainerPortMapping { + pub windows_port: u16, + pub container_port: u16, + pub protocol: WslcPortProtocol, + pub windows_address: *const c_void, +} + pub const WSLC_IMAGE_NAME_LENGTH: usize = 256; #[repr(C)] @@ -315,6 +342,11 @@ mod ffi_types { *const WslcContainerVolume, u32, ) -> HRESULT; + pub type WslcSetContainerSettingsPortMappingsFn = unsafe extern "system" fn( + *mut WslcContainerSettings, + *const WslcContainerPortMapping, + u32, + ) -> HRESULT; pub type WslcSetContainerSettingsInitProcessFn = unsafe extern "system" fn(*mut WslcContainerSettings, *mut WslcProcessSettings) -> HRESULT; pub type WslcCreateContainerFn = unsafe extern "system" fn( @@ -382,6 +414,7 @@ pub struct WslcSdk { pub WslcSetContainerSettingsNetworkingMode: ffi_types::WslcSetContainerSettingsNetworkingModeFn, pub WslcSetContainerSettingsFlags: ffi_types::WslcSetContainerSettingsFlagsFn, pub WslcSetContainerSettingsVolumes: ffi_types::WslcSetContainerSettingsVolumesFn, + pub WslcSetContainerSettingsPortMappings: ffi_types::WslcSetContainerSettingsPortMappingsFn, pub WslcSetContainerSettingsInitProcess: ffi_types::WslcSetContainerSettingsInitProcessFn, pub WslcCreateContainer: ffi_types::WslcCreateContainerFn, pub WslcStartContainer: ffi_types::WslcStartContainerFn, @@ -469,6 +502,10 @@ impl WslcSdk { lib, b"WslcSetContainerSettingsVolumes\0" ), + WslcSetContainerSettingsPortMappings: load_fn!( + lib, + b"WslcSetContainerSettingsPortMappings\0" + ), WslcSetContainerSettingsInitProcess: load_fn!( lib, b"WslcSetContainerSettingsInitProcess\0" @@ -679,6 +716,20 @@ mod tests { assert_eq!(mem::align_of::(), 8); } + #[test] + fn port_mapping_struct_layout_matches_c_header() { + // WslcContainerPortMapping in wslcsdk.h: + // uint16_t windowsPort; // offset 0 + // uint16_t containerPort; // offset 2 + // WslcPortProtocol protocol; // offset 4 (u32 enum) + // struct sockaddr_storage* windowsAddress; // offset 8 (pointer, 8-byte aligned) + // Total: 16 bytes on 64-bit, 8-byte aligned because of the pointer. + assert_eq!(mem::size_of::(), 16); + assert_eq!(mem::align_of::(), 8); + assert_eq!(WslcPortProtocol::Tcp as u32, 0); + assert_eq!(WslcPortProtocol::Udp as u32, 1); + } + #[test] fn enum_discriminant_values() { assert_eq!(WslcContainerNetworkingMode::None as i32, 0); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 10bc49abb..546662588 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -111,7 +111,6 @@ struct RawPortMapping { windows_port: u16, #[serde(rename = "containerPort")] container_port: u16, - #[serde(default)] protocol: Option, } @@ -1213,19 +1212,50 @@ fn convert_raw_config_inner( config.storage_path = cc.storage_path; if let Some(mappings) = cc.port_mappings { let mut converted = Vec::with_capacity(mappings.len()); - for m in mappings { - if m.windows_port == 0 || m.container_port == 0 { - return Err(WxcError::ConfigParse(format!( - "experimental.wslc.portMappings: port 0 is not a valid forward (windowsPort={}, containerPort={})", - m.windows_port, m.container_port - ))); + for (idx, m) in mappings.into_iter().enumerate() { + if m.windows_port == 0 { + let msg = format!( + "experimental.wslc.portMappings[{idx}]: 'windowsPort' must be > 0" + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); + } + if m.container_port == 0 { + let msg = format!( + "experimental.wslc.portMappings[{idx}]: 'containerPort' must be > 0" + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); } - let protocol = m.protocol.unwrap_or_else(|| "tcp".to_string()); + let protocol = m + .protocol + .unwrap_or_else(|| "tcp".to_string()) + .to_lowercase(); if protocol != "tcp" && protocol != "udp" { - return Err(WxcError::ConfigParse(format!( - "experimental.wslc.portMappings: protocol must be 'tcp' or 'udp', got '{}'", - protocol - ))); + let msg = format!( + "experimental.wslc.portMappings[{idx}]: protocol '{protocol}' \ + not supported; expected 'tcp'" + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); + } + // The WSLC SDK header (Microsoft.WSL.Containers 2.8.1, + // vendored under external/wslc-sdk/) declares + // WSLC_PORT_PROTOCOL_UDP = 1, but the shipped runtime + // returns E_NOTIMPL (0x80004001) when UDP is actually + // passed to WslcSetContainerSettingsPortMappings. Reject + // UDP at parse time with a clear message until a future + // SDK version implements it. + if protocol == "udp" { + let msg = format!( + "experimental.wslc.portMappings[{idx}]: protocol 'udp' is \ + not supported by the vendored WSLC SDK runtime \ + (Microsoft.WSL.Containers 2.8.1). Only 'tcp' is currently \ + implemented. UDP support will be enabled when a future SDK \ + version ships it." + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); } converted.push(PortMapping { windows_port: m.windows_port, @@ -1233,6 +1263,23 @@ fn convert_raw_config_inner( protocol, }); } + // Reject duplicate (windowsPort, protocol) entries. Same host + // port on TCP+UDP would in principle be legal, but UDP is + // rejected earlier; the second protocol dimension is retained + // in the dedupe key in case UDP support is enabled later. + let mut seen: std::collections::HashSet<(u16, &str)> = + std::collections::HashSet::new(); + for pm in &converted { + if !seen.insert((pm.windows_port, pm.protocol.as_str())) { + let msg = format!( + "experimental.wslc.portMappings: duplicate windowsPort {} \ + for protocol '{}'", + pm.windows_port, pm.protocol + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); + } + } config.port_mappings = converted; } Some(config) @@ -3090,18 +3137,17 @@ mod tests { } #[test] - fn wslc_port_mappings_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}, {"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}}"#; + 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 encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); let wslc = req.experimental.wslc.unwrap(); - assert_eq!(wslc.port_mappings.len(), 2); + assert_eq!(wslc.port_mappings.len(), 1); assert_eq!(wslc.port_mappings[0].windows_port, 8080); assert_eq!(wslc.port_mappings[0].container_port, 80); assert_eq!(wslc.port_mappings[0].protocol, "tcp"); - assert_eq!(wslc.port_mappings[1].protocol, "udp"); } #[test] @@ -3116,30 +3162,128 @@ mod tests { } #[test] - fn wslc_port_mappings_missing_windows_port_rejected() { + fn wslc_port_mapping_protocol_normalized_to_lowercase() { + // "TCP" is normalized to "tcp" and accepted. (UDP rejection is + // covered by wslc_port_mapping_udp_rejected_with_sdk_limitation_message.) + let json = r#"{"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(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + let wslc = req.experimental.wslc.unwrap(); + assert_eq!(wslc.port_mappings[0].protocol, "tcp"); + } + + #[test] + fn wslc_port_mapping_udp_rejected_with_sdk_limitation_message() { + // The vendored WSLC SDK 2.8.1 runtime returns E_NOTIMPL for UDP. The + // parser must reject UDP up front with a clear message until a + // future SDK version implements it. + let json = r#"{"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(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("udp") && msg.contains("not supported"), + "got: {msg}" + ); + } + + #[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 encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - assert!(load_request(&encoded, &mut logger, true).is_err()); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("windows_port") || msg.contains("windowsPort"), + "expected serde missing-field error mentioning windowsPort, got: {msg}" + ); + } + + #[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 encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("container_port") || msg.contains("containerPort"), + "expected serde missing-field error mentioning containerPort, got: {msg}" + ); } #[test] - fn wslc_port_mappings_zero_port_rejected() { + 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 encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - assert!(load_request(&encoded, &mut logger, true).is_err()); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("windowsPort") && msg.contains("> 0"), + "got: {msg}" + ); + } + + #[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 encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("containerPort") && msg.contains("> 0"), + "got: {msg}" + ); } #[test] - fn wslc_port_mappings_invalid_protocol_rejected() { + fn wslc_port_mapping_unsupported_protocol_rejected() { let json = r#"{"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(); - assert!(load_request(&encoded, &mut logger, true).is_err()); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("sctp") && msg.contains("not supported"), + "got: {msg}" + ); + } + + #[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 encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("duplicate") && msg.contains("8080"), + "got: {msg}" + ); + } + + #[test] + fn wslc_port_mapping_empty_list_default() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + let wslc = req.experimental.wslc.unwrap(); + assert!(wslc.port_mappings.is_empty()); } // ---------- Experimental feature tests ---------- diff --git a/tests/configs/wslc_port_mapping_multiple.json b/tests/configs/wslc_port_mapping_multiple.json new file mode 100644 index 000000000..6feec8de2 --- /dev/null +++ b/tests/configs/wslc_port_mapping_multiple.json @@ -0,0 +1,20 @@ +{ + "version": "0.8.0-alpha", + "containerId": "wslc-port-mapping-multiple", + "containment": "wslc", + "process": { + "commandLine": "for port in 8080 9090; do python3 -c \"import socket; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(('0.0.0.0',$port)); s.close(); print('bound tcp', $port)\" || { echo bind failed for $port >&2; exit 1; }; done; echo PORT_MAPPING_MULTI_OK" + }, + "network": { + "defaultPolicy": "allow" + }, + "experimental": { + "wslc": { + "image": "python:3.12-alpine", + "portMappings": [ + { "windowsPort": 18080, "containerPort": 8080, "protocol": "tcp" }, + { "windowsPort": 19090, "containerPort": 9090, "protocol": "tcp" } + ] + } + } +} diff --git a/tests/configs/wslc_port_mapping_tcp.json b/tests/configs/wslc_port_mapping_tcp.json new file mode 100644 index 000000000..99f72f178 --- /dev/null +++ b/tests/configs/wslc_port_mapping_tcp.json @@ -0,0 +1,19 @@ +{ + "version": "0.8.0-alpha", + "containerId": "wslc-port-mapping-tcp", + "containment": "wslc", + "process": { + "commandLine": "python3 -c 'import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind((\"0.0.0.0\",8080)); s.close(); print(\"PORT_MAPPING_TCP_OK\")'" + }, + "network": { + "defaultPolicy": "allow" + }, + "experimental": { + "wslc": { + "image": "python:3.12-alpine", + "portMappings": [ + { "windowsPort": 18080, "containerPort": 8080, "protocol": "tcp" } + ] + } + } +} diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index a4669814e..b2af492aa 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -217,6 +217,8 @@ $null = $results.Add((Run-WslcTest "wslc_readonly_mount.json" -OutputContains "R Write-Host "`n--- Network Tests ---" -ForegroundColor Cyan $null = $results.Add((Run-WslcTest "wslc_network_isolated.json")) +$null = $results.Add((Run-WslcTest "wslc_port_mapping_tcp.json" -OutputContains "PORT_MAPPING_TCP_OK")) +$null = $results.Add((Run-WslcTest "wslc_port_mapping_multiple.json" -OutputContains "PORT_MAPPING_MULTI_OK")) Write-Host "`n--- Image Tests ---" -ForegroundColor Cyan $null = $results.Add((Run-WslcTest "wslc_python_hello.json" -OutputContains "Hello from Python"))