Skip to main content

shelly/
invariants.rs

1use crate::{ClientMessage, ResumeStatus, ServerMessage, PROTOCOL_VERSION_V1};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION_V1];
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ProtocolDirection {
10    ClientToServer,
11    ServerToClient,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ProtocolAuthority {
17    UntrustedClient,
18    TrustedServer,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ProtocolInstructionClass {
24    Lifecycle,
25    Event,
26    Render,
27    Navigation,
28    Upload,
29    Diagnostics,
30    Stream,
31    Chart,
32    Notification,
33    Grid,
34    Interop,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ProtocolOrdering {
40    Unordered,
41    PerSessionOrdered,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ProtocolDurability {
47    Ephemeral,
48    Replayable,
49    Resumable,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ProtocolRenderEffect {
55    None,
56    Patch,
57    Diff,
58    Stream,
59    Navigation,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ProtocolInstructionDescriptor {
64    pub class: ProtocolInstructionClass,
65    pub direction: ProtocolDirection,
66    pub authority: ProtocolAuthority,
67    pub ordering: ProtocolOrdering,
68    pub durability: ProtocolDurability,
69    pub render_effect: ProtocolRenderEffect,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ProtocolInvariantViolation {
74    pub code: String,
75    pub message: String,
76}
77
78impl ProtocolInvariantViolation {
79    fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
80        Self {
81            code: code.into(),
82            message: message.into(),
83        }
84    }
85}
86
87pub fn is_supported_protocol_version(protocol: &str) -> bool {
88    SUPPORTED_PROTOCOL_VERSIONS.contains(&protocol)
89}
90
91pub fn describe_client_message(message: &ClientMessage) -> ProtocolInstructionDescriptor {
92    let class = match message {
93        ClientMessage::Connect { .. } => ProtocolInstructionClass::Lifecycle,
94        ClientMessage::Event { .. } => ProtocolInstructionClass::Event,
95        ClientMessage::Ping { .. } => ProtocolInstructionClass::Diagnostics,
96        ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
97            ProtocolInstructionClass::Navigation
98        }
99        ClientMessage::UploadStart { .. }
100        | ClientMessage::UploadChunk { .. }
101        | ClientMessage::UploadComplete { .. } => ProtocolInstructionClass::Upload,
102    };
103    let durability = match message {
104        ClientMessage::Connect { .. } => ProtocolDurability::Resumable,
105        ClientMessage::Event { .. } => ProtocolDurability::Replayable,
106        ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
107            ProtocolDurability::Replayable
108        }
109        ClientMessage::UploadStart { .. }
110        | ClientMessage::UploadChunk { .. }
111        | ClientMessage::UploadComplete { .. }
112        | ClientMessage::Ping { .. } => ProtocolDurability::Ephemeral,
113    };
114    let render_effect = match message {
115        ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
116            ProtocolRenderEffect::Navigation
117        }
118        _ => ProtocolRenderEffect::None,
119    };
120    ProtocolInstructionDescriptor {
121        class,
122        direction: ProtocolDirection::ClientToServer,
123        authority: ProtocolAuthority::UntrustedClient,
124        ordering: ProtocolOrdering::PerSessionOrdered,
125        durability,
126        render_effect,
127    }
128}
129
130pub fn describe_server_message(message: &ServerMessage) -> ProtocolInstructionDescriptor {
131    let (class, render_effect, durability) = match message {
132        ServerMessage::Hello { .. } => (
133            ProtocolInstructionClass::Lifecycle,
134            ProtocolRenderEffect::None,
135            ProtocolDurability::Resumable,
136        ),
137        ServerMessage::Patch { .. } => (
138            ProtocolInstructionClass::Render,
139            ProtocolRenderEffect::Patch,
140            ProtocolDurability::Replayable,
141        ),
142        ServerMessage::Diff { .. } => (
143            ProtocolInstructionClass::Render,
144            ProtocolRenderEffect::Diff,
145            ProtocolDurability::Replayable,
146        ),
147        ServerMessage::StreamInsert { .. }
148        | ServerMessage::StreamDelete { .. }
149        | ServerMessage::StreamBatch { .. } => (
150            ProtocolInstructionClass::Stream,
151            ProtocolRenderEffect::Stream,
152            ProtocolDurability::Replayable,
153        ),
154        ServerMessage::ChartSeriesAppend { .. }
155        | ServerMessage::ChartSeriesAppendMany { .. }
156        | ServerMessage::ChartSeriesReplace { .. }
157        | ServerMessage::ChartReset { .. }
158        | ServerMessage::ChartAnnotationUpsert { .. }
159        | ServerMessage::ChartAnnotationDelete { .. } => (
160            ProtocolInstructionClass::Chart,
161            ProtocolRenderEffect::None,
162            ProtocolDurability::Replayable,
163        ),
164        ServerMessage::ToastPush { .. }
165        | ServerMessage::ToastDismiss { .. }
166        | ServerMessage::InboxUpsert { .. }
167        | ServerMessage::InboxDelete { .. } => (
168            ProtocolInstructionClass::Notification,
169            ProtocolRenderEffect::None,
170            ProtocolDurability::Replayable,
171        ),
172        ServerMessage::GridReplace { .. } | ServerMessage::GridRowsReplace { .. } => (
173            ProtocolInstructionClass::Grid,
174            ProtocolRenderEffect::None,
175            ProtocolDurability::Replayable,
176        ),
177        ServerMessage::InteropDispatch { .. } => (
178            ProtocolInstructionClass::Interop,
179            ProtocolRenderEffect::None,
180            ProtocolDurability::Ephemeral,
181        ),
182        ServerMessage::Pong { .. } | ServerMessage::Error { .. } => (
183            ProtocolInstructionClass::Diagnostics,
184            ProtocolRenderEffect::None,
185            ProtocolDurability::Ephemeral,
186        ),
187        ServerMessage::Redirect { .. }
188        | ServerMessage::PatchUrl { .. }
189        | ServerMessage::Navigate { .. } => (
190            ProtocolInstructionClass::Navigation,
191            ProtocolRenderEffect::Navigation,
192            ProtocolDurability::Replayable,
193        ),
194        ServerMessage::UploadProgress { .. }
195        | ServerMessage::UploadComplete { .. }
196        | ServerMessage::UploadError { .. } => (
197            ProtocolInstructionClass::Upload,
198            ProtocolRenderEffect::None,
199            ProtocolDurability::Ephemeral,
200        ),
201    };
202    ProtocolInstructionDescriptor {
203        class,
204        direction: ProtocolDirection::ServerToClient,
205        authority: ProtocolAuthority::TrustedServer,
206        ordering: ProtocolOrdering::PerSessionOrdered,
207        durability,
208        render_effect,
209    }
210}
211
212pub fn validate_client_message_invariants(
213    message: &ClientMessage,
214) -> Result<(), ProtocolInvariantViolation> {
215    match message {
216        ClientMessage::Connect {
217            protocol,
218            trace_id,
219            span_id,
220            parent_span_id,
221            ..
222        } => {
223            if !is_supported_protocol_version(protocol) {
224                return Err(ProtocolInvariantViolation::new(
225                    "unsupported_protocol_version",
226                    format!(
227                        "client connect protocol '{}' is unsupported; expected one of {:?}",
228                        protocol, SUPPORTED_PROTOCOL_VERSIONS
229                    ),
230                ));
231            }
232            validate_hex_id("trace_id", trace_id.as_deref(), 32)?;
233            validate_hex_id("span_id", span_id.as_deref(), 16)?;
234            validate_hex_id("parent_span_id", parent_span_id.as_deref(), 16)?;
235            Ok(())
236        }
237        ClientMessage::Event { event, .. } => {
238            if event.trim().is_empty() {
239                return Err(ProtocolInvariantViolation::new(
240                    "empty_event_name",
241                    "event message must include a non-empty event name",
242                ));
243            }
244            Ok(())
245        }
246        ClientMessage::PatchUrl { to } => validate_internal_path("patch_url", to),
247        ClientMessage::Navigate { to } => validate_internal_path("navigate", to),
248        ClientMessage::UploadStart {
249            upload_id,
250            event,
251            name,
252            ..
253        } => {
254            if upload_id.trim().is_empty() {
255                return Err(ProtocolInvariantViolation::new(
256                    "empty_upload_id",
257                    "upload_start.upload_id cannot be empty",
258                ));
259            }
260            if event.trim().is_empty() {
261                return Err(ProtocolInvariantViolation::new(
262                    "empty_upload_event",
263                    "upload_start.event cannot be empty",
264                ));
265            }
266            if name.trim().is_empty() {
267                return Err(ProtocolInvariantViolation::new(
268                    "empty_upload_name",
269                    "upload_start.name cannot be empty",
270                ));
271            }
272            Ok(())
273        }
274        ClientMessage::UploadChunk { upload_id, .. }
275        | ClientMessage::UploadComplete { upload_id } => {
276            if upload_id.trim().is_empty() {
277                return Err(ProtocolInvariantViolation::new(
278                    "empty_upload_id",
279                    "upload message upload_id cannot be empty",
280                ));
281            }
282            Ok(())
283        }
284        ClientMessage::Ping { .. } => Ok(()),
285    }
286}
287
288pub fn validate_server_message_invariants(
289    message: &ServerMessage,
290) -> Result<(), ProtocolInvariantViolation> {
291    match message {
292        ServerMessage::Hello {
293            session_id,
294            target,
295            protocol,
296            revision,
297            server_revision,
298            resume_status,
299            resume_reason,
300            resume_token,
301            resume_expires_in_ms,
302        } => {
303            if session_id.trim().is_empty() {
304                return Err(ProtocolInvariantViolation::new(
305                    "empty_session_id",
306                    "hello.session_id cannot be empty",
307                ));
308            }
309            if target.trim().is_empty() {
310                return Err(ProtocolInvariantViolation::new(
311                    "empty_target",
312                    "hello.target cannot be empty",
313                ));
314            }
315            if !is_supported_protocol_version(protocol) {
316                return Err(ProtocolInvariantViolation::new(
317                    "unsupported_protocol_version",
318                    format!(
319                        "hello.protocol '{}' is unsupported; expected one of {:?}",
320                        protocol, SUPPORTED_PROTOCOL_VERSIONS
321                    ),
322                ));
323            }
324            if let Some(server_revision) = server_revision {
325                if server_revision < revision {
326                    return Err(ProtocolInvariantViolation::new(
327                        "server_revision_regression",
328                        format!(
329                            "hello.server_revision ({server_revision}) cannot be lower than hello.revision ({revision})",
330                        ),
331                    ));
332                }
333            }
334            if let Some(status) = resume_status {
335                if matches!(status, ResumeStatus::Fallback)
336                    && resume_reason
337                        .as_ref()
338                        .map(|reason| reason.trim().is_empty())
339                        .unwrap_or(true)
340                {
341                    return Err(ProtocolInvariantViolation::new(
342                        "missing_resume_reason",
343                        "hello.resume_reason must be present for fallback resume status",
344                    ));
345                }
346            }
347            if let Some(reason) = resume_reason {
348                if reason.trim().is_empty() {
349                    return Err(ProtocolInvariantViolation::new(
350                        "empty_resume_reason",
351                        "hello.resume_reason cannot be empty when present",
352                    ));
353                }
354            }
355            if resume_expires_in_ms.is_some() && resume_token.is_none() {
356                return Err(ProtocolInvariantViolation::new(
357                    "missing_resume_token",
358                    "hello.resume_token must be present when resume_expires_in_ms is set",
359                ));
360            }
361            Ok(())
362        }
363        ServerMessage::Patch { target, .. } => validate_non_empty("patch.target", target),
364        ServerMessage::Diff { target, slots, .. } => {
365            validate_non_empty("diff.target", target)?;
366            let mut indices = BTreeSet::new();
367            for slot in slots {
368                if !indices.insert(slot.index) {
369                    return Err(ProtocolInvariantViolation::new(
370                        "duplicate_diff_slot",
371                        format!("diff contains duplicate slot index {}", slot.index),
372                    ));
373                }
374            }
375            Ok(())
376        }
377        ServerMessage::StreamInsert { target, id, .. }
378        | ServerMessage::StreamDelete { target, id } => {
379            validate_non_empty("stream.target", target)?;
380            validate_non_empty("stream.id", id)
381        }
382        ServerMessage::StreamBatch { target, operations } => {
383            validate_non_empty("stream_batch.target", target)?;
384            if operations.is_empty() {
385                return Err(ProtocolInvariantViolation::new(
386                    "empty_stream_batch",
387                    "stream_batch.operations cannot be empty",
388                ));
389            }
390            Ok(())
391        }
392        ServerMessage::Redirect { to }
393        | ServerMessage::PatchUrl { to }
394        | ServerMessage::Navigate { to } => validate_internal_path("server_navigation", to),
395        ServerMessage::UploadProgress {
396            upload_id,
397            received,
398            total,
399        } => {
400            validate_non_empty("upload_progress.upload_id", upload_id)?;
401            if received > total {
402                return Err(ProtocolInvariantViolation::new(
403                    "upload_progress_out_of_bounds",
404                    format!("upload progress received {received} cannot exceed total {total}"),
405                ));
406            }
407            Ok(())
408        }
409        ServerMessage::UploadComplete {
410            upload_id, name, ..
411        } => {
412            validate_non_empty("upload_complete.upload_id", upload_id)?;
413            validate_non_empty("upload_complete.name", name)
414        }
415        ServerMessage::UploadError {
416            upload_id, message, ..
417        } => {
418            validate_non_empty("upload_error.upload_id", upload_id)?;
419            validate_non_empty("upload_error.message", message)
420        }
421        ServerMessage::Error { message, .. } => validate_non_empty("error.message", message),
422        ServerMessage::ChartSeriesAppend { chart, series, .. }
423        | ServerMessage::ChartSeriesAppendMany { chart, series, .. }
424        | ServerMessage::ChartSeriesReplace { chart, series, .. } => {
425            validate_non_empty("chart", chart)?;
426            validate_non_empty("series", series)
427        }
428        ServerMessage::ChartReset { chart } => validate_non_empty("chart", chart),
429        ServerMessage::ChartAnnotationUpsert { chart, annotation } => {
430            validate_non_empty("chart", chart)?;
431            validate_non_empty("annotation.id", &annotation.id)
432        }
433        ServerMessage::ChartAnnotationDelete { chart, id } => {
434            validate_non_empty("chart", chart)?;
435            validate_non_empty("annotation.id", id)
436        }
437        ServerMessage::ToastPush { toast } => validate_non_empty("toast.id", &toast.id),
438        ServerMessage::ToastDismiss { id } => validate_non_empty("toast.id", id),
439        ServerMessage::InboxUpsert { item } => validate_non_empty("inbox.id", &item.id),
440        ServerMessage::InboxDelete { id } => validate_non_empty("inbox.id", id),
441        ServerMessage::GridReplace { grid, .. } | ServerMessage::GridRowsReplace { grid, .. } => {
442            validate_non_empty("grid", grid)
443        }
444        ServerMessage::InteropDispatch { dispatch } => {
445            validate_non_empty("interop.event", &dispatch.event)
446        }
447        ServerMessage::Pong { .. } => Ok(()),
448    }
449}
450
451pub fn validate_server_message_sequence(
452    messages: &[ServerMessage],
453) -> Result<(), ProtocolInvariantViolation> {
454    let mut seen_hello = false;
455    let mut last_render_revision: Option<u64> = None;
456
457    for (index, message) in messages.iter().enumerate() {
458        validate_server_message_invariants(message)?;
459        if matches!(message, ServerMessage::Hello { .. }) {
460            if seen_hello {
461                return Err(ProtocolInvariantViolation::new(
462                    "duplicate_hello",
463                    "server transcript cannot contain multiple hello messages",
464                ));
465            }
466            if index != 0 {
467                return Err(ProtocolInvariantViolation::new(
468                    "hello_not_first",
469                    "hello message must be first in a server transcript",
470                ));
471            }
472            seen_hello = true;
473            continue;
474        }
475
476        let current_render_revision = match message {
477            ServerMessage::Patch { revision, .. } | ServerMessage::Diff { revision, .. } => {
478                Some(*revision)
479            }
480            _ => None,
481        };
482        if let Some(current_render_revision) = current_render_revision {
483            if let Some(previous) = last_render_revision {
484                if current_render_revision <= previous {
485                    return Err(ProtocolInvariantViolation::new(
486                        "non_monotonic_revision",
487                        format!(
488                            "render revisions must increase monotonically (previous={previous}, next={current_render_revision})"
489                        ),
490                    ));
491                }
492            }
493            last_render_revision = Some(current_render_revision);
494        }
495    }
496    Ok(())
497}
498
499fn validate_internal_path(label: &str, path: &str) -> Result<(), ProtocolInvariantViolation> {
500    if path.trim().is_empty() {
501        return Err(ProtocolInvariantViolation::new(
502            "empty_path",
503            format!("{label} path cannot be empty"),
504        ));
505    }
506    let normalized = path.trim();
507    if !normalized.starts_with('/') {
508        return Err(ProtocolInvariantViolation::new(
509            "non_internal_path",
510            format!("{label} path must start with '/'"),
511        ));
512    }
513    if normalized.starts_with("//")
514        || normalized.starts_with("/\\")
515        || normalized.contains("://")
516        || normalized.starts_with("/http")
517    {
518        return Err(ProtocolInvariantViolation::new(
519            "external_path",
520            format!("{label} path must stay internal to the current application"),
521        ));
522    }
523    Ok(())
524}
525
526fn validate_non_empty(label: &str, value: &str) -> Result<(), ProtocolInvariantViolation> {
527    if value.trim().is_empty() {
528        return Err(ProtocolInvariantViolation::new(
529            "empty_field",
530            format!("{label} cannot be empty"),
531        ));
532    }
533    Ok(())
534}
535
536fn validate_hex_id(
537    label: &str,
538    value: Option<&str>,
539    expected_len: usize,
540) -> Result<(), ProtocolInvariantViolation> {
541    let Some(value) = value else {
542        return Ok(());
543    };
544    if value.len() != expected_len {
545        return Err(ProtocolInvariantViolation::new(
546            "invalid_hex_id_length",
547            format!("{label} must contain exactly {expected_len} hex chars"),
548        ));
549    }
550    if !value.chars().all(|char| char.is_ascii_hexdigit()) {
551        return Err(ProtocolInvariantViolation::new(
552            "invalid_hex_id_charset",
553            format!("{label} must contain only ascii hex chars"),
554        ));
555    }
556    Ok(())
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use serde_json::json;
563
564    fn valid_connect() -> ClientMessage {
565        ClientMessage::Connect {
566            protocol: PROTOCOL_VERSION_V1.to_string(),
567            session_id: None,
568            last_revision: None,
569            resume_token: None,
570            tenant_id: None,
571            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
572            span_id: Some("00f067aa0ba902b7".to_string()),
573            parent_span_id: Some("89abcdef01234567".to_string()),
574            correlation_id: None,
575            request_id: None,
576        }
577    }
578
579    fn valid_hello() -> ServerMessage {
580        ServerMessage::Hello {
581            session_id: "sid".to_string(),
582            target: "root".to_string(),
583            revision: 0,
584            protocol: PROTOCOL_VERSION_V1.to_string(),
585            server_revision: Some(0),
586            resume_status: Some(ResumeStatus::Fresh),
587            resume_reason: None,
588            resume_token: None,
589            resume_expires_in_ms: None,
590        }
591    }
592
593    #[test]
594    fn client_connect_rejects_unsupported_protocol() {
595        let invalid = ClientMessage::Connect {
596            protocol: "shelly/2".to_string(),
597            session_id: None,
598            last_revision: None,
599            resume_token: None,
600            tenant_id: None,
601            trace_id: None,
602            span_id: None,
603            parent_span_id: None,
604            correlation_id: None,
605            request_id: None,
606        };
607        let err = validate_client_message_invariants(&invalid).expect_err("unsupported protocol");
608        assert_eq!(err.code, "unsupported_protocol_version");
609    }
610
611    #[test]
612    fn client_invariants_cover_hex_event_navigation_and_upload_rules() {
613        assert!(validate_client_message_invariants(&valid_connect()).is_ok());
614
615        let mut invalid_trace = valid_connect();
616        if let ClientMessage::Connect { trace_id, .. } = &mut invalid_trace {
617            *trace_id = Some("abcd".to_string());
618        }
619        let err = validate_client_message_invariants(&invalid_trace).expect_err("trace length");
620        assert_eq!(err.code, "invalid_hex_id_length");
621
622        let mut invalid_span = valid_connect();
623        if let ClientMessage::Connect { span_id, .. } = &mut invalid_span {
624            *span_id = Some("zzzzzzzzzzzzzzzz".to_string());
625        }
626        let err = validate_client_message_invariants(&invalid_span).expect_err("span charset");
627        assert_eq!(err.code, "invalid_hex_id_charset");
628
629        let err = validate_client_message_invariants(&ClientMessage::Event {
630            event: "   ".to_string(),
631            target: None,
632            value: json!({}),
633            metadata: serde_json::Map::new(),
634        })
635        .expect_err("empty event should fail");
636        assert_eq!(err.code, "empty_event_name");
637
638        let err = validate_client_message_invariants(&ClientMessage::PatchUrl {
639            to: "users/1".to_string(),
640        })
641        .expect_err("patch_url must stay internal");
642        assert_eq!(err.code, "non_internal_path");
643
644        let err = validate_client_message_invariants(&ClientMessage::Navigate {
645            to: "/http://evil.example".to_string(),
646        })
647        .expect_err("navigate must stay internal");
648        assert_eq!(err.code, "external_path");
649
650        let err = validate_client_message_invariants(&ClientMessage::UploadStart {
651            upload_id: "".to_string(),
652            event: "uploaded".to_string(),
653            target: None,
654            name: "file.txt".to_string(),
655            size: 1,
656            content_type: None,
657        })
658        .expect_err("upload id is required");
659        assert_eq!(err.code, "empty_upload_id");
660
661        let err = validate_client_message_invariants(&ClientMessage::UploadStart {
662            upload_id: "u1".to_string(),
663            event: "   ".to_string(),
664            target: None,
665            name: "file.txt".to_string(),
666            size: 1,
667            content_type: None,
668        })
669        .expect_err("upload event is required");
670        assert_eq!(err.code, "empty_upload_event");
671
672        let err = validate_client_message_invariants(&ClientMessage::UploadStart {
673            upload_id: "u1".to_string(),
674            event: "uploaded".to_string(),
675            target: None,
676            name: "   ".to_string(),
677            size: 1,
678            content_type: None,
679        })
680        .expect_err("upload name is required");
681        assert_eq!(err.code, "empty_upload_name");
682
683        let err = validate_client_message_invariants(&ClientMessage::UploadChunk {
684            upload_id: "  ".to_string(),
685            offset: 0,
686            data: "AA==".to_string(),
687        })
688        .expect_err("upload chunk id is required");
689        assert_eq!(err.code, "empty_upload_id");
690
691        let err = validate_client_message_invariants(&ClientMessage::UploadComplete {
692            upload_id: "  ".to_string(),
693        })
694        .expect_err("upload complete id is required");
695        assert_eq!(err.code, "empty_upload_id");
696
697        assert!(validate_client_message_invariants(&ClientMessage::Ping { nonce: None }).is_ok());
698    }
699
700    #[test]
701    fn server_hello_invariants_cover_resume_and_protocol_rules() {
702        assert!(validate_server_message_invariants(&valid_hello()).is_ok());
703
704        let mut message = valid_hello();
705        if let ServerMessage::Hello { session_id, .. } = &mut message {
706            *session_id = " ".to_string();
707        }
708        let err = validate_server_message_invariants(&message).expect_err("session id required");
709        assert_eq!(err.code, "empty_session_id");
710
711        let mut message = valid_hello();
712        if let ServerMessage::Hello { target, .. } = &mut message {
713            *target = " ".to_string();
714        }
715        let err = validate_server_message_invariants(&message).expect_err("target required");
716        assert_eq!(err.code, "empty_target");
717
718        let mut message = valid_hello();
719        if let ServerMessage::Hello { protocol, .. } = &mut message {
720            *protocol = "shelly/9".to_string();
721        }
722        let err = validate_server_message_invariants(&message).expect_err("protocol unsupported");
723        assert_eq!(err.code, "unsupported_protocol_version");
724
725        let mut message = valid_hello();
726        if let ServerMessage::Hello {
727            revision,
728            server_revision,
729            ..
730        } = &mut message
731        {
732            *revision = 3;
733            *server_revision = Some(2);
734        }
735        let err = validate_server_message_invariants(&message)
736            .expect_err("server revision cannot regress below revision");
737        assert_eq!(err.code, "server_revision_regression");
738
739        let mut message = valid_hello();
740        if let ServerMessage::Hello {
741            resume_status,
742            resume_reason,
743            ..
744        } = &mut message
745        {
746            *resume_status = Some(ResumeStatus::Fallback);
747            *resume_reason = None;
748        }
749        let err =
750            validate_server_message_invariants(&message).expect_err("fallback reason is required");
751        assert_eq!(err.code, "missing_resume_reason");
752
753        let mut message = valid_hello();
754        if let ServerMessage::Hello {
755            resume_status,
756            resume_reason,
757            ..
758        } = &mut message
759        {
760            *resume_status = Some(ResumeStatus::Fresh);
761            *resume_reason = Some("   ".to_string());
762        }
763        let err = validate_server_message_invariants(&message)
764            .expect_err("resume reason cannot be blank when present");
765        assert_eq!(err.code, "empty_resume_reason");
766
767        let mut message = valid_hello();
768        if let ServerMessage::Hello {
769            resume_token,
770            resume_expires_in_ms,
771            ..
772        } = &mut message
773        {
774            *resume_token = None;
775            *resume_expires_in_ms = Some(60_000);
776        }
777        let err = validate_server_message_invariants(&message)
778            .expect_err("resume expiry requires resume token");
779        assert_eq!(err.code, "missing_resume_token");
780    }
781
782    #[test]
783    fn server_message_invariants_cover_render_stream_upload_and_navigation() {
784        let err = validate_server_message_invariants(&ServerMessage::Patch {
785            target: "".to_string(),
786            html: "<p>x</p>".to_string(),
787            revision: 1,
788        })
789        .expect_err("patch target is required");
790        assert_eq!(err.code, "empty_field");
791
792        let err = validate_server_message_invariants(&ServerMessage::Diff {
793            target: "root".to_string(),
794            revision: 1,
795            slots: vec![
796                crate::DynamicSlotPatch {
797                    index: 1,
798                    html: "a".to_string(),
799                },
800                crate::DynamicSlotPatch {
801                    index: 1,
802                    html: "b".to_string(),
803                },
804            ],
805        })
806        .expect_err("duplicate diff slots should fail");
807        assert_eq!(err.code, "duplicate_diff_slot");
808
809        let err = validate_server_message_invariants(&ServerMessage::StreamInsert {
810            target: "root".to_string(),
811            id: "".to_string(),
812            html: "<li>x</li>".to_string(),
813            at: crate::StreamPosition::Append,
814        })
815        .expect_err("stream id is required");
816        assert_eq!(err.code, "empty_field");
817
818        let err = validate_server_message_invariants(&ServerMessage::StreamBatch {
819            target: "root".to_string(),
820            operations: vec![],
821        })
822        .expect_err("stream batch must not be empty");
823        assert_eq!(err.code, "empty_stream_batch");
824
825        let err = validate_server_message_invariants(&ServerMessage::Redirect {
826            to: "/http://evil.example".to_string(),
827        })
828        .expect_err("redirect must stay internal");
829        assert_eq!(err.code, "external_path");
830
831        let err = validate_server_message_invariants(&ServerMessage::UploadProgress {
832            upload_id: "u1".to_string(),
833            received: 2,
834            total: 1,
835        })
836        .expect_err("upload progress bounds");
837        assert_eq!(err.code, "upload_progress_out_of_bounds");
838
839        let err = validate_server_message_invariants(&ServerMessage::UploadComplete {
840            upload_id: "u1".to_string(),
841            name: "".to_string(),
842            size: 1,
843            content_type: None,
844        })
845        .expect_err("upload name required");
846        assert_eq!(err.code, "empty_field");
847
848        let err = validate_server_message_invariants(&ServerMessage::UploadError {
849            upload_id: "u1".to_string(),
850            message: "".to_string(),
851            code: Some("bad".to_string()),
852        })
853        .expect_err("upload error message required");
854        assert_eq!(err.code, "empty_field");
855
856        let err = validate_server_message_invariants(&ServerMessage::Error {
857            message: "".to_string(),
858            code: None,
859        })
860        .expect_err("error message required");
861        assert_eq!(err.code, "empty_field");
862    }
863
864    #[test]
865    fn server_message_invariants_cover_chart_notification_grid_and_interop() {
866        let err = validate_server_message_invariants(&ServerMessage::ChartSeriesAppend {
867            chart: "".to_string(),
868            series: "s1".to_string(),
869            point: crate::ChartPoint { x: 1.0, y: 2.0 },
870        })
871        .expect_err("chart id required");
872        assert_eq!(err.code, "empty_field");
873
874        let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationUpsert {
875            chart: "chart-1".to_string(),
876            annotation: crate::ChartAnnotation {
877                id: "".to_string(),
878                x: 1.0,
879                label: "L".to_string(),
880            },
881        })
882        .expect_err("annotation id required");
883        assert_eq!(err.code, "empty_field");
884
885        let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationDelete {
886            chart: "chart-1".to_string(),
887            id: "".to_string(),
888        })
889        .expect_err("annotation delete id required");
890        assert_eq!(err.code, "empty_field");
891
892        let err = validate_server_message_invariants(&ServerMessage::ToastPush {
893            toast: crate::Toast {
894                id: "".to_string(),
895                level: crate::ToastLevel::Info,
896                title: None,
897                message: "hello".to_string(),
898                ttl_ms: None,
899            },
900        })
901        .expect_err("toast id required");
902        assert_eq!(err.code, "empty_field");
903
904        let err = validate_server_message_invariants(&ServerMessage::InboxUpsert {
905            item: crate::InboxItem {
906                id: "".to_string(),
907                title: "t".to_string(),
908                body: "b".to_string(),
909                read: false,
910                inserted_at: None,
911            },
912        })
913        .expect_err("inbox id required");
914        assert_eq!(err.code, "empty_field");
915
916        let err = validate_server_message_invariants(&ServerMessage::GridReplace {
917            grid: "".to_string(),
918            state: crate::GridState {
919                columns: vec![],
920                rows: vec![],
921                total_rows: 0,
922                offset: 0,
923                limit: 20,
924                views: vec![],
925                active_view: None,
926                group_by: None,
927                query: None,
928                sort: None,
929            },
930        })
931        .expect_err("grid id required");
932        assert_eq!(err.code, "empty_field");
933
934        let err = validate_server_message_invariants(&ServerMessage::InteropDispatch {
935            dispatch: crate::JsInteropDispatch {
936                target: None,
937                event: "".to_string(),
938                detail: json!({}),
939                bubbles: true,
940            },
941        })
942        .expect_err("interop event required");
943        assert_eq!(err.code, "empty_field");
944
945        assert!(validate_server_message_invariants(&ServerMessage::Pong { nonce: None }).is_ok());
946    }
947
948    #[test]
949    fn server_sequence_requires_monotonic_revisions() {
950        let messages = vec![
951            ServerMessage::Patch {
952                target: "root".to_string(),
953                html: "<p>1</p>".to_string(),
954                revision: 2,
955            },
956            ServerMessage::Diff {
957                target: "root".to_string(),
958                revision: 2,
959                slots: vec![],
960            },
961        ];
962        let err =
963            validate_server_message_sequence(&messages).expect_err("non monotonic should fail");
964        assert_eq!(err.code, "non_monotonic_revision");
965    }
966
967    #[test]
968    fn server_sequence_rejects_hello_order_and_duplicates() {
969        let err = validate_server_message_sequence(&[
970            ServerMessage::Patch {
971                target: "root".to_string(),
972                html: "<p>1</p>".to_string(),
973                revision: 1,
974            },
975            valid_hello(),
976        ])
977        .expect_err("hello must be first");
978        assert_eq!(err.code, "hello_not_first");
979
980        let err = validate_server_message_sequence(&[valid_hello(), valid_hello()])
981            .expect_err("duplicate hello should fail");
982        assert_eq!(err.code, "duplicate_hello");
983
984        assert!(validate_server_message_sequence(&[
985            valid_hello(),
986            ServerMessage::Patch {
987                target: "root".to_string(),
988                html: "<p>1</p>".to_string(),
989                revision: 1,
990            },
991            ServerMessage::Diff {
992                target: "root".to_string(),
993                revision: 2,
994                slots: vec![],
995            },
996        ])
997        .is_ok());
998    }
999
1000    #[test]
1001    fn hello_fallback_requires_reason() {
1002        let message = ServerMessage::Hello {
1003            session_id: "sid".to_string(),
1004            target: "root".to_string(),
1005            revision: 0,
1006            protocol: PROTOCOL_VERSION_V1.to_string(),
1007            server_revision: None,
1008            resume_status: Some(ResumeStatus::Fallback),
1009            resume_reason: None,
1010            resume_token: Some("token".to_string()),
1011            resume_expires_in_ms: Some(120_000),
1012        };
1013        let err =
1014            validate_server_message_invariants(&message).expect_err("missing reason should fail");
1015        assert_eq!(err.code, "missing_resume_reason");
1016    }
1017
1018    #[test]
1019    fn descriptors_map_to_expected_axes() {
1020        let client = ClientMessage::Event {
1021            event: "save".to_string(),
1022            target: None,
1023            value: json!({"id": 1}),
1024            metadata: serde_json::Map::new(),
1025        };
1026        let server = ServerMessage::Patch {
1027            target: "root".to_string(),
1028            html: "<p>ok</p>".to_string(),
1029            revision: 1,
1030        };
1031        let c = describe_client_message(&client);
1032        let s = describe_server_message(&server);
1033        assert_eq!(c.direction, ProtocolDirection::ClientToServer);
1034        assert_eq!(c.authority, ProtocolAuthority::UntrustedClient);
1035        assert_eq!(c.class, ProtocolInstructionClass::Event);
1036        assert_eq!(s.direction, ProtocolDirection::ServerToClient);
1037        assert_eq!(s.authority, ProtocolAuthority::TrustedServer);
1038        assert_eq!(s.render_effect, ProtocolRenderEffect::Patch);
1039    }
1040
1041    #[test]
1042    fn describe_client_message_covers_every_instruction_class_branch() {
1043        let descriptors = vec![
1044            describe_client_message(&ClientMessage::Connect {
1045                protocol: PROTOCOL_VERSION_V1.to_string(),
1046                session_id: None,
1047                last_revision: None,
1048                resume_token: None,
1049                tenant_id: None,
1050                trace_id: None,
1051                span_id: None,
1052                parent_span_id: None,
1053                correlation_id: None,
1054                request_id: None,
1055            }),
1056            describe_client_message(&ClientMessage::Event {
1057                event: "save".to_string(),
1058                target: None,
1059                value: json!({"ok": true}),
1060                metadata: serde_json::Map::new(),
1061            }),
1062            describe_client_message(&ClientMessage::Ping { nonce: None }),
1063            describe_client_message(&ClientMessage::PatchUrl {
1064                to: "/users".to_string(),
1065            }),
1066            describe_client_message(&ClientMessage::Navigate {
1067                to: "/users/1".to_string(),
1068            }),
1069            describe_client_message(&ClientMessage::UploadStart {
1070                upload_id: "u1".to_string(),
1071                event: "upload".to_string(),
1072                target: None,
1073                name: "avatar.png".to_string(),
1074                size: 1,
1075                content_type: Some("image/png".to_string()),
1076            }),
1077            describe_client_message(&ClientMessage::UploadChunk {
1078                upload_id: "u1".to_string(),
1079                offset: 0,
1080                data: "AA==".to_string(),
1081            }),
1082            describe_client_message(&ClientMessage::UploadComplete {
1083                upload_id: "u1".to_string(),
1084            }),
1085        ];
1086
1087        assert!(descriptors.iter().all(|descriptor| {
1088            descriptor.direction == ProtocolDirection::ClientToServer
1089                && descriptor.authority == ProtocolAuthority::UntrustedClient
1090                && descriptor.ordering == ProtocolOrdering::PerSessionOrdered
1091        }));
1092        assert!(descriptors
1093            .iter()
1094            .any(|descriptor| descriptor.class == ProtocolInstructionClass::Lifecycle));
1095        assert!(descriptors
1096            .iter()
1097            .any(|descriptor| descriptor.class == ProtocolInstructionClass::Event));
1098        assert!(descriptors
1099            .iter()
1100            .any(|descriptor| descriptor.class == ProtocolInstructionClass::Diagnostics));
1101        assert!(descriptors
1102            .iter()
1103            .any(|descriptor| descriptor.class == ProtocolInstructionClass::Navigation));
1104        assert!(descriptors
1105            .iter()
1106            .any(|descriptor| descriptor.class == ProtocolInstructionClass::Upload));
1107    }
1108
1109    #[test]
1110    fn describe_server_message_and_invariants_cover_remaining_variant_matrix() {
1111        let messages = vec![
1112            valid_hello(),
1113            ServerMessage::Patch {
1114                target: "root".to_string(),
1115                html: "<p>ok</p>".to_string(),
1116                revision: 1,
1117            },
1118            ServerMessage::Diff {
1119                target: "root".to_string(),
1120                revision: 2,
1121                slots: vec![crate::DynamicSlotPatch {
1122                    index: 0,
1123                    html: "ok".to_string(),
1124                }],
1125            },
1126            ServerMessage::StreamInsert {
1127                target: "items".to_string(),
1128                id: "item-1".to_string(),
1129                html: "<li>One</li>".to_string(),
1130                at: crate::StreamPosition::Append,
1131            },
1132            ServerMessage::StreamDelete {
1133                target: "items".to_string(),
1134                id: "item-1".to_string(),
1135            },
1136            ServerMessage::StreamBatch {
1137                target: "items".to_string(),
1138                operations: vec![
1139                    crate::StreamBatchOperation::Insert {
1140                        id: "item-2".to_string(),
1141                        html: "<li>Two</li>".to_string(),
1142                        at: crate::StreamPosition::Append,
1143                    },
1144                    crate::StreamBatchOperation::Delete {
1145                        id: "item-1".to_string(),
1146                    },
1147                ],
1148            },
1149            ServerMessage::ChartSeriesAppend {
1150                chart: "throughput".to_string(),
1151                series: "p95".to_string(),
1152                point: crate::ChartPoint { x: 1.0, y: 2.0 },
1153            },
1154            ServerMessage::ChartSeriesAppendMany {
1155                chart: "throughput".to_string(),
1156                series: "p95".to_string(),
1157                points: vec![crate::ChartPoint { x: 2.0, y: 3.0 }],
1158            },
1159            ServerMessage::ChartSeriesReplace {
1160                chart: "throughput".to_string(),
1161                series: "p95".to_string(),
1162                points: vec![crate::ChartPoint { x: 3.0, y: 4.0 }],
1163            },
1164            ServerMessage::ChartReset {
1165                chart: "throughput".to_string(),
1166            },
1167            ServerMessage::ChartAnnotationUpsert {
1168                chart: "throughput".to_string(),
1169                annotation: crate::ChartAnnotation {
1170                    id: "a-1".to_string(),
1171                    x: 4.0,
1172                    label: "deploy".to_string(),
1173                },
1174            },
1175            ServerMessage::ChartAnnotationDelete {
1176                chart: "throughput".to_string(),
1177                id: "a-1".to_string(),
1178            },
1179            ServerMessage::ToastPush {
1180                toast: crate::Toast {
1181                    id: "toast-1".to_string(),
1182                    level: crate::ToastLevel::Info,
1183                    title: Some("Info".to_string()),
1184                    message: "ok".to_string(),
1185                    ttl_ms: Some(2_000),
1186                },
1187            },
1188            ServerMessage::ToastDismiss {
1189                id: "toast-1".to_string(),
1190            },
1191            ServerMessage::InboxUpsert {
1192                item: crate::InboxItem {
1193                    id: "inbox-1".to_string(),
1194                    title: "Ready".to_string(),
1195                    body: "Done".to_string(),
1196                    read: false,
1197                    inserted_at: None,
1198                },
1199            },
1200            ServerMessage::InboxDelete {
1201                id: "inbox-1".to_string(),
1202            },
1203            ServerMessage::GridReplace {
1204                grid: "accounts".to_string(),
1205                state: crate::GridState {
1206                    columns: vec![],
1207                    rows: vec![],
1208                    total_rows: 0,
1209                    offset: 0,
1210                    limit: 25,
1211                    views: vec![],
1212                    active_view: None,
1213                    group_by: None,
1214                    query: None,
1215                    sort: None,
1216                },
1217            },
1218            ServerMessage::GridRowsReplace {
1219                grid: "accounts".to_string(),
1220                window: crate::GridRowsWindow {
1221                    offset: 0,
1222                    total_rows: 0,
1223                    rows: vec![],
1224                },
1225            },
1226            ServerMessage::InteropDispatch {
1227                dispatch: crate::JsInteropDispatch {
1228                    target: Some("#root".to_string()),
1229                    event: "interop:event".to_string(),
1230                    detail: json!({"k": "v"}),
1231                    bubbles: true,
1232                },
1233            },
1234            ServerMessage::Pong { nonce: None },
1235            ServerMessage::Redirect {
1236                to: "/dashboard".to_string(),
1237            },
1238            ServerMessage::PatchUrl {
1239                to: "/dashboard?page=2".to_string(),
1240            },
1241            ServerMessage::Navigate {
1242                to: "/settings".to_string(),
1243            },
1244            ServerMessage::UploadProgress {
1245                upload_id: "u1".to_string(),
1246                received: 1,
1247                total: 2,
1248            },
1249            ServerMessage::UploadComplete {
1250                upload_id: "u1".to_string(),
1251                name: "avatar.png".to_string(),
1252                size: 2,
1253                content_type: Some("image/png".to_string()),
1254            },
1255            ServerMessage::UploadError {
1256                upload_id: "u1".to_string(),
1257                message: "rejected".to_string(),
1258                code: Some("too_large".to_string()),
1259            },
1260            ServerMessage::Error {
1261                message: "oops".to_string(),
1262                code: Some("runtime".to_string()),
1263            },
1264        ];
1265
1266        for message in &messages {
1267            let descriptor = describe_server_message(message);
1268            assert_eq!(descriptor.direction, ProtocolDirection::ServerToClient);
1269            assert_eq!(descriptor.authority, ProtocolAuthority::TrustedServer);
1270            assert_eq!(descriptor.ordering, ProtocolOrdering::PerSessionOrdered);
1271            assert!(validate_server_message_invariants(message).is_ok());
1272        }
1273    }
1274}