1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub const GLASS_PROTOCOL_VERSION: u32 = 1;
13const MAX_ID_BYTES: usize = 128;
14const MAX_OPERATION_BYTES: usize = 96;
15const MAX_ERROR_CODE_BYTES: usize = 64;
16const MAX_MESSAGE_BYTES: usize = 512;
17const MAX_DEADLINE_MS: u64 = 15 * 60 * 1_000;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct MutationLeaseRef {
23 pub session_id: String,
24 pub token: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct GlassRequest {
31 pub protocol_version: u32,
32 pub request_id: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub correlation_id: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub session_id: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub mutation_lease: Option<MutationLeaseRef>,
39 pub operation: String,
40 pub payload: Value,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub deadline_ms: Option<u64>,
43}
44
45impl GlassRequest {
46 pub fn validate(&self) -> Result<(), ProtocolError> {
48 if self.protocol_version != GLASS_PROTOCOL_VERSION {
49 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
50 }
51 validate_identifier(&self.request_id, "requestId")?;
52 if let Some(correlation_id) = &self.correlation_id {
53 validate_identifier(correlation_id, "correlationId")?;
54 }
55 if let Some(session_id) = &self.session_id {
56 validate_identifier(session_id, "sessionId")?;
57 }
58 if let Some(lease) = &self.mutation_lease {
59 validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
60 validate_identifier(&lease.token, "mutationLease.token")?;
61 }
62 if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
63 return Err(ProtocolError::InvalidField(
64 "operation must be a bounded non-empty string".into(),
65 ));
66 }
67 if self.operation.chars().any(char::is_whitespace) {
68 return Err(ProtocolError::InvalidField(
69 "operation must not contain whitespace".into(),
70 ));
71 }
72 if let Some(deadline_ms) = self.deadline_ms
73 && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
74 {
75 return Err(ProtocolError::InvalidField(format!(
76 "deadlineMs must be 1..={MAX_DEADLINE_MS}"
77 )));
78 }
79 Ok(())
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct GlassResponse {
87 pub protocol_version: u32,
88 pub request_id: String,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub correlation_id: Option<String>,
91 pub ok: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub result: Option<Value>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub error: Option<GlassError>,
96}
97
98impl GlassResponse {
99 pub fn validate(&self) -> Result<(), ProtocolError> {
101 if self.protocol_version != GLASS_PROTOCOL_VERSION {
102 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
103 }
104 validate_identifier(&self.request_id, "requestId")?;
105 if let Some(correlation_id) = &self.correlation_id {
106 validate_identifier(correlation_id, "correlationId")?;
107 }
108 match (self.ok, self.result.is_some(), self.error.is_some()) {
109 (true, true, false) | (false, false, true) => Ok(()),
110 _ => Err(ProtocolError::InvalidField(
111 "ok responses require result and error responses require error".into(),
112 )),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "camelCase")]
120pub enum ErrorPhase {
121 #[default]
122 Preflight,
123 Dispatch,
124 PostDispatch,
125 Verification,
126 Reconciliation,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
131#[serde(rename_all = "camelCase")]
132pub enum RetryClassification {
133 SafeImmediate,
134 #[default]
135 SafeAfterReobserve,
136 SafeAfterReconcile,
137 UnsafeUntilReconciled,
138 RequiresUserDecision,
139 NotRetryable,
140 Unknown,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct RetryGuidance {
147 pub classification: RetryClassification,
148 pub recommended_operation: String,
149}
150
151impl Default for RetryGuidance {
152 fn default() -> Self {
153 Self {
154 classification: RetryClassification::SafeAfterReobserve,
155 recommended_operation: "inspect_page".into(),
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct GlassError {
164 pub code: String,
165 #[serde(default)]
166 pub phase: ErrorPhase,
167 pub message: String,
168 #[serde(default)]
169 pub mutation_possible: bool,
170 #[serde(default)]
171 pub retry: RetryGuidance,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub retryable: Option<bool>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub details: Option<Value>,
177}
178
179impl GlassError {
180 pub fn validate(&self) -> Result<(), ProtocolError> {
182 if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
183 return Err(ProtocolError::InvalidField(
184 "error code must be a bounded non-empty string".into(),
185 ));
186 }
187 if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
188 return Err(ProtocolError::InvalidField(
189 "error message must be a bounded non-empty string".into(),
190 ));
191 }
192 validate_identifier(
193 &self.retry.recommended_operation,
194 "retry.recommendedOperation",
195 )?;
196 Ok(())
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ProtocolError {
203 UnsupportedVersion(u32),
204 InvalidField(String),
205}
206
207impl std::fmt::Display for ProtocolError {
208 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 match self {
210 Self::UnsupportedVersion(version) => {
211 write!(formatter, "unsupported Glass protocol version {version}")
212 }
213 Self::InvalidField(detail) => formatter.write_str(detail),
214 }
215 }
216}
217
218impl std::error::Error for ProtocolError {}
219
220fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
221 if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
222 return Err(ProtocolError::InvalidField(format!(
223 "{field} must be a bounded non-whitespace identifier"
224 )));
225 }
226 Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn request() -> GlassRequest {
234 GlassRequest {
235 protocol_version: GLASS_PROTOCOL_VERSION,
236 request_id: "request-1".into(),
237 correlation_id: Some("run-1".into()),
238 session_id: Some("session-1".into()),
239 mutation_lease: Some(MutationLeaseRef {
240 session_id: "session-1".into(),
241 token: "lease-1".into(),
242 }),
243 operation: "browser.observe".into(),
244 payload: serde_json::json!({"level": "interactive"}),
245 deadline_ms: Some(5_000),
246 }
247 }
248
249 #[test]
250 fn request_round_trips_and_validates() {
251 let request = request();
252 request.validate().unwrap();
253 let value = serde_json::to_value(&request).unwrap();
254 assert_eq!(value["protocolVersion"], 1);
255 assert_eq!(value["mutationLease"]["sessionId"], "session-1");
256 let decoded: GlassRequest = serde_json::from_value(value).unwrap();
257 assert_eq!(decoded, request);
258 }
259
260 #[test]
261 fn response_requires_exactly_one_outcome() {
262 let response = GlassResponse {
263 protocol_version: GLASS_PROTOCOL_VERSION,
264 request_id: "request-1".into(),
265 correlation_id: None,
266 ok: false,
267 result: None,
268 error: Some(GlassError {
269 code: "target.stale".into(),
270 phase: ErrorPhase::Preflight,
271 message: "a mutation lease is required".into(),
272 mutation_possible: false,
273 retry: RetryGuidance {
274 classification: RetryClassification::SafeAfterReobserve,
275 recommended_operation: "inspect_page".into(),
276 },
277 retryable: Some(true),
278 details: None,
279 }),
280 };
281 response.validate().unwrap();
282 let mut invalid = response.clone();
283 invalid.ok = true;
284 assert!(invalid.validate().is_err());
285 }
286
287 #[test]
288 fn bounds_and_unknown_fields_fail_closed() {
289 let mut request = request();
290 request.operation = "bad operation".into();
291 assert!(request.validate().is_err());
292 let unknown = serde_json::json!({
293 "protocolVersion": 1,
294 "requestId": "request-1",
295 "operation": "browser.observe",
296 "payload": {},
297 "future": true
298 });
299 assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
300 }
301
302 #[test]
303 fn additive_response_fields_are_tolerated() {
304 let response: GlassResponse = serde_json::from_value(serde_json::json!({
305 "protocolVersion": 1,
306 "requestId": "request-1",
307 "ok": false,
308 "error": {
309 "code": "target.stale",
310 "message": "stale",
311 "retryable": true,
312 "future": "ignored"
313 },
314 "future": true
315 }))
316 .unwrap();
317 assert_eq!(
318 response.error.unwrap().retry.classification,
319 RetryClassification::SafeAfterReobserve
320 );
321 }
322}