1use std::{collections::BTreeSet, fmt};
2
3use serde::{Deserialize, Deserializer, Serialize};
4use uuid::Uuid;
5
6pub mod feature {
7 pub const DEVICE_ROUTING_V1: &str = "device.routing.v1";
9
10 pub const EVENTS_SNAPSHOT_V1: &str = "events.snapshot.v1";
12
13 pub const SESSION_EXPORT_PAGE_V1: &str = "session.export.page.v1";
15
16 pub const REQUEST_CONTROL_V1: &str = "request.control.v1";
18
19 pub const ACTION_PROTECTED_V1: &str = "action.protected.v1";
21
22 pub const EVENTS_STREAM_V1: &str = "events.stream.v1";
24
25 pub const MEDIA_STREAM_V1: &str = "media.stream.v1";
27
28 pub const OBSERVATION_UI_SNAPSHOT_V1: &str = "observation.uiSnapshot.v1";
30
31 pub const DEVICE_SEMANTIC_ACTIONS_V1: &str = "device.semanticActions.v1";
33
34 pub const VERDICT_RECORD_V1: &str = "verdict.record.v1";
36}
37
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct ProtocolVersion {
42 pub major: u16,
43 pub minor: u16,
44}
45
46impl ProtocolVersion {
47 pub const fn new(major: u16, minor: u16) -> Self {
48 Self { major, minor }
49 }
50}
51
52impl fmt::Display for ProtocolVersion {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(formatter, "{}.{}", self.major, self.minor)
55 }
56}
57
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
61#[serde(rename_all = "camelCase", deny_unknown_fields)]
62pub struct ProtocolRange {
63 pub major: u16,
64 pub min_minor: u16,
65 pub max_minor: u16,
66}
67
68impl ProtocolRange {
69 pub const fn new(major: u16, min_minor: u16, max_minor: u16) -> Self {
70 Self {
71 major,
72 min_minor,
73 max_minor,
74 }
75 }
76
77 pub const fn exact(version: ProtocolVersion) -> Self {
78 Self::new(version.major, version.minor, version.minor)
79 }
80
81 pub const fn is_valid(self) -> bool {
82 self.min_minor <= self.max_minor
83 }
84
85 pub const fn minimum(self) -> ProtocolVersion {
86 ProtocolVersion::new(self.major, self.min_minor)
87 }
88
89 pub const fn maximum(self) -> ProtocolVersion {
90 ProtocolVersion::new(self.major, self.max_minor)
91 }
92}
93
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct ProtocolOffer {
100 #[cfg_attr(feature = "schema", schemars(length(min = 1)))]
101 pub ranges: Vec<ProtocolRange>,
102}
103
104impl ProtocolOffer {
105 pub fn new(ranges: Vec<ProtocolRange>) -> Self {
106 Self { ranges }
107 }
108
109 pub fn exact(version: ProtocolVersion) -> Self {
110 Self::new(vec![ProtocolRange::exact(version)])
111 }
112
113 pub fn minimum(&self) -> Option<ProtocolVersion> {
114 self.ranges.iter().map(|range| range.minimum()).min()
115 }
116
117 pub fn maximum(&self) -> Option<ProtocolVersion> {
118 self.ranges.iter().map(|range| range.maximum()).max()
119 }
120}
121
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
124#[serde(rename_all = "camelCase")]
125pub enum ProtocolIncompatibilityReason {
126 ClientTooOld,
127 ServerTooOld,
128 NoCommonVersion,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum ProtocolNegotiationError {
133 EmptyClientOffer,
134 EmptyServerOffer,
135 InvalidClientRange,
136 InvalidServerRange,
137 Incompatible(ProtocolIncompatibilityReason),
138}
139
140pub fn negotiate_protocol(
142 client: &ProtocolOffer,
143 server: &ProtocolOffer,
144) -> Result<ProtocolVersion, ProtocolNegotiationError> {
145 validate_offer(client, true)?;
146 validate_offer(server, false)?;
147
148 let mut selected: Option<ProtocolVersion> = None;
149 for client_range in &client.ranges {
150 for server_range in &server.ranges {
151 if client_range.major != server_range.major {
152 continue;
153 }
154
155 let min_minor = client_range.min_minor.max(server_range.min_minor);
156 let max_minor = client_range.max_minor.min(server_range.max_minor);
157 if min_minor <= max_minor {
158 let candidate = ProtocolVersion::new(client_range.major, max_minor);
159 selected = Some(selected.map_or(candidate, |current| current.max(candidate)));
160 }
161 }
162 }
163
164 selected.ok_or_else(|| {
165 ProtocolNegotiationError::Incompatible(incompatibility_reason(client, server))
166 })
167}
168
169fn validate_offer(offer: &ProtocolOffer, is_client: bool) -> Result<(), ProtocolNegotiationError> {
170 if offer.ranges.is_empty() {
171 return Err(if is_client {
172 ProtocolNegotiationError::EmptyClientOffer
173 } else {
174 ProtocolNegotiationError::EmptyServerOffer
175 });
176 }
177
178 if offer.ranges.iter().any(|range| !range.is_valid()) {
179 return Err(if is_client {
180 ProtocolNegotiationError::InvalidClientRange
181 } else {
182 ProtocolNegotiationError::InvalidServerRange
183 });
184 }
185
186 Ok(())
187}
188
189fn incompatibility_reason(
190 client: &ProtocolOffer,
191 server: &ProtocolOffer,
192) -> ProtocolIncompatibilityReason {
193 match (
194 client.maximum(),
195 server.minimum(),
196 client.minimum(),
197 server.maximum(),
198 ) {
199 (Some(client_max), Some(server_min), _, _) if client_max < server_min => {
200 ProtocolIncompatibilityReason::ClientTooOld
201 }
202 (_, _, Some(client_min), Some(server_max)) if client_min > server_max => {
203 ProtocolIncompatibilityReason::ServerTooOld
204 }
205 _ => ProtocolIncompatibilityReason::NoCommonVersion,
206 }
207}
208
209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
210#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct FeatureOffer {
213 #[serde(default, deserialize_with = "deserialize_unique_string_set")]
214 pub required: BTreeSet<String>,
215 #[serde(default, deserialize_with = "deserialize_unique_string_set")]
216 pub optional: BTreeSet<String>,
217}
218
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222pub struct FeatureSelection {
223 #[serde(deserialize_with = "deserialize_unique_string_set")]
224 pub enabled: BTreeSet<String>,
225}
226
227fn deserialize_unique_string_set<'de, D>(deserializer: D) -> Result<BTreeSet<String>, D::Error>
228where
229 D: Deserializer<'de>,
230{
231 let values = Vec::<String>::deserialize(deserializer)?;
232 let mut unique = BTreeSet::new();
233 for value in values {
234 if !unique.insert(value.clone()) {
235 return Err(serde::de::Error::custom(format!(
236 "duplicate feature name: {value}"
237 )));
238 }
239 }
240 Ok(unique)
241}
242
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct FeatureNegotiationError {
245 pub unsupported_required: BTreeSet<String>,
246}
247
248pub fn negotiate_features(
249 client: &FeatureOffer,
250 available: &BTreeSet<String>,
251) -> Result<FeatureSelection, FeatureNegotiationError> {
252 let unsupported_required = client
253 .required
254 .difference(available)
255 .cloned()
256 .collect::<BTreeSet<_>>();
257 if !unsupported_required.is_empty() {
258 return Err(FeatureNegotiationError {
259 unsupported_required,
260 });
261 }
262
263 let enabled = client
264 .required
265 .union(&client.optional)
266 .filter(|feature| available.contains(*feature))
267 .cloned()
268 .collect();
269 Ok(FeatureSelection { enabled })
270}
271
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct PeerInfo {
276 pub name: String,
277 pub version: String,
278}
279
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283pub struct HelloParams {
284 pub client: PeerInfo,
285 pub protocol: ProtocolOffer,
286 #[serde(default)]
287 pub features: FeatureOffer,
288}
289
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
292#[serde(rename_all = "camelCase", deny_unknown_fields)]
293pub struct ProtocolSelection {
294 pub selected: ProtocolVersion,
295}
296
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
299#[serde(rename_all = "camelCase", deny_unknown_fields)]
300pub struct TransportInfo {
301 pub kind: String,
302 pub framing: String,
303}
304
305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
306#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
307#[serde(rename_all = "camelCase", deny_unknown_fields)]
308pub struct HelloResult {
309 pub connection_id: Uuid,
310 pub protocol: ProtocolSelection,
311 pub server: PeerInfo,
312 pub transport: TransportInfo,
313 pub features: FeatureSelection,
314}
315
316pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::new(1, 5);
318
319pub fn supported_protocol_offer() -> ProtocolOffer {
320 ProtocolOffer::new(vec![ProtocolRange::new(1, 0, PROTOCOL_VERSION.minor)])
321}
322
323#[cfg(test)]
324mod tests {
325 use std::collections::BTreeSet;
326
327 use serde_json::json;
328
329 use super::{
330 FeatureOffer, HelloParams, ProtocolIncompatibilityReason, ProtocolNegotiationError,
331 ProtocolOffer, ProtocolRange, ProtocolVersion, feature, negotiate_features,
332 negotiate_protocol, supported_protocol_offer,
333 };
334
335 #[test]
336 fn chooses_newest_version_across_unsorted_multi_major_offers() {
337 let client = ProtocolOffer::new(vec![
338 ProtocolRange::new(1, 0, 8),
339 ProtocolRange::new(3, 0, 2),
340 ]);
341 let server = ProtocolOffer::new(vec![
342 ProtocolRange::new(3, 1, 4),
343 ProtocolRange::new(1, 2, 9),
344 ]);
345
346 assert_eq!(
347 negotiate_protocol(&client, &server),
348 Ok(ProtocolVersion::new(3, 2))
349 );
350 }
351
352 #[test]
353 fn offer_does_not_imply_support_for_a_missing_major() {
354 let client = ProtocolOffer::new(vec![
355 ProtocolRange::new(1, 0, 4),
356 ProtocolRange::new(3, 0, 4),
357 ]);
358 let server = ProtocolOffer::new(vec![ProtocolRange::new(2, 0, 4)]);
359
360 assert_eq!(
361 negotiate_protocol(&client, &server),
362 Err(ProtocolNegotiationError::Incompatible(
363 ProtocolIncompatibilityReason::NoCommonVersion
364 ))
365 );
366 }
367
368 #[test]
369 fn distinguishes_clients_that_are_too_old_or_too_new() {
370 let server = ProtocolOffer::exact(ProtocolVersion::new(2, 0));
371 let old_client = ProtocolOffer::new(vec![ProtocolRange::new(1, 0, 9)]);
372 let new_client = ProtocolOffer::new(vec![ProtocolRange::new(3, 0, 9)]);
373
374 assert_eq!(
375 negotiate_protocol(&old_client, &server),
376 Err(ProtocolNegotiationError::Incompatible(
377 ProtocolIncompatibilityReason::ClientTooOld
378 ))
379 );
380 assert_eq!(
381 negotiate_protocol(&new_client, &server),
382 Err(ProtocolNegotiationError::Incompatible(
383 ProtocolIncompatibilityReason::ServerTooOld
384 ))
385 );
386 }
387
388 #[test]
389 fn validates_empty_and_inverted_offers() {
390 let server = ProtocolOffer::exact(ProtocolVersion::new(1, 0));
391 assert_eq!(
392 negotiate_protocol(&ProtocolOffer::new(vec![]), &server),
393 Err(ProtocolNegotiationError::EmptyClientOffer)
394 );
395 assert_eq!(
396 negotiate_protocol(
397 &ProtocolOffer::new(vec![ProtocolRange::new(1, 2, 1)]),
398 &server
399 ),
400 Err(ProtocolNegotiationError::InvalidClientRange)
401 );
402 }
403
404 #[test]
405 fn v1_offer_preserves_minor_zero_and_advertises_current_features() {
406 assert_eq!(
407 supported_protocol_offer(),
408 ProtocolOffer::new(vec![ProtocolRange::new(1, 0, 5)])
409 );
410 assert_eq!(feature::REQUEST_CONTROL_V1, "request.control.v1");
411 assert_eq!(feature::DEVICE_ROUTING_V1, "device.routing.v1");
412 assert_eq!(feature::ACTION_PROTECTED_V1, "action.protected.v1");
413 assert_eq!(feature::SESSION_EXPORT_PAGE_V1, "session.export.page.v1");
414 assert_eq!(
415 feature::OBSERVATION_UI_SNAPSHOT_V1,
416 "observation.uiSnapshot.v1"
417 );
418 assert_eq!(
419 feature::DEVICE_SEMANTIC_ACTIONS_V1,
420 "device.semanticActions.v1"
421 );
422 assert_eq!(feature::VERDICT_RECORD_V1, "verdict.record.v1");
423 }
424
425 #[test]
426 fn feature_negotiation_requires_required_and_ignores_unknown_optional() {
427 let available = BTreeSet::from([feature::EVENTS_SNAPSHOT_V1.to_owned()]);
428 let offer = FeatureOffer {
429 required: BTreeSet::from([feature::EVENTS_SNAPSHOT_V1.to_owned()]),
430 optional: BTreeSet::from(["events.push.v1".to_owned()]),
431 };
432 let selected = negotiate_features(&offer, &available).expect("features are compatible");
433 assert_eq!(selected.enabled, available);
434
435 let unsupported = FeatureOffer {
436 required: BTreeSet::from(["z.v1".to_owned(), "a.v1".to_owned()]),
437 optional: BTreeSet::new(),
438 };
439 let error = negotiate_features(&unsupported, &available).expect_err("required are absent");
440 assert_eq!(
441 error.unsupported_required.into_iter().collect::<Vec<_>>(),
442 ["a.v1", "z.v1"]
443 );
444 }
445
446 #[test]
447 fn offer_uses_camel_case_wire_fields() {
448 let value = serde_json::to_value(ProtocolOffer::new(vec![ProtocolRange::new(1, 0, 2)]))
449 .expect("serialize offer");
450 assert_eq!(
451 value,
452 json!({ "ranges": [{ "major": 1, "minMinor": 0, "maxMinor": 2 }] })
453 );
454 }
455
456 #[test]
457 fn hello_rejects_unknown_or_misspelled_fields() {
458 let misspelled = json!({
459 "client": { "name": "client", "version": "0.1.0" },
460 "protocol": {
461 "ranges": [{ "major": 1, "minMinor": 0, "maxMinor": 0 }]
462 },
463 "feature": { "required": [], "optional": [] }
464 });
465
466 assert!(serde_json::from_value::<HelloParams>(misspelled).is_err());
467 }
468
469 #[test]
470 fn feature_sets_reject_duplicate_wire_values() {
471 let duplicated = json!({
472 "client": { "name": "client", "version": "0.1.0" },
473 "protocol": {
474 "ranges": [{ "major": 1, "minMinor": 0, "maxMinor": 0 }]
475 },
476 "features": {
477 "required": ["events.snapshot.v1", "events.snapshot.v1"],
478 "optional": []
479 }
480 });
481
482 assert!(serde_json::from_value::<HelloParams>(duplicated).is_err());
483 }
484}