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", deny_unknown_fields)]
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, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase", deny_unknown_fields)]
120pub struct GlassError {
121 pub code: String,
122 pub message: String,
123 pub retryable: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub details: Option<Value>,
126}
127
128impl GlassError {
129 pub fn validate(&self) -> Result<(), ProtocolError> {
131 if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
132 return Err(ProtocolError::InvalidField(
133 "error code must be a bounded non-empty string".into(),
134 ));
135 }
136 if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
137 return Err(ProtocolError::InvalidField(
138 "error message must be a bounded non-empty string".into(),
139 ));
140 }
141 Ok(())
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ProtocolError {
148 UnsupportedVersion(u32),
149 InvalidField(String),
150}
151
152impl std::fmt::Display for ProtocolError {
153 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::UnsupportedVersion(version) => {
156 write!(formatter, "unsupported Glass protocol version {version}")
157 }
158 Self::InvalidField(detail) => formatter.write_str(detail),
159 }
160 }
161}
162
163impl std::error::Error for ProtocolError {}
164
165fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
166 if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
167 return Err(ProtocolError::InvalidField(format!(
168 "{field} must be a bounded non-whitespace identifier"
169 )));
170 }
171 Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn request() -> GlassRequest {
179 GlassRequest {
180 protocol_version: GLASS_PROTOCOL_VERSION,
181 request_id: "request-1".into(),
182 correlation_id: Some("run-1".into()),
183 session_id: Some("session-1".into()),
184 mutation_lease: Some(MutationLeaseRef {
185 session_id: "session-1".into(),
186 token: "lease-1".into(),
187 }),
188 operation: "browser.observe".into(),
189 payload: serde_json::json!({"level": "interactive"}),
190 deadline_ms: Some(5_000),
191 }
192 }
193
194 #[test]
195 fn request_round_trips_and_validates() {
196 let request = request();
197 request.validate().unwrap();
198 let value = serde_json::to_value(&request).unwrap();
199 assert_eq!(value["protocolVersion"], 1);
200 assert_eq!(value["mutationLease"]["sessionId"], "session-1");
201 let decoded: GlassRequest = serde_json::from_value(value).unwrap();
202 assert_eq!(decoded, request);
203 }
204
205 #[test]
206 fn response_requires_exactly_one_outcome() {
207 let response = GlassResponse {
208 protocol_version: GLASS_PROTOCOL_VERSION,
209 request_id: "request-1".into(),
210 correlation_id: None,
211 ok: false,
212 result: None,
213 error: Some(GlassError {
214 code: "leaseRequired".into(),
215 message: "a mutation lease is required".into(),
216 retryable: true,
217 details: None,
218 }),
219 };
220 response.validate().unwrap();
221 let mut invalid = response.clone();
222 invalid.ok = true;
223 assert!(invalid.validate().is_err());
224 }
225
226 #[test]
227 fn bounds_and_unknown_fields_fail_closed() {
228 let mut request = request();
229 request.operation = "bad operation".into();
230 assert!(request.validate().is_err());
231 let unknown = serde_json::json!({
232 "protocolVersion": 1,
233 "requestId": "request-1",
234 "operation": "browser.observe",
235 "payload": {},
236 "future": true
237 });
238 assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
239 }
240}