zlayer_gcs/frame.rs
1//! GCS protocol frame codec.
2//!
3//! Each GCS message on the wire is a 16-byte little-endian header followed
4//! by a UTF-8 JSON payload. Matches hcsshim's
5//! `internal/gcs/prot/protocol.go::HdrLength`/`MessageHeader`/`MessageType`
6//! constants.
7
8use crate::error::{GcsError, GcsResult};
9
10/// Fixed header length in bytes.
11pub const HEADER_LEN: usize = 16;
12
13/// Maximum payload we accept on decode — guards against absurd `Size` values
14/// from a malicious or buggy guest. 4 MiB is far above any real GCS message.
15pub const MAX_PAYLOAD_LEN: usize = 4 * 1024 * 1024;
16
17/// Top 4 bits of `MessageType` distinguish request / response / notify.
18/// Mirrors hcsshim `internal/gcs/prot/protocol.go::MsgType{Request,Response,Notify,Mask}`.
19pub const MSG_TYPE_REQUEST: u32 = 0x1000_0000;
20pub const MSG_TYPE_RESPONSE: u32 = 0x2000_0000;
21pub const MSG_TYPE_NOTIFY: u32 = 0x3000_0000;
22pub const MSG_TYPE_MASK: u32 = 0xF000_0000;
23
24/// Category for compute-system / container RPCs. Mirrors hcsshim
25/// `ComputeSystem = 0x00100000`.
26pub const CATEGORY_COMPUTE_SYSTEM: u32 = 0x0010_0000;
27
28/// Category for compute-service RPCs (e.g. log forwarding). Mirrors hcsshim
29/// `ComputeService = 0x00200000`.
30pub const CATEGORY_COMPUTE_SERVICE: u32 = 0x0020_0000;
31
32/// RPC type codes for the `ComputeSystem` category.
33///
34/// Each value already encodes `(iota+1)<<8 | 1` per hcsshim's
35/// `RPCProc = Category | (iota+1)<<8 | 1` formula in
36/// `internal/gcs/prot/protocol.go`, so a `NegotiateProtocol` REQUEST frame's
37/// wire `type` is exactly `MSG_TYPE_REQUEST | CATEGORY_COMPUTE_SYSTEM |
38/// (rpc as u32)` = `0x10100B01`. An earlier iteration of this enum used
39/// `0x0001..=0x000A` and was missing both the per-RPC `(iota+1)<<8` byte
40/// AND the `MSG_TYPE_REQUEST` marker, causing the in-guest GCS to close
41/// the bridge the moment it saw a frame with an unrecognized type
42/// (verified via `gcs-bridge-reader: header read failed after 0 frame(s):
43/// bridge closed` against `nanoserver:ltsc2022` with the dep-override
44/// applied).
45#[repr(u32)]
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub enum RpcMessageType {
48 Create = 0x0101,
49 Start = 0x0201,
50 ShutdownGraceful = 0x0301,
51 ShutdownForced = 0x0401,
52 ExecuteProcess = 0x0501,
53 WaitForProcess = 0x0601,
54 SignalProcess = 0x0701,
55 ResizeConsole = 0x0801,
56 GetProperties = 0x0901,
57 ModifySettings = 0x0A01,
58 NegotiateProtocol = 0x0B01,
59 DumpStacks = 0x0C01,
60 DeleteContainerState = 0x0D01,
61 UpdateContainer = 0x0E01,
62 LifecycleNotification = 0x0F01,
63 /// `RPCModifyServiceSettings` — the ONLY RPC in hcsshim's `ComputeService`
64 /// category (`internal/gcs/prot/protocol.go`):
65 /// `RPCModifyServiceSettings RPCProc = ComputeService | (iota+1)<<8 | 1`.
66 /// `iota` RESETS to 0 in that second `const` block, so the per-RPC byte is
67 /// `(0+1)<<8 = 0x100` and the low 16 bits are `0x0101` — identical to
68 /// `Create`'s low bits, but it lives in a DIFFERENT category
69 /// (`ComputeService = 0x0020_0000`, not `ComputeSystem = 0x0010_0000`).
70 /// Used to drive the in-guest GCS log-forward service
71 /// (`internal/uvm/log_wcow.go`). Because the category differs, the
72 /// discriminant alone cannot be OR'd with `CATEGORY_COMPUTE_SYSTEM` —
73 /// [`RpcMessageType::as_request_type`] special-cases it. The discriminant
74 /// is offset into a private range so it does not numerically collide with
75 /// `Create = 0x0101` inside the Rust enum.
76 ModifyServiceSettings = 0x1_0101,
77}
78
79impl RpcMessageType {
80 /// hcsshim message category for this RPC. Every container/system RPC is
81 /// `ComputeSystem`; only [`RpcMessageType::ModifyServiceSettings`] is
82 /// `ComputeService`.
83 #[must_use]
84 const fn category(self) -> u32 {
85 match self {
86 Self::ModifyServiceSettings => CATEGORY_COMPUTE_SERVICE,
87 _ => CATEGORY_COMPUTE_SYSTEM,
88 }
89 }
90
91 /// The low-16-bit `(iota+1)<<8 | 1` RPC code, stripped of the synthetic
92 /// enum-disambiguation offset carried by [`RpcMessageType::ModifyServiceSettings`].
93 #[must_use]
94 const fn proc_code(self) -> u32 {
95 (self as u32) & 0xFFFF
96 }
97
98 /// Encode as the on-wire request `type` u32: `MSG_TYPE_REQUEST | category |
99 /// rpc`.
100 #[must_use]
101 pub const fn as_request_type(self) -> u32 {
102 MSG_TYPE_REQUEST | self.category() | self.proc_code()
103 }
104
105 /// Encode as the expected on-wire response `type` u32:
106 /// `MSG_TYPE_RESPONSE | category | rpc`.
107 #[must_use]
108 pub const fn as_response_type(self) -> u32 {
109 MSG_TYPE_RESPONSE | self.category() | self.proc_code()
110 }
111}
112
113/// Parsed frame header.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct FrameHeader {
116 pub r#type: u32,
117 pub size: u32,
118 pub message_id: u64,
119}
120
121/// Encode a frame: writes `HEADER_LEN + payload.len()` bytes into `out`
122/// (preallocates / extends as needed).
123///
124/// # Panics
125/// Panics if `HEADER_LEN + payload.len()` does not fit in a `u32` (i.e. the
126/// payload is ~4 GiB). Real GCS messages are bounded by [`MAX_PAYLOAD_LEN`]
127/// (4 MiB), so this is a programmer-error guard rather than a runtime path.
128pub fn encode_frame(r#type: u32, message_id: u64, payload: &[u8], out: &mut Vec<u8>) {
129 let total =
130 u32::try_from(HEADER_LEN + payload.len()).expect("frame total length must fit in u32");
131 out.clear();
132 out.reserve(HEADER_LEN + payload.len());
133 out.extend_from_slice(&r#type.to_le_bytes());
134 out.extend_from_slice(&total.to_le_bytes());
135 out.extend_from_slice(&message_id.to_le_bytes());
136 out.extend_from_slice(payload);
137}
138
139/// Decode just the header from a 16-byte slice. Validates `size >= HEADER_LEN`
140/// and `size <= HEADER_LEN + MAX_PAYLOAD_LEN`.
141pub fn decode_header(bytes: &[u8; HEADER_LEN]) -> GcsResult<FrameHeader> {
142 // The 4/8-byte sub-slices are guaranteed to fit into the fixed-size arrays
143 // because `bytes` is a `&[u8; HEADER_LEN]` (HEADER_LEN == 16). `expect` is
144 // unreachable but preferred over `unwrap` per crate lint floor.
145 let r#type = u32::from_le_bytes(
146 bytes[0..4]
147 .try_into()
148 .expect("static 4-byte slice of 16-byte header"),
149 );
150 let size = u32::from_le_bytes(
151 bytes[4..8]
152 .try_into()
153 .expect("static 4-byte slice of 16-byte header"),
154 );
155 let message_id = u64::from_le_bytes(
156 bytes[8..16]
157 .try_into()
158 .expect("static 8-byte slice of 16-byte header"),
159 );
160 if (size as usize) < HEADER_LEN {
161 return Err(GcsError::Protocol(format!(
162 "frame size {size} < header length {HEADER_LEN}"
163 )));
164 }
165 if (size as usize) > HEADER_LEN + MAX_PAYLOAD_LEN {
166 return Err(GcsError::Protocol(format!(
167 "frame size {size} exceeds MAX_PAYLOAD_LEN+header={}",
168 HEADER_LEN + MAX_PAYLOAD_LEN
169 )));
170 }
171 Ok(FrameHeader {
172 r#type,
173 size,
174 message_id,
175 })
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn round_trip_empty_payload() {
184 let mut buf = Vec::new();
185 encode_frame(0x0010_0001, 42, b"", &mut buf);
186 assert_eq!(buf.len(), HEADER_LEN);
187 let hdr_bytes: [u8; HEADER_LEN] = buf[..HEADER_LEN]
188 .try_into()
189 .expect("buf has HEADER_LEN bytes after encode_frame");
190 let h = decode_header(&hdr_bytes).unwrap();
191 assert_eq!(h.r#type, 0x0010_0001);
192 assert_eq!(h.size as usize, HEADER_LEN);
193 assert_eq!(h.message_id, 42);
194 }
195
196 #[test]
197 fn round_trip_with_payload() {
198 let payload = br#"{"hello":"world"}"#;
199 let mut buf = Vec::new();
200 encode_frame(0x1010_0001, 99, payload, &mut buf);
201 assert_eq!(buf.len(), HEADER_LEN + payload.len());
202 let hdr_bytes: [u8; HEADER_LEN] = buf[..HEADER_LEN]
203 .try_into()
204 .expect("buf has HEADER_LEN bytes after encode_frame");
205 let h = decode_header(&hdr_bytes).unwrap();
206 assert_eq!(h.size as usize, HEADER_LEN + payload.len());
207 assert_eq!(&buf[HEADER_LEN..], payload);
208 }
209
210 #[test]
211 fn decode_rejects_undersized_size_field() {
212 let mut bytes = [0u8; HEADER_LEN];
213 bytes[4..8].copy_from_slice(&8u32.to_le_bytes()); // size=8 < HEADER_LEN=16
214 let err = decode_header(&bytes).unwrap_err();
215 assert!(matches!(err, GcsError::Protocol(_)));
216 }
217
218 #[test]
219 fn decode_rejects_oversized_size_field() {
220 let mut bytes = [0u8; HEADER_LEN];
221 let bad_size: u32 =
222 u32::try_from(HEADER_LEN + MAX_PAYLOAD_LEN + 1).expect("test constant fits in u32");
223 bytes[4..8].copy_from_slice(&bad_size.to_le_bytes());
224 let err = decode_header(&bytes).unwrap_err();
225 assert!(matches!(err, GcsError::Protocol(_)));
226 }
227
228 #[test]
229 fn request_vs_response_type_bit() {
230 let req = RpcMessageType::Create.as_request_type();
231 let resp = RpcMessageType::Create.as_response_type();
232 // Request: 0x10100101, Response: 0x20100101 — differ only in the
233 // top 4 bits per hcsshim's `MsgTypeMask`.
234 assert_eq!(req & MSG_TYPE_MASK, MSG_TYPE_REQUEST);
235 assert_eq!(resp & MSG_TYPE_MASK, MSG_TYPE_RESPONSE);
236 assert_eq!(req & !MSG_TYPE_MASK, resp & !MSG_TYPE_MASK);
237 assert_eq!(req & CATEGORY_COMPUTE_SYSTEM, CATEGORY_COMPUTE_SYSTEM);
238 }
239
240 /// Pin the on-wire `NegotiateProtocol` REQUEST type to the exact
241 /// 32-bit value hcsshim's in-guest GCS expects (`0x10100B01`). If
242 /// this number changes, every WCOW UVM under `nanoserver:ltsc2022`
243 /// will reject the connection at the first frame.
244 #[test]
245 fn negotiate_protocol_wire_type_pinned() {
246 assert_eq!(
247 RpcMessageType::NegotiateProtocol.as_request_type(),
248 0x1010_0B01
249 );
250 assert_eq!(
251 RpcMessageType::NegotiateProtocol.as_response_type(),
252 0x2010_0B01
253 );
254 }
255
256 /// Pin the on-wire `ModifyServiceSettings` REQUEST/RESPONSE types. This RPC
257 /// lives in hcsshim's `ComputeService` category (not `ComputeSystem`), so
258 /// its wire value is `MSG_TYPE_* | 0x0020_0000 | 0x0101`. The Rust enum
259 /// discriminant carries a synthetic `0x1_0000` offset to avoid colliding
260 /// with `Create = 0x0101`; `proc_code()` must strip it so the wire bytes
261 /// are exactly `0x1020_0101` (request) / `0x2020_0101` (response). If this
262 /// drifts, the in-guest log-forward service will reject the RPC and the
263 /// guest GCS log will never reach the host.
264 #[test]
265 fn modify_service_settings_wire_type_pinned() {
266 let req = RpcMessageType::ModifyServiceSettings.as_request_type();
267 let resp = RpcMessageType::ModifyServiceSettings.as_response_type();
268 assert_eq!(req, 0x1020_0101);
269 assert_eq!(resp, 0x2020_0101);
270 // Category bits must be ComputeService, NOT ComputeSystem.
271 assert_eq!(req & CATEGORY_COMPUTE_SERVICE, CATEGORY_COMPUTE_SERVICE);
272 assert_eq!(req & CATEGORY_COMPUTE_SYSTEM, 0);
273 // No synthetic enum-offset bits leak onto the wire.
274 assert_eq!(req & 0x1_0000, 0);
275 }
276}