Skip to main content

termivar_scanner/
websocket_review.rs

1//! Strict, transport-neutral policy for bounded WebSocket review.
2//!
3//! Parsing validates an operator-authored declaration against an already
4//! selected application target. It does not open a socket, verify either
5//! operator assertion, grant broader target authority, or turn an expected
6//! response into observed evidence. Raw endpoints, subprotocols, message IDs,
7//! message text, and expected-response digests remain private to the
8//! crate-owned execution boundary. Message IDs are operator-declared,
9//! non-secret revision handles: the operator must change an ID when the
10//! corresponding outbound or expected-response semantics change. Public
11//! references never hash the message or expected-response bytes.
12
13use std::{collections::BTreeSet, fmt, net::IpAddr};
14
15use serde::de::Error as _;
16use serde::{
17    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
18    Deserialize, Deserializer,
19};
20use serde_json::{Map, Number, Value};
21use sha2::{Digest, Sha256};
22use thiserror::Error;
23use url::{Host, Url};
24
25/// The only WebSocket review policy schema accepted by this module.
26pub const WEBSOCKET_REVIEW_POLICY_SCHEMA: &str = "security.websocket-review-policy/v1";
27/// Stable interpretation and reference algorithm applied to the V1 schema.
28pub const WEBSOCKET_REVIEW_POLICY_ALGORITHM: &str = "termivar.websocket-review-policy/v1";
29/// Maximum accepted exact JSON policy bytes.
30pub const MAX_WEBSOCKET_REVIEW_POLICY_BYTES: usize = 128 * 1024;
31/// Maximum number of ordered outbound message declarations.
32pub const MAX_WEBSOCKET_REVIEW_MESSAGES: usize = 8;
33/// Maximum UTF-8 bytes in one declared outbound text message.
34pub const MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES: usize = 16 * 1024;
35/// Maximum cumulative application-message bytes in either direction.
36pub const MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES: u64 = 64 * 1024;
37/// Maximum control frames admitted across the isolated connection.
38pub const MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES: u16 = 16;
39/// Maximum wall-clock duration of one isolated review.
40pub const MAX_WEBSOCKET_REVIEW_WALL_TIME_MS: u64 = 10_000;
41/// Maximum bytes in one safe message identifier.
42pub const MAX_WEBSOCKET_REVIEW_MESSAGE_ID_BYTES: usize = 128;
43/// Maximum bytes in the optional WebSocket subprotocol token.
44pub const MAX_WEBSOCKET_REVIEW_SUBPROTOCOL_BYTES: usize = 128;
45
46/// Compatibility name for the hard per-direction application-byte ceiling.
47pub const MAX_WEBSOCKET_REVIEW_MESSAGE_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
48/// Compatibility name for the cumulative declared outbound-text ceiling.
49pub const MAX_WEBSOCKET_REVIEW_TOTAL_OUTBOUND_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
50/// Compatibility name for the cumulative expected inbound-payload ceiling.
51pub const MAX_WEBSOCKET_REVIEW_TOTAL_INBOUND_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
52
53const MAX_WEBSOCKET_REVIEW_ENDPOINT_BYTES: usize = 2048;
54const DUPLICATE_KEY_SENTINEL: &str = "termivar_websocket_review_duplicate_json_key";
55const POLICY_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.policy-reference.v1\0";
56const APPLICATION_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.application-reference.v1\0";
57const ENDPOINT_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.endpoint-reference.v1\0";
58const MESSAGE_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.message-reference.v1\0";
59const EXPECTED_RESPONSE_REFERENCE_DOMAIN: &[u8] =
60    b"termivar.websocket-review.expected-response-reference.v1\0";
61
62/// Origin-header behavior selected by a validated V1 policy.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum WebSocketOriginMode {
66    /// Send the HTTP(S) origin of the authorized application target.
67    ApplicationOrigin,
68    /// Deliberately omit the `Origin` header.
69    Omit,
70}
71
72impl WebSocketOriginMode {
73    /// Returns the exact V1 wire token.
74    #[must_use]
75    pub const fn as_str(self) -> &'static str {
76        match self {
77            Self::ApplicationOrigin => "application_origin",
78            Self::Omit => "omit",
79        }
80    }
81}
82
83/// Validated finite runtime ceilings from one policy.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub struct WebSocketReviewLimits {
86    max_inbound_message_bytes: u64,
87    max_outbound_message_bytes: u64,
88    max_messages: u16,
89    max_control_frames: u16,
90    max_wall_time_ms: u64,
91}
92
93impl WebSocketReviewLimits {
94    /// Maximum bytes in one inbound application message.
95    #[must_use]
96    pub const fn max_inbound_message_bytes(self) -> u64 {
97        self.max_inbound_message_bytes
98    }
99
100    /// Maximum bytes in one outbound application message.
101    #[must_use]
102    pub const fn max_outbound_message_bytes(self) -> u64 {
103        self.max_outbound_message_bytes
104    }
105
106    /// Shared directional application-message count ceiling.
107    #[must_use]
108    pub const fn max_messages(self) -> u16 {
109        self.max_messages
110    }
111
112    /// Connection-wide control-frame count ceiling.
113    #[must_use]
114    pub const fn max_control_frames(self) -> u16 {
115        self.max_control_frames
116    }
117
118    /// Wall-clock ceiling in whole milliseconds.
119    #[must_use]
120    pub const fn max_wall_time_ms(self) -> u64 {
121        self.max_wall_time_ms
122    }
123}
124
125#[derive(Clone, PartialEq, Eq)]
126struct WebSocketReviewMessage {
127    text: String,
128    expected_response_sha256: [u8; 32],
129    expected_response_length: u64,
130    message_reference: String,
131    expected_response_reference: String,
132}
133
134impl fmt::Debug for WebSocketReviewMessage {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter
137            .debug_struct("WebSocketReviewMessage")
138            .field("id", &"<redacted>")
139            .field("text", &"<redacted>")
140            .field("expected_response", &"<redacted>")
141            .field("message_reference", &self.message_reference)
142            .field(
143                "expected_response_reference",
144                &self.expected_response_reference,
145            )
146            .field("outbound_length", &self.text.len())
147            .field("expected_response_length", &self.expected_response_length)
148            .finish()
149    }
150}
151
152/// Safe borrowed metadata for one ordered message declaration.
153///
154/// Raw identifiers, text, and expected digests are intentionally available
155/// only to the crate-owned transport boundary.
156#[derive(Clone, Copy)]
157pub struct WebSocketReviewMessageRef<'a> {
158    message: &'a WebSocketReviewMessage,
159}
160
161impl<'a> WebSocketReviewMessageRef<'a> {
162    /// Stable value-free reference for the operator-declared message revision.
163    #[must_use]
164    pub fn message_reference(self) -> &'a str {
165        &self.message.message_reference
166    }
167
168    /// Stable value-free reference for that revision's expected response.
169    #[must_use]
170    pub fn expected_response_reference(self) -> &'a str {
171        &self.message.expected_response_reference
172    }
173
174    /// UTF-8 byte length of the declared outbound text.
175    #[must_use]
176    pub fn outbound_length(self) -> u64 {
177        u64::try_from(self.message.text.len()).unwrap_or(u64::MAX)
178    }
179
180    /// Exact expected inbound application-payload length.
181    #[must_use]
182    pub const fn expected_response_length(self) -> u64 {
183        self.message.expected_response_length
184    }
185
186    pub(crate) fn execution_text(self) -> &'a str {
187        &self.message.text
188    }
189
190    pub(crate) const fn expected_response_sha256(self) -> &'a [u8; 32] {
191        &self.message.expected_response_sha256
192    }
193}
194
195impl fmt::Debug for WebSocketReviewMessageRef<'_> {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter
198            .debug_struct("WebSocketReviewMessageRef")
199            .field("message_reference", &self.message.message_reference)
200            .field(
201                "expected_response_reference",
202                &self.message.expected_response_reference,
203            )
204            .field("outbound_length", &self.message.text.len())
205            .field(
206                "expected_response_length",
207                &self.message.expected_response_length,
208            )
209            .finish()
210    }
211}
212
213/// Strict, bounded declaration for one isolated WebSocket connection.
214#[derive(Clone, PartialEq, Eq)]
215pub struct WebSocketReviewPolicy {
216    endpoint: Url,
217    application_origin: Option<String>,
218    origin_mode: WebSocketOriginMode,
219    subprotocol: Option<String>,
220    messages: Vec<WebSocketReviewMessage>,
221    limits: WebSocketReviewLimits,
222    policy_reference: String,
223    application_reference: String,
224    endpoint_reference: String,
225}
226
227impl WebSocketReviewPolicy {
228    /// Parses strict JSON and validates it as a narrowing child of the exact
229    /// host, effective port, and application-path scope of `application_target`.
230    ///
231    /// The two required `true` values are retained only as operator
232    /// assertions. Parsing does not independently verify authorization or
233    /// message side effects.
234    pub fn parse_json(
235        application_target: &Url,
236        source: &[u8],
237    ) -> Result<Self, WebSocketReviewPolicyError> {
238        if source.is_empty() {
239            return Err(WebSocketReviewPolicyError::EmptyPolicy);
240        }
241        if source.len() > MAX_WEBSOCKET_REVIEW_POLICY_BYTES {
242            return Err(WebSocketReviewPolicyError::PolicyTooLarge);
243        }
244
245        let application = validate_application_target(application_target)?;
246        let value = parse_strict_json(source)?;
247        let wire: WirePolicy = serde_json::from_value(value)
248            .map_err(|_| WebSocketReviewPolicyError::MalformedPolicy)?;
249        if wire.schema != WEBSOCKET_REVIEW_POLICY_SCHEMA {
250            return Err(WebSocketReviewPolicyError::UnsupportedSchema);
251        }
252        if !wire.target_authorized {
253            return Err(WebSocketReviewPolicyError::TargetAuthorizationRequired);
254        }
255        if !wire.messages_read_only_acknowledged {
256            return Err(WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired);
257        }
258        if !wire.message_content_is_non_secret {
259            return Err(WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired);
260        }
261        if wire.compression {
262            return Err(WebSocketReviewPolicyError::CompressionNotPermitted);
263        }
264        if wire.reconnect {
265            return Err(WebSocketReviewPolicyError::ReconnectNotPermitted);
266        }
267
268        let endpoint = validate_endpoint(&application, &wire.endpoint)?;
269        let origin_mode = match wire.origin_mode.as_str() {
270            "application_origin" => WebSocketOriginMode::ApplicationOrigin,
271            "omit" => WebSocketOriginMode::Omit,
272            _ => return Err(WebSocketReviewPolicyError::UnsupportedOriginMode),
273        };
274        if wire
275            .subprotocol
276            .as_deref()
277            .is_some_and(|value| !valid_subprotocol(value))
278        {
279            return Err(WebSocketReviewPolicyError::InvalidSubprotocol);
280        }
281
282        let limits = validate_limits(&wire.limits, wire.messages.len())?;
283        let mut seen_ids = BTreeSet::new();
284        let mut total_outbound_bytes = 0_u64;
285        let mut total_expected_inbound_bytes = 0_u64;
286        let mut messages = Vec::with_capacity(wire.messages.len());
287        for declaration in wire.messages {
288            if !valid_message_id(&declaration.id) {
289                return Err(WebSocketReviewPolicyError::InvalidMessageId);
290            }
291            if !seen_ids.insert(declaration.id.clone()) {
292                return Err(WebSocketReviewPolicyError::DuplicateMessageId);
293            }
294            if declaration.text.len() > MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES {
295                return Err(WebSocketReviewPolicyError::MessageTextTooLarge);
296            }
297            let outbound_length = u64::try_from(declaration.text.len())
298                .map_err(|_| WebSocketReviewPolicyError::MessageTextTooLarge)?;
299            total_outbound_bytes = total_outbound_bytes
300                .checked_add(outbound_length)
301                .filter(|length| *length <= MAX_WEBSOCKET_REVIEW_TOTAL_OUTBOUND_BYTES)
302                .ok_or(WebSocketReviewPolicyError::MessageTextAggregateTooLarge)?;
303            let expected_response_sha256 = parse_sha256(&declaration.expected_response.sha256)?;
304            let expected_response_length = declaration.expected_response.length;
305            total_expected_inbound_bytes = total_expected_inbound_bytes
306                .checked_add(expected_response_length)
307                .filter(|length| *length <= MAX_WEBSOCKET_REVIEW_TOTAL_INBOUND_BYTES)
308                .ok_or(WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge)?;
309            if outbound_length > limits.max_outbound_message_bytes
310                || expected_response_length > limits.max_inbound_message_bytes
311            {
312                return Err(WebSocketReviewPolicyError::InvalidLimits);
313            }
314            // Public references are derived only from the operator-declared,
315            // non-secret revision identifier and public lengths. Hashing the
316            // message or expected-response bytes here would publish an
317            // offline dictionary oracle for low-entropy private values.
318            let expected_response_reference = digest_reference(
319                EXPECTED_RESPONSE_REFERENCE_DOMAIN,
320                "websocket-expected-response-revision-sha256",
321                &[
322                    declaration.id.as_bytes(),
323                    &expected_response_length.to_be_bytes(),
324                ],
325            );
326            let message_reference = digest_reference(
327                MESSAGE_REFERENCE_DOMAIN,
328                "websocket-message-revision-sha256",
329                &[declaration.id.as_bytes()],
330            );
331            messages.push(WebSocketReviewMessage {
332                text: declaration.text,
333                expected_response_sha256,
334                expected_response_length,
335                message_reference,
336                expected_response_reference,
337            });
338        }
339
340        let application_reference = digest_reference(
341            APPLICATION_REFERENCE_DOMAIN,
342            "websocket-application-sha256",
343            &[application.as_str().as_bytes()],
344        );
345        let endpoint_reference = digest_reference(
346            ENDPOINT_REFERENCE_DOMAIN,
347            "websocket-endpoint-sha256",
348            &[endpoint.as_str().as_bytes()],
349        );
350        let policy_reference = policy_reference(
351            &application_reference,
352            &endpoint,
353            origin_mode,
354            wire.subprotocol.as_deref(),
355            &messages,
356            limits,
357        );
358        let application_origin = (origin_mode == WebSocketOriginMode::ApplicationOrigin)
359            .then(|| application.origin().ascii_serialization());
360
361        Ok(Self {
362            endpoint,
363            application_origin,
364            origin_mode,
365            subprotocol: wire.subprotocol,
366            messages,
367            limits,
368            policy_reference,
369            application_reference,
370            endpoint_reference,
371        })
372    }
373
374    /// Returns the accepted policy schema.
375    #[must_use]
376    pub const fn schema(&self) -> &'static str {
377        WEBSOCKET_REVIEW_POLICY_SCHEMA
378    }
379
380    /// Returns the stable reference for the operator-versioned policy identity.
381    #[must_use]
382    pub fn policy_reference(&self) -> &str {
383        &self.policy_reference
384    }
385
386    /// Returns the stable reference for the application authority scope.
387    #[must_use]
388    pub fn application_reference(&self) -> &str {
389        &self.application_reference
390    }
391
392    /// Returns the stable reference for the exact WebSocket endpoint.
393    #[must_use]
394    pub fn endpoint_reference(&self) -> &str {
395        &self.endpoint_reference
396    }
397
398    /// Returns the number of ordered outbound declarations.
399    #[must_use]
400    pub fn message_count(&self) -> usize {
401        self.messages.len()
402    }
403
404    /// Returns value-free metadata for the ordered message declarations.
405    pub fn messages(&self) -> impl ExactSizeIterator<Item = WebSocketReviewMessageRef<'_>> {
406        self.messages
407            .iter()
408            .map(|message| WebSocketReviewMessageRef { message })
409    }
410
411    /// Returns the selected Origin-header behavior.
412    #[must_use]
413    pub const fn origin_mode(&self) -> WebSocketOriginMode {
414        self.origin_mode
415    }
416
417    /// Reports whether one validated subprotocol token is present without
418    /// exposing the operator-authored token.
419    #[must_use]
420    pub const fn has_subprotocol(&self) -> bool {
421        self.subprotocol.is_some()
422    }
423
424    /// Returns the required, unverified operator authorization assertion.
425    #[must_use]
426    pub const fn target_authorized(&self) -> bool {
427        true
428    }
429
430    /// Returns the required, unverified read-only-message acknowledgement.
431    #[must_use]
432    pub const fn messages_read_only_acknowledged(&self) -> bool {
433        true
434    }
435
436    /// Returns the required operator assertion that message bodies are not secrets.
437    #[must_use]
438    pub const fn message_content_is_non_secret(&self) -> bool {
439        true
440    }
441
442    /// V1 always disables WebSocket extension negotiation.
443    #[must_use]
444    pub const fn compression_enabled(&self) -> bool {
445        false
446    }
447
448    /// V1 always owns exactly one connection attempt and never reconnects.
449    #[must_use]
450    pub const fn reconnect_enabled(&self) -> bool {
451        false
452    }
453
454    /// Returns all validated finite runtime ceilings.
455    #[must_use]
456    pub const fn limits(&self) -> WebSocketReviewLimits {
457        self.limits
458    }
459
460    /// Maximum bytes in one inbound application message.
461    #[must_use]
462    pub const fn max_inbound_message_bytes(&self) -> u64 {
463        self.limits.max_inbound_message_bytes
464    }
465
466    /// Maximum bytes in one outbound application message.
467    #[must_use]
468    pub const fn max_outbound_message_bytes(&self) -> u64 {
469        self.limits.max_outbound_message_bytes
470    }
471
472    /// Shared directional application-message count ceiling.
473    #[must_use]
474    pub const fn max_messages(&self) -> u16 {
475        self.limits.max_messages
476    }
477
478    /// Connection-wide control-frame count ceiling.
479    #[must_use]
480    pub const fn max_control_frames(&self) -> u16 {
481        self.limits.max_control_frames
482    }
483
484    /// Wall-clock ceiling in whole milliseconds.
485    #[must_use]
486    pub const fn max_wall_time_ms(&self) -> u64 {
487        self.limits.max_wall_time_ms
488    }
489
490    pub(crate) const fn execution_endpoint(&self) -> &Url {
491        &self.endpoint
492    }
493
494    /// Repeats the exact selected-application binding at runtime composition
495    /// and again when the one-shot transport authority is minted. A policy
496    /// parsed for one same-origin application directory cannot therefore be
497    /// replayed under a broader or sibling assessment root.
498    pub(crate) fn is_bound_to_application(&self, application_target: &Url) -> bool {
499        validate_application_target(application_target).is_ok_and(|application| {
500            digest_reference(
501                APPLICATION_REFERENCE_DOMAIN,
502                "websocket-application-sha256",
503                &[application.as_str().as_bytes()],
504            ) == self.application_reference
505        })
506    }
507
508    pub(crate) fn execution_origin(&self) -> Option<&str> {
509        self.application_origin.as_deref()
510    }
511
512    pub(crate) fn execution_subprotocol(&self) -> Option<&str> {
513        self.subprotocol.as_deref()
514    }
515
516    pub(crate) fn execution_messages(
517        &self,
518    ) -> impl ExactSizeIterator<Item = WebSocketReviewMessageRef<'_>> {
519        self.messages()
520    }
521}
522
523impl fmt::Debug for WebSocketReviewPolicy {
524    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
525        formatter
526            .debug_struct("WebSocketReviewPolicy")
527            .field("application", &"<redacted>")
528            .field("endpoint", &"<redacted>")
529            .field("origin", &"<redacted>")
530            .field("subprotocol", &"<redacted>")
531            .field("messages", &"<redacted>")
532            .field("schema", &WEBSOCKET_REVIEW_POLICY_SCHEMA)
533            .field("origin_mode", &self.origin_mode)
534            .field("subprotocol_present", &self.subprotocol.is_some())
535            .field("message_count", &self.messages.len())
536            .field("limits", &self.limits)
537            .field("policy_reference", &self.policy_reference)
538            .field("application_reference", &self.application_reference)
539            .field("endpoint_reference", &self.endpoint_reference)
540            .finish()
541    }
542}
543
544/// Static, value-free policy parsing or validation failure.
545#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
546#[non_exhaustive]
547pub enum WebSocketReviewPolicyError {
548    /// No JSON bytes were supplied.
549    #[error("WebSocket review policy is empty")]
550    EmptyPolicy,
551    /// Exact input bytes exceeded the compiled policy ceiling.
552    #[error("WebSocket review policy exceeds its compiled byte limit")]
553    PolicyTooLarge,
554    /// JSON syntax, type, required fields, or unknown fields were invalid.
555    #[error("WebSocket review policy is malformed")]
556    MalformedPolicy,
557    /// A JSON object contained a duplicate decoded key.
558    #[error("WebSocket review policy contains a duplicate JSON key")]
559    DuplicateJsonKey,
560    /// The exact schema identifier is not supported.
561    #[error("WebSocket review policy schema is unsupported")]
562    UnsupportedSchema,
563    /// The application target cannot provide the required authority scope.
564    #[error("WebSocket review application target is invalid")]
565    InvalidApplicationTarget,
566    /// The operator did not assert authorization for the selected target.
567    #[error("WebSocket review requires the target_authorized operator assertion")]
568    TargetAuthorizationRequired,
569    /// The operator did not acknowledge that declared messages are read-only.
570    #[error("WebSocket review requires the read-only message acknowledgement")]
571    ReadOnlyAcknowledgementRequired,
572    /// The operator did not assert that message bodies contain no secret material.
573    #[error("WebSocket review requires the non-secret message-content acknowledgement")]
574    NonSecretContentAcknowledgementRequired,
575    /// The absolute endpoint was malformed or contained forbidden URL parts.
576    #[error("WebSocket review endpoint is invalid")]
577    InvalidEndpoint,
578    /// The endpoint escaped the exact host, port, or application path scope.
579    #[error("WebSocket review endpoint is outside application authority")]
580    EndpointOutsideApplication,
581    /// Plaintext WebSocket was selected outside numeric loopback scope.
582    #[error("plaintext WebSocket review is permitted only on numeric loopback")]
583    InsecureEndpoint,
584    /// The Origin-header mode is not implemented by V1.
585    #[error("WebSocket review origin mode is unsupported")]
586    UnsupportedOriginMode,
587    /// The optional subprotocol was not one bounded HTTP token.
588    #[error("WebSocket review subprotocol is invalid")]
589    InvalidSubprotocol,
590    /// V1 prohibits compression and all other extension negotiation.
591    #[error("WebSocket review compression must be disabled")]
592    CompressionNotPermitted,
593    /// V1 prohibits reconnects and retries.
594    #[error("WebSocket review reconnect must be disabled")]
595    ReconnectNotPermitted,
596    /// V1 requires one through eight message declarations.
597    #[error("WebSocket review message count is invalid")]
598    InvalidMessageCount,
599    /// A message identifier violated the bounded safe-token contract.
600    #[error("WebSocket review message identifier is invalid")]
601    InvalidMessageId,
602    /// Two message declarations used the same identifier.
603    #[error("WebSocket review message identifier is duplicated")]
604    DuplicateMessageId,
605    /// One declared text message exceeded its compiled byte ceiling.
606    #[error("WebSocket review message text exceeds its compiled byte limit")]
607    MessageTextTooLarge,
608    /// Declared outbound text exceeded the connection-wide byte ceiling.
609    #[error("WebSocket review outbound message bytes exceed their compiled limit")]
610    MessageTextAggregateTooLarge,
611    /// An expected response digest or length was invalid.
612    #[error("WebSocket review expected response is invalid")]
613    InvalidExpectedResponse,
614    /// Expected inbound payloads exceeded the connection-wide byte ceiling.
615    #[error("WebSocket review expected response bytes exceed their compiled limit")]
616    ExpectedResponseAggregateTooLarge,
617    /// A finite runtime limit was zero, inconsistent, or above its hard cap.
618    #[error("WebSocket review runtime limits are invalid")]
619    InvalidLimits,
620}
621
622impl WebSocketReviewPolicyError {
623    /// Returns a stable value-free machine token.
624    #[must_use]
625    pub const fn as_str(self) -> &'static str {
626        match self {
627            Self::EmptyPolicy => "empty_policy",
628            Self::PolicyTooLarge => "policy_too_large",
629            Self::MalformedPolicy => "malformed_policy",
630            Self::DuplicateJsonKey => "duplicate_json_key",
631            Self::UnsupportedSchema => "unsupported_schema",
632            Self::InvalidApplicationTarget => "invalid_application_target",
633            Self::TargetAuthorizationRequired => "target_authorization_required",
634            Self::ReadOnlyAcknowledgementRequired => "read_only_acknowledgement_required",
635            Self::NonSecretContentAcknowledgementRequired => {
636                "non_secret_content_acknowledgement_required"
637            },
638            Self::InvalidEndpoint => "invalid_endpoint",
639            Self::EndpointOutsideApplication => "endpoint_outside_application",
640            Self::InsecureEndpoint => "insecure_endpoint",
641            Self::UnsupportedOriginMode => "unsupported_origin_mode",
642            Self::InvalidSubprotocol => "invalid_subprotocol",
643            Self::CompressionNotPermitted => "compression_not_permitted",
644            Self::ReconnectNotPermitted => "reconnect_not_permitted",
645            Self::InvalidMessageCount => "invalid_message_count",
646            Self::InvalidMessageId => "invalid_message_id",
647            Self::DuplicateMessageId => "duplicate_message_id",
648            Self::MessageTextTooLarge => "message_text_too_large",
649            Self::MessageTextAggregateTooLarge => "message_text_aggregate_too_large",
650            Self::InvalidExpectedResponse => "invalid_expected_response",
651            Self::ExpectedResponseAggregateTooLarge => "expected_response_aggregate_too_large",
652            Self::InvalidLimits => "invalid_limits",
653        }
654    }
655}
656
657#[derive(Deserialize)]
658#[serde(deny_unknown_fields)]
659struct WirePolicy {
660    schema: String,
661    target_authorized: bool,
662    messages_read_only_acknowledged: bool,
663    message_content_is_non_secret: bool,
664    endpoint: String,
665    origin_mode: String,
666    subprotocol: Option<String>,
667    compression: bool,
668    reconnect: bool,
669    messages: Vec<WireMessage>,
670    limits: WireLimits,
671}
672
673#[derive(Deserialize)]
674#[serde(deny_unknown_fields)]
675struct WireMessage {
676    id: String,
677    text: String,
678    expected_response: WireExpectedResponse,
679}
680
681#[derive(Deserialize)]
682#[serde(deny_unknown_fields)]
683struct WireExpectedResponse {
684    sha256: String,
685    length: u64,
686}
687
688#[derive(Deserialize)]
689#[serde(deny_unknown_fields)]
690struct WireLimits {
691    max_inbound_message_bytes: u64,
692    max_outbound_message_bytes: u64,
693    max_messages: u16,
694    max_control_frames: u16,
695    max_wall_time_ms: u64,
696}
697
698fn validate_application_target(
699    application_target: &Url,
700) -> Result<Url, WebSocketReviewPolicyError> {
701    if !matches!(application_target.scheme(), "http" | "https")
702        || application_target.host().is_none()
703        || application_target.port_or_known_default().is_none()
704        || !application_target.username().is_empty()
705        || application_target.password().is_some()
706        || application_target.query().is_some()
707        || application_target.fragment().is_some()
708        || !valid_scoped_path(application_target.path())
709    {
710        return Err(WebSocketReviewPolicyError::InvalidApplicationTarget);
711    }
712    let mut application = application_target.clone();
713    application.set_query(None);
714    application.set_fragment(None);
715    Ok(application)
716}
717
718fn validate_endpoint(application: &Url, raw: &str) -> Result<Url, WebSocketReviewPolicyError> {
719    if raw.is_empty()
720        || raw.len() > MAX_WEBSOCKET_REVIEW_ENDPOINT_BYTES
721        || raw.trim() != raw
722        || raw.contains('%')
723        || raw.contains('\\')
724        || raw
725            .chars()
726            .any(|character| character.is_ascii_control() || character.is_whitespace())
727        || raw_url_has_dot_path_segment(raw)
728    {
729        return Err(WebSocketReviewPolicyError::InvalidEndpoint);
730    }
731    let endpoint = Url::parse(raw).map_err(|_| WebSocketReviewPolicyError::InvalidEndpoint)?;
732    if !matches!(endpoint.scheme(), "ws" | "wss")
733        || endpoint.host().is_none()
734        || endpoint.port_or_known_default().is_none()
735        || !endpoint.username().is_empty()
736        || endpoint.password().is_some()
737        || endpoint.query().is_some()
738        || endpoint.fragment().is_some()
739        || !valid_scoped_path(endpoint.path())
740    {
741        return Err(WebSocketReviewPolicyError::InvalidEndpoint);
742    }
743    if endpoint.host() != application.host()
744        || endpoint.port_or_known_default() != application.port_or_known_default()
745        || !path_is_within(application.path(), endpoint.path())
746    {
747        return Err(WebSocketReviewPolicyError::EndpointOutsideApplication);
748    }
749    if !matches!(
750        (application.scheme(), endpoint.scheme()),
751        ("http", "ws") | ("https", "wss")
752    ) {
753        return Err(WebSocketReviewPolicyError::InvalidEndpoint);
754    }
755    if endpoint.scheme() == "ws"
756        && (application.scheme() != "http"
757            || !numeric_loopback(application.host())
758            || !numeric_loopback(endpoint.host()))
759    {
760        return Err(WebSocketReviewPolicyError::InsecureEndpoint);
761    }
762    Ok(endpoint)
763}
764
765fn raw_url_has_dot_path_segment(raw: &str) -> bool {
766    let Some((_, after_scheme)) = raw.split_once("://") else {
767        return false;
768    };
769    let Some(path_start) = after_scheme.find('/') else {
770        return false;
771    };
772    after_scheme[path_start..]
773        .split(['?', '#'])
774        .next()
775        .unwrap_or_default()
776        .split('/')
777        .any(|segment| matches!(segment, "." | ".."))
778}
779
780fn valid_scoped_path(path: &str) -> bool {
781    !path.is_empty()
782        && path.starts_with('/')
783        && !path.contains('%')
784        && !path.contains('\\')
785        && !path.contains("//")
786        && !path.chars().any(char::is_control)
787}
788
789fn path_is_within(application_path: &str, endpoint_path: &str) -> bool {
790    if application_path == "/" {
791        return endpoint_path.starts_with('/');
792    }
793    let application_path = application_path.trim_end_matches('/');
794    endpoint_path == application_path
795        || endpoint_path
796            .strip_prefix(application_path)
797            .is_some_and(|suffix| suffix.starts_with('/'))
798}
799
800fn numeric_loopback(host: Option<Host<&str>>) -> bool {
801    match host {
802        Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(),
803        Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(),
804        _ => false,
805    }
806}
807
808fn valid_subprotocol(value: &str) -> bool {
809    !value.is_empty()
810        && value.len() <= MAX_WEBSOCKET_REVIEW_SUBPROTOCOL_BYTES
811        && value.bytes().all(http_token_byte)
812}
813
814fn valid_message_id(value: &str) -> bool {
815    !value.is_empty()
816        && value.len() <= MAX_WEBSOCKET_REVIEW_MESSAGE_ID_BYTES
817        && value.as_bytes()[0].is_ascii_alphanumeric()
818        && value
819            .bytes()
820            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
821}
822
823fn http_token_byte(byte: u8) -> bool {
824    byte.is_ascii_alphanumeric()
825        || matches!(
826            byte,
827            b'!' | b'#'
828                | b'$'
829                | b'%'
830                | b'&'
831                | b'\''
832                | b'*'
833                | b'+'
834                | b'-'
835                | b'.'
836                | b'^'
837                | b'_'
838                | b'`'
839                | b'|'
840                | b'~'
841        )
842}
843
844fn validate_limits(
845    wire: &WireLimits,
846    message_count: usize,
847) -> Result<WebSocketReviewLimits, WebSocketReviewPolicyError> {
848    if message_count == 0 || message_count > MAX_WEBSOCKET_REVIEW_MESSAGES {
849        return Err(WebSocketReviewPolicyError::InvalidMessageCount);
850    }
851    let message_count = u16::try_from(message_count)
852        .map_err(|_| WebSocketReviewPolicyError::InvalidMessageCount)?;
853    if wire.max_inbound_message_bytes == 0
854        || wire.max_inbound_message_bytes > MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES
855        || wire.max_outbound_message_bytes == 0
856        || wire.max_outbound_message_bytes > MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES
857        || wire.max_messages < message_count
858        || usize::from(wire.max_messages) > MAX_WEBSOCKET_REVIEW_MESSAGES
859        || wire.max_control_frames == 0
860        || wire.max_control_frames > MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES
861        || wire.max_wall_time_ms == 0
862        || wire.max_wall_time_ms > MAX_WEBSOCKET_REVIEW_WALL_TIME_MS
863    {
864        return Err(WebSocketReviewPolicyError::InvalidLimits);
865    }
866    Ok(WebSocketReviewLimits {
867        max_inbound_message_bytes: wire.max_inbound_message_bytes,
868        max_outbound_message_bytes: wire.max_outbound_message_bytes,
869        max_messages: wire.max_messages,
870        max_control_frames: wire.max_control_frames,
871        max_wall_time_ms: wire.max_wall_time_ms,
872    })
873}
874
875fn parse_sha256(value: &str) -> Result<[u8; 32], WebSocketReviewPolicyError> {
876    if value.len() != 64 {
877        return Err(WebSocketReviewPolicyError::InvalidExpectedResponse);
878    }
879    let mut decoded = [0_u8; 32];
880    let encoded = value.as_bytes();
881    for (index, byte) in decoded.iter_mut().enumerate() {
882        let offset = index * 2;
883        let high = lower_hex_nibble(encoded[offset])
884            .ok_or(WebSocketReviewPolicyError::InvalidExpectedResponse)?;
885        let low = lower_hex_nibble(encoded[offset + 1])
886            .ok_or(WebSocketReviewPolicyError::InvalidExpectedResponse)?;
887        *byte = (high << 4) | low;
888    }
889    Ok(decoded)
890}
891
892fn lower_hex_nibble(byte: u8) -> Option<u8> {
893    match byte {
894        b'0'..=b'9' => Some(byte - b'0'),
895        b'a'..=b'f' => Some(byte - b'a' + 10),
896        _ => None,
897    }
898}
899
900fn policy_reference(
901    application_reference: &str,
902    endpoint: &Url,
903    origin_mode: WebSocketOriginMode,
904    subprotocol: Option<&str>,
905    messages: &[WebSocketReviewMessage],
906    limits: WebSocketReviewLimits,
907) -> String {
908    let mut hasher = Sha256::new();
909    hasher.update(POLICY_REFERENCE_DOMAIN);
910    framed(&mut hasher, WEBSOCKET_REVIEW_POLICY_SCHEMA.as_bytes());
911    framed(&mut hasher, WEBSOCKET_REVIEW_POLICY_ALGORITHM.as_bytes());
912    framed(&mut hasher, application_reference.as_bytes());
913    framed(&mut hasher, endpoint.as_str().as_bytes());
914    framed(&mut hasher, origin_mode.as_str().as_bytes());
915    match subprotocol {
916        Some(value) => {
917            framed(&mut hasher, b"subprotocol-present");
918            framed(&mut hasher, value.as_bytes());
919        },
920        None => framed(&mut hasher, b"subprotocol-absent"),
921    }
922    framed(&mut hasher, b"target-authorized-asserted");
923    framed(&mut hasher, b"messages-read-only-acknowledged");
924    framed(&mut hasher, b"message-content-non-secret-acknowledged");
925    framed(&mut hasher, b"compression-disabled");
926    framed(&mut hasher, b"reconnect-disabled");
927    for message in messages {
928        framed(&mut hasher, message.message_reference.as_bytes());
929    }
930    for value in [
931        limits.max_inbound_message_bytes,
932        limits.max_outbound_message_bytes,
933        u64::from(limits.max_messages),
934        u64::from(limits.max_control_frames),
935        limits.max_wall_time_ms,
936    ] {
937        framed(&mut hasher, &value.to_be_bytes());
938    }
939    format!("websocket-policy-sha256:{}", hex(hasher.finalize().into()))
940}
941
942fn digest_reference(domain: &[u8], prefix: &str, values: &[&[u8]]) -> String {
943    let mut hasher = Sha256::new();
944    hasher.update(domain);
945    for value in values {
946        framed(&mut hasher, value);
947    }
948    format!("{prefix}:{}", hex(hasher.finalize().into()))
949}
950
951fn framed(hasher: &mut Sha256, value: &[u8]) {
952    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
953    hasher.update(value);
954}
955
956fn hex(bytes: [u8; 32]) -> String {
957    const HEX: &[u8; 16] = b"0123456789abcdef";
958    let mut output = String::with_capacity(64);
959    for byte in bytes {
960        output.push(char::from(HEX[usize::from(byte >> 4)]));
961        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
962    }
963    output
964}
965
966#[derive(Default)]
967struct StrictJsonSeed;
968
969impl<'de> DeserializeSeed<'de> for StrictJsonSeed {
970    type Value = Value;
971
972    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
973    where
974        D: Deserializer<'de>,
975    {
976        deserializer.deserialize_any(StrictJsonVisitor)
977    }
978}
979
980struct StrictJsonVisitor;
981
982impl<'de> Visitor<'de> for StrictJsonVisitor {
983    type Value = Value;
984
985    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
986        formatter.write_str("strict WebSocket review policy JSON")
987    }
988
989    fn visit_unit<E>(self) -> Result<Self::Value, E> {
990        Ok(Value::Null)
991    }
992
993    fn visit_none<E>(self) -> Result<Self::Value, E> {
994        Ok(Value::Null)
995    }
996
997    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
998        Ok(Value::Bool(value))
999    }
1000
1001    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1002        Ok(Value::Number(Number::from(value)))
1003    }
1004
1005    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1006        Ok(Value::Number(Number::from(value)))
1007    }
1008
1009    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1010    where
1011        E: de::Error,
1012    {
1013        Number::from_f64(value)
1014            .map(Value::Number)
1015            .ok_or_else(|| E::custom("invalid JSON number"))
1016    }
1017
1018    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1019        Ok(Value::String(value.to_owned()))
1020    }
1021
1022    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1023        Ok(Value::String(value))
1024    }
1025
1026    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1027    where
1028        A: SeqAccess<'de>,
1029    {
1030        let mut values = Vec::new();
1031        while let Some(value) = sequence.next_element_seed(StrictJsonSeed)? {
1032            values.push(value);
1033        }
1034        Ok(Value::Array(values))
1035    }
1036
1037    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1038    where
1039        A: MapAccess<'de>,
1040    {
1041        let mut values = Map::new();
1042        while let Some(key) = map.next_key::<String>()? {
1043            if values.contains_key(&key) {
1044                return Err(A::Error::custom(DUPLICATE_KEY_SENTINEL));
1045            }
1046            let value = map.next_value_seed(StrictJsonSeed)?;
1047            values.insert(key, value);
1048        }
1049        Ok(Value::Object(values))
1050    }
1051}
1052
1053fn parse_strict_json(source: &[u8]) -> Result<Value, WebSocketReviewPolicyError> {
1054    let mut deserializer = serde_json::Deserializer::from_slice(source);
1055    let value = StrictJsonSeed
1056        .deserialize(&mut deserializer)
1057        .map_err(classify_json_error)?;
1058    deserializer.end().map_err(classify_json_error)?;
1059    Ok(value)
1060}
1061
1062fn classify_json_error(error: serde_json::Error) -> WebSocketReviewPolicyError {
1063    if error.to_string().contains(DUPLICATE_KEY_SENTINEL) {
1064        WebSocketReviewPolicyError::DuplicateJsonKey
1065    } else {
1066        WebSocketReviewPolicyError::MalformedPolicy
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use serde_json::{json, Value};
1074
1075    const ENDPOINT_CANARY: &str = "endpoint-canary-4f190f";
1076    const ID_CANARY: &str = "message-canary-2e731b";
1077    const TEXT_CANARY: &str = "text-canary-60bbcf";
1078    const SUBPROTOCOL_CANARY: &str = "protocol-canary-771a0f";
1079    const DIGEST_CANARY: &str = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
1080
1081    fn application() -> Url {
1082        Url::parse("https://example.test:8443/application/").unwrap()
1083    }
1084
1085    fn valid_value() -> Value {
1086        json!({
1087            "schema": WEBSOCKET_REVIEW_POLICY_SCHEMA,
1088            "target_authorized": true,
1089            "messages_read_only_acknowledged": true,
1090            "message_content_is_non_secret": true,
1091            "endpoint": format!("wss://example.test:8443/application/{ENDPOINT_CANARY}"),
1092            "origin_mode": "application_origin",
1093            "subprotocol": SUBPROTOCOL_CANARY,
1094            "compression": false,
1095            "reconnect": false,
1096            "messages": [{
1097                "id": ID_CANARY,
1098                "text": TEXT_CANARY,
1099                "expected_response": {
1100                    "sha256": DIGEST_CANARY,
1101                    "length": 19
1102                }
1103            }],
1104            "limits": {
1105                "max_inbound_message_bytes": 4096,
1106                "max_outbound_message_bytes": 4096,
1107                "max_messages": 1,
1108                "max_control_frames": 4,
1109                "max_wall_time_ms": 1000
1110            }
1111        })
1112    }
1113
1114    fn parse(value: &Value) -> Result<WebSocketReviewPolicy, WebSocketReviewPolicyError> {
1115        WebSocketReviewPolicy::parse_json(&application(), &serde_json::to_vec(value).unwrap())
1116    }
1117
1118    #[test]
1119    fn valid_policy_exposes_only_bounded_metadata_and_redacted_debug() {
1120        let policy = parse(&valid_value()).unwrap();
1121        assert_eq!(policy.schema(), WEBSOCKET_REVIEW_POLICY_SCHEMA);
1122        assert_eq!(policy.origin_mode(), WebSocketOriginMode::ApplicationOrigin);
1123        assert!(policy.has_subprotocol());
1124        assert!(policy.target_authorized());
1125        assert!(policy.messages_read_only_acknowledged());
1126        assert!(policy.message_content_is_non_secret());
1127        assert!(!policy.compression_enabled());
1128        assert!(!policy.reconnect_enabled());
1129        assert_eq!(policy.message_count(), 1);
1130        assert_eq!(policy.max_messages(), 1);
1131        assert_eq!(policy.max_control_frames(), 4);
1132        assert_eq!(policy.max_wall_time_ms(), 1000);
1133        assert_eq!(policy.execution_origin(), Some("https://example.test:8443"));
1134        assert_eq!(policy.execution_subprotocol(), Some(SUBPROTOCOL_CANARY));
1135        let message = policy.messages().next().unwrap();
1136        assert!(message
1137            .message_reference()
1138            .starts_with("websocket-message-revision-sha256:"));
1139        assert!(message
1140            .expected_response_reference()
1141            .starts_with("websocket-expected-response-revision-sha256:"));
1142        assert_eq!(message.outbound_length(), TEXT_CANARY.len() as u64);
1143        assert_eq!(message.expected_response_length(), 19);
1144
1145        let rendered = format!("{policy:?} {message:?}");
1146        for canary in [
1147            ENDPOINT_CANARY,
1148            ID_CANARY,
1149            TEXT_CANARY,
1150            SUBPROTOCOL_CANARY,
1151            DIGEST_CANARY,
1152            "example.test",
1153        ] {
1154            assert!(!rendered.contains(canary), "Debug leaked {canary}");
1155        }
1156        assert!(rendered.contains(policy.policy_reference()));
1157        assert!(rendered.contains(policy.endpoint_reference()));
1158    }
1159
1160    #[test]
1161    fn policy_metadata_getters_and_internal_debug_remain_value_free() {
1162        let policy = parse(&valid_value()).unwrap();
1163        assert!(policy
1164            .application_reference()
1165            .starts_with("websocket-application-sha256:"));
1166
1167        let limits = policy.limits();
1168        assert_eq!(limits.max_inbound_message_bytes(), 4096);
1169        assert_eq!(limits.max_outbound_message_bytes(), 4096);
1170        assert_eq!(limits.max_messages(), 1);
1171        assert_eq!(limits.max_control_frames(), 4);
1172        assert_eq!(limits.max_wall_time_ms(), 1000);
1173        assert!(format!("{limits:?}").contains("max_wall_time_ms: 1000"));
1174
1175        let rendered = format!("{:?}", policy.messages.first().unwrap());
1176        assert!(rendered.contains("id: \"<redacted>\""));
1177        assert!(rendered.contains("text: \"<redacted>\""));
1178        assert!(rendered.contains("expected_response: \"<redacted>\""));
1179        assert!(rendered.contains("outbound_length: 18"));
1180        assert!(rendered.contains("expected_response_length: 19"));
1181        for canary in [ID_CANARY, TEXT_CANARY, DIGEST_CANARY] {
1182            assert!(!rendered.contains(canary), "internal Debug leaked {canary}");
1183        }
1184    }
1185
1186    #[test]
1187    fn policy_error_tokens_cover_every_static_failure_class() {
1188        for (error, token) in [
1189            (WebSocketReviewPolicyError::EmptyPolicy, "empty_policy"),
1190            (
1191                WebSocketReviewPolicyError::PolicyTooLarge,
1192                "policy_too_large",
1193            ),
1194            (
1195                WebSocketReviewPolicyError::MalformedPolicy,
1196                "malformed_policy",
1197            ),
1198            (
1199                WebSocketReviewPolicyError::DuplicateJsonKey,
1200                "duplicate_json_key",
1201            ),
1202            (
1203                WebSocketReviewPolicyError::UnsupportedSchema,
1204                "unsupported_schema",
1205            ),
1206            (
1207                WebSocketReviewPolicyError::InvalidApplicationTarget,
1208                "invalid_application_target",
1209            ),
1210            (
1211                WebSocketReviewPolicyError::TargetAuthorizationRequired,
1212                "target_authorization_required",
1213            ),
1214            (
1215                WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired,
1216                "read_only_acknowledgement_required",
1217            ),
1218            (
1219                WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired,
1220                "non_secret_content_acknowledgement_required",
1221            ),
1222            (
1223                WebSocketReviewPolicyError::InvalidEndpoint,
1224                "invalid_endpoint",
1225            ),
1226            (
1227                WebSocketReviewPolicyError::EndpointOutsideApplication,
1228                "endpoint_outside_application",
1229            ),
1230            (
1231                WebSocketReviewPolicyError::InsecureEndpoint,
1232                "insecure_endpoint",
1233            ),
1234            (
1235                WebSocketReviewPolicyError::UnsupportedOriginMode,
1236                "unsupported_origin_mode",
1237            ),
1238            (
1239                WebSocketReviewPolicyError::InvalidSubprotocol,
1240                "invalid_subprotocol",
1241            ),
1242            (
1243                WebSocketReviewPolicyError::CompressionNotPermitted,
1244                "compression_not_permitted",
1245            ),
1246            (
1247                WebSocketReviewPolicyError::ReconnectNotPermitted,
1248                "reconnect_not_permitted",
1249            ),
1250            (
1251                WebSocketReviewPolicyError::InvalidMessageCount,
1252                "invalid_message_count",
1253            ),
1254            (
1255                WebSocketReviewPolicyError::InvalidMessageId,
1256                "invalid_message_id",
1257            ),
1258            (
1259                WebSocketReviewPolicyError::DuplicateMessageId,
1260                "duplicate_message_id",
1261            ),
1262            (
1263                WebSocketReviewPolicyError::MessageTextTooLarge,
1264                "message_text_too_large",
1265            ),
1266            (
1267                WebSocketReviewPolicyError::MessageTextAggregateTooLarge,
1268                "message_text_aggregate_too_large",
1269            ),
1270            (
1271                WebSocketReviewPolicyError::InvalidExpectedResponse,
1272                "invalid_expected_response",
1273            ),
1274            (
1275                WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge,
1276                "expected_response_aggregate_too_large",
1277            ),
1278            (WebSocketReviewPolicyError::InvalidLimits, "invalid_limits"),
1279        ] {
1280            assert_eq!(error.as_str(), token);
1281        }
1282    }
1283
1284    #[test]
1285    fn strict_json_scalar_visitor_preserves_types_and_rejects_non_finite_numbers() {
1286        struct Expectation;
1287
1288        impl fmt::Display for Expectation {
1289            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1290                StrictJsonVisitor.expecting(formatter)
1291            }
1292        }
1293
1294        type ValueError = de::value::Error;
1295        assert_eq!(
1296            Expectation.to_string(),
1297            "strict WebSocket review policy JSON"
1298        );
1299        assert_eq!(
1300            StrictJsonVisitor.visit_none::<ValueError>().unwrap(),
1301            Value::Null
1302        );
1303        assert_eq!(
1304            StrictJsonVisitor.visit_i64::<ValueError>(-17).unwrap(),
1305            json!(-17)
1306        );
1307        assert_eq!(
1308            StrictJsonVisitor.visit_f64::<ValueError>(1.25).unwrap(),
1309            json!(1.25)
1310        );
1311        assert_eq!(
1312            StrictJsonVisitor
1313                .visit_string::<ValueError>("owned scalar".to_owned())
1314                .unwrap(),
1315            json!("owned scalar")
1316        );
1317        assert!(StrictJsonVisitor
1318            .visit_f64::<ValueError>(f64::NAN)
1319            .unwrap_err()
1320            .to_string()
1321            .contains("invalid JSON number"));
1322    }
1323
1324    #[test]
1325    fn public_references_do_not_form_a_dictionary_oracle_for_private_message_bytes() {
1326        let baseline = parse(&valid_value()).unwrap();
1327        let mut private_change = valid_value();
1328        private_change["messages"][0]["text"] = json!("x".repeat(TEXT_CANARY.len()));
1329        private_change["messages"][0]["expected_response"]["sha256"] = json!("1".repeat(64));
1330        let private_change = parse(&private_change).unwrap();
1331
1332        let baseline_message = baseline.messages().next().unwrap();
1333        let private_message = private_change.messages().next().unwrap();
1334        assert_eq!(
1335            baseline_message.message_reference(),
1336            private_message.message_reference()
1337        );
1338        assert_eq!(
1339            baseline_message.expected_response_reference(),
1340            private_message.expected_response_reference()
1341        );
1342        assert_eq!(
1343            baseline.policy_reference(),
1344            private_change.policy_reference()
1345        );
1346
1347        let mut revised = valid_value();
1348        revised["messages"][0]["id"] = json!("message-revision-2");
1349        let revised = parse(&revised).unwrap();
1350        let revised_message = revised.messages().next().unwrap();
1351        assert_ne!(
1352            baseline_message.message_reference(),
1353            revised_message.message_reference()
1354        );
1355        assert_ne!(
1356            baseline_message.expected_response_reference(),
1357            revised_message.expected_response_reference()
1358        );
1359        assert_ne!(baseline.policy_reference(), revised.policy_reference());
1360    }
1361
1362    #[test]
1363    fn duplicate_keys_are_rejected_at_every_depth_after_json_unescaping() {
1364        let root = br#"{"schema":"security.websocket-review-policy/v1","schema":"security.websocket-review-policy/v1"}"#;
1365        assert_eq!(
1366            WebSocketReviewPolicy::parse_json(&application(), root),
1367            Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1368        );
1369        let nested = serde_json::to_string(&valid_value()).unwrap().replacen(
1370            &format!("\"sha256\":\"{DIGEST_CANARY}\""),
1371            &format!("\"sha256\":\"{DIGEST_CANARY}\",\"sha256\":\"{DIGEST_CANARY}\""),
1372            1,
1373        );
1374        assert_eq!(
1375            WebSocketReviewPolicy::parse_json(&application(), nested.as_bytes()),
1376            Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1377        );
1378        let escaped = serde_json::to_string(&valid_value()).unwrap().replacen(
1379            "\"length\":19",
1380            "\"length\":19,\"len\\u0067th\":19",
1381            1,
1382        );
1383        assert_eq!(
1384            WebSocketReviewPolicy::parse_json(&application(), escaped.as_bytes()),
1385            Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1386        );
1387    }
1388
1389    #[test]
1390    fn endpoint_is_absolute_exact_authority_and_segment_contained() {
1391        for endpoint in [
1392            "wss://other.test:8443/application/ws",
1393            "wss://example.test:9443/application/ws",
1394            "wss://example.test:8443/application-other/ws",
1395            "wss://example.test:8443",
1396        ] {
1397            let mut value = valid_value();
1398            value["endpoint"] = json!(endpoint);
1399            assert_eq!(
1400                parse(&value),
1401                Err(WebSocketReviewPolicyError::EndpointOutsideApplication),
1402                "{endpoint}"
1403            );
1404        }
1405        for endpoint in [
1406            "/application/ws",
1407            "https://example.test:8443/application/ws",
1408            "wss://user@example.test:8443/application/ws",
1409            "wss://example.test:8443/application/ws?secret=value",
1410            "wss://example.test:8443/application/ws#fragment",
1411            "wss://example.test:8443/application/%77s",
1412            "wss://example.test:8443/application/../application/ws",
1413            "wss://example.test:8443/application/./ws",
1414            "wss://example.test:8443/application//ws",
1415            "wss:\\\\example.test:8443\\application\\ws",
1416            "wss://example.test:8443/application/ ws",
1417        ] {
1418            let mut value = valid_value();
1419            value["endpoint"] = json!(endpoint);
1420            assert_eq!(
1421                parse(&value),
1422                Err(WebSocketReviewPolicyError::InvalidEndpoint),
1423                "{endpoint}"
1424            );
1425        }
1426    }
1427
1428    #[test]
1429    fn endpoint_scheme_root_scope_and_ipv6_loopback_are_explicit() {
1430        let mut value = valid_value();
1431        value["endpoint"] = json!("ws://example.test:8443/application/ws");
1432        assert_eq!(
1433            parse(&value),
1434            Err(WebSocketReviewPolicyError::InvalidEndpoint)
1435        );
1436
1437        let http_loopback = Url::parse("http://127.0.0.1:8080/application/").unwrap();
1438        value["endpoint"] = json!("wss://127.0.0.1:8080/application/ws");
1439        assert_eq!(
1440            WebSocketReviewPolicy::parse_json(&http_loopback, &serde_json::to_vec(&value).unwrap()),
1441            Err(WebSocketReviewPolicyError::InvalidEndpoint)
1442        );
1443
1444        let root = Url::parse("https://example.test:8443/").unwrap();
1445        value["endpoint"] = json!("wss://example.test:8443/socket");
1446        WebSocketReviewPolicy::parse_json(&root, &serde_json::to_vec(&value).unwrap()).unwrap();
1447
1448        let ipv6_loopback = Url::parse("http://[::1]:8080/application/").unwrap();
1449        value["endpoint"] = json!("ws://[::1]:8080/application/socket");
1450        WebSocketReviewPolicy::parse_json(&ipv6_loopback, &serde_json::to_vec(&value).unwrap())
1451            .unwrap();
1452    }
1453
1454    #[test]
1455    fn policy_binding_cannot_be_replayed_under_a_sibling_or_broader_application() {
1456        let policy = parse(&valid_value()).unwrap();
1457        assert!(policy.is_bound_to_application(&application()));
1458        assert!(!policy.is_bound_to_application(&Url::parse("https://example.test:8443/").unwrap()));
1459        assert!(!policy.is_bound_to_application(
1460            &Url::parse("https://example.test:8443/application-other/").unwrap()
1461        ));
1462        assert!(!policy
1463            .is_bound_to_application(&Url::parse("https://other.test:8443/application/").unwrap()));
1464    }
1465
1466    #[test]
1467    fn plaintext_requires_http_and_numeric_loopback_on_both_sides() {
1468        let mut value = valid_value();
1469        value["endpoint"] = json!("ws://example.test:8443/application/ws");
1470        let domain_application = Url::parse("http://example.test:8443/application/").unwrap();
1471        assert_eq!(
1472            WebSocketReviewPolicy::parse_json(
1473                &domain_application,
1474                &serde_json::to_vec(&value).unwrap()
1475            ),
1476            Err(WebSocketReviewPolicyError::InsecureEndpoint)
1477        );
1478
1479        let loopback = Url::parse("http://127.0.0.1:8080/application/").unwrap();
1480        value["endpoint"] = json!("ws://127.0.0.1:8080/application/ws");
1481        WebSocketReviewPolicy::parse_json(&loopback, &serde_json::to_vec(&value).unwrap()).unwrap();
1482
1483        let named_loopback = Url::parse("http://localhost:8080/application/").unwrap();
1484        value["endpoint"] = json!("ws://localhost:8080/application/ws");
1485        assert_eq!(
1486            WebSocketReviewPolicy::parse_json(
1487                &named_loopback,
1488                &serde_json::to_vec(&value).unwrap()
1489            ),
1490            Err(WebSocketReviewPolicyError::InsecureEndpoint)
1491        );
1492
1493        let non_loopback = Url::parse("http://192.0.2.1:8080/application/").unwrap();
1494        value["endpoint"] = json!("ws://192.0.2.1:8080/application/ws");
1495        assert_eq!(
1496            WebSocketReviewPolicy::parse_json(&non_loopback, &serde_json::to_vec(&value).unwrap()),
1497            Err(WebSocketReviewPolicyError::InsecureEndpoint)
1498        );
1499    }
1500
1501    #[test]
1502    fn assertions_modes_subprotocol_compression_and_reconnect_fail_closed() {
1503        for (field, replacement, expected) in [
1504            (
1505                "target_authorized",
1506                json!(false),
1507                WebSocketReviewPolicyError::TargetAuthorizationRequired,
1508            ),
1509            (
1510                "messages_read_only_acknowledged",
1511                json!(false),
1512                WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired,
1513            ),
1514            (
1515                "message_content_is_non_secret",
1516                json!(false),
1517                WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired,
1518            ),
1519            (
1520                "origin_mode",
1521                json!("reflect_server"),
1522                WebSocketReviewPolicyError::UnsupportedOriginMode,
1523            ),
1524            (
1525                "subprotocol",
1526                json!("one, two"),
1527                WebSocketReviewPolicyError::InvalidSubprotocol,
1528            ),
1529            (
1530                "compression",
1531                json!(true),
1532                WebSocketReviewPolicyError::CompressionNotPermitted,
1533            ),
1534            (
1535                "reconnect",
1536                json!(true),
1537                WebSocketReviewPolicyError::ReconnectNotPermitted,
1538            ),
1539        ] {
1540            let mut value = valid_value();
1541            value[field] = replacement;
1542            assert_eq!(parse(&value), Err(expected), "{field}");
1543        }
1544        let mut omitted = valid_value();
1545        omitted["origin_mode"] = json!("omit");
1546        omitted["subprotocol"] = Value::Null;
1547        let policy = parse(&omitted).unwrap();
1548        assert_eq!(policy.origin_mode(), WebSocketOriginMode::Omit);
1549        assert_eq!(policy.execution_origin(), None);
1550        assert_eq!(policy.execution_subprotocol(), None);
1551    }
1552
1553    #[test]
1554    fn message_count_identity_and_byte_bounds_are_exact() {
1555        let mut empty = valid_value();
1556        empty["messages"] = json!([]);
1557        assert_eq!(
1558            parse(&empty),
1559            Err(WebSocketReviewPolicyError::InvalidMessageCount)
1560        );
1561
1562        let message = valid_value()["messages"][0].clone();
1563        let mut nine = valid_value();
1564        nine["messages"] = Value::Array(vec![message.clone(); 9]);
1565        assert_eq!(
1566            parse(&nine),
1567            Err(WebSocketReviewPolicyError::InvalidMessageCount)
1568        );
1569
1570        let mut duplicate = valid_value();
1571        duplicate["messages"] = Value::Array(vec![message.clone(), message.clone()]);
1572        duplicate["limits"]["max_messages"] = json!(2);
1573        assert_eq!(
1574            parse(&duplicate),
1575            Err(WebSocketReviewPolicyError::DuplicateMessageId)
1576        );
1577
1578        let mut unsafe_id = valid_value();
1579        unsafe_id["messages"][0]["id"] = json!("../unsafe");
1580        assert_eq!(
1581            parse(&unsafe_id),
1582            Err(WebSocketReviewPolicyError::InvalidMessageId)
1583        );
1584
1585        let mut oversized = valid_value();
1586        oversized["messages"][0]["text"] =
1587            json!("x".repeat(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES + 1));
1588        assert_eq!(
1589            parse(&oversized),
1590            Err(WebSocketReviewPolicyError::MessageTextTooLarge)
1591        );
1592
1593        let mut aggregate = valid_value();
1594        let mut messages = Vec::new();
1595        for index in 0..5 {
1596            let mut current = message.clone();
1597            current["id"] = json!(format!("message-{index}"));
1598            current["text"] = json!("x".repeat(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES));
1599            messages.push(current);
1600        }
1601        aggregate["messages"] = Value::Array(messages);
1602        aggregate["limits"]["max_messages"] = json!(5);
1603        aggregate["limits"]["max_outbound_message_bytes"] =
1604            json!(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES);
1605        assert_eq!(
1606            parse(&aggregate),
1607            Err(WebSocketReviewPolicyError::MessageTextAggregateTooLarge)
1608        );
1609    }
1610
1611    #[test]
1612    fn expected_responses_and_runtime_limits_are_bounded() {
1613        for digest in ["a".repeat(63), "A".repeat(64), "g".repeat(64)] {
1614            let mut value = valid_value();
1615            value["messages"][0]["expected_response"]["sha256"] = json!(digest);
1616            assert_eq!(
1617                parse(&value),
1618                Err(WebSocketReviewPolicyError::InvalidExpectedResponse)
1619            );
1620        }
1621        for (field, invalid) in [
1622            ("max_inbound_message_bytes", 0_u64),
1623            (
1624                "max_inbound_message_bytes",
1625                MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES + 1,
1626            ),
1627            ("max_outbound_message_bytes", 0),
1628            ("max_messages", 0),
1629            (
1630                "max_messages",
1631                u64::try_from(MAX_WEBSOCKET_REVIEW_MESSAGES).unwrap() + 1,
1632            ),
1633            ("max_control_frames", 0),
1634            (
1635                "max_control_frames",
1636                u64::from(MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES) + 1,
1637            ),
1638            ("max_wall_time_ms", 0),
1639            ("max_wall_time_ms", MAX_WEBSOCKET_REVIEW_WALL_TIME_MS + 1),
1640        ] {
1641            let mut value = valid_value();
1642            value["limits"][field] = json!(invalid);
1643            assert_eq!(
1644                parse(&value),
1645                Err(WebSocketReviewPolicyError::InvalidLimits),
1646                "{field}={invalid}"
1647            );
1648        }
1649
1650        let mut too_short = valid_value();
1651        too_short["limits"]["max_outbound_message_bytes"] = json!(TEXT_CANARY.len() - 1);
1652        assert_eq!(
1653            parse(&too_short),
1654            Err(WebSocketReviewPolicyError::InvalidLimits)
1655        );
1656
1657        let mut inbound_aggregate = valid_value();
1658        let mut messages = Vec::new();
1659        for index in 0..2 {
1660            let mut current = valid_value()["messages"][0].clone();
1661            current["id"] = json!(format!("expected-{index}"));
1662            current["expected_response"]["length"] = json!(40_000);
1663            messages.push(current);
1664        }
1665        inbound_aggregate["messages"] = Value::Array(messages);
1666        inbound_aggregate["limits"]["max_messages"] = json!(2);
1667        inbound_aggregate["limits"]["max_inbound_message_bytes"] = json!(40_000);
1668        assert_eq!(
1669            parse(&inbound_aggregate),
1670            Err(WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge)
1671        );
1672    }
1673
1674    #[test]
1675    fn malformed_unknown_oversized_and_invalid_application_inputs_are_static_errors() {
1676        assert_eq!(
1677            WebSocketReviewPolicy::parse_json(&application(), b""),
1678            Err(WebSocketReviewPolicyError::EmptyPolicy)
1679        );
1680        assert_eq!(
1681            WebSocketReviewPolicy::parse_json(
1682                &application(),
1683                &vec![b' '; MAX_WEBSOCKET_REVIEW_POLICY_BYTES + 1]
1684            ),
1685            Err(WebSocketReviewPolicyError::PolicyTooLarge)
1686        );
1687        assert_eq!(
1688            WebSocketReviewPolicy::parse_json(&application(), b"not-json"),
1689            Err(WebSocketReviewPolicyError::MalformedPolicy)
1690        );
1691        let mut unsupported_schema = valid_value();
1692        unsupported_schema["schema"] = json!("security.websocket-review-policy/v2");
1693        assert_eq!(
1694            parse(&unsupported_schema),
1695            Err(WebSocketReviewPolicyError::UnsupportedSchema)
1696        );
1697        let mut unknown = valid_value();
1698        unknown["credential"] = json!("secret");
1699        assert_eq!(
1700            parse(&unknown),
1701            Err(WebSocketReviewPolicyError::MalformedPolicy)
1702        );
1703        let invalid_application = Url::parse("https://example.test/application/?query=1").unwrap();
1704        assert_eq!(
1705            WebSocketReviewPolicy::parse_json(
1706                &invalid_application,
1707                &serde_json::to_vec(&valid_value()).unwrap()
1708            ),
1709            Err(WebSocketReviewPolicyError::InvalidApplicationTarget)
1710        );
1711    }
1712}