Skip to main content

zlayer_gcs/
protocol.rs

1//! GCS RPC message body types.
2//!
3//! Every RPC over the GCS bridge is a `(frame_header, body)` pair where the
4//! body is a UTF-8 JSON document with shapes documented here. Mirrors
5//! hcsshim's `internal/gcs/prot/protocol.go::Msg{Negotiate,Create,...}`.
6//!
7//! The frame header carries the message id + type code (see [`crate::frame`]);
8//! these types only describe the payload.
9
10use serde::de::{self, Deserializer};
11use serde::{Deserialize, Serialize, Serializer};
12use uuid::Uuid;
13
14/// hcsshim's `AnyInString` — a JSON value that is wire-encoded as a JSON
15/// **string** whose content is the escaped JSON of the inner value
16/// (double-encoded).
17///
18/// Mirrors hcsshim's `internal/gcs/prot/protocol.go`:
19/// ```go
20/// type AnyInString struct { Value interface{} }
21/// func (a *AnyInString) MarshalText() ([]byte, error) { return json.Marshal(a.Value) }
22/// ```
23/// Because `AnyInString` implements `MarshalText`, the outer `json.Marshal`
24/// emits the field as a JSON string containing the escaped JSON of the inner
25/// value. The inbox guest GCS uses a STRICT JSON unmarshaller (hcsshim issue
26/// #2714) that rejects unknown/misnamed fields and tears down the VM, so the
27/// double-encoding must match exactly.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct AnyInString(pub serde_json::Value);
30
31impl AnyInString {
32    /// Wrap a JSON value for double-encoded `AnyInString` serialization.
33    #[must_use]
34    pub const fn new(value: serde_json::Value) -> Self {
35        Self(value)
36    }
37
38    /// Borrow the inner JSON value.
39    #[must_use]
40    pub const fn value(&self) -> &serde_json::Value {
41        &self.0
42    }
43
44    /// Consume and return the inner JSON value.
45    #[must_use]
46    pub fn into_value(self) -> serde_json::Value {
47        self.0
48    }
49}
50
51impl From<serde_json::Value> for AnyInString {
52    fn from(value: serde_json::Value) -> Self {
53        Self(value)
54    }
55}
56
57impl Serialize for AnyInString {
58    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
59    where
60        S: Serializer,
61    {
62        // Serialize the inner value to a JSON string, then serialize THAT
63        // string — yielding a double-encoded `"{...escaped...}"` on the wire,
64        // exactly as hcsshim's `MarshalText` does.
65        let inner =
66            serde_json::to_string(&self.0).map_err(<S::Error as serde::ser::Error>::custom)?;
67        serializer.serialize_str(&inner)
68    }
69}
70
71impl<'de> Deserialize<'de> for AnyInString {
72    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
73    where
74        D: Deserializer<'de>,
75    {
76        // Read the outer JSON string, then parse its escaped JSON content
77        // back into a `serde_json::Value`.
78        let s = String::deserialize(deserializer)?;
79        let value = serde_json::from_str(&s).map_err(de::Error::custom)?;
80        Ok(Self(value))
81    }
82}
83
84/// Base fields present on every request body.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all = "PascalCase")]
87pub struct RequestBase {
88    pub activity_id: Uuid,
89    pub container_id: String,
90}
91
92/// Base fields present on every response body.
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
94#[serde(rename_all = "PascalCase")]
95pub struct ResponseBase {
96    #[serde(default)]
97    pub activity_id: Uuid,
98    /// HRESULT i32 — 0 = success, negative = failure. Matches Windows
99    /// HRESULT layout (top bit = severity).
100    #[serde(default)]
101    pub result: i32,
102    #[serde(default)]
103    pub error_message: String,
104    /// On failure, the in-guest GCS populates this with a JSON **array** of
105    /// `ErrorRecord` objects (e.g.
106    /// `[{"Result":-1070137077,"Message":"...","ModuleName":"vmcomputeagent.exe",...}]`).
107    /// It is kept as a raw [`serde_json::Value`] so callers can render or parse
108    /// it on demand; a `String` field here fails to deserialize the guest's
109    /// array shape with "invalid type: sequence, expected a string".
110    #[serde(
111        default,
112        rename = "ErrorRecords",
113        skip_serializing_if = "serde_json::Value::is_null"
114    )]
115    pub error_records: serde_json::Value,
116}
117
118/// `RpcNegotiateProtocol` request — proposes a min/max protocol version
119/// the host supports. The guest responds with the version chosen and a
120/// capabilities block.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "PascalCase")]
123pub struct NegotiateProtocolRequest {
124    #[serde(flatten)]
125    pub base: RequestBase,
126    pub minimum_version: u32,
127    pub maximum_version: u32,
128}
129
130/// `RpcNegotiateProtocol` response.
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132#[serde(rename_all = "PascalCase")]
133pub struct NegotiateProtocolResponse {
134    #[serde(flatten)]
135    pub base: ResponseBase,
136    pub version: u32,
137    pub capabilities: ProtocolSupport,
138}
139
140/// Capability block returned in the `NegotiateProtocol` response.
141///
142/// Field set is dictated by hcsshim's `ProtocolSupport` — we cannot collapse
143/// the bool flags into an enum without diverging from the wire format.
144#[allow(clippy::struct_excessive_bools)]
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146#[serde(rename_all = "PascalCase")]
147pub struct ProtocolSupport {
148    #[serde(default)]
149    pub protocol_version: u32,
150    #[serde(default)]
151    pub send_host_create_message: bool,
152    #[serde(default)]
153    pub send_host_start_message: bool,
154    #[serde(default)]
155    pub hv_socket_config_on_startup: bool,
156    #[serde(default)]
157    pub send_lifecycle_notifications: bool,
158    /// Whether the guest advertises support for the `ModifyServiceSettings`
159    /// RPC (hcsshim's `GcsCapabilities.ModifyServiceSettingsSupported`). The
160    /// Server 2025 inbox GCS does NOT advertise this, and sending the RPC
161    /// (e.g. `StartLogForwarding`) yields `Message Type 270532865 unknown`.
162    /// hcsshim gates `StartLogForwarding` on this flag; so do we.
163    #[serde(default)]
164    pub modify_service_settings_supported: bool,
165}
166
167/// `RpcCreate` — create the COMPUTE-system root.
168///
169/// For a cold-start UVM this is the null container `00000000-...` with a
170/// `ContainerConfig` block describing the UVM container kind
171/// (`{"SystemType":"Container"}`).
172///
173/// The `ContainerConfig` field is hcsshim's `AnyInString`: it is wire-encoded
174/// as a JSON **string** containing the escaped JSON of the config value
175/// (double-encoded). The field name is literally `ContainerConfig`, NOT
176/// `Settings` — the inbox guest GCS strictly rejects any other shape.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "PascalCase")]
179pub struct CreateRequest {
180    #[serde(flatten)]
181    pub base: RequestBase,
182    /// Container creation config — double-encoded JSON string on the wire.
183    #[serde(rename = "ContainerConfig")]
184    pub container_config: AnyInString,
185}
186
187pub type CreateResponse = ResponseBase;
188
189/// `RpcStart` — start a previously-created container. No body beyond the base.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "PascalCase")]
192pub struct StartRequest {
193    #[serde(flatten)]
194    pub base: RequestBase,
195}
196
197pub type StartResponse = ResponseBase;
198
199/// `RpcShutdown` — graceful shutdown.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(rename_all = "PascalCase")]
202pub struct ShutdownRequest {
203    #[serde(flatten)]
204    pub base: RequestBase,
205}
206
207pub type ShutdownResponse = ResponseBase;
208
209/// `RpcModifySettings` — hot-add/remove/update a setting on a running
210/// compute system (VSMB share, SCSI attachment, network endpoint, ...).
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "PascalCase")]
213pub struct ModifySettingsRequest {
214    #[serde(flatten)]
215    pub base: RequestBase,
216    /// The `ModifySettingRequest` (singular) per hcsshim — a JSON value
217    /// with `ResourcePath`, `RequestType`, `Settings` fields.
218    pub request: serde_json::Value,
219}
220
221pub type ModifySettingsResponse = ResponseBase;
222
223/// `RpcExecuteProcess` — run a process inside the hosted container.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "PascalCase")]
226pub struct ExecuteProcessRequest {
227    #[serde(flatten)]
228    pub base: RequestBase,
229    /// `ProcessParameters` JSON: `{CommandLine, User, WorkingDirectory,
230    /// CreateStdInPipe, CreateStdOutPipe, CreateStdErrPipe, ...}`.
231    pub settings: ExecuteProcessSettings,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(rename_all = "PascalCase")]
236pub struct ExecuteProcessSettings {
237    pub process_parameters: serde_json::Value,
238    /// Optional hvsock-listener GUIDs for stdio redirection. When `None`,
239    /// the guest uses pipes within the container.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub stdio_relay_settings: Option<StdioRelaySettings>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(rename_all = "PascalCase")]
246pub struct StdioRelaySettings {
247    // hcsshim's `ExecuteProcessStdioRelaySettings` serializes these as the
248    // bare `StdIn` / `StdOut` / `StdErr` (each `json:",omitempty"`), NOT
249    // `StdInPipe` / `StdOutPipe` / `StdErrPipe`. The inbox guest GCS uses a
250    // strict unmarshaller, so the relay GUIDs must land under the exact field
251    // names or the guest never dials back to the host stdio listeners.
252    #[serde(rename = "StdIn", default, skip_serializing_if = "Option::is_none")]
253    pub stdin_pipe: Option<Uuid>,
254    #[serde(rename = "StdOut", default, skip_serializing_if = "Option::is_none")]
255    pub stdout_pipe: Option<Uuid>,
256    #[serde(rename = "StdErr", default, skip_serializing_if = "Option::is_none")]
257    pub stderr_pipe: Option<Uuid>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261#[serde(rename_all = "PascalCase")]
262pub struct ExecuteProcessResponse {
263    #[serde(flatten)]
264    pub base: ResponseBase,
265    /// PID of the spawned process inside the guest.
266    #[serde(default)]
267    pub process_id: u32,
268}
269
270/// `RpcWaitForProcess` — wait for an `ExecuteProcess`-spawned PID to exit.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "PascalCase")]
273pub struct WaitForProcessRequest {
274    #[serde(flatten)]
275    pub base: RequestBase,
276    pub process_id: u32,
277    /// Timeout in milliseconds. `u32::MAX` for "wait forever".
278    pub timeout_in_ms: u32,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282#[serde(rename_all = "PascalCase")]
283pub struct WaitForProcessResponse {
284    #[serde(flatten)]
285    pub base: ResponseBase,
286    #[serde(default)]
287    pub exit_code: u32,
288}
289
290/// `RpcSignalProcess` — send a signal to a spawned process.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "PascalCase")]
293pub struct SignalProcessRequest {
294    #[serde(flatten)]
295    pub base: RequestBase,
296    pub process_id: u32,
297    pub options: serde_json::Value,
298}
299
300pub type SignalProcessResponse = ResponseBase;
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use serde_json::json;
306
307    #[test]
308    fn negotiate_request_round_trip_pascal_case() {
309        let req = NegotiateProtocolRequest {
310            base: RequestBase {
311                activity_id: Uuid::nil(),
312                container_id: "abc".into(),
313            },
314            minimum_version: 1,
315            maximum_version: 4,
316        };
317        let s = serde_json::to_string(&req).unwrap();
318        assert!(s.contains("\"MinimumVersion\":1"));
319        assert!(s.contains("\"MaximumVersion\":4"));
320        assert!(s.contains("\"ContainerId\":\"abc\""));
321        assert!(s.contains("\"ActivityId\""));
322        let _back: NegotiateProtocolRequest = serde_json::from_str(&s).unwrap();
323    }
324
325    #[test]
326    fn response_default_carries_zero_hresult() {
327        let resp = NegotiateProtocolResponse::default();
328        assert_eq!(resp.base.result, 0);
329        assert!(resp.base.error_message.is_empty());
330        let s = serde_json::to_string(&resp).unwrap();
331        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
332        assert_eq!(back["Result"], json!(0));
333    }
334
335    #[test]
336    fn any_in_string_double_encodes_as_json_string() {
337        let v = AnyInString::new(json!({"SystemType": "Container"}));
338        let s = serde_json::to_string(&v).unwrap();
339        // Top-level serialization is a JSON STRING (starts/ends with a quote),
340        // and the inner braces/quotes are escaped.
341        assert_eq!(s, "\"{\\\"SystemType\\\":\\\"Container\\\"}\"");
342        // Round-trips back to the same value.
343        let back: AnyInString = serde_json::from_str(&s).unwrap();
344        assert_eq!(back, v);
345    }
346
347    #[test]
348    fn create_request_container_config_is_escaped_string() {
349        let req = CreateRequest {
350            base: RequestBase {
351                activity_id: Uuid::nil(),
352                container_id: "00000000-0000-0000-0000-000000000000".into(),
353            },
354            container_config: AnyInString::new(json!({
355                "SystemType": "Container",
356            })),
357        };
358        let s = serde_json::to_string(&req).unwrap();
359        // Field name is literally `ContainerConfig` (NOT `Settings`), and its
360        // value is a STRING containing escaped JSON (double-encoded).
361        assert!(
362            s.contains("\"ContainerConfig\":\"{\\\"SystemType\\\":\\\"Container\\\"}\""),
363            "expected double-encoded ContainerConfig string, got: {s}"
364        );
365        assert!(
366            !s.contains("\"Settings\""),
367            "must not emit a Settings field"
368        );
369        assert!(s.contains("\"ContainerId\":\"00000000-0000-0000-0000-000000000000\""));
370        assert!(s.contains("\"ActivityId\""));
371
372        // Full round-trip back into a CreateRequest.
373        let back: CreateRequest = serde_json::from_str(&s).unwrap();
374        assert_eq!(back.base.container_id, req.base.container_id);
375        assert_eq!(back.container_config, req.container_config);
376    }
377
378    #[test]
379    fn response_base_parses_error_records_array() {
380        // The in-guest GCS sends `ErrorRecords` as a JSON ARRAY. Previously
381        // `error_records: String` failed this with "invalid type: sequence,
382        // expected a string". It must now deserialize into a Value array.
383        let wire = r#"{
384            "Result": -1070137077,
385            "ActivityId": "00000000-0000-0000-0000-000000000000",
386            "ErrorRecords": [
387                {
388                    "Result": -1070137077,
389                    "Message": "Message Type 270532865 unknown (215 byte)",
390                    "ModuleName": "vmcomputeagent.exe"
391                }
392            ]
393        }"#;
394        let base: ResponseBase = serde_json::from_str(wire).unwrap();
395        assert_eq!(base.result, -1_070_137_077);
396        assert!(
397            base.error_records.is_array(),
398            "error_records must be an array"
399        );
400        assert_eq!(
401            base.error_records[0]["Message"],
402            json!("Message Type 270532865 unknown (215 byte)")
403        );
404
405        // A response with no ErrorRecords still parses, leaving a null Value
406        // that is skipped on re-serialization.
407        let empty: ResponseBase = serde_json::from_str(r#"{"Result":0}"#).unwrap();
408        assert!(empty.error_records.is_null());
409        let s = serde_json::to_string(&empty).unwrap();
410        assert!(!s.contains("ErrorRecords"), "null records must be skipped");
411    }
412
413    #[test]
414    fn protocol_support_parses_modify_service_settings_supported() {
415        // Guest that DOES advertise the capability.
416        let with: ProtocolSupport =
417            serde_json::from_str(r#"{"ModifyServiceSettingsSupported": true}"#).unwrap();
418        assert!(with.modify_service_settings_supported);
419        // Guest (Server 2025 inbox) that omits it → defaults to false.
420        let without: ProtocolSupport = serde_json::from_str(r"{}").unwrap();
421        assert!(!without.modify_service_settings_supported);
422    }
423
424    #[test]
425    fn execute_process_settings_optional_stdio_relay() {
426        let s = ExecuteProcessSettings {
427            process_parameters: json!({"CommandLine": "cmd /c ver"}),
428            stdio_relay_settings: None,
429        };
430        let json_s = serde_json::to_string(&s).unwrap();
431        assert!(!json_s.contains("StdioRelaySettings"), "absent when None");
432    }
433}