Skip to main content

devicerail_protocol/
handshake.rs

1use std::{collections::BTreeSet, fmt};
2
3use serde::{Deserialize, Deserializer, Serialize};
4use uuid::Uuid;
5
6pub mod feature {
7    /// Enables per-connection device discovery and default-device selection.
8    pub const DEVICE_ROUTING_V1: &str = "device.routing.v1";
9
10    /// Enables the v1 event snapshot management methods (`events.list` and `events.clear`).
11    pub const EVENTS_SNAPSHOT_V1: &str = "events.snapshot.v1";
12
13    /// Enables bounded, cursor-resumable results from `session.export`.
14    pub const SESSION_EXPORT_PAGE_V1: &str = "session.export.page.v1";
15
16    /// Enables request deadlines and cooperative cancellation with `request.cancel`.
17    pub const REQUEST_CONTROL_V1: &str = "request.control.v1";
18
19    /// Enables discovery and execution of actions whose durable arguments are redacted.
20    pub const ACTION_PROTECTED_V1: &str = "action.protected.v1";
21
22    /// Enables the resumable real-time Session event stream transport.
23    pub const EVENTS_STREAM_V1: &str = "events.stream.v1";
24
25    /// Enables Evidence-referenced screenshot/video stream lifecycle events.
26    pub const MEDIA_STREAM_V1: &str = "media.stream.v1";
27
28    /// Enables Evidence-referenced canonical UI Trees and `ui.snapshot.get`.
29    pub const OBSERVATION_UI_SNAPSHOT_V1: &str = "observation.uiSnapshot.v1";
30
31    /// Enables selectors, stable node references, and semantic action execution metadata.
32    pub const DEVICE_SEMANTIC_ACTIONS_V1: &str = "device.semanticActions.v1";
33
34    /// Enables persisting an existing Verdict through `verdict.record`.
35    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/// A contiguous minor-version range within one protocol major version.
59#[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/// An explicit set of supported ranges. Multiple entries avoid implying support
95/// for major versions that a peer intentionally skipped.
96#[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
140/// Selects the lexicographically newest version present in both offers.
141pub 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
316/// Highest protocol version implemented by this crate.
317pub 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}