diff --git a/fixtures/conformance/gateway-input-feedback-v1.tsv b/fixtures/conformance/gateway-input-feedback-v1.tsv index 686f5ac..2c97000 100644 --- a/fixtures/conformance/gateway-input-feedback-v1.tsv +++ b/fixtures/conformance/gateway-input-feedback-v1.tsv @@ -9,9 +9,12 @@ valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040 valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid valid-scroll 1 gateway_input hex=564749310704ff880078 valid +valid-controller-arrival 1 gateway_input hex=564749310808020003010000001f valid valid-idr 1 gateway_feedback hex=5647463100010000 valid valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid +valid-quality-prompt 1 gateway_feedback hex=564746310004001812345678123456781234567812345678000000000000002a valid +valid-stop-prompt 1 gateway_feedback hex=564746310005001012345678123456781234567812345678 valid valid-termination 1 gateway_feedback hex=564746310110000400000001 valid valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid valid-hdr 1 gateway_feedback hex=564746310112000101 valid @@ -30,5 +33,9 @@ invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 inval invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length -invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type +invalid-feedback-type 1 gateway_feedback hex=5647463100060000 invalid:type +invalid-quality-prompt-trailing 1 gateway_feedback hex=564746310004001912345678123456781234567812345678000000000000002a00 invalid:length +invalid-quality-prompt-zero-uuid 1 gateway_feedback hex=564746310004001800000000000000000000000000000000000000000000002a invalid:field +invalid-stop-prompt-zero-uuid 1 gateway_feedback hex=564746310005001000000000000000000000000000000000 invalid:field +invalid-controller-arrival-family 1 gateway_input hex=564749310808020003040000001f invalid:field invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length diff --git a/fixtures/invalid/bitrate-preference-auto-target.json b/fixtures/invalid/bitrate-preference-auto-target.json new file mode 100644 index 0000000..0983dcd --- /dev/null +++ b/fixtures/invalid/bitrate-preference-auto-target.json @@ -0,0 +1 @@ +{"mode":"auto","target_kbps":1000} diff --git a/fixtures/invalid/capability-rc5-opaque.json b/fixtures/invalid/capability-rc5-opaque.json new file mode 100644 index 0000000..c597986 --- /dev/null +++ b/fixtures/invalid/capability-rc5-opaque.json @@ -0,0 +1 @@ +{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]} diff --git a/fixtures/invalid/manifest-provider-field.json b/fixtures/invalid/manifest-provider-field.json index 4efc393..3031ea6 100644 --- a/fixtures/invalid/manifest-provider-field.json +++ b/fixtures/invalid/manifest-provider-field.json @@ -26,5 +26,14 @@ "audience": "versevdi-gateway" }, "correlation_id": "correlation-1", - "provider_url": "https://provider.invalid" + "provider_url": "https://provider.invalid", + "selected_descriptor": { + "video_profile": {"codec": "h264", "bit_depth": 8, "chroma_subsampling": "4:2:0", "color_space": "bt709-limited", "transfer_function": "sdr"}, + "audio_profile": {"codec": "opus", "sample_rate_hz": 48000, "channels": 2, "channel_layout": "stereo", "packet_duration_ms": 5}, + "display_mode": {"resolution_width": 1920, "resolution_height": 1080, "fps": 60}, + "bitrate_target_kbps": 20000, + "bitrate_maximum_kbps": 40000, + "adjustment": {"display_reason": "none", "bitrate_reason": "none"}, + "media_timestamp_basis": "gateway-send-wall-clock-ms" + } } diff --git a/fixtures/invalid/provider-stream-policy-rc5.json b/fixtures/invalid/provider-stream-policy-rc5.json new file mode 100644 index 0000000..97c71a0 --- /dev/null +++ b/fixtures/invalid/provider-stream-policy-rc5.json @@ -0,0 +1 @@ +{"resolution_width":1920,"resolution_height":1080,"fps":60,"codec":"H264","bitrate_kbps":20000,"audio_enabled":true} diff --git a/fixtures/invalid/selected-session-descriptor-provider-field.json b/fixtures/invalid/selected-session-descriptor-provider-field.json new file mode 100644 index 0000000..69fd524 --- /dev/null +++ b/fixtures/invalid/selected-session-descriptor-provider-field.json @@ -0,0 +1 @@ +{"video_profile":{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"},"audio_profile":{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60},"bitrate_target_kbps":20000,"bitrate_maximum_kbps":40000,"adjustment":{"display_reason":"none","bitrate_reason":"none"},"media_timestamp_basis":"gateway-send-wall-clock-ms","provider_identity":"forbidden"} diff --git a/fixtures/invalid/session-request-rc5.json b/fixtures/invalid/session-request-rc5.json new file mode 100644 index 0000000..7d81454 --- /dev/null +++ b/fixtures/invalid/session-request-rc5.json @@ -0,0 +1 @@ +{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1"} diff --git a/fixtures/invalid/video-profile-unknown.json b/fixtures/invalid/video-profile-unknown.json new file mode 100644 index 0000000..734d59a --- /dev/null +++ b/fixtures/invalid/video-profile-unknown.json @@ -0,0 +1 @@ +{"codec":"vp9","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"} diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 56e430e..cd40de3 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -11,5 +11,26 @@ "fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30" + "corpus_sha256": "10751a9192961aeb8ddcf3bbf720b026b07f92c871c813c301660dd3de930186", + "json_files": [ + "fixtures/invalid/authenticated-session-partial-native-identity.json", + "fixtures/invalid/bitrate-preference-auto-target.json", + "fixtures/invalid/browser-session-native-identity.json", + "fixtures/invalid/capability-rc5-opaque.json", + "fixtures/invalid/manifest-provider-field.json", + "fixtures/invalid/native-session-missing-identity.json", + "fixtures/invalid/provider-stream-policy-rc5.json", + "fixtures/invalid/selected-session-descriptor-provider-field.json", + "fixtures/invalid/session-request-policy-snapshot.json", + "fixtures/invalid/session-request-rc5.json", + "fixtures/invalid/video-profile-unknown.json", + "fixtures/valid/authenticated-browser-session.json", + "fixtures/valid/authenticated-native-session.json", + "fixtures/valid/manifest.json", + "fixtures/valid/native-tunnel-credential.json", + "fixtures/valid/selected-session-descriptor.json", + "fixtures/valid/session-quality-limits.json", + "fixtures/valid/session-request.json" + ], + "json_corpus_sha256": "2b111ca1c1723d6063aeafbf08c4e77972b16422ac3a84a054170039b2eff4d3" } diff --git a/fixtures/valid/manifest.json b/fixtures/valid/manifest.json index dfdebde..91bb532 100644 --- a/fixtures/valid/manifest.json +++ b/fixtures/valid/manifest.json @@ -25,5 +25,14 @@ "expires_at": "2099-01-01T00:00:00Z", "audience": "versevdi-gateway" }, - "correlation_id": "correlation-1" + "correlation_id": "correlation-1", + "selected_descriptor": { + "video_profile": {"codec": "h264", "bit_depth": 8, "chroma_subsampling": "4:2:0", "color_space": "bt709-limited", "transfer_function": "sdr"}, + "audio_profile": {"codec": "opus", "sample_rate_hz": 48000, "channels": 2, "channel_layout": "stereo", "packet_duration_ms": 5}, + "display_mode": {"resolution_width": 1920, "resolution_height": 1080, "fps": 60}, + "bitrate_target_kbps": 20000, + "bitrate_maximum_kbps": 40000, + "adjustment": {"display_reason": "none", "bitrate_reason": "none"}, + "media_timestamp_basis": "gateway-send-wall-clock-ms" + } } diff --git a/fixtures/valid/selected-session-descriptor.json b/fixtures/valid/selected-session-descriptor.json new file mode 100644 index 0000000..280f60b --- /dev/null +++ b/fixtures/valid/selected-session-descriptor.json @@ -0,0 +1,9 @@ +{ + "video_profile": {"codec": "hevc", "bit_depth": 8, "chroma_subsampling": "4:2:0", "color_space": "bt709-limited", "transfer_function": "sdr"}, + "audio_profile": {"codec": "opus", "sample_rate_hz": 48000, "channels": 2, "channel_layout": "stereo", "packet_duration_ms": 5}, + "display_mode": {"resolution_width": 2560, "resolution_height": 1440, "fps": 120}, + "bitrate_target_kbps": 40000, + "bitrate_maximum_kbps": 50000, + "adjustment": {"display_reason": "none", "bitrate_reason": "policy_default"}, + "media_timestamp_basis": "gateway-send-wall-clock-ms" +} diff --git a/fixtures/valid/session-quality-limits.json b/fixtures/valid/session-quality-limits.json new file mode 100644 index 0000000..a3177ae --- /dev/null +++ b/fixtures/valid/session-quality-limits.json @@ -0,0 +1,9 @@ +{ + "policy_version_id": "policy-1", + "policy_display_limit": {"resolution_width": 3840, "resolution_height": 2160, "fps": 120}, + "selectable_display_maximum": {"resolution_width": 3840, "resolution_height": 2160, "fps": 120}, + "display_limit_override": false, + "bitrate_minimum_kbps": 1000, + "bitrate_target_kbps": 20000, + "bitrate_maximum_kbps": 50000 +} diff --git a/fixtures/valid/session-request.json b/fixtures/valid/session-request.json index d878df6..1346271 100644 --- a/fixtures/valid/session-request.json +++ b/fixtures/valid/session-request.json @@ -2,5 +2,9 @@ "client_device_id": "device-1", "device_key_id": "key-1", "pool_id": "pool-1", - "idempotency_key": "request-1" + "idempotency_key": "request-1", + "video_profiles": [ + {"codec": "h264", "bit_depth": 8, "chroma_subsampling": "4:2:0", "color_space": "bt709-limited", "transfer_function": "sdr"} + ], + "bitrate_preference": {"mode": "auto"} } diff --git a/frames/gateway-input-feedback-v1.md b/frames/gateway-input-feedback-v1.md index d50903f..8f59a77 100644 --- a/frames/gateway-input-feedback-v1.md +++ b/frames/gateway-input-feedback-v1.md @@ -36,6 +36,7 @@ during cleanup. | `0x05` | controller state | `controller` (0 through 15), `active_mask` (u16), `button_flags` (u16), `left_trigger` (u8), `right_trigger` (u8), `left_x` (i16), `left_y` (i16), `right_x` (i16), `right_y` (i16), `extra_button_flags` (u16). A zero `active_mask` and zero state is release. | | `0x06` | absolute mouse | `x` (u16), `y` (u16), `viewport_width` (u16), `viewport_height` (u16). Viewport dimensions must be nonzero and each coordinate must be strictly smaller than its corresponding dimension. | | `0x07` | high-resolution scroll | `vertical_delta` (i16), `horizontal_delta` (i16). Positive and negative values preserve the client scroll direction without provider-specific scaling. | +| `0x08` | controller arrival | `controller` (u8, 0 through 15), `active_mask` (u16), `family` (u8: `0` unknown, `1` xbox, `2` playstation, `3` nintendo), `supported_button_flags` (u32). It must precede controller state for that controller. | Keyboard, mouse button, UTF-8, controller, absolute mouse, and scroll messages are delivered over the gateway's reliable ordered input flow. Relative and @@ -60,7 +61,12 @@ is the following exact envelope: | 8 | N | payload | exact type-specific body | The client-to-gateway types are `0x01` IDR request (empty), `0x02` FEC -status, and `0x03` terminal receipt (empty). FEC status contains +status, `0x03` terminal receipt (empty), `0x04` quality-work prompt, and +`0x05` Stop-work prompt. The quality prompt is exactly `operation_id` (16 +canonical UUID bytes) followed by `revision` (nonzero u64). The Stop prompt is +exactly `operation_id` (16 canonical UUID bytes). Prompts never authorize or +complete work; the gateway acquires the matching durable mTLS work item. +FEC status contains `frame_index` (u32), `highest_received_sequence` (u16), `next_contiguous_sequence` (u16), `missing_before_highest` (u16), `total_data_packets` (u16), `total_parity_packets` (u16), diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 8984eac..f111eb4 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -14,7 +14,7 @@ import ( "time" ) -const SchemaSHA256 = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248" +const SchemaSHA256 = "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "2" const NMinus1WireVersion = "1" @@ -31,6 +31,77 @@ type ValidationError struct { func (e ValidationError) Error() string { return "protocol validation failed" } +func validCanonicalUUID(value string) bool { + if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' { + return false + } + for index, char := range []byte(value) { + if index == 8 || index == 13 || index == 18 || index == 23 { + continue + } + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { + return false + } + } + return value != "00000000-0000-0000-0000-000000000000" +} + +func rejectDuplicateJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + var scan func(json.Token) error + scan = func(token json.Token) error { + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]struct{}{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("invalid JSON object key") + } + if _, exists := seen[key]; exists { + return errors.New("duplicate JSON object key") + } + seen[key] = struct{}{} + value, err := decoder.Token() + if err != nil { + return err + } + if err := scan(value); err != nil { + return err + } + } + _, err := decoder.Token() + return err + case '[': + for decoder.More() { + value, err := decoder.Token() + if err != nil { + return err + } + if err := scan(value); err != nil { + return err + } + } + _, err := decoder.Token() + return err + } + return nil + } + token, err := decoder.Token() + if err != nil { + return err + } + return scan(token) +} + type AllocationPolicy struct { MinimumKbps int64 `json:"minimum_kbps"` TargetKbps int64 `json:"target_kbps"` @@ -44,30 +115,47 @@ type AllocationPolicy struct { } type AssignedDesktop struct { - AssignmentID string `json:"assignment_id"` - PoolID string `json:"pool_id"` - Name string `json:"name"` - Availability string `json:"availability"` + AssignmentID string `json:"assignment_id"` + PoolID string `json:"pool_id"` + Name string `json:"name"` + Availability string `json:"availability"` + QualityLimits SessionQualityLimits `json:"quality_limits"` +} + +type AudioProfile struct { + Codec string `json:"codec"` + SampleRateHz int64 `json:"sample_rate_hz"` + Channels int64 `json:"channels"` + ChannelLayout string `json:"channel_layout"` + PacketDurationMs int64 `json:"packet_duration_ms"` +} + +type BitratePreference struct { + Mode string `json:"mode"` + TargetKbps *int64 `json:"target_kbps,omitempty"` } type BrokerSession struct { - ID string `json:"id"` - PrincipalID string `json:"principal_id"` - PoolID string `json:"pool_id"` - AssignmentID string `json:"assignment_id,omitempty"` - State string `json:"state"` - PolicySnapshot AllocationPolicy `json:"policy_snapshot"` - ReconnectDeadline string `json:"reconnect_deadline,omitempty"` - Outcome string `json:"outcome,omitempty"` - FailureCode string `json:"failure_code,omitempty"` - CleanupState string `json:"cleanup_state"` - IdempotencyKey string `json:"idempotency_key"` - CorrelationID string `json:"correlation_id"` - RequestedAt string `json:"requested_at"` - EndedAt string `json:"ended_at,omitempty"` - Version int64 `json:"version"` - RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` - EffectiveDisplayMode *DisplayMode `json:"effective_display_mode,omitempty"` + ID string `json:"id"` + PrincipalID string `json:"principal_id"` + PoolID string `json:"pool_id"` + AssignmentID string `json:"assignment_id,omitempty"` + State string `json:"state"` + PolicySnapshot AllocationPolicy `json:"policy_snapshot"` + ReconnectDeadline string `json:"reconnect_deadline,omitempty"` + Outcome string `json:"outcome,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + CleanupState string `json:"cleanup_state"` + IdempotencyKey string `json:"idempotency_key"` + CorrelationID string `json:"correlation_id"` + RequestedAt string `json:"requested_at"` + EndedAt string `json:"ended_at,omitempty"` + Version int64 `json:"version"` + RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` + EffectiveDisplayMode *DisplayMode `json:"effective_display_mode,omitempty"` + RequestedVideoProfiles []VideoProfile `json:"requested_video_profiles"` + RequestedBitratePreference BitratePreference `json:"requested_bitrate_preference"` + SelectedDescriptor *SelectedSessionDescriptor `json:"selected_descriptor,omitempty"` } type BrowserAuthenticatedSession struct { @@ -78,12 +166,12 @@ type BrowserAuthenticatedSession struct { } type CapabilityProfile struct { - Transport string `json:"transport"` - Framing string `json:"framing"` - Media string `json:"media"` - Audio string `json:"audio"` - SourceRateControl string `json:"source_rate_control"` - ClientDecode []string `json:"client_decode"` + Transport string `json:"transport"` + Framing string `json:"framing"` + Media string `json:"media"` + SourceRateControl string `json:"source_rate_control"` + VideoProfiles []VideoProfile `json:"video_profiles"` + AudioProfiles []AudioProfile `json:"audio_profiles"` } type ChannelFrame struct { @@ -98,13 +186,14 @@ type ChannelFrame struct { } type ClientSessionAuthority struct { - Version string `json:"version"` - SessionID string `json:"session_id"` - GatewayID string `json:"gateway_id"` - Audience string `json:"audience"` - ReconnectSequence int64 `json:"reconnect_sequence"` - ExpiresAt string `json:"expires_at"` - Capabilities CapabilityProfile `json:"capabilities"` + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + Audience string `json:"audience"` + ReconnectSequence int64 `json:"reconnect_sequence"` + ExpiresAt string `json:"expires_at"` + Capabilities CapabilityProfile `json:"capabilities"` + SelectedDescriptor SelectedSessionDescriptor `json:"selected_descriptor"` } type ClipboardPolicy struct { @@ -120,15 +209,16 @@ type ClipboardText struct { } type ConnectionManifest struct { - Version string `json:"version"` - Purpose string `json:"purpose"` - SessionID string `json:"session_id"` - ReconnectSequence int64 `json:"reconnect_sequence"` - Gateway ManifestGateway `json:"gateway"` - Tunnel ManifestTunnel `json:"tunnel"` - Profile ManifestProfile `json:"profile"` - Grant GrantReference `json:"grant"` - CorrelationID string `json:"correlation_id"` + Version string `json:"version"` + Purpose string `json:"purpose"` + SessionID string `json:"session_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + Gateway ManifestGateway `json:"gateway"` + Tunnel ManifestTunnel `json:"tunnel"` + Profile ManifestProfile `json:"profile"` + Grant GrantReference `json:"grant"` + CorrelationID string `json:"correlation_id"` + SelectedDescriptor SelectedSessionDescriptor `json:"selected_descriptor"` } type DeviceChallenge struct { @@ -154,6 +244,11 @@ type DeviceRegistrationRequest struct { PublicKey string `json:"public_key"` } +type DisplayLimitOverrideRequest struct { + AllowDisplayLimitOverride bool `json:"allow_display_limit_override"` + ExpectedVersion int64 `json:"expected_version"` +} + type DisplayMode struct { ResolutionWidth int64 `json:"resolution_width"` ResolutionHeight int64 `json:"resolution_height"` @@ -161,9 +256,10 @@ type DisplayMode struct { } type EntitledPool struct { - PoolID string `json:"pool_id"` - Name string `json:"name"` - AssignmentState string `json:"assignment_state"` + PoolID string `json:"pool_id"` + Name string `json:"name"` + AssignmentState string `json:"assignment_state"` + QualityLimits SessionQualityLimits `json:"quality_limits"` } type ErrorEnvelope struct { @@ -227,6 +323,40 @@ type GatewayHeartbeat struct { Telemetry GatewayTelemetry `json:"telemetry"` } +type GatewayQualityAck struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` + Revision int64 `json:"revision"` + Outcome string `json:"outcome"` + CurrentAppliedRevision int64 `json:"current_applied_revision"` + FailureCode string `json:"failure_code,omitempty"` +} + +type GatewayQualityWork struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` + Revision int64 `json:"revision"` + LeaseExpiresAt string `json:"lease_expires_at"` + SelectedDescriptor SelectedSessionDescriptor `json:"selected_descriptor"` + CurrentAppliedRevision *int64 `json:"current_applied_revision,omitempty"` +} + +type GatewayQualityWorkRequest struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` + Revision int64 `json:"revision"` + CurrentAppliedRevision *int64 `json:"current_applied_revision,omitempty"` +} + type GatewayRegistration struct { Version string `json:"version"` GatewayID string `json:"gateway_id"` @@ -243,6 +373,33 @@ type GatewayRegistration struct { Capabilities CapabilityProfile `json:"capabilities"` } +type GatewayStopAck struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` + Outcome string `json:"outcome"` + FailureCode string `json:"failure_code,omitempty"` +} + +type GatewayStopWork struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` + Attempt int64 `json:"attempt"` +} + +type GatewayStopWorkRequest struct { + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + ReconnectSequence int64 `json:"reconnect_sequence"` + OperationID string `json:"operation_id"` +} + type GatewayTelemetry struct { AdmittedSessions int64 `json:"admitted_sessions"` AdmissionRejects int64 `json:"admission_rejects"` @@ -366,12 +523,32 @@ type ProviderState struct { } type ProviderStreamPolicy struct { - ResolutionWidth int64 `json:"resolution_width"` - ResolutionHeight int64 `json:"resolution_height"` - Fps int64 `json:"fps"` - Codec string `json:"codec"` - BitrateKbps int64 `json:"bitrate_kbps"` - AudioEnabled bool `json:"audio_enabled"` + VideoProfile VideoProfile `json:"video_profile"` + AudioProfile AudioProfile `json:"audio_profile"` + DisplayMode DisplayMode `json:"display_mode"` + BitrateTargetKbps int64 `json:"bitrate_target_kbps"` + BitrateMaximumKbps int64 `json:"bitrate_maximum_kbps"` +} + +type QualityChangeOperation struct { + OperationID string `json:"operation_id"` + SessionID string `json:"session_id"` + Revision int64 `json:"revision"` + State string `json:"state"` + RequestedBitratePreference BitratePreference `json:"requested_bitrate_preference"` + EffectiveBitrateKbps int64 `json:"effective_bitrate_kbps"` + GoverningPolicyVersion string `json:"governing_policy_version"` + SessionVersion int64 `json:"session_version"` + CreatedAt string `json:"created_at"` + DeadlineAt string `json:"deadline_at"` + UpdatedAt string `json:"updated_at"` + FailureCode string `json:"failure_code,omitempty"` +} + +type QualityChangeRequest struct { + BitratePreference BitratePreference `json:"bitrate_preference"` + ExpectedSessionVersion int64 `json:"expected_session_version"` + ExpectedPolicyVersion string `json:"expected_policy_version"` } type ReauthGrant struct { @@ -386,9 +563,10 @@ type ReauthRequest struct { } type ReconnectRequest struct { - ClientDeviceID string `json:"client_device_id"` - DeviceKeyID string `json:"device_key_id"` - ExpectedVersion int64 `json:"expected_version"` + ClientDeviceID string `json:"client_device_id"` + DeviceKeyID string `json:"device_key_id"` + ExpectedVersion int64 `json:"expected_version"` + DisplayRelaunchConfirmed bool `json:"display_relaunch_confirmed"` } type RefreshRequest struct { @@ -418,24 +596,52 @@ type ResourceList struct { Page PageInfo `json:"page"` } +type SelectedSessionDescriptor struct { + VideoProfile VideoProfile `json:"video_profile"` + AudioProfile AudioProfile `json:"audio_profile"` + DisplayMode DisplayMode `json:"display_mode"` + BitrateTargetKbps int64 `json:"bitrate_target_kbps"` + BitrateMaximumKbps int64 `json:"bitrate_maximum_kbps"` + Adjustment SessionAdjustment `json:"adjustment"` + MediaTimestampBasis string `json:"media_timestamp_basis"` +} + +type SessionAdjustment struct { + DisplayReason string `json:"display_reason"` + BitrateReason string `json:"bitrate_reason"` +} + type SessionAuthority struct { - Version string `json:"version"` - SessionID string `json:"session_id"` - GatewayID string `json:"gateway_id"` - Audience string `json:"audience"` - ReconnectSequence int64 `json:"reconnect_sequence"` - ExpiresAt string `json:"expires_at"` - Capabilities CapabilityProfile `json:"capabilities"` - ProviderProfile string `json:"provider_profile"` - ProviderIdentity string `json:"provider_identity"` + Version string `json:"version"` + SessionID string `json:"session_id"` + GatewayID string `json:"gateway_id"` + Audience string `json:"audience"` + ReconnectSequence int64 `json:"reconnect_sequence"` + ExpiresAt string `json:"expires_at"` + Capabilities CapabilityProfile `json:"capabilities"` + ProviderProfile string `json:"provider_profile"` + ProviderIdentity string `json:"provider_identity"` + SelectedDescriptor SelectedSessionDescriptor `json:"selected_descriptor"` +} + +type SessionQualityLimits struct { + PolicyVersionID string `json:"policy_version_id"` + PolicyDisplayLimit DisplayMode `json:"policy_display_limit"` + SelectableDisplayMaximum DisplayMode `json:"selectable_display_maximum"` + DisplayLimitOverride bool `json:"display_limit_override"` + BitrateMinimumKbps int64 `json:"bitrate_minimum_kbps"` + BitrateTargetKbps int64 `json:"bitrate_target_kbps"` + BitrateMaximumKbps int64 `json:"bitrate_maximum_kbps"` } type SessionRequest struct { - ClientDeviceID string `json:"client_device_id"` - DeviceKeyID string `json:"device_key_id"` - PoolID string `json:"pool_id"` - IdempotencyKey string `json:"idempotency_key"` - RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` + ClientDeviceID string `json:"client_device_id"` + DeviceKeyID string `json:"device_key_id"` + PoolID string `json:"pool_id"` + IdempotencyKey string `json:"idempotency_key"` + VideoProfiles []VideoProfile `json:"video_profiles"` + BitratePreference BitratePreference `json:"bitrate_preference"` + RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"` } type StableError struct { @@ -445,6 +651,17 @@ type StableError struct { Retryable bool `json:"retryable"` } +type StopOperation struct { + OperationID string `json:"operation_id"` + SessionID string `json:"session_id"` + State string `json:"state"` + SessionVersion int64 `json:"session_version"` + CreatedAt string `json:"created_at"` + DeadlineAt string `json:"deadline_at"` + UpdatedAt string `json:"updated_at"` + FailureCode string `json:"failure_code,omitempty"` +} + type TunnelAdmissionRequest struct { Version string `json:"version"` SessionID string `json:"session_id"` @@ -462,6 +679,14 @@ type VersionNegotiation struct { Features []string `json:"features"` } +type VideoProfile struct { + Codec string `json:"codec"` + BitDepth int64 `json:"bit_depth"` + ChromaSubsampling string `json:"chroma_subsampling"` + ColorSpace string `json:"color_space"` + TransferFunction string `json:"transfer_function"` +} + func (v AllocationPolicy) Validate() error { var violations []FieldViolation if v.MinimumKbps == 0 { @@ -556,6 +781,9 @@ func DecodeAllocationPolicy(data []byte) (AllocationPolicy, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -650,6 +878,12 @@ func (v AssignedDesktop) Validate() error { if len(v.Availability) > 64 { violations = append(violations, FieldViolation{Field: "availability", Code: "max_length"}) } + if reflect.DeepEqual(v.QualityLimits, SessionQualityLimits{}) { + violations = append(violations, FieldViolation{Field: "quality_limits", Code: "required"}) + } + if err := v.QualityLimits.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "quality_limits", Code: "invalid_object"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -661,6 +895,9 @@ func DecodeAssignedDesktop(data []byte) (AssignedDesktop, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -677,6 +914,9 @@ func DecodeAssignedDesktop(data []byte) (AssignedDesktop, error) { if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}} } + if raw, ok := fields["quality_limits"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "quality_limits", Code: "required"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -702,6 +942,168 @@ func EncodeAssignedDesktop(value AssignedDesktop) ([]byte, error) { return json.Marshal(value) } +func (v AudioProfile) Validate() error { + var violations []FieldViolation + if v.Codec == "" { + violations = append(violations, FieldViolation{Field: "codec", Code: "required"}) + } + if v.Codec != "opus" && v.Codec != "" { + violations = append(violations, FieldViolation{Field: "codec", Code: "invalid_value"}) + } + if v.SampleRateHz == 0 { + violations = append(violations, FieldViolation{Field: "sample_rate_hz", Code: "required"}) + } + if v.SampleRateHz != 0 && v.SampleRateHz < 48000 { + violations = append(violations, FieldViolation{Field: "sample_rate_hz", Code: "minimum"}) + } + if v.SampleRateHz > 48000 { + violations = append(violations, FieldViolation{Field: "sample_rate_hz", Code: "maximum"}) + } + if v.Channels == 0 { + violations = append(violations, FieldViolation{Field: "channels", Code: "required"}) + } + if v.Channels != 0 && v.Channels < 2 { + violations = append(violations, FieldViolation{Field: "channels", Code: "minimum"}) + } + if v.Channels > 2 { + violations = append(violations, FieldViolation{Field: "channels", Code: "maximum"}) + } + if v.ChannelLayout == "" { + violations = append(violations, FieldViolation{Field: "channel_layout", Code: "required"}) + } + if v.ChannelLayout != "stereo" && v.ChannelLayout != "" { + violations = append(violations, FieldViolation{Field: "channel_layout", Code: "invalid_value"}) + } + if v.PacketDurationMs == 0 { + violations = append(violations, FieldViolation{Field: "packet_duration_ms", Code: "required"}) + } + if v.PacketDurationMs != 0 && v.PacketDurationMs < 5 { + violations = append(violations, FieldViolation{Field: "packet_duration_ms", Code: "minimum"}) + } + if v.PacketDurationMs > 5 { + violations = append(violations, FieldViolation{Field: "packet_duration_ms", Code: "maximum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeAudioProfile(data []byte) (AudioProfile, error) { + var value AudioProfile + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["channel_layout"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "channel_layout", Code: "required"}}} + } + if raw, ok := fields["channels"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "channels", Code: "required"}}} + } + if raw, ok := fields["codec"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "codec", Code: "required"}}} + } + if raw, ok := fields["packet_duration_ms"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "packet_duration_ms", Code: "required"}}} + } + if raw, ok := fields["sample_rate_hz"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "sample_rate_hz", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeAudioProfile(value AudioProfile) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v BitratePreference) Validate() error { + var violations []FieldViolation + if v.Mode == "" { + violations = append(violations, FieldViolation{Field: "mode", Code: "required"}) + } + if v.Mode != "" && !(v.Mode == "auto" || v.Mode == "explicit") { + violations = append(violations, FieldViolation{Field: "mode", Code: "invalid_value"}) + } + if v.TargetKbps != nil && *v.TargetKbps != 0 && *v.TargetKbps < 100 { + violations = append(violations, FieldViolation{Field: "target_kbps", Code: "minimum"}) + } + if v.TargetKbps != nil && *v.TargetKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "target_kbps", Code: "maximum"}) + } + if v.Mode == "auto" && v.TargetKbps != nil || v.Mode == "explicit" && v.TargetKbps == nil { + violations = append(violations, FieldViolation{Field: "target_kbps", Code: "invalid_tagged_value"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeBitratePreference(data []byte) (BitratePreference, error) { + var value BitratePreference + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["mode"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "mode", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeBitratePreference(value BitratePreference) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v BrokerSession) Validate() error { var violations []FieldViolation if v.ID == "" { @@ -825,6 +1227,38 @@ func (v BrokerSession) Validate() error { violations = append(violations, FieldViolation{Field: "effective_display_mode", Code: "invalid_object"}) } } + if v.RequestedVideoProfiles == nil { + violations = append(violations, FieldViolation{Field: "requested_video_profiles", Code: "required"}) + } + if len(v.RequestedVideoProfiles) < 1 { + violations = append(violations, FieldViolation{Field: "requested_video_profiles", Code: "min_items"}) + } + if len(v.RequestedVideoProfiles) > 12 { + violations = append(violations, FieldViolation{Field: "requested_video_profiles", Code: "max_items"}) + } + for index, item := range v.RequestedVideoProfiles { + for prior := 0; prior < index; prior++ { + if reflect.DeepEqual(item, v.RequestedVideoProfiles[prior]) { + violations = append(violations, FieldViolation{Field: "requested_video_profiles", Code: "duplicate_item"}) + } + } + } + for index := range v.RequestedVideoProfiles { + if err := v.RequestedVideoProfiles[index].Validate(); err != nil { + violations = append(violations, FieldViolation{Field: fmt.Sprintf("requested_video_profiles[%d]", index), Code: "invalid_item"}) + } + } + if reflect.DeepEqual(v.RequestedBitratePreference, BitratePreference{}) { + violations = append(violations, FieldViolation{Field: "requested_bitrate_preference", Code: "required"}) + } + if err := v.RequestedBitratePreference.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "requested_bitrate_preference", Code: "invalid_object"}) + } + if v.SelectedDescriptor != nil { + if err := v.SelectedDescriptor.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "invalid_object"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -836,6 +1270,9 @@ func DecodeBrokerSession(data []byte) (BrokerSession, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -864,6 +1301,12 @@ func DecodeBrokerSession(data []byte) (BrokerSession, error) { if raw, ok := fields["requested_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "requested_at", Code: "required"}}} } + if raw, ok := fields["requested_bitrate_preference"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "requested_bitrate_preference", Code: "required"}}} + } + if raw, ok := fields["requested_video_profiles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "requested_video_profiles", Code: "required"}}} + } if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}} } @@ -876,6 +1319,9 @@ func DecodeBrokerSession(data []byte) (BrokerSession, error) { if raw, ok := fields["effective_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "effective_display_mode", Code: "invalid_object"}}} } + if raw, ok := fields["selected_descriptor"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selected_descriptor", Code: "invalid_object"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -959,6 +1405,9 @@ func DecodeBrowserAuthenticatedSession(data []byte) (BrowserAuthenticatedSession if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1026,15 +1475,6 @@ func (v CapabilityProfile) Validate() error { if len(v.Media) > 64 { violations = append(violations, FieldViolation{Field: "media", Code: "max_length"}) } - if v.Audio == "" { - violations = append(violations, FieldViolation{Field: "audio", Code: "required"}) - } - if len(v.Audio) < 1 && v.Audio != "" { - violations = append(violations, FieldViolation{Field: "audio", Code: "min_length"}) - } - if len(v.Audio) > 64 { - violations = append(violations, FieldViolation{Field: "audio", Code: "max_length"}) - } if v.SourceRateControl == "" { violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "required"}) } @@ -1044,27 +1484,48 @@ func (v CapabilityProfile) Validate() error { if len(v.SourceRateControl) > 64 { violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "max_length"}) } - if v.ClientDecode == nil { - violations = append(violations, FieldViolation{Field: "client_decode", Code: "required"}) + if v.VideoProfiles == nil { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "required"}) } - if len(v.ClientDecode) < 1 { - violations = append(violations, FieldViolation{Field: "client_decode", Code: "min_items"}) + if len(v.VideoProfiles) < 1 { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "min_items"}) } - if len(v.ClientDecode) > 2 { - violations = append(violations, FieldViolation{Field: "client_decode", Code: "max_items"}) + if len(v.VideoProfiles) > 12 { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "max_items"}) } - for _, item := range v.ClientDecode { - if !(item == "h264-opus" || item == "hevc-opus") { - violations = append(violations, FieldViolation{Field: "client_decode", Code: "invalid_item"}) + for index, item := range v.VideoProfiles { + for prior := 0; prior < index; prior++ { + if reflect.DeepEqual(item, v.VideoProfiles[prior]) { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "duplicate_item"}) + } } } - for index, item := range v.ClientDecode { + for index := range v.VideoProfiles { + if err := v.VideoProfiles[index].Validate(); err != nil { + violations = append(violations, FieldViolation{Field: fmt.Sprintf("video_profiles[%d]", index), Code: "invalid_item"}) + } + } + if v.AudioProfiles == nil { + violations = append(violations, FieldViolation{Field: "audio_profiles", Code: "required"}) + } + if len(v.AudioProfiles) < 1 { + violations = append(violations, FieldViolation{Field: "audio_profiles", Code: "min_items"}) + } + if len(v.AudioProfiles) > 1 { + violations = append(violations, FieldViolation{Field: "audio_profiles", Code: "max_items"}) + } + for index, item := range v.AudioProfiles { for prior := 0; prior < index; prior++ { - if item == v.ClientDecode[prior] { - violations = append(violations, FieldViolation{Field: "client_decode", Code: "duplicate_item"}) + if reflect.DeepEqual(item, v.AudioProfiles[prior]) { + violations = append(violations, FieldViolation{Field: "audio_profiles", Code: "duplicate_item"}) } } } + for index := range v.AudioProfiles { + if err := v.AudioProfiles[index].Validate(); err != nil { + violations = append(violations, FieldViolation{Field: fmt.Sprintf("audio_profiles[%d]", index), Code: "invalid_item"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -1076,15 +1537,15 @@ func DecodeCapabilityProfile(data []byte) (CapabilityProfile, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err } - if raw, ok := fields["audio"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "audio", Code: "required"}}} - } - if raw, ok := fields["client_decode"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "client_decode", Code: "required"}}} + if raw, ok := fields["audio_profiles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audio_profiles", Code: "required"}}} } if raw, ok := fields["framing"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "framing", Code: "required"}}} @@ -1098,6 +1559,9 @@ func DecodeCapabilityProfile(data []byte) (CapabilityProfile, error) { if raw, ok := fields["transport"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "transport", Code: "required"}}} } + if raw, ok := fields["video_profiles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "video_profiles", Code: "required"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -1190,6 +1654,9 @@ func DecodeChannelFrame(data []byte) (ChannelFrame, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1298,6 +1765,12 @@ func (v ClientSessionAuthority) Validate() error { if err := v.Capabilities.Validate(); err != nil { violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"}) } + if reflect.DeepEqual(v.SelectedDescriptor, SelectedSessionDescriptor{}) { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "required"}) + } + if err := v.SelectedDescriptor.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "invalid_object"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -1309,6 +1782,9 @@ func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1328,6 +1804,9 @@ func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) { if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} } + if raw, ok := fields["selected_descriptor"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selected_descriptor", Code: "required"}}} + } if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} } @@ -1390,6 +1869,9 @@ func DecodeClipboardPolicy(data []byte) (ClipboardPolicy, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1456,6 +1938,9 @@ func DecodeClipboardText(data []byte) (ClipboardText, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1550,6 +2035,12 @@ func (v ConnectionManifest) Validate() error { if len(v.CorrelationID) > 128 { violations = append(violations, FieldViolation{Field: "correlation_id", Code: "max_length"}) } + if reflect.DeepEqual(v.SelectedDescriptor, SelectedSessionDescriptor{}) { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "required"}) + } + if err := v.SelectedDescriptor.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "invalid_object"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -1561,6 +2052,9 @@ func DecodeConnectionManifest(data []byte) (ConnectionManifest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1583,6 +2077,9 @@ func DecodeConnectionManifest(data []byte) (ConnectionManifest, error) { if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} } + if raw, ok := fields["selected_descriptor"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selected_descriptor", Code: "required"}}} + } if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} } @@ -1689,6 +2186,9 @@ func DecodeDeviceChallenge(data []byte) (DeviceChallenge, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1770,6 +2270,9 @@ func DecodeDeviceProofRequest(data []byte) (DeviceProofRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1860,6 +2363,9 @@ func DecodeDeviceRegistrationRequest(data []byte) (DeviceRegistrationRequest, er if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -1904,6 +2410,63 @@ func EncodeDeviceRegistrationRequest(value DeviceRegistrationRequest) ([]byte, e return json.Marshal(value) } +func (v DisplayLimitOverrideRequest) Validate() error { + var violations []FieldViolation + if v.ExpectedVersion == 0 { + violations = append(violations, FieldViolation{Field: "expected_version", Code: "required"}) + } + if v.ExpectedVersion != 0 && v.ExpectedVersion < 1 { + violations = append(violations, FieldViolation{Field: "expected_version", Code: "minimum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeDisplayLimitOverrideRequest(data []byte) (DisplayLimitOverrideRequest, error) { + var value DisplayLimitOverrideRequest + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["allow_display_limit_override"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "allow_display_limit_override", Code: "required"}}} + } + if raw, ok := fields["expected_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "expected_version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeDisplayLimitOverrideRequest(value DisplayLimitOverrideRequest) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v DisplayMode) Validate() error { var violations []FieldViolation if v.ResolutionWidth == 0 { @@ -1944,6 +2507,9 @@ func DecodeDisplayMode(data []byte) (DisplayMode, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2011,6 +2577,12 @@ func (v EntitledPool) Validate() error { if len(v.AssignmentState) > 64 { violations = append(violations, FieldViolation{Field: "assignment_state", Code: "max_length"}) } + if reflect.DeepEqual(v.QualityLimits, SessionQualityLimits{}) { + violations = append(violations, FieldViolation{Field: "quality_limits", Code: "required"}) + } + if err := v.QualityLimits.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "quality_limits", Code: "invalid_object"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -2022,6 +2594,9 @@ func DecodeEntitledPool(data []byte) (EntitledPool, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2035,6 +2610,9 @@ func DecodeEntitledPool(data []byte) (EntitledPool, error) { if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}} } + if raw, ok := fields["quality_limits"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "quality_limits", Code: "required"}}} + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -2129,6 +2707,9 @@ func DecodeErrorEnvelope(data []byte) (ErrorEnvelope, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2251,6 +2832,9 @@ func DecodeEventEnvelope(data []byte) (EventEnvelope, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2329,6 +2913,9 @@ func DecodeEventResume(data []byte) (EventResume, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2395,6 +2982,9 @@ func DecodeFieldViolation(data []byte) (FieldViolation, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2482,6 +3072,9 @@ func DecodeGatewayClipboardAudit(data []byte) (GatewayClipboardAudit, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2577,6 +3170,9 @@ func DecodeGatewayClipboardText(data []byte) (GatewayClipboardText, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2672,6 +3268,9 @@ func DecodeGatewayDrain(data []byte) (GatewayDrain, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2785,6 +3384,9 @@ func DecodeGatewayHeartbeat(data []byte) (GatewayHeartbeat, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -2838,6 +3440,380 @@ func EncodeGatewayHeartbeat(value GatewayHeartbeat) ([]byte, error) { return json.Marshal(value) } +func (v GatewayQualityAck) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.Revision == 0 { + violations = append(violations, FieldViolation{Field: "revision", Code: "required"}) + } + if v.Revision != 0 && v.Revision < 1 { + violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) + } + if v.Outcome == "" { + violations = append(violations, FieldViolation{Field: "outcome", Code: "required"}) + } + if v.Outcome != "" && !(v.Outcome == "applied" || v.Outcome == "not_applied" || v.Outcome == "uncertain") { + violations = append(violations, FieldViolation{Field: "outcome", Code: "invalid_value"}) + } + if v.CurrentAppliedRevision != 0 && v.CurrentAppliedRevision < 0 { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "minimum"}) + } + if len(v.FailureCode) < 1 && v.FailureCode != "" { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "min_length"}) + } + if len(v.FailureCode) > 128 { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayQualityAck(data []byte) (GatewayQualityAck, error) { + var value GatewayQualityAck + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["current_applied_revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "current_applied_revision", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["outcome"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "outcome", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "revision", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayQualityAck(value GatewayQualityAck) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayQualityWork) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.Revision == 0 { + violations = append(violations, FieldViolation{Field: "revision", Code: "required"}) + } + if v.Revision != 0 && v.Revision < 1 { + violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) + } + if v.LeaseExpiresAt == "" { + violations = append(violations, FieldViolation{Field: "lease_expires_at", Code: "required"}) + } + if len(v.LeaseExpiresAt) > 64 { + violations = append(violations, FieldViolation{Field: "lease_expires_at", Code: "max_length"}) + } + if v.LeaseExpiresAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.LeaseExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.LeaseExpiresAt { + violations = append(violations, FieldViolation{Field: "lease_expires_at", Code: "invalid_time"}) + } + } + if reflect.DeepEqual(v.SelectedDescriptor, SelectedSessionDescriptor{}) { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "required"}) + } + if err := v.SelectedDescriptor.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "invalid_object"}) + } + if v.CurrentAppliedRevision != nil && *v.CurrentAppliedRevision != 0 && *v.CurrentAppliedRevision < 0 { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "minimum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayQualityWork(data []byte) (GatewayQualityWork, error) { + var value GatewayQualityWork + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["lease_expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "lease_expires_at", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "revision", Code: "required"}}} + } + if raw, ok := fields["selected_descriptor"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selected_descriptor", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayQualityWork(value GatewayQualityWork) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayQualityWorkRequest) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.Revision == 0 { + violations = append(violations, FieldViolation{Field: "revision", Code: "required"}) + } + if v.Revision != 0 && v.Revision < 1 { + violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) + } + if v.CurrentAppliedRevision != nil && *v.CurrentAppliedRevision != 0 && *v.CurrentAppliedRevision < 0 { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "minimum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayQualityWorkRequest(data []byte) (GatewayQualityWorkRequest, error) { + var value GatewayQualityWorkRequest + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "revision", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayQualityWorkRequest(value GatewayQualityWorkRequest) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v GatewayRegistration) Validate() error { var violations []FieldViolation if v.Version == "" { @@ -2972,6 +3948,9 @@ func DecodeGatewayRegistration(data []byte) (GatewayRegistration, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3040,6 +4019,330 @@ func EncodeGatewayRegistration(value GatewayRegistration) ([]byte, error) { return json.Marshal(value) } +func (v GatewayStopAck) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.Outcome == "" { + violations = append(violations, FieldViolation{Field: "outcome", Code: "required"}) + } + if v.Outcome != "" && !(v.Outcome == "applied" || v.Outcome == "failed" || v.Outcome == "termination_unconfirmed") { + violations = append(violations, FieldViolation{Field: "outcome", Code: "invalid_value"}) + } + if len(v.FailureCode) < 1 && v.FailureCode != "" { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "min_length"}) + } + if len(v.FailureCode) > 128 { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayStopAck(data []byte) (GatewayStopAck, error) { + var value GatewayStopAck + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["outcome"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "outcome", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayStopAck(value GatewayStopAck) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayStopWork) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.Attempt == 0 { + violations = append(violations, FieldViolation{Field: "attempt", Code: "required"}) + } + if v.Attempt != 0 && v.Attempt < 1 { + violations = append(violations, FieldViolation{Field: "attempt", Code: "minimum"}) + } + if v.Attempt > 1 { + violations = append(violations, FieldViolation{Field: "attempt", Code: "maximum"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayStopWork(data []byte) (GatewayStopWork, error) { + var value GatewayStopWork + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["attempt"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "attempt", Code: "required"}}} + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayStopWork(value GatewayStopWork) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v GatewayStopWorkRequest) Validate() error { + var violations []FieldViolation + if v.Version == "" { + violations = append(violations, FieldViolation{Field: "version", Code: "required"}) + } + if v.Version != "1" && v.Version != "" { + violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.GatewayID == "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"}) + } + if len(v.GatewayID) < 1 && v.GatewayID != "" { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"}) + } + if len(v.GatewayID) > 128 { + violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"}) + } + if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { + violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) + } + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeGatewayStopWorkRequest(data []byte) (GatewayStopWorkRequest, error) { + var value GatewayStopWorkRequest + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeGatewayStopWorkRequest(value GatewayStopWorkRequest) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v GatewayTelemetry) Validate() error { var violations []FieldViolation if v.AdmittedSessions != 0 && v.AdmittedSessions < 0 { @@ -3161,6 +4464,9 @@ func DecodeGatewayTelemetry(data []byte) (GatewayTelemetry, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3286,6 +4592,9 @@ func DecodeGrantReference(data []byte) (GrantReference, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3358,6 +4667,9 @@ func DecodeLoginRequest(data []byte) (LoginRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3436,6 +4748,9 @@ func DecodeManifestBounds(data []byte) (ManifestBounds, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3524,6 +4839,9 @@ func DecodeManifestGateway(data []byte) (ManifestGateway, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3595,6 +4913,9 @@ func DecodeManifestProfile(data []byte) (ManifestProfile, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3681,6 +5002,9 @@ func DecodeManifestTunnel(data []byte) (ManifestTunnel, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3780,6 +5104,9 @@ func DecodeNativeAuthenticatedSession(data []byte) (NativeAuthenticatedSession, if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3886,6 +5213,9 @@ func DecodeNativeCredential(data []byte) (NativeCredential, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -3958,6 +5288,9 @@ func DecodeNativeSessionIdentity(data []byte) (NativeSessionIdentity, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4053,6 +5386,9 @@ func DecodeNativeTunnelCredential(data []byte) (NativeTunnelCredential, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4125,6 +5461,9 @@ func DecodePageInfo(data []byte) (PageInfo, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4328,6 +5667,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4467,6 +5809,9 @@ func DecodeProviderState(data []byte) (ProviderState, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4513,47 +5858,44 @@ func EncodeProviderState(value ProviderState) ([]byte, error) { func (v ProviderStreamPolicy) Validate() error { var violations []FieldViolation - if v.ResolutionWidth == 0 { - violations = append(violations, FieldViolation{Field: "resolution_width", Code: "required"}) + if reflect.DeepEqual(v.VideoProfile, VideoProfile{}) { + violations = append(violations, FieldViolation{Field: "video_profile", Code: "required"}) } - if v.ResolutionWidth != 0 && v.ResolutionWidth < 320 { - violations = append(violations, FieldViolation{Field: "resolution_width", Code: "minimum"}) + if err := v.VideoProfile.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "video_profile", Code: "invalid_object"}) } - if v.ResolutionWidth > 16384 { - violations = append(violations, FieldViolation{Field: "resolution_width", Code: "maximum"}) + if reflect.DeepEqual(v.AudioProfile, AudioProfile{}) { + violations = append(violations, FieldViolation{Field: "audio_profile", Code: "required"}) } - if v.ResolutionHeight == 0 { - violations = append(violations, FieldViolation{Field: "resolution_height", Code: "required"}) + if err := v.AudioProfile.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "audio_profile", Code: "invalid_object"}) } - if v.ResolutionHeight != 0 && v.ResolutionHeight < 200 { - violations = append(violations, FieldViolation{Field: "resolution_height", Code: "minimum"}) + if reflect.DeepEqual(v.DisplayMode, DisplayMode{}) { + violations = append(violations, FieldViolation{Field: "display_mode", Code: "required"}) } - if v.ResolutionHeight > 8640 { - violations = append(violations, FieldViolation{Field: "resolution_height", Code: "maximum"}) + if err := v.DisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "display_mode", Code: "invalid_object"}) } - if v.Fps == 0 { - violations = append(violations, FieldViolation{Field: "fps", Code: "required"}) + if v.BitrateTargetKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "required"}) } - if v.Fps != 0 && v.Fps < 1 { - violations = append(violations, FieldViolation{Field: "fps", Code: "minimum"}) + if v.BitrateTargetKbps != 0 && v.BitrateTargetKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "minimum"}) } - if v.Fps > 240 { - violations = append(violations, FieldViolation{Field: "fps", Code: "maximum"}) + if v.BitrateTargetKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "maximum"}) } - if v.Codec == "" { - violations = append(violations, FieldViolation{Field: "codec", Code: "required"}) + if v.BitrateMaximumKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "required"}) } - if v.Codec != "" && !(v.Codec == "H264" || v.Codec == "HEVC" || v.Codec == "AV1") { - violations = append(violations, FieldViolation{Field: "codec", Code: "invalid_value"}) + if v.BitrateMaximumKbps != 0 && v.BitrateMaximumKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "minimum"}) } - if v.BitrateKbps == 0 { - violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "required"}) + if v.BitrateMaximumKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "maximum"}) } - if v.BitrateKbps != 0 && v.BitrateKbps < 100 { - violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "minimum"}) - } - if v.BitrateKbps > 1000000 { - violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "maximum"}) + if v.BitrateTargetKbps > v.BitrateMaximumKbps { + violations = append(violations, FieldViolation{Field: "bitrate_bounds", Code: "invalid_order"}) } if len(violations) > 0 { return ValidationError{Violations: violations} @@ -4566,27 +5908,27 @@ func DecodeProviderStreamPolicy(data []byte) (ProviderStreamPolicy, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err } - if raw, ok := fields["audio_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "audio_enabled", Code: "required"}}} + if raw, ok := fields["audio_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audio_profile", Code: "required"}}} } - if raw, ok := fields["bitrate_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_kbps", Code: "required"}}} + if raw, ok := fields["bitrate_maximum_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_maximum_kbps", Code: "required"}}} } - if raw, ok := fields["codec"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "codec", Code: "required"}}} + if raw, ok := fields["bitrate_target_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_target_kbps", Code: "required"}}} } - if raw, ok := fields["fps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "fps", Code: "required"}}} + if raw, ok := fields["display_mode"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_mode", Code: "required"}}} } - if raw, ok := fields["resolution_height"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_height", Code: "required"}}} - } - if raw, ok := fields["resolution_width"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_width", Code: "required"}}} + if raw, ok := fields["video_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "video_profile", Code: "required"}}} } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() @@ -4613,6 +5955,261 @@ func EncodeProviderStreamPolicy(value ProviderStreamPolicy) ([]byte, error) { return json.Marshal(value) } +func (v QualityChangeOperation) Validate() error { + var violations []FieldViolation + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.Revision == 0 { + violations = append(violations, FieldViolation{Field: "revision", Code: "required"}) + } + if v.Revision != 0 && v.Revision < 1 { + violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) + } + if v.State == "" { + violations = append(violations, FieldViolation{Field: "state", Code: "required"}) + } + if v.State != "" && !(v.State == "pending" || v.State == "applying" || v.State == "applied" || v.State == "failed" || v.State == "timed_out") { + violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"}) + } + if reflect.DeepEqual(v.RequestedBitratePreference, BitratePreference{}) { + violations = append(violations, FieldViolation{Field: "requested_bitrate_preference", Code: "required"}) + } + if err := v.RequestedBitratePreference.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "requested_bitrate_preference", Code: "invalid_object"}) + } + if v.EffectiveBitrateKbps == 0 { + violations = append(violations, FieldViolation{Field: "effective_bitrate_kbps", Code: "required"}) + } + if v.EffectiveBitrateKbps != 0 && v.EffectiveBitrateKbps < 100 { + violations = append(violations, FieldViolation{Field: "effective_bitrate_kbps", Code: "minimum"}) + } + if v.EffectiveBitrateKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "effective_bitrate_kbps", Code: "maximum"}) + } + if v.GoverningPolicyVersion == "" { + violations = append(violations, FieldViolation{Field: "governing_policy_version", Code: "required"}) + } + if len(v.GoverningPolicyVersion) < 1 && v.GoverningPolicyVersion != "" { + violations = append(violations, FieldViolation{Field: "governing_policy_version", Code: "min_length"}) + } + if len(v.GoverningPolicyVersion) > 128 { + violations = append(violations, FieldViolation{Field: "governing_policy_version", Code: "max_length"}) + } + if v.SessionVersion == 0 { + violations = append(violations, FieldViolation{Field: "session_version", Code: "required"}) + } + if v.SessionVersion != 0 && v.SessionVersion < 1 { + violations = append(violations, FieldViolation{Field: "session_version", Code: "minimum"}) + } + if v.CreatedAt == "" { + violations = append(violations, FieldViolation{Field: "created_at", Code: "required"}) + } + if len(v.CreatedAt) > 64 { + violations = append(violations, FieldViolation{Field: "created_at", Code: "max_length"}) + } + if v.CreatedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.CreatedAt { + violations = append(violations, FieldViolation{Field: "created_at", Code: "invalid_time"}) + } + } + if v.DeadlineAt == "" { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "required"}) + } + if len(v.DeadlineAt) > 64 { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "max_length"}) + } + if v.DeadlineAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.DeadlineAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.DeadlineAt { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "invalid_time"}) + } + } + if v.UpdatedAt == "" { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "required"}) + } + if len(v.UpdatedAt) > 64 { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "max_length"}) + } + if v.UpdatedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.UpdatedAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.UpdatedAt { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "invalid_time"}) + } + } + if len(v.FailureCode) < 1 && v.FailureCode != "" { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "min_length"}) + } + if len(v.FailureCode) > 128 { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeQualityChangeOperation(data []byte) (QualityChangeOperation, error) { + var value QualityChangeOperation + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["created_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "created_at", Code: "required"}}} + } + if raw, ok := fields["deadline_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "deadline_at", Code: "required"}}} + } + if raw, ok := fields["effective_bitrate_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "effective_bitrate_kbps", Code: "required"}}} + } + if raw, ok := fields["governing_policy_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "governing_policy_version", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["requested_bitrate_preference"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "requested_bitrate_preference", Code: "required"}}} + } + if raw, ok := fields["revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "revision", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["session_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_version", Code: "required"}}} + } + if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}} + } + if raw, ok := fields["updated_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "updated_at", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeQualityChangeOperation(value QualityChangeOperation) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v QualityChangeRequest) Validate() error { + var violations []FieldViolation + if reflect.DeepEqual(v.BitratePreference, BitratePreference{}) { + violations = append(violations, FieldViolation{Field: "bitrate_preference", Code: "required"}) + } + if err := v.BitratePreference.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "bitrate_preference", Code: "invalid_object"}) + } + if v.ExpectedSessionVersion == 0 { + violations = append(violations, FieldViolation{Field: "expected_session_version", Code: "required"}) + } + if v.ExpectedSessionVersion != 0 && v.ExpectedSessionVersion < 1 { + violations = append(violations, FieldViolation{Field: "expected_session_version", Code: "minimum"}) + } + if v.ExpectedPolicyVersion == "" { + violations = append(violations, FieldViolation{Field: "expected_policy_version", Code: "required"}) + } + if len(v.ExpectedPolicyVersion) < 1 && v.ExpectedPolicyVersion != "" { + violations = append(violations, FieldViolation{Field: "expected_policy_version", Code: "min_length"}) + } + if len(v.ExpectedPolicyVersion) > 128 { + violations = append(violations, FieldViolation{Field: "expected_policy_version", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeQualityChangeRequest(data []byte) (QualityChangeRequest, error) { + var value QualityChangeRequest + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["bitrate_preference"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_preference", Code: "required"}}} + } + if raw, ok := fields["expected_policy_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "expected_policy_version", Code: "required"}}} + } + if raw, ok := fields["expected_session_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "expected_session_version", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeQualityChangeRequest(value QualityChangeRequest) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v ReauthGrant) Validate() error { var violations []FieldViolation if v.Token == "" { @@ -4655,6 +6252,9 @@ func DecodeReauthGrant(data []byte) (ReauthGrant, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4721,6 +6321,9 @@ func DecodeReauthRequest(data []byte) (ReauthRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4793,6 +6396,9 @@ func DecodeReconnectRequest(data []byte) (ReconnectRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4803,6 +6409,9 @@ func DecodeReconnectRequest(data []byte) (ReconnectRequest, error) { if raw, ok := fields["device_key_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "device_key_id", Code: "required"}}} } + if raw, ok := fields["display_relaunch_confirmed"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_relaunch_confirmed", Code: "required"}}} + } if raw, ok := fields["expected_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "expected_version", Code: "required"}}} } @@ -4862,6 +6471,9 @@ func DecodeRefreshRequest(data []byte) (RefreshRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -4966,6 +6578,9 @@ func DecodeResource(data []byte) (Resource, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5050,6 +6665,9 @@ func DecodeResourceLink(data []byte) (ResourceLink, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5129,6 +6747,9 @@ func DecodeResourceList(data []byte) (ResourceList, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5167,6 +6788,186 @@ func EncodeResourceList(value ResourceList) ([]byte, error) { return json.Marshal(value) } +func (v SelectedSessionDescriptor) Validate() error { + var violations []FieldViolation + if reflect.DeepEqual(v.VideoProfile, VideoProfile{}) { + violations = append(violations, FieldViolation{Field: "video_profile", Code: "required"}) + } + if err := v.VideoProfile.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "video_profile", Code: "invalid_object"}) + } + if reflect.DeepEqual(v.AudioProfile, AudioProfile{}) { + violations = append(violations, FieldViolation{Field: "audio_profile", Code: "required"}) + } + if err := v.AudioProfile.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "audio_profile", Code: "invalid_object"}) + } + if reflect.DeepEqual(v.DisplayMode, DisplayMode{}) { + violations = append(violations, FieldViolation{Field: "display_mode", Code: "required"}) + } + if err := v.DisplayMode.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "display_mode", Code: "invalid_object"}) + } + if v.BitrateTargetKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "required"}) + } + if v.BitrateTargetKbps != 0 && v.BitrateTargetKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "minimum"}) + } + if v.BitrateTargetKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "maximum"}) + } + if v.BitrateMaximumKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "required"}) + } + if v.BitrateMaximumKbps != 0 && v.BitrateMaximumKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "minimum"}) + } + if v.BitrateMaximumKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "maximum"}) + } + if reflect.DeepEqual(v.Adjustment, SessionAdjustment{}) { + violations = append(violations, FieldViolation{Field: "adjustment", Code: "required"}) + } + if err := v.Adjustment.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "adjustment", Code: "invalid_object"}) + } + if v.MediaTimestampBasis == "" { + violations = append(violations, FieldViolation{Field: "media_timestamp_basis", Code: "required"}) + } + if v.MediaTimestampBasis != "gateway-send-wall-clock-ms" && v.MediaTimestampBasis != "" { + violations = append(violations, FieldViolation{Field: "media_timestamp_basis", Code: "invalid_value"}) + } + if v.BitrateTargetKbps > v.BitrateMaximumKbps { + violations = append(violations, FieldViolation{Field: "bitrate_bounds", Code: "invalid_order"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeSelectedSessionDescriptor(data []byte) (SelectedSessionDescriptor, error) { + var value SelectedSessionDescriptor + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["adjustment"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "adjustment", Code: "required"}}} + } + if raw, ok := fields["audio_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "audio_profile", Code: "required"}}} + } + if raw, ok := fields["bitrate_maximum_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_maximum_kbps", Code: "required"}}} + } + if raw, ok := fields["bitrate_target_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_target_kbps", Code: "required"}}} + } + if raw, ok := fields["display_mode"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_mode", Code: "required"}}} + } + if raw, ok := fields["media_timestamp_basis"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "media_timestamp_basis", Code: "required"}}} + } + if raw, ok := fields["video_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "video_profile", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeSelectedSessionDescriptor(value SelectedSessionDescriptor) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + +func (v SessionAdjustment) Validate() error { + var violations []FieldViolation + if v.DisplayReason == "" { + violations = append(violations, FieldViolation{Field: "display_reason", Code: "required"}) + } + if v.DisplayReason != "" && !(v.DisplayReason == "none" || v.DisplayReason == "policy_clamp" || v.DisplayReason == "direct_entitlement_override") { + violations = append(violations, FieldViolation{Field: "display_reason", Code: "invalid_value"}) + } + if v.BitrateReason == "" { + violations = append(violations, FieldViolation{Field: "bitrate_reason", Code: "required"}) + } + if v.BitrateReason != "" && !(v.BitrateReason == "none" || v.BitrateReason == "policy_default" || v.BitrateReason == "session_ceiling_clamp" || v.BitrateReason == "aggregate_capacity_clamp") { + violations = append(violations, FieldViolation{Field: "bitrate_reason", Code: "invalid_value"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeSessionAdjustment(data []byte) (SessionAdjustment, error) { + var value SessionAdjustment + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["bitrate_reason"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_reason", Code: "required"}}} + } + if raw, ok := fields["display_reason"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_reason", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeSessionAdjustment(value SessionAdjustment) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v SessionAuthority) Validate() error { var violations []FieldViolation if v.Version == "" { @@ -5237,6 +7038,12 @@ func (v SessionAuthority) Validate() error { if len(v.ProviderIdentity) > 256 { violations = append(violations, FieldViolation{Field: "provider_identity", Code: "max_length"}) } + if reflect.DeepEqual(v.SelectedDescriptor, SelectedSessionDescriptor{}) { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "required"}) + } + if err := v.SelectedDescriptor.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selected_descriptor", Code: "invalid_object"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -5248,6 +7055,9 @@ func DecodeSessionAuthority(data []byte) (SessionAuthority, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5273,6 +7083,9 @@ func DecodeSessionAuthority(data []byte) (SessionAuthority, error) { if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} } + if raw, ok := fields["selected_descriptor"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selected_descriptor", Code: "required"}}} + } if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} } @@ -5304,6 +7117,123 @@ func EncodeSessionAuthority(value SessionAuthority) ([]byte, error) { return json.Marshal(value) } +func (v SessionQualityLimits) Validate() error { + var violations []FieldViolation + if v.PolicyVersionID == "" { + violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "required"}) + } + if len(v.PolicyVersionID) < 1 && v.PolicyVersionID != "" { + violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "min_length"}) + } + if len(v.PolicyVersionID) > 128 { + violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "max_length"}) + } + if reflect.DeepEqual(v.PolicyDisplayLimit, DisplayMode{}) { + violations = append(violations, FieldViolation{Field: "policy_display_limit", Code: "required"}) + } + if err := v.PolicyDisplayLimit.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "policy_display_limit", Code: "invalid_object"}) + } + if reflect.DeepEqual(v.SelectableDisplayMaximum, DisplayMode{}) { + violations = append(violations, FieldViolation{Field: "selectable_display_maximum", Code: "required"}) + } + if err := v.SelectableDisplayMaximum.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "selectable_display_maximum", Code: "invalid_object"}) + } + if v.BitrateMinimumKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_minimum_kbps", Code: "required"}) + } + if v.BitrateMinimumKbps != 0 && v.BitrateMinimumKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_minimum_kbps", Code: "minimum"}) + } + if v.BitrateMinimumKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_minimum_kbps", Code: "maximum"}) + } + if v.BitrateTargetKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "required"}) + } + if v.BitrateTargetKbps != 0 && v.BitrateTargetKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "minimum"}) + } + if v.BitrateTargetKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_target_kbps", Code: "maximum"}) + } + if v.BitrateMaximumKbps == 0 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "required"}) + } + if v.BitrateMaximumKbps != 0 && v.BitrateMaximumKbps < 100 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "minimum"}) + } + if v.BitrateMaximumKbps > 1000000 { + violations = append(violations, FieldViolation{Field: "bitrate_maximum_kbps", Code: "maximum"}) + } + if v.BitrateMinimumKbps > v.BitrateTargetKbps || v.BitrateTargetKbps > v.BitrateMaximumKbps { + violations = append(violations, FieldViolation{Field: "bitrate_bounds", Code: "invalid_order"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeSessionQualityLimits(data []byte) (SessionQualityLimits, error) { + var value SessionQualityLimits + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["bitrate_maximum_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_maximum_kbps", Code: "required"}}} + } + if raw, ok := fields["bitrate_minimum_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_minimum_kbps", Code: "required"}}} + } + if raw, ok := fields["bitrate_target_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_target_kbps", Code: "required"}}} + } + if raw, ok := fields["display_limit_override"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "display_limit_override", Code: "required"}}} + } + if raw, ok := fields["policy_display_limit"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "policy_display_limit", Code: "required"}}} + } + if raw, ok := fields["policy_version_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "policy_version_id", Code: "required"}}} + } + if raw, ok := fields["selectable_display_maximum"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "selectable_display_maximum", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeSessionQualityLimits(value SessionQualityLimits) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v SessionRequest) Validate() error { var violations []FieldViolation if v.ClientDeviceID == "" { @@ -5342,6 +7272,33 @@ func (v SessionRequest) Validate() error { if len(v.IdempotencyKey) > 256 { violations = append(violations, FieldViolation{Field: "idempotency_key", Code: "max_length"}) } + if v.VideoProfiles == nil { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "required"}) + } + if len(v.VideoProfiles) < 1 { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "min_items"}) + } + if len(v.VideoProfiles) > 12 { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "max_items"}) + } + for index, item := range v.VideoProfiles { + for prior := 0; prior < index; prior++ { + if reflect.DeepEqual(item, v.VideoProfiles[prior]) { + violations = append(violations, FieldViolation{Field: "video_profiles", Code: "duplicate_item"}) + } + } + } + for index := range v.VideoProfiles { + if err := v.VideoProfiles[index].Validate(); err != nil { + violations = append(violations, FieldViolation{Field: fmt.Sprintf("video_profiles[%d]", index), Code: "invalid_item"}) + } + } + if reflect.DeepEqual(v.BitratePreference, BitratePreference{}) { + violations = append(violations, FieldViolation{Field: "bitrate_preference", Code: "required"}) + } + if err := v.BitratePreference.Validate(); err != nil { + violations = append(violations, FieldViolation{Field: "bitrate_preference", Code: "invalid_object"}) + } if v.RequestedDisplayMode != nil { if err := v.RequestedDisplayMode.Validate(); err != nil { violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"}) @@ -5358,10 +7315,16 @@ func DecodeSessionRequest(data []byte) (SessionRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err } + if raw, ok := fields["bitrate_preference"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_preference", Code: "required"}}} + } if raw, ok := fields["client_device_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "client_device_id", Code: "required"}}} } @@ -5374,6 +7337,9 @@ func DecodeSessionRequest(data []byte) (SessionRequest, error) { if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}} } + if raw, ok := fields["video_profiles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "video_profiles", Code: "required"}}} + } if raw, ok := fields["requested_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "requested_display_mode", Code: "invalid_object"}}} } @@ -5439,6 +7405,9 @@ func DecodeStableError(data []byte) (StableError, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5480,6 +7449,144 @@ func EncodeStableError(value StableError) ([]byte, error) { return json.Marshal(value) } +func (v StopOperation) Validate() error { + var violations []FieldViolation + if v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + } + if len(v.OperationID) < 36 && v.OperationID != "" { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) + } + if len(v.OperationID) > 36 { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "max_length"}) + } + if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { + violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) + } + if v.SessionID == "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "required"}) + } + if len(v.SessionID) < 1 && v.SessionID != "" { + violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"}) + } + if len(v.SessionID) > 128 { + violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"}) + } + if v.State == "" { + violations = append(violations, FieldViolation{Field: "state", Code: "required"}) + } + if v.State != "" && !(v.State == "pending" || v.State == "attempting" || v.State == "applied" || v.State == "failed" || v.State == "termination_unconfirmed") { + violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"}) + } + if v.SessionVersion == 0 { + violations = append(violations, FieldViolation{Field: "session_version", Code: "required"}) + } + if v.SessionVersion != 0 && v.SessionVersion < 1 { + violations = append(violations, FieldViolation{Field: "session_version", Code: "minimum"}) + } + if v.CreatedAt == "" { + violations = append(violations, FieldViolation{Field: "created_at", Code: "required"}) + } + if len(v.CreatedAt) > 64 { + violations = append(violations, FieldViolation{Field: "created_at", Code: "max_length"}) + } + if v.CreatedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.CreatedAt { + violations = append(violations, FieldViolation{Field: "created_at", Code: "invalid_time"}) + } + } + if v.DeadlineAt == "" { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "required"}) + } + if len(v.DeadlineAt) > 64 { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "max_length"}) + } + if v.DeadlineAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.DeadlineAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.DeadlineAt { + violations = append(violations, FieldViolation{Field: "deadline_at", Code: "invalid_time"}) + } + } + if v.UpdatedAt == "" { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "required"}) + } + if len(v.UpdatedAt) > 64 { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "max_length"}) + } + if v.UpdatedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, v.UpdatedAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.UpdatedAt { + violations = append(violations, FieldViolation{Field: "updated_at", Code: "invalid_time"}) + } + } + if len(v.FailureCode) < 1 && v.FailureCode != "" { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "min_length"}) + } + if len(v.FailureCode) > 128 { + violations = append(violations, FieldViolation{Field: "failure_code", Code: "max_length"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeStopOperation(data []byte) (StopOperation, error) { + var value StopOperation + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["created_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "created_at", Code: "required"}}} + } + if raw, ok := fields["deadline_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "deadline_at", Code: "required"}}} + } + if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} + } + if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} + } + if raw, ok := fields["session_version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "session_version", Code: "required"}}} + } + if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}} + } + if raw, ok := fields["updated_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "updated_at", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeStopOperation(value StopOperation) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func (v TunnelAdmissionRequest) Validate() error { var violations []FieldViolation if v.Version == "" { @@ -5562,6 +7669,9 @@ func DecodeTunnelAdmissionRequest(data []byte) (TunnelAdmissionRequest, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5656,6 +7766,9 @@ func DecodeVersionNegotiation(data []byte) (VersionNegotiation, error) { if len(data) > 1024*1024 { return value, errors.New("protocol payload exceeds limit") } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return value, err @@ -5691,6 +7804,99 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) { return json.Marshal(value) } +func (v VideoProfile) Validate() error { + var violations []FieldViolation + if v.Codec == "" { + violations = append(violations, FieldViolation{Field: "codec", Code: "required"}) + } + if v.Codec != "" && !(v.Codec == "h264" || v.Codec == "hevc" || v.Codec == "av1") { + violations = append(violations, FieldViolation{Field: "codec", Code: "invalid_value"}) + } + if v.BitDepth == 0 { + violations = append(violations, FieldViolation{Field: "bit_depth", Code: "required"}) + } + if v.BitDepth != 0 && v.BitDepth < 8 { + violations = append(violations, FieldViolation{Field: "bit_depth", Code: "minimum"}) + } + if v.BitDepth > 8 { + violations = append(violations, FieldViolation{Field: "bit_depth", Code: "maximum"}) + } + if v.ChromaSubsampling == "" { + violations = append(violations, FieldViolation{Field: "chroma_subsampling", Code: "required"}) + } + if v.ChromaSubsampling != "4:2:0" && v.ChromaSubsampling != "" { + violations = append(violations, FieldViolation{Field: "chroma_subsampling", Code: "invalid_value"}) + } + if v.ColorSpace == "" { + violations = append(violations, FieldViolation{Field: "color_space", Code: "required"}) + } + if v.ColorSpace != "bt709-limited" && v.ColorSpace != "" { + violations = append(violations, FieldViolation{Field: "color_space", Code: "invalid_value"}) + } + if v.TransferFunction == "" { + violations = append(violations, FieldViolation{Field: "transfer_function", Code: "required"}) + } + if v.TransferFunction != "sdr" && v.TransferFunction != "" { + violations = append(violations, FieldViolation{Field: "transfer_function", Code: "invalid_value"}) + } + if len(violations) > 0 { + return ValidationError{Violations: violations} + } + return nil +} + +func DecodeVideoProfile(data []byte) (VideoProfile, error) { + var value VideoProfile + if len(data) > 1024*1024 { + return value, errors.New("protocol payload exceeds limit") + } + if err := rejectDuplicateJSONKeys(data); err != nil { + return value, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return value, err + } + if raw, ok := fields["bit_depth"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "bit_depth", Code: "required"}}} + } + if raw, ok := fields["chroma_subsampling"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "chroma_subsampling", Code: "required"}}} + } + if raw, ok := fields["codec"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "codec", Code: "required"}}} + } + if raw, ok := fields["color_space"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "color_space", Code: "required"}}} + } + if raw, ok := fields["transfer_function"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "transfer_function", Code: "required"}}} + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, errors.New("trailing JSON value") + } + return value, err + } + if err := value.Validate(); err != nil { + return value, err + } + return value, nil +} + +func EncodeVideoProfile(value VideoProfile) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + return json.Marshal(value) +} + func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) { for _, value := range []struct { field string @@ -5725,32 +7931,49 @@ func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfi if err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap } - common := append([]string(nil), selected.ClientDecode...) + commonVideo := append([]VideoProfile(nil), selected.VideoProfiles...) + commonAudio := append([]AudioProfile(nil), selected.AudioProfiles...) for _, profile := range profiles[1:] { - if err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.Audio != selected.Audio || profile.SourceRateControl != selected.SourceRateControl { + if err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.SourceRateControl != selected.SourceRateControl { return CapabilityProfile{}, ErrNoCapabilityOverlap } - next := common[:0] - for _, candidate := range common { - for _, offered := range profile.ClientDecode { + nextVideo := commonVideo[:0] + for _, candidate := range commonVideo { + for _, offered := range profile.VideoProfiles { if candidate == offered { - next = append(next, candidate) + nextVideo = append(nextVideo, candidate) break } } } - common = next - if len(common) == 0 { + commonVideo = nextVideo + nextAudio := commonAudio[:0] + for _, candidate := range commonAudio { + for _, offered := range profile.AudioProfiles { + if candidate == offered { + nextAudio = append(nextAudio, candidate) + break + } + } + } + commonAudio = nextAudio + if len(commonVideo) == 0 || len(commonAudio) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap } } - selected.ClientDecode = common + selected.VideoProfiles = commonVideo + selected.AudioProfiles = commonAudio return selected, nil } func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte { - fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, fmt.Sprintf("%d", len(v.Capabilities.ClientDecode))} - fields = append(fields, v.Capabilities.ClientDecode...) + fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.SourceRateControl, fmt.Sprintf("%d", len(v.Capabilities.VideoProfiles)), fmt.Sprintf("%d", len(v.Capabilities.AudioProfiles))} + for _, profile := range v.Capabilities.VideoProfiles { + fields = append(fields, profile.Codec, fmt.Sprintf("%d", profile.BitDepth), profile.ChromaSubsampling, profile.ColorSpace, profile.TransferFunction) + } + for _, profile := range v.Capabilities.AudioProfiles { + fields = append(fields, profile.Codec, fmt.Sprintf("%d", profile.SampleRateHz), fmt.Sprintf("%d", profile.Channels), profile.ChannelLayout, fmt.Sprintf("%d", profile.PacketDurationMs)) + } var transcript strings.Builder transcript.WriteString("versevdi/tunnel-admission/v1") for _, field := range fields { diff --git a/gen/manifest.json b/gen/manifest.json index cc78852..74e5ef6 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "3" ] }, - "generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360", + "generator_sha256": "9f6e95fac0f3c8389d8111e6ba0a03b48e155b71e345a11640ef824b8b3a4910", "protocol_version": "1.0.0", - "schema_sha256": "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248" + "schema_sha256": "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300" } diff --git a/gen/protobuf/tunnel-v1.pb b/gen/protobuf/tunnel-v1.pb index 281d041..4c89b2b 100644 Binary files a/gen/protobuf/tunnel-v1.pb and b/gen/protobuf/tunnel-v1.pb differ diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 6f857f1..202d9c4 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"; +pub const SCHEMA_SHA256: &str = "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300"; pub const CURRENT_WIRE_VERSION: &str = "2"; pub const N_MINUS_1_WIRE_VERSION: &str = "1"; pub const N_MINUS_2_WIRE_VERSION: &str = "0"; @@ -43,6 +43,10 @@ fn valid_rfc3339_utc(value: &str) -> bool { let fraction = &bytes[20..bytes.len() - 1]; bytes[19] == b'.' && !fraction.is_empty() && fraction.len() <= 9 && fraction.iter().all(u8::is_ascii_digit) && *fraction.last().unwrap() != b'0' } +fn valid_canonical_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 && [8, 13, 18, 23].iter().all(|index| bytes[*index] == b'-') && bytes.iter().enumerate().all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) && value != "00000000-0000-0000-0000-000000000000" +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct AllocationPolicy { @@ -103,11 +107,12 @@ pub struct AssignedDesktop { poolId: String, name: String, availability: String, + qualityLimits: SessionQualityLimits, } impl AssignedDesktop { - pub fn new(assignmentId: String, poolId: String, name: String, availability: String) -> Result { - let value = Self { assignmentId, poolId, name, availability }; + pub fn new(assignmentId: String, poolId: String, name: String, availability: String, qualityLimits: SessionQualityLimits) -> Result { + let value = Self { assignmentId, poolId, name, availability, qualityLimits }; value.validate()?; Ok(value) } @@ -124,12 +129,72 @@ impl AssignedDesktop { if self.availability.is_empty() { return Err(ValidationError::new("availability", "required")); } if !self.availability.is_empty() && self.availability.len() < 1 { return Err(ValidationError::new("availability", "min_length")); } if self.availability.len() > 64 { return Err(ValidationError::new("availability", "max_length")); } + self.qualityLimits.validate().map_err(|_| ValidationError::new("quality_limits", "invalid_object"))?; Ok(()) } pub fn assignmentId(&self) -> &String { &self.assignmentId } pub fn poolId(&self) -> &String { &self.poolId } pub fn name(&self) -> &String { &self.name } pub fn availability(&self) -> &String { &self.availability } + pub fn qualityLimits(&self) -> &SessionQualityLimits { &self.qualityLimits } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioProfile { + codec: String, + sampleRateHz: i64, + channels: i64, + channelLayout: String, + packetDurationMs: i64, +} + +impl AudioProfile { + pub fn new(codec: String, sampleRateHz: i64, channels: i64, channelLayout: String, packetDurationMs: i64) -> Result { + let value = Self { codec, sampleRateHz, channels, channelLayout, packetDurationMs }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.codec != "opus" { return Err(ValidationError::new("codec", "invalid_value")); } + if self.sampleRateHz < 48000 { return Err(ValidationError::new("sample_rate_hz", "minimum")); } + if self.sampleRateHz > 48000 { return Err(ValidationError::new("sample_rate_hz", "maximum")); } + if self.channels < 2 { return Err(ValidationError::new("channels", "minimum")); } + if self.channels > 2 { return Err(ValidationError::new("channels", "maximum")); } + if self.channelLayout != "stereo" { return Err(ValidationError::new("channel_layout", "invalid_value")); } + if self.packetDurationMs < 5 { return Err(ValidationError::new("packet_duration_ms", "minimum")); } + if self.packetDurationMs > 5 { return Err(ValidationError::new("packet_duration_ms", "maximum")); } + Ok(()) + } + pub fn codec(&self) -> &String { &self.codec } + pub fn sampleRateHz(&self) -> &i64 { &self.sampleRateHz } + pub fn channels(&self) -> &i64 { &self.channels } + pub fn channelLayout(&self) -> &String { &self.channelLayout } + pub fn packetDurationMs(&self) -> &i64 { &self.packetDurationMs } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitratePreference { + mode: String, + targetKbps: Option, +} + +impl BitratePreference { + pub fn new(mode: String, targetKbps: Option) -> Result { + let value = Self { mode, targetKbps }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.mode != "auto" && self.mode != "explicit" { return Err(ValidationError::new("mode", "invalid_value")); } + if let Some(value) = &self.targetKbps { + if *value < 100 { return Err(ValidationError::new("target_kbps", "minimum")); } + if *value > 1000000 { return Err(ValidationError::new("target_kbps", "maximum")); } + } + if self.mode == "auto" && self.targetKbps.is_some() || self.mode == "explicit" && self.targetKbps.is_none() { return Err(ValidationError::new("target_kbps", "invalid_tagged_value")); } + Ok(()) + } + pub fn mode(&self) -> &String { &self.mode } + pub fn targetKbps(&self) -> &Option { &self.targetKbps } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -151,11 +216,14 @@ pub struct BrokerSession { version: i64, requestedDisplayMode: Option, effectiveDisplayMode: Option, + requestedVideoProfiles: Vec, + requestedBitratePreference: BitratePreference, + selectedDescriptor: Option, } impl BrokerSession { - pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option, outcome: Option, failureCode: Option, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option, version: i64, requestedDisplayMode: Option, effectiveDisplayMode: Option) -> Result { - let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version, requestedDisplayMode, effectiveDisplayMode }; + pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option, outcome: Option, failureCode: Option, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option, version: i64, requestedDisplayMode: Option, effectiveDisplayMode: Option, requestedVideoProfiles: Vec, requestedBitratePreference: BitratePreference, selectedDescriptor: Option) -> Result { + let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version, requestedDisplayMode, effectiveDisplayMode, requestedVideoProfiles, requestedBitratePreference, selectedDescriptor }; value.validate()?; Ok(value) } @@ -208,6 +276,14 @@ impl BrokerSession { if let Some(value) = &self.effectiveDisplayMode { value.validate().map_err(|_| ValidationError::new("effective_display_mode", "invalid_object"))?; } + if self.requestedVideoProfiles.len() < 1 { return Err(ValidationError::new("requested_video_profiles", "min_items")); } + if self.requestedVideoProfiles.len() > 12 { return Err(ValidationError::new("requested_video_profiles", "max_items")); } + for (index, item) in self.requestedVideoProfiles.iter().enumerate() { if self.requestedVideoProfiles[..index].contains(item) { return Err(ValidationError::new("requested_video_profiles", "duplicate_item")); } } + for item in self.requestedVideoProfiles.iter() { item.validate().map_err(|_| ValidationError::new("requested_video_profiles", "invalid_item"))?; } + self.requestedBitratePreference.validate().map_err(|_| ValidationError::new("requested_bitrate_preference", "invalid_object"))?; + if let Some(value) = &self.selectedDescriptor { + value.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; + } Ok(()) } pub fn id(&self) -> &String { &self.id } @@ -227,6 +303,9 @@ impl BrokerSession { pub fn version(&self) -> &i64 { &self.version } pub fn requestedDisplayMode(&self) -> &Option { &self.requestedDisplayMode } pub fn effectiveDisplayMode(&self) -> &Option { &self.effectiveDisplayMode } + pub fn requestedVideoProfiles(&self) -> &Vec { &self.requestedVideoProfiles } + pub fn requestedBitratePreference(&self) -> &BitratePreference { &self.requestedBitratePreference } + pub fn selectedDescriptor(&self) -> &Option { &self.selectedDescriptor } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -268,14 +347,14 @@ pub struct CapabilityProfile { transport: String, framing: String, media: String, - audio: String, sourceRateControl: String, - clientDecode: Vec, + videoProfiles: Vec, + audioProfiles: Vec, } impl CapabilityProfile { - pub fn new(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: Vec) -> Result { - let value = Self { transport, framing, media, audio, sourceRateControl, clientDecode }; + pub fn new(transport: String, framing: String, media: String, sourceRateControl: String, videoProfiles: Vec, audioProfiles: Vec) -> Result { + let value = Self { transport, framing, media, sourceRateControl, videoProfiles, audioProfiles }; value.validate()?; Ok(value) } @@ -287,24 +366,25 @@ impl CapabilityProfile { if self.media.is_empty() { return Err(ValidationError::new("media", "required")); } if !self.media.is_empty() && self.media.len() < 1 { return Err(ValidationError::new("media", "min_length")); } if self.media.len() > 64 { return Err(ValidationError::new("media", "max_length")); } - if self.audio.is_empty() { return Err(ValidationError::new("audio", "required")); } - if !self.audio.is_empty() && self.audio.len() < 1 { return Err(ValidationError::new("audio", "min_length")); } - if self.audio.len() > 64 { return Err(ValidationError::new("audio", "max_length")); } if self.sourceRateControl.is_empty() { return Err(ValidationError::new("source_rate_control", "required")); } if !self.sourceRateControl.is_empty() && self.sourceRateControl.len() < 1 { return Err(ValidationError::new("source_rate_control", "min_length")); } if self.sourceRateControl.len() > 64 { return Err(ValidationError::new("source_rate_control", "max_length")); } - if self.clientDecode.len() < 1 { return Err(ValidationError::new("client_decode", "min_items")); } - if self.clientDecode.len() > 2 { return Err(ValidationError::new("client_decode", "max_items")); } - for item in self.clientDecode.iter() { if item != "h264-opus" && item != "hevc-opus" { return Err(ValidationError::new("client_decode", "invalid_item")); } } - for (index, item) in self.clientDecode.iter().enumerate() { if self.clientDecode[..index].contains(item) { return Err(ValidationError::new("client_decode", "duplicate_item")); } } + if self.videoProfiles.len() < 1 { return Err(ValidationError::new("video_profiles", "min_items")); } + if self.videoProfiles.len() > 12 { return Err(ValidationError::new("video_profiles", "max_items")); } + for (index, item) in self.videoProfiles.iter().enumerate() { if self.videoProfiles[..index].contains(item) { return Err(ValidationError::new("video_profiles", "duplicate_item")); } } + for item in self.videoProfiles.iter() { item.validate().map_err(|_| ValidationError::new("video_profiles", "invalid_item"))?; } + if self.audioProfiles.len() < 1 { return Err(ValidationError::new("audio_profiles", "min_items")); } + if self.audioProfiles.len() > 1 { return Err(ValidationError::new("audio_profiles", "max_items")); } + for (index, item) in self.audioProfiles.iter().enumerate() { if self.audioProfiles[..index].contains(item) { return Err(ValidationError::new("audio_profiles", "duplicate_item")); } } + for item in self.audioProfiles.iter() { item.validate().map_err(|_| ValidationError::new("audio_profiles", "invalid_item"))?; } Ok(()) } pub fn transport(&self) -> &String { &self.transport } pub fn framing(&self) -> &String { &self.framing } pub fn media(&self) -> &String { &self.media } - pub fn audio(&self) -> &String { &self.audio } pub fn sourceRateControl(&self) -> &String { &self.sourceRateControl } - pub fn clientDecode(&self) -> &Vec { &self.clientDecode } + pub fn videoProfiles(&self) -> &Vec { &self.videoProfiles } + pub fn audioProfiles(&self) -> &Vec { &self.audioProfiles } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -362,11 +442,12 @@ pub struct ClientSessionAuthority { reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile, + selectedDescriptor: SelectedSessionDescriptor, } impl ClientSessionAuthority { - pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile) -> Result { - let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities }; + pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile, selectedDescriptor: SelectedSessionDescriptor) -> Result { + let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities, selectedDescriptor }; value.validate()?; Ok(value) } @@ -385,6 +466,7 @@ impl ClientSessionAuthority { if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); } if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); } self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?; + self.selectedDescriptor.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; Ok(()) } pub fn version(&self) -> &String { &self.version } @@ -394,6 +476,7 @@ impl ClientSessionAuthority { pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } pub fn expiresAt(&self) -> &String { &self.expiresAt } pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities } + pub fn selectedDescriptor(&self) -> &SelectedSessionDescriptor { &self.selectedDescriptor } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -455,11 +538,12 @@ pub struct ConnectionManifest { profile: ManifestProfile, grant: GrantReference, correlationId: String, + selectedDescriptor: SelectedSessionDescriptor, } impl ConnectionManifest { - pub fn new(version: String, purpose: String, sessionId: String, reconnectSequence: i64, gateway: ManifestGateway, tunnel: ManifestTunnel, profile: ManifestProfile, grant: GrantReference, correlationId: String) -> Result { - let value = Self { version, purpose, sessionId, reconnectSequence, gateway, tunnel, profile, grant, correlationId }; + pub fn new(version: String, purpose: String, sessionId: String, reconnectSequence: i64, gateway: ManifestGateway, tunnel: ManifestTunnel, profile: ManifestProfile, grant: GrantReference, correlationId: String, selectedDescriptor: SelectedSessionDescriptor) -> Result { + let value = Self { version, purpose, sessionId, reconnectSequence, gateway, tunnel, profile, grant, correlationId, selectedDescriptor }; value.validate()?; Ok(value) } @@ -477,6 +561,7 @@ impl ConnectionManifest { if self.correlationId.is_empty() { return Err(ValidationError::new("correlation_id", "required")); } if !self.correlationId.is_empty() && self.correlationId.len() < 1 { return Err(ValidationError::new("correlation_id", "min_length")); } if self.correlationId.len() > 128 { return Err(ValidationError::new("correlation_id", "max_length")); } + self.selectedDescriptor.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; Ok(()) } pub fn version(&self) -> &String { &self.version } @@ -488,6 +573,7 @@ impl ConnectionManifest { pub fn profile(&self) -> &ManifestProfile { &self.profile } pub fn grant(&self) -> &GrantReference { &self.grant } pub fn correlationId(&self) -> &String { &self.correlationId } + pub fn selectedDescriptor(&self) -> &SelectedSessionDescriptor { &self.selectedDescriptor } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -598,6 +684,26 @@ impl DeviceRegistrationRequest { pub fn publicKey(&self) -> &String { &self.publicKey } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplayLimitOverrideRequest { + allowDisplayLimitOverride: bool, + expectedVersion: i64, +} + +impl DisplayLimitOverrideRequest { + pub fn new(allowDisplayLimitOverride: bool, expectedVersion: i64) -> Result { + let value = Self { allowDisplayLimitOverride, expectedVersion }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.expectedVersion < 1 { return Err(ValidationError::new("expected_version", "minimum")); } + Ok(()) + } + pub fn allowDisplayLimitOverride(&self) -> &bool { &self.allowDisplayLimitOverride } + pub fn expectedVersion(&self) -> &i64 { &self.expectedVersion } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DisplayMode { resolutionWidth: i64, @@ -630,11 +736,12 @@ pub struct EntitledPool { poolId: String, name: String, assignmentState: String, + qualityLimits: SessionQualityLimits, } impl EntitledPool { - pub fn new(poolId: String, name: String, assignmentState: String) -> Result { - let value = Self { poolId, name, assignmentState }; + pub fn new(poolId: String, name: String, assignmentState: String, qualityLimits: SessionQualityLimits) -> Result { + let value = Self { poolId, name, assignmentState, qualityLimits }; value.validate()?; Ok(value) } @@ -648,11 +755,13 @@ impl EntitledPool { if self.assignmentState.is_empty() { return Err(ValidationError::new("assignment_state", "required")); } if !self.assignmentState.is_empty() && self.assignmentState.len() < 1 { return Err(ValidationError::new("assignment_state", "min_length")); } if self.assignmentState.len() > 64 { return Err(ValidationError::new("assignment_state", "max_length")); } + self.qualityLimits.validate().map_err(|_| ValidationError::new("quality_limits", "invalid_object"))?; Ok(()) } pub fn poolId(&self) -> &String { &self.poolId } pub fn name(&self) -> &String { &self.name } pub fn assignmentState(&self) -> &String { &self.assignmentState } + pub fn qualityLimits(&self) -> &SessionQualityLimits { &self.qualityLimits } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -938,6 +1047,155 @@ impl GatewayHeartbeat { pub fn telemetry(&self) -> &GatewayTelemetry { &self.telemetry } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayQualityAck { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, + revision: i64, + outcome: String, + currentAppliedRevision: i64, + failureCode: Option, +} + +impl GatewayQualityAck { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, outcome: String, currentAppliedRevision: i64, failureCode: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, outcome, currentAppliedRevision, failureCode }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if self.outcome != "applied" && self.outcome != "not_applied" && self.outcome != "uncertain" { return Err(ValidationError::new("outcome", "invalid_value")); } + if self.currentAppliedRevision < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } + if let Some(value) = &self.failureCode { + if !value.is_empty() && value.len() < 1 { return Err(ValidationError::new("failure_code", "min_length")); } + if value.len() > 128 { return Err(ValidationError::new("failure_code", "max_length")); } + } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn revision(&self) -> &i64 { &self.revision } + pub fn outcome(&self) -> &String { &self.outcome } + pub fn currentAppliedRevision(&self) -> &i64 { &self.currentAppliedRevision } + pub fn failureCode(&self) -> &Option { &self.failureCode } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayQualityWork { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, + revision: i64, + leaseExpiresAt: String, + selectedDescriptor: SelectedSessionDescriptor, + currentAppliedRevision: Option, +} + +impl GatewayQualityWork { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, leaseExpiresAt, selectedDescriptor, currentAppliedRevision }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if self.leaseExpiresAt.len() > 64 { return Err(ValidationError::new("lease_expires_at", "max_length")); } + if !valid_rfc3339_utc(self.leaseExpiresAt.as_str()) { return Err(ValidationError::new("lease_expires_at", "invalid_time")); } + self.selectedDescriptor.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; + if let Some(value) = &self.currentAppliedRevision { + if *value < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } + } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn revision(&self) -> &i64 { &self.revision } + pub fn leaseExpiresAt(&self) -> &String { &self.leaseExpiresAt } + pub fn selectedDescriptor(&self) -> &SelectedSessionDescriptor { &self.selectedDescriptor } + pub fn currentAppliedRevision(&self) -> &Option { &self.currentAppliedRevision } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayQualityWorkRequest { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, + revision: i64, + currentAppliedRevision: Option, +} + +impl GatewayQualityWorkRequest { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, currentAppliedRevision: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, currentAppliedRevision }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if let Some(value) = &self.currentAppliedRevision { + if *value < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } + } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn revision(&self) -> &i64 { &self.revision } + pub fn currentAppliedRevision(&self) -> &Option { &self.currentAppliedRevision } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GatewayRegistration { version: String, @@ -1011,6 +1269,130 @@ impl GatewayRegistration { pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayStopAck { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, + outcome: String, + failureCode: Option, +} + +impl GatewayStopAck { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, outcome: String, failureCode: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, outcome, failureCode }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.outcome != "applied" && self.outcome != "failed" && self.outcome != "termination_unconfirmed" { return Err(ValidationError::new("outcome", "invalid_value")); } + if let Some(value) = &self.failureCode { + if !value.is_empty() && value.len() < 1 { return Err(ValidationError::new("failure_code", "min_length")); } + if value.len() > 128 { return Err(ValidationError::new("failure_code", "max_length")); } + } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn outcome(&self) -> &String { &self.outcome } + pub fn failureCode(&self) -> &Option { &self.failureCode } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayStopWork { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, + attempt: i64, +} + +impl GatewayStopWork { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, attempt: i64) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, attempt }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.attempt < 1 { return Err(ValidationError::new("attempt", "minimum")); } + if self.attempt > 1 { return Err(ValidationError::new("attempt", "maximum")); } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn attempt(&self) -> &i64 { &self.attempt } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayStopWorkRequest { + version: String, + sessionId: String, + gatewayId: String, + reconnectSequence: i64, + operationId: String, +} + +impl GatewayStopWorkRequest { + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); } + if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } + if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } + if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + Ok(()) + } + pub fn version(&self) -> &String { &self.version } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn gatewayId(&self) -> &String { &self.gatewayId } + pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } + pub fn operationId(&self) -> &String { &self.operationId } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GatewayTelemetry { admittedSessions: i64, @@ -1571,38 +1953,126 @@ impl ProviderState { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderStreamPolicy { - resolutionWidth: i64, - resolutionHeight: i64, - fps: i64, - codec: String, - bitrateKbps: i64, - audioEnabled: bool, + videoProfile: VideoProfile, + audioProfile: AudioProfile, + displayMode: DisplayMode, + bitrateTargetKbps: i64, + bitrateMaximumKbps: i64, } impl ProviderStreamPolicy { - pub fn new(resolutionWidth: i64, resolutionHeight: i64, fps: i64, codec: String, bitrateKbps: i64, audioEnabled: bool) -> Result { - let value = Self { resolutionWidth, resolutionHeight, fps, codec, bitrateKbps, audioEnabled }; + pub fn new(videoProfile: VideoProfile, audioProfile: AudioProfile, displayMode: DisplayMode, bitrateTargetKbps: i64, bitrateMaximumKbps: i64) -> Result { + let value = Self { videoProfile, audioProfile, displayMode, bitrateTargetKbps, bitrateMaximumKbps }; value.validate()?; Ok(value) } pub fn validate(&self) -> Result<(), ValidationError> { - if self.resolutionWidth < 320 { return Err(ValidationError::new("resolution_width", "minimum")); } - if self.resolutionWidth > 16384 { return Err(ValidationError::new("resolution_width", "maximum")); } - if self.resolutionHeight < 200 { return Err(ValidationError::new("resolution_height", "minimum")); } - if self.resolutionHeight > 8640 { return Err(ValidationError::new("resolution_height", "maximum")); } - if self.fps < 1 { return Err(ValidationError::new("fps", "minimum")); } - if self.fps > 240 { return Err(ValidationError::new("fps", "maximum")); } - if self.codec != "H264" && self.codec != "HEVC" && self.codec != "AV1" { return Err(ValidationError::new("codec", "invalid_value")); } - if self.bitrateKbps < 100 { return Err(ValidationError::new("bitrate_kbps", "minimum")); } - if self.bitrateKbps > 1000000 { return Err(ValidationError::new("bitrate_kbps", "maximum")); } + self.videoProfile.validate().map_err(|_| ValidationError::new("video_profile", "invalid_object"))?; + self.audioProfile.validate().map_err(|_| ValidationError::new("audio_profile", "invalid_object"))?; + self.displayMode.validate().map_err(|_| ValidationError::new("display_mode", "invalid_object"))?; + if self.bitrateTargetKbps < 100 { return Err(ValidationError::new("bitrate_target_kbps", "minimum")); } + if self.bitrateTargetKbps > 1000000 { return Err(ValidationError::new("bitrate_target_kbps", "maximum")); } + if self.bitrateMaximumKbps < 100 { return Err(ValidationError::new("bitrate_maximum_kbps", "minimum")); } + if self.bitrateMaximumKbps > 1000000 { return Err(ValidationError::new("bitrate_maximum_kbps", "maximum")); } + if self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new("bitrate_bounds", "invalid_order")); } Ok(()) } - pub fn resolutionWidth(&self) -> &i64 { &self.resolutionWidth } - pub fn resolutionHeight(&self) -> &i64 { &self.resolutionHeight } - pub fn fps(&self) -> &i64 { &self.fps } - pub fn codec(&self) -> &String { &self.codec } - pub fn bitrateKbps(&self) -> &i64 { &self.bitrateKbps } - pub fn audioEnabled(&self) -> &bool { &self.audioEnabled } + pub fn videoProfile(&self) -> &VideoProfile { &self.videoProfile } + pub fn audioProfile(&self) -> &AudioProfile { &self.audioProfile } + pub fn displayMode(&self) -> &DisplayMode { &self.displayMode } + pub fn bitrateTargetKbps(&self) -> &i64 { &self.bitrateTargetKbps } + pub fn bitrateMaximumKbps(&self) -> &i64 { &self.bitrateMaximumKbps } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QualityChangeOperation { + operationId: String, + sessionId: String, + revision: i64, + state: String, + requestedBitratePreference: BitratePreference, + effectiveBitrateKbps: i64, + governingPolicyVersion: String, + sessionVersion: i64, + createdAt: String, + deadlineAt: String, + updatedAt: String, + failureCode: Option, +} + +impl QualityChangeOperation { + pub fn new(operationId: String, sessionId: String, revision: i64, state: String, requestedBitratePreference: BitratePreference, effectiveBitrateKbps: i64, governingPolicyVersion: String, sessionVersion: i64, createdAt: String, deadlineAt: String, updatedAt: String, failureCode: Option) -> Result { + let value = Self { operationId, sessionId, revision, state, requestedBitratePreference, effectiveBitrateKbps, governingPolicyVersion, sessionVersion, createdAt, deadlineAt, updatedAt, failureCode }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if self.state != "pending" && self.state != "applying" && self.state != "applied" && self.state != "failed" && self.state != "timed_out" { return Err(ValidationError::new("state", "invalid_value")); } + self.requestedBitratePreference.validate().map_err(|_| ValidationError::new("requested_bitrate_preference", "invalid_object"))?; + if self.effectiveBitrateKbps < 100 { return Err(ValidationError::new("effective_bitrate_kbps", "minimum")); } + if self.effectiveBitrateKbps > 1000000 { return Err(ValidationError::new("effective_bitrate_kbps", "maximum")); } + if self.governingPolicyVersion.is_empty() { return Err(ValidationError::new("governing_policy_version", "required")); } + if !self.governingPolicyVersion.is_empty() && self.governingPolicyVersion.len() < 1 { return Err(ValidationError::new("governing_policy_version", "min_length")); } + if self.governingPolicyVersion.len() > 128 { return Err(ValidationError::new("governing_policy_version", "max_length")); } + if self.sessionVersion < 1 { return Err(ValidationError::new("session_version", "minimum")); } + if self.createdAt.len() > 64 { return Err(ValidationError::new("created_at", "max_length")); } + if !valid_rfc3339_utc(self.createdAt.as_str()) { return Err(ValidationError::new("created_at", "invalid_time")); } + if self.deadlineAt.len() > 64 { return Err(ValidationError::new("deadline_at", "max_length")); } + if !valid_rfc3339_utc(self.deadlineAt.as_str()) { return Err(ValidationError::new("deadline_at", "invalid_time")); } + if self.updatedAt.len() > 64 { return Err(ValidationError::new("updated_at", "max_length")); } + if !valid_rfc3339_utc(self.updatedAt.as_str()) { return Err(ValidationError::new("updated_at", "invalid_time")); } + if let Some(value) = &self.failureCode { + if !value.is_empty() && value.len() < 1 { return Err(ValidationError::new("failure_code", "min_length")); } + if value.len() > 128 { return Err(ValidationError::new("failure_code", "max_length")); } + } + Ok(()) + } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn revision(&self) -> &i64 { &self.revision } + pub fn state(&self) -> &String { &self.state } + pub fn requestedBitratePreference(&self) -> &BitratePreference { &self.requestedBitratePreference } + pub fn effectiveBitrateKbps(&self) -> &i64 { &self.effectiveBitrateKbps } + pub fn governingPolicyVersion(&self) -> &String { &self.governingPolicyVersion } + pub fn sessionVersion(&self) -> &i64 { &self.sessionVersion } + pub fn createdAt(&self) -> &String { &self.createdAt } + pub fn deadlineAt(&self) -> &String { &self.deadlineAt } + pub fn updatedAt(&self) -> &String { &self.updatedAt } + pub fn failureCode(&self) -> &Option { &self.failureCode } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QualityChangeRequest { + bitratePreference: BitratePreference, + expectedSessionVersion: i64, + expectedPolicyVersion: String, +} + +impl QualityChangeRequest { + pub fn new(bitratePreference: BitratePreference, expectedSessionVersion: i64, expectedPolicyVersion: String) -> Result { + let value = Self { bitratePreference, expectedSessionVersion, expectedPolicyVersion }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + self.bitratePreference.validate().map_err(|_| ValidationError::new("bitrate_preference", "invalid_object"))?; + if self.expectedSessionVersion < 1 { return Err(ValidationError::new("expected_session_version", "minimum")); } + if self.expectedPolicyVersion.is_empty() { return Err(ValidationError::new("expected_policy_version", "required")); } + if !self.expectedPolicyVersion.is_empty() && self.expectedPolicyVersion.len() < 1 { return Err(ValidationError::new("expected_policy_version", "min_length")); } + if self.expectedPolicyVersion.len() > 128 { return Err(ValidationError::new("expected_policy_version", "max_length")); } + Ok(()) + } + pub fn bitratePreference(&self) -> &BitratePreference { &self.bitratePreference } + pub fn expectedSessionVersion(&self) -> &i64 { &self.expectedSessionVersion } + pub fn expectedPolicyVersion(&self) -> &String { &self.expectedPolicyVersion } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1662,11 +2132,12 @@ pub struct ReconnectRequest { clientDeviceId: String, deviceKeyId: String, expectedVersion: i64, + displayRelaunchConfirmed: bool, } impl ReconnectRequest { - pub fn new(clientDeviceId: String, deviceKeyId: String, expectedVersion: i64) -> Result { - let value = Self { clientDeviceId, deviceKeyId, expectedVersion }; + pub fn new(clientDeviceId: String, deviceKeyId: String, expectedVersion: i64, displayRelaunchConfirmed: bool) -> Result { + let value = Self { clientDeviceId, deviceKeyId, expectedVersion, displayRelaunchConfirmed }; value.validate()?; Ok(value) } @@ -1683,6 +2154,7 @@ impl ReconnectRequest { pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId } pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId } pub fn expectedVersion(&self) -> &i64 { &self.expectedVersion } + pub fn displayRelaunchConfirmed(&self) -> &bool { &self.displayRelaunchConfirmed } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1811,6 +2283,66 @@ impl ResourceList { pub fn page(&self) -> &PageInfo { &self.page } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedSessionDescriptor { + videoProfile: VideoProfile, + audioProfile: AudioProfile, + displayMode: DisplayMode, + bitrateTargetKbps: i64, + bitrateMaximumKbps: i64, + adjustment: SessionAdjustment, + mediaTimestampBasis: String, +} + +impl SelectedSessionDescriptor { + pub fn new(videoProfile: VideoProfile, audioProfile: AudioProfile, displayMode: DisplayMode, bitrateTargetKbps: i64, bitrateMaximumKbps: i64, adjustment: SessionAdjustment, mediaTimestampBasis: String) -> Result { + let value = Self { videoProfile, audioProfile, displayMode, bitrateTargetKbps, bitrateMaximumKbps, adjustment, mediaTimestampBasis }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + self.videoProfile.validate().map_err(|_| ValidationError::new("video_profile", "invalid_object"))?; + self.audioProfile.validate().map_err(|_| ValidationError::new("audio_profile", "invalid_object"))?; + self.displayMode.validate().map_err(|_| ValidationError::new("display_mode", "invalid_object"))?; + if self.bitrateTargetKbps < 100 { return Err(ValidationError::new("bitrate_target_kbps", "minimum")); } + if self.bitrateTargetKbps > 1000000 { return Err(ValidationError::new("bitrate_target_kbps", "maximum")); } + if self.bitrateMaximumKbps < 100 { return Err(ValidationError::new("bitrate_maximum_kbps", "minimum")); } + if self.bitrateMaximumKbps > 1000000 { return Err(ValidationError::new("bitrate_maximum_kbps", "maximum")); } + self.adjustment.validate().map_err(|_| ValidationError::new("adjustment", "invalid_object"))?; + if self.mediaTimestampBasis != "gateway-send-wall-clock-ms" { return Err(ValidationError::new("media_timestamp_basis", "invalid_value")); } + if self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new("bitrate_bounds", "invalid_order")); } + Ok(()) + } + pub fn videoProfile(&self) -> &VideoProfile { &self.videoProfile } + pub fn audioProfile(&self) -> &AudioProfile { &self.audioProfile } + pub fn displayMode(&self) -> &DisplayMode { &self.displayMode } + pub fn bitrateTargetKbps(&self) -> &i64 { &self.bitrateTargetKbps } + pub fn bitrateMaximumKbps(&self) -> &i64 { &self.bitrateMaximumKbps } + pub fn adjustment(&self) -> &SessionAdjustment { &self.adjustment } + pub fn mediaTimestampBasis(&self) -> &String { &self.mediaTimestampBasis } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionAdjustment { + displayReason: String, + bitrateReason: String, +} + +impl SessionAdjustment { + pub fn new(displayReason: String, bitrateReason: String) -> Result { + let value = Self { displayReason, bitrateReason }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.displayReason != "none" && self.displayReason != "policy_clamp" && self.displayReason != "direct_entitlement_override" { return Err(ValidationError::new("display_reason", "invalid_value")); } + if self.bitrateReason != "none" && self.bitrateReason != "policy_default" && self.bitrateReason != "session_ceiling_clamp" && self.bitrateReason != "aggregate_capacity_clamp" { return Err(ValidationError::new("bitrate_reason", "invalid_value")); } + Ok(()) + } + pub fn displayReason(&self) -> &String { &self.displayReason } + pub fn bitrateReason(&self) -> &String { &self.bitrateReason } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionAuthority { version: String, @@ -1822,11 +2354,12 @@ pub struct SessionAuthority { capabilities: CapabilityProfile, providerProfile: String, providerIdentity: String, + selectedDescriptor: SelectedSessionDescriptor, } impl SessionAuthority { - pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile, providerProfile: String, providerIdentity: String) -> Result { - let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities, providerProfile, providerIdentity }; + pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile, providerProfile: String, providerIdentity: String, selectedDescriptor: SelectedSessionDescriptor) -> Result { + let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities, providerProfile, providerIdentity, selectedDescriptor }; value.validate()?; Ok(value) } @@ -1849,6 +2382,7 @@ impl SessionAuthority { if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); } if !self.providerIdentity.is_empty() && self.providerIdentity.len() < 1 { return Err(ValidationError::new("provider_identity", "min_length")); } if self.providerIdentity.len() > 256 { return Err(ValidationError::new("provider_identity", "max_length")); } + self.selectedDescriptor.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; Ok(()) } pub fn version(&self) -> &String { &self.version } @@ -1860,6 +2394,48 @@ impl SessionAuthority { pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities } pub fn providerProfile(&self) -> &String { &self.providerProfile } pub fn providerIdentity(&self) -> &String { &self.providerIdentity } + pub fn selectedDescriptor(&self) -> &SelectedSessionDescriptor { &self.selectedDescriptor } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionQualityLimits { + policyVersionId: String, + policyDisplayLimit: DisplayMode, + selectableDisplayMaximum: DisplayMode, + displayLimitOverride: bool, + bitrateMinimumKbps: i64, + bitrateTargetKbps: i64, + bitrateMaximumKbps: i64, +} + +impl SessionQualityLimits { + pub fn new(policyVersionId: String, policyDisplayLimit: DisplayMode, selectableDisplayMaximum: DisplayMode, displayLimitOverride: bool, bitrateMinimumKbps: i64, bitrateTargetKbps: i64, bitrateMaximumKbps: i64) -> Result { + let value = Self { policyVersionId, policyDisplayLimit, selectableDisplayMaximum, displayLimitOverride, bitrateMinimumKbps, bitrateTargetKbps, bitrateMaximumKbps }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.policyVersionId.is_empty() { return Err(ValidationError::new("policy_version_id", "required")); } + if !self.policyVersionId.is_empty() && self.policyVersionId.len() < 1 { return Err(ValidationError::new("policy_version_id", "min_length")); } + if self.policyVersionId.len() > 128 { return Err(ValidationError::new("policy_version_id", "max_length")); } + self.policyDisplayLimit.validate().map_err(|_| ValidationError::new("policy_display_limit", "invalid_object"))?; + self.selectableDisplayMaximum.validate().map_err(|_| ValidationError::new("selectable_display_maximum", "invalid_object"))?; + if self.bitrateMinimumKbps < 100 { return Err(ValidationError::new("bitrate_minimum_kbps", "minimum")); } + if self.bitrateMinimumKbps > 1000000 { return Err(ValidationError::new("bitrate_minimum_kbps", "maximum")); } + if self.bitrateTargetKbps < 100 { return Err(ValidationError::new("bitrate_target_kbps", "minimum")); } + if self.bitrateTargetKbps > 1000000 { return Err(ValidationError::new("bitrate_target_kbps", "maximum")); } + if self.bitrateMaximumKbps < 100 { return Err(ValidationError::new("bitrate_maximum_kbps", "minimum")); } + if self.bitrateMaximumKbps > 1000000 { return Err(ValidationError::new("bitrate_maximum_kbps", "maximum")); } + if self.bitrateMinimumKbps > self.bitrateTargetKbps || self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new("bitrate_bounds", "invalid_order")); } + Ok(()) + } + pub fn policyVersionId(&self) -> &String { &self.policyVersionId } + pub fn policyDisplayLimit(&self) -> &DisplayMode { &self.policyDisplayLimit } + pub fn selectableDisplayMaximum(&self) -> &DisplayMode { &self.selectableDisplayMaximum } + pub fn displayLimitOverride(&self) -> &bool { &self.displayLimitOverride } + pub fn bitrateMinimumKbps(&self) -> &i64 { &self.bitrateMinimumKbps } + pub fn bitrateTargetKbps(&self) -> &i64 { &self.bitrateTargetKbps } + pub fn bitrateMaximumKbps(&self) -> &i64 { &self.bitrateMaximumKbps } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1868,12 +2444,14 @@ pub struct SessionRequest { deviceKeyId: String, poolId: String, idempotencyKey: String, + videoProfiles: Vec, + bitratePreference: BitratePreference, requestedDisplayMode: Option, } impl SessionRequest { - pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: Option) -> Result { - let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, requestedDisplayMode }; + pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, videoProfiles: Vec, bitratePreference: BitratePreference, requestedDisplayMode: Option) -> Result { + let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, videoProfiles, bitratePreference, requestedDisplayMode }; value.validate()?; Ok(value) } @@ -1890,6 +2468,11 @@ impl SessionRequest { if self.idempotencyKey.is_empty() { return Err(ValidationError::new("idempotency_key", "required")); } if !self.idempotencyKey.is_empty() && self.idempotencyKey.len() < 1 { return Err(ValidationError::new("idempotency_key", "min_length")); } if self.idempotencyKey.len() > 256 { return Err(ValidationError::new("idempotency_key", "max_length")); } + if self.videoProfiles.len() < 1 { return Err(ValidationError::new("video_profiles", "min_items")); } + if self.videoProfiles.len() > 12 { return Err(ValidationError::new("video_profiles", "max_items")); } + for (index, item) in self.videoProfiles.iter().enumerate() { if self.videoProfiles[..index].contains(item) { return Err(ValidationError::new("video_profiles", "duplicate_item")); } } + for item in self.videoProfiles.iter() { item.validate().map_err(|_| ValidationError::new("video_profiles", "invalid_item"))?; } + self.bitratePreference.validate().map_err(|_| ValidationError::new("bitrate_preference", "invalid_object"))?; if let Some(value) = &self.requestedDisplayMode { value.validate().map_err(|_| ValidationError::new("requested_display_mode", "invalid_object"))?; } @@ -1899,6 +2482,8 @@ impl SessionRequest { pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId } pub fn poolId(&self) -> &String { &self.poolId } pub fn idempotencyKey(&self) -> &String { &self.idempotencyKey } + pub fn videoProfiles(&self) -> &Vec { &self.videoProfiles } + pub fn bitratePreference(&self) -> &BitratePreference { &self.bitratePreference } pub fn requestedDisplayMode(&self) -> &Option { &self.requestedDisplayMode } } @@ -1932,6 +2517,56 @@ impl StableError { pub fn retryable(&self) -> &bool { &self.retryable } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StopOperation { + operationId: String, + sessionId: String, + state: String, + sessionVersion: i64, + createdAt: String, + deadlineAt: String, + updatedAt: String, + failureCode: Option, +} + +impl StopOperation { + pub fn new(operationId: String, sessionId: String, state: String, sessionVersion: i64, createdAt: String, deadlineAt: String, updatedAt: String, failureCode: Option) -> Result { + let value = Self { operationId, sessionId, state, sessionVersion, createdAt, deadlineAt, updatedAt, failureCode }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } + if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); } + if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); } + if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); } + if self.state != "pending" && self.state != "attempting" && self.state != "applied" && self.state != "failed" && self.state != "termination_unconfirmed" { return Err(ValidationError::new("state", "invalid_value")); } + if self.sessionVersion < 1 { return Err(ValidationError::new("session_version", "minimum")); } + if self.createdAt.len() > 64 { return Err(ValidationError::new("created_at", "max_length")); } + if !valid_rfc3339_utc(self.createdAt.as_str()) { return Err(ValidationError::new("created_at", "invalid_time")); } + if self.deadlineAt.len() > 64 { return Err(ValidationError::new("deadline_at", "max_length")); } + if !valid_rfc3339_utc(self.deadlineAt.as_str()) { return Err(ValidationError::new("deadline_at", "invalid_time")); } + if self.updatedAt.len() > 64 { return Err(ValidationError::new("updated_at", "max_length")); } + if !valid_rfc3339_utc(self.updatedAt.as_str()) { return Err(ValidationError::new("updated_at", "invalid_time")); } + if let Some(value) = &self.failureCode { + if !value.is_empty() && value.len() < 1 { return Err(ValidationError::new("failure_code", "min_length")); } + if value.len() > 128 { return Err(ValidationError::new("failure_code", "max_length")); } + } + Ok(()) + } + pub fn operationId(&self) -> &String { &self.operationId } + pub fn sessionId(&self) -> &String { &self.sessionId } + pub fn state(&self) -> &String { &self.state } + pub fn sessionVersion(&self) -> &i64 { &self.sessionVersion } + pub fn createdAt(&self) -> &String { &self.createdAt } + pub fn deadlineAt(&self) -> &String { &self.deadlineAt } + pub fn updatedAt(&self) -> &String { &self.updatedAt } + pub fn failureCode(&self) -> &Option { &self.failureCode } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TunnelAdmissionRequest { version: String, @@ -1986,9 +2621,12 @@ impl TunnelAdmissionRequest { pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities } pub fn device_admission_transcript(&self) -> Vec { let reconnect_sequence = self.reconnectSequence.to_string(); - let client_decode_count = self.capabilities.clientDecode.len().to_string(); - let mut fields = vec![self.sessionId.as_str(), self.gatewayId.as_str(), self.audience.as_str(), self.grant.as_str(), reconnect_sequence.as_str(), self.clientNonce.as_str(), self.capabilities.transport.as_str(), self.capabilities.framing.as_str(), self.capabilities.media.as_str(), self.capabilities.audio.as_str(), self.capabilities.sourceRateControl.as_str(), client_decode_count.as_str()]; - fields.extend(self.capabilities.clientDecode.iter().map(String::as_str)); + let video_count = self.capabilities.videoProfiles.len().to_string(); + let audio_count = self.capabilities.audioProfiles.len().to_string(); + let mut owned = vec![self.sessionId.clone(), self.gatewayId.clone(), self.audience.clone(), self.grant.clone(), reconnect_sequence, self.clientNonce.clone(), self.capabilities.transport.clone(), self.capabilities.framing.clone(), self.capabilities.media.clone(), self.capabilities.sourceRateControl.clone(), video_count, audio_count]; + for profile in &self.capabilities.videoProfiles { owned.extend([profile.codec.clone(), profile.bitDepth.to_string(), profile.chromaSubsampling.clone(), profile.colorSpace.clone(), profile.transferFunction.clone()]); } + for profile in &self.capabilities.audioProfiles { owned.extend([profile.codec.clone(), profile.sampleRateHz.to_string(), profile.channels.to_string(), profile.channelLayout.clone(), profile.packetDurationMs.to_string()]); } + let fields: Vec<&str> = owned.iter().map(String::as_str).collect(); let mut transcript = String::from("versevdi/tunnel-admission/v1"); for field in fields { transcript.push_str(&format!("{}:{}", field.as_bytes().len(), field)); } transcript.into_bytes() @@ -2019,6 +2657,37 @@ impl VersionNegotiation { pub fn features(&self) -> &Vec { &self.features } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VideoProfile { + codec: String, + bitDepth: i64, + chromaSubsampling: String, + colorSpace: String, + transferFunction: String, +} + +impl VideoProfile { + pub fn new(codec: String, bitDepth: i64, chromaSubsampling: String, colorSpace: String, transferFunction: String) -> Result { + let value = Self { codec, bitDepth, chromaSubsampling, colorSpace, transferFunction }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<(), ValidationError> { + if self.codec != "h264" && self.codec != "hevc" && self.codec != "av1" { return Err(ValidationError::new("codec", "invalid_value")); } + if self.bitDepth < 8 { return Err(ValidationError::new("bit_depth", "minimum")); } + if self.bitDepth > 8 { return Err(ValidationError::new("bit_depth", "maximum")); } + if self.chromaSubsampling != "4:2:0" { return Err(ValidationError::new("chroma_subsampling", "invalid_value")); } + if self.colorSpace != "bt709-limited" { return Err(ValidationError::new("color_space", "invalid_value")); } + if self.transferFunction != "sdr" { return Err(ValidationError::new("transfer_function", "invalid_value")); } + Ok(()) + } + pub fn codec(&self) -> &String { &self.codec } + pub fn bitDepth(&self) -> &i64 { &self.bitDepth } + pub fn chromaSubsampling(&self) -> &String { &self.chromaSubsampling } + pub fn colorSpace(&self) -> &String { &self.colorSpace } + pub fn transferFunction(&self) -> &String { &self.transferFunction } +} + pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result, ValidationError> { for (field, value, length) in [("server_id", server_id, 16), ("principal_id", principal_id, 16), ("device_id", device_id, 16), ("challenge", challenge, 32)] { if value.len() != length { return Err(ValidationError::new(field, "invalid_length")); } @@ -2039,9 +2708,10 @@ pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result String { + guard index < data.count, data[index] == 34 else { throw ContractValidationError(field: "json", code: "invalid_json") } + let start = index + index += 1 + while index < data.count { + if data[index] == 92 { index += 2; continue } + if data[index] == 34 { index += 1; return try JSONDecoder().decode(String.self, from: data[start..() + skipWhitespace() + if index < data.count, data[index] == 125 { index += 1; return } + while true { + skipWhitespace() + let key = try parseString() + guard keys.insert(key).inserted else { throw ContractValidationError(field: key, code: "duplicate_field") } + skipWhitespace() + guard index < data.count, data[index] == 58 else { throw ContractValidationError(field: "json", code: "invalid_json") } + index += 1 + try parseValue() + skipWhitespace() + guard index < data.count else { throw ContractValidationError(field: "json", code: "invalid_json") } + if data[index] == 125 { index += 1; return } + guard data[index] == 44 else { throw ContractValidationError(field: "json", code: "invalid_json") } + index += 1 + } + } + if data[index] == 91 { + index += 1 + skipWhitespace() + if index < data.count, data[index] == 93 { index += 1; return } + while true { + try parseValue() + skipWhitespace() + guard index < data.count else { throw ContractValidationError(field: "json", code: "invalid_json") } + if data[index] == 93 { index += 1; return } + guard data[index] == 44 else { throw ContractValidationError(field: "json", code: "invalid_json") } + index += 1 + } + } + if data[index] == 34 { _ = try parseString(); return } + let start = index + while index < data.count && ![9, 10, 13, 32, 44, 93, 125].contains(data[index]) { index += 1 } + guard index > start else { throw ContractValidationError(field: "json", code: "invalid_json") } + } + try parseValue() + skipWhitespace() + guard index == data.count else { throw ContractValidationError(field: "json", code: "trailing_json") } +} private func validBase64URL(_ value: String) -> Bool { guard !value.isEmpty, value.utf8.allSatisfy({ byte in (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95 @@ -33,6 +92,11 @@ private func validRFC3339UTC(_ value: String) -> Bool { let fraction = bytes[20..<(bytes.count - 1)] return bytes[19] == 46 && !fraction.isEmpty && fraction.count <= 9 && fraction.allSatisfy { $0 >= 48 && $0 <= 57 } && fraction.last != 48 } +private func validCanonicalUUID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard bytes.count == 36, bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45, value != "00000000-0000-0000-0000-000000000000" else { return false } + return bytes.enumerated().allSatisfy { index, byte in [8, 13, 18, 23].contains(index) || (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) } +} public struct AllocationPolicy: Codable, Equatable { public let minimumKbps: Int64 @@ -99,7 +163,7 @@ public struct AllocationPolicy: Codable, Equatable { if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: "bounds", code: "invalid_order") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -108,18 +172,21 @@ public struct AssignedDesktop: Codable, Equatable { public let poolId: String public let name: String public let availability: String + public let qualityLimits: SessionQualityLimits enum CodingKeys: String, CodingKey { case assignmentId = "assignment_id" case poolId = "pool_id" case name = "name" case availability = "availability" + case qualityLimits = "quality_limits" } - public init(assignmentId: String, poolId: String, name: String, availability: String) throws { + public init(assignmentId: String, poolId: String, name: String, availability: String, qualityLimits: SessionQualityLimits) throws { self.assignmentId = assignmentId self.poolId = poolId self.name = name self.availability = availability + self.qualityLimits = qualityLimits try validate() } @@ -127,7 +194,7 @@ public struct AssignedDesktop: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(assignmentId: try c.decode(String.self, forKey: .assignmentId), poolId: try c.decode(String.self, forKey: .poolId), name: try c.decode(String.self, forKey: .name), availability: try c.decode(String.self, forKey: .availability)) + try self.init(assignmentId: try c.decode(String.self, forKey: .assignmentId), poolId: try c.decode(String.self, forKey: .poolId), name: try c.decode(String.self, forKey: .name), availability: try c.decode(String.self, forKey: .availability), qualityLimits: try c.decode(SessionQualityLimits.self, forKey: .qualityLimits)) } public func validate() throws { @@ -143,9 +210,89 @@ public struct AssignedDesktop: Codable, Equatable { if self.availability.isEmpty { throw ContractValidationError(field: "availability", code: "required") } if !self.availability.isEmpty && self.availability.utf8.count < 1 { throw ContractValidationError(field: "availability", code: "min_length") } if self.availability.utf8.count > 64 { throw ContractValidationError(field: "availability", code: "max_length") } + try self.qualityLimits.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct AudioProfile: Codable, Equatable { + public let codec: String + public let sampleRateHz: Int64 + public let channels: Int64 + public let channelLayout: String + public let packetDurationMs: Int64 + enum CodingKeys: String, CodingKey { + case codec = "codec" + case sampleRateHz = "sample_rate_hz" + case channels = "channels" + case channelLayout = "channel_layout" + case packetDurationMs = "packet_duration_ms" + } + + public init(codec: String, sampleRateHz: Int64, channels: Int64, channelLayout: String, packetDurationMs: Int64) throws { + self.codec = codec + self.sampleRateHz = sampleRateHz + self.channels = channels + self.channelLayout = channelLayout + self.packetDurationMs = packetDurationMs + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(codec: try c.decode(String.self, forKey: .codec), sampleRateHz: try c.decode(Int64.self, forKey: .sampleRateHz), channels: try c.decode(Int64.self, forKey: .channels), channelLayout: try c.decode(String.self, forKey: .channelLayout), packetDurationMs: try c.decode(Int64.self, forKey: .packetDurationMs)) + } + + public func validate() throws { + if self.codec != "opus" { throw ContractValidationError(field: "codec", code: "invalid_value") } + if self.sampleRateHz < 48000 { throw ContractValidationError(field: "sample_rate_hz", code: "minimum") } + if self.sampleRateHz > 48000 { throw ContractValidationError(field: "sample_rate_hz", code: "maximum") } + if self.channels < 2 { throw ContractValidationError(field: "channels", code: "minimum") } + if self.channels > 2 { throw ContractValidationError(field: "channels", code: "maximum") } + if self.channelLayout != "stereo" { throw ContractValidationError(field: "channel_layout", code: "invalid_value") } + if self.packetDurationMs < 5 { throw ContractValidationError(field: "packet_duration_ms", code: "minimum") } + if self.packetDurationMs > 5 { throw ContractValidationError(field: "packet_duration_ms", code: "maximum") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct BitratePreference: Codable, Equatable { + public let mode: String + public let targetKbps: Int64? + enum CodingKeys: String, CodingKey { + case mode = "mode" + case targetKbps = "target_kbps" + } + + public init(mode: String, targetKbps: Int64?) throws { + self.mode = mode + self.targetKbps = targetKbps + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(mode: try c.decode(String.self, forKey: .mode), targetKbps: try c.decodeIfPresent(Int64.self, forKey: .targetKbps)) + } + + public func validate() throws { + if !["auto", "explicit"].contains(self.mode) { throw ContractValidationError(field: "mode", code: "invalid_value") } + if let value = self.targetKbps { + if value < 100 { throw ContractValidationError(field: "target_kbps", code: "minimum") } + if value > 1000000 { throw ContractValidationError(field: "target_kbps", code: "maximum") } + } + if mode == "auto" && targetKbps != nil || mode == "explicit" && targetKbps == nil { throw ContractValidationError(field: "target_kbps", code: "invalid_tagged_value") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -167,6 +314,9 @@ public struct BrokerSession: Codable, Equatable { public let version: Int64 public let requestedDisplayMode: DisplayMode? public let effectiveDisplayMode: DisplayMode? + public let requestedVideoProfiles: [VideoProfile] + public let requestedBitratePreference: BitratePreference + public let selectedDescriptor: SelectedSessionDescriptor? enum CodingKeys: String, CodingKey { case id = "id" case principalId = "principal_id" @@ -185,9 +335,12 @@ public struct BrokerSession: Codable, Equatable { case version = "version" case requestedDisplayMode = "requested_display_mode" case effectiveDisplayMode = "effective_display_mode" + case requestedVideoProfiles = "requested_video_profiles" + case requestedBitratePreference = "requested_bitrate_preference" + case selectedDescriptor = "selected_descriptor" } - public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64, requestedDisplayMode: DisplayMode?, effectiveDisplayMode: DisplayMode?) throws { + public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64, requestedDisplayMode: DisplayMode?, effectiveDisplayMode: DisplayMode?, requestedVideoProfiles: [VideoProfile], requestedBitratePreference: BitratePreference, selectedDescriptor: SelectedSessionDescriptor?) throws { self.id = id self.principalId = principalId self.poolId = poolId @@ -205,6 +358,9 @@ public struct BrokerSession: Codable, Equatable { self.version = version self.requestedDisplayMode = requestedDisplayMode self.effectiveDisplayMode = effectiveDisplayMode + self.requestedVideoProfiles = requestedVideoProfiles + self.requestedBitratePreference = requestedBitratePreference + self.selectedDescriptor = selectedDescriptor try validate() } @@ -212,7 +368,7 @@ public struct BrokerSession: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil, effectiveDisplayMode: try c.contains(.effectiveDisplayMode) ? c.decode(DisplayMode.self, forKey: .effectiveDisplayMode) : nil) + try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil, effectiveDisplayMode: try c.contains(.effectiveDisplayMode) ? c.decode(DisplayMode.self, forKey: .effectiveDisplayMode) : nil, requestedVideoProfiles: try c.decode([VideoProfile].self, forKey: .requestedVideoProfiles), requestedBitratePreference: try c.decode(BitratePreference.self, forKey: .requestedBitratePreference), selectedDescriptor: try c.contains(.selectedDescriptor) ? c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor) : nil) } public func validate() throws { @@ -264,9 +420,17 @@ public struct BrokerSession: Codable, Equatable { if let value = self.effectiveDisplayMode { try value.validate() } + if self.requestedVideoProfiles.count < 1 { throw ContractValidationError(field: "requested_video_profiles", code: "min_items") } + if self.requestedVideoProfiles.count > 12 { throw ContractValidationError(field: "requested_video_profiles", code: "max_items") } + for (index, item) in self.requestedVideoProfiles.enumerated() where self.requestedVideoProfiles[.. Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -311,7 +475,7 @@ public struct BrowserAuthenticatedSession: Codable, Equatable { if !["user", "admin"].contains(self.role) { throw ContractValidationError(field: "role", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -319,25 +483,25 @@ public struct CapabilityProfile: Codable, Equatable { public let transport: String public let framing: String public let media: String - public let audio: String public let sourceRateControl: String - public let clientDecode: [String] + public let videoProfiles: [VideoProfile] + public let audioProfiles: [AudioProfile] enum CodingKeys: String, CodingKey { case transport = "transport" case framing = "framing" case media = "media" - case audio = "audio" case sourceRateControl = "source_rate_control" - case clientDecode = "client_decode" + case videoProfiles = "video_profiles" + case audioProfiles = "audio_profiles" } - public init(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: [String]) throws { + public init(transport: String, framing: String, media: String, sourceRateControl: String, videoProfiles: [VideoProfile], audioProfiles: [AudioProfile]) throws { self.transport = transport self.framing = framing self.media = media - self.audio = audio self.sourceRateControl = sourceRateControl - self.clientDecode = clientDecode + self.videoProfiles = videoProfiles + self.audioProfiles = audioProfiles try validate() } @@ -345,7 +509,7 @@ public struct CapabilityProfile: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(transport: try c.decode(String.self, forKey: .transport), framing: try c.decode(String.self, forKey: .framing), media: try c.decode(String.self, forKey: .media), audio: try c.decode(String.self, forKey: .audio), sourceRateControl: try c.decode(String.self, forKey: .sourceRateControl), clientDecode: try c.decode([String].self, forKey: .clientDecode)) + try self.init(transport: try c.decode(String.self, forKey: .transport), framing: try c.decode(String.self, forKey: .framing), media: try c.decode(String.self, forKey: .media), sourceRateControl: try c.decode(String.self, forKey: .sourceRateControl), videoProfiles: try c.decode([VideoProfile].self, forKey: .videoProfiles), audioProfiles: try c.decode([AudioProfile].self, forKey: .audioProfiles)) } public func validate() throws { @@ -356,19 +520,20 @@ public struct CapabilityProfile: Codable, Equatable { if self.media.isEmpty { throw ContractValidationError(field: "media", code: "required") } if !self.media.isEmpty && self.media.utf8.count < 1 { throw ContractValidationError(field: "media", code: "min_length") } if self.media.utf8.count > 64 { throw ContractValidationError(field: "media", code: "max_length") } - if self.audio.isEmpty { throw ContractValidationError(field: "audio", code: "required") } - if !self.audio.isEmpty && self.audio.utf8.count < 1 { throw ContractValidationError(field: "audio", code: "min_length") } - if self.audio.utf8.count > 64 { throw ContractValidationError(field: "audio", code: "max_length") } if self.sourceRateControl.isEmpty { throw ContractValidationError(field: "source_rate_control", code: "required") } if !self.sourceRateControl.isEmpty && self.sourceRateControl.utf8.count < 1 { throw ContractValidationError(field: "source_rate_control", code: "min_length") } if self.sourceRateControl.utf8.count > 64 { throw ContractValidationError(field: "source_rate_control", code: "max_length") } - if self.clientDecode.count < 1 { throw ContractValidationError(field: "client_decode", code: "min_items") } - if self.clientDecode.count > 2 { throw ContractValidationError(field: "client_decode", code: "max_items") } - for item in self.clientDecode where !["h264-opus", "hevc-opus"].contains(item) { throw ContractValidationError(field: "client_decode", code: "invalid_item") } - if Set(self.clientDecode).count != self.clientDecode.count { throw ContractValidationError(field: "client_decode", code: "duplicate_item") } + if self.videoProfiles.count < 1 { throw ContractValidationError(field: "video_profiles", code: "min_items") } + if self.videoProfiles.count > 12 { throw ContractValidationError(field: "video_profiles", code: "max_items") } + for (index, item) in self.videoProfiles.enumerated() where self.videoProfiles[.. 1 { throw ContractValidationError(field: "audio_profiles", code: "max_items") } + for (index, item) in self.audioProfiles.enumerated() where self.audioProfiles[.. Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -429,7 +594,7 @@ public struct ChannelFrame: Codable, Equatable { if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -441,6 +606,7 @@ public struct ClientSessionAuthority: Codable, Equatable { public let reconnectSequence: Int64 public let expiresAt: String public let capabilities: CapabilityProfile + public let selectedDescriptor: SelectedSessionDescriptor enum CodingKeys: String, CodingKey { case version = "version" case sessionId = "session_id" @@ -449,9 +615,10 @@ public struct ClientSessionAuthority: Codable, Equatable { case reconnectSequence = "reconnect_sequence" case expiresAt = "expires_at" case capabilities = "capabilities" + case selectedDescriptor = "selected_descriptor" } - public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile) throws { + public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile, selectedDescriptor: SelectedSessionDescriptor) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId @@ -459,6 +626,7 @@ public struct ClientSessionAuthority: Codable, Equatable { self.reconnectSequence = reconnectSequence self.expiresAt = expiresAt self.capabilities = capabilities + self.selectedDescriptor = selectedDescriptor try validate() } @@ -466,7 +634,7 @@ public struct ClientSessionAuthority: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor)) } public func validate() throws { @@ -484,9 +652,10 @@ public struct ClientSessionAuthority: Codable, Equatable { if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") } if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") } try self.capabilities.validate() + try self.selectedDescriptor.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -524,7 +693,7 @@ public struct ClipboardPolicy: Codable, Equatable { if self.maxUpdatesPerMinute > 120 { throw ContractValidationError(field: "max_updates_per_minute", code: "maximum") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -554,7 +723,7 @@ public struct ClipboardText: Codable, Equatable { if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -568,6 +737,7 @@ public struct ConnectionManifest: Codable, Equatable { public let profile: ManifestProfile public let grant: GrantReference public let correlationId: String + public let selectedDescriptor: SelectedSessionDescriptor enum CodingKeys: String, CodingKey { case version = "version" case purpose = "purpose" @@ -578,9 +748,10 @@ public struct ConnectionManifest: Codable, Equatable { case profile = "profile" case grant = "grant" case correlationId = "correlation_id" + case selectedDescriptor = "selected_descriptor" } - public init(version: String, purpose: String, sessionId: String, reconnectSequence: Int64, gateway: ManifestGateway, tunnel: ManifestTunnel, profile: ManifestProfile, grant: GrantReference, correlationId: String) throws { + public init(version: String, purpose: String, sessionId: String, reconnectSequence: Int64, gateway: ManifestGateway, tunnel: ManifestTunnel, profile: ManifestProfile, grant: GrantReference, correlationId: String, selectedDescriptor: SelectedSessionDescriptor) throws { self.version = version self.purpose = purpose self.sessionId = sessionId @@ -590,6 +761,7 @@ public struct ConnectionManifest: Codable, Equatable { self.profile = profile self.grant = grant self.correlationId = correlationId + self.selectedDescriptor = selectedDescriptor try validate() } @@ -597,7 +769,7 @@ public struct ConnectionManifest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), purpose: try c.decode(String.self, forKey: .purpose), sessionId: try c.decode(String.self, forKey: .sessionId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), gateway: try c.decode(ManifestGateway.self, forKey: .gateway), tunnel: try c.decode(ManifestTunnel.self, forKey: .tunnel), profile: try c.decode(ManifestProfile.self, forKey: .profile), grant: try c.decode(GrantReference.self, forKey: .grant), correlationId: try c.decode(String.self, forKey: .correlationId)) + try self.init(version: try c.decode(String.self, forKey: .version), purpose: try c.decode(String.self, forKey: .purpose), sessionId: try c.decode(String.self, forKey: .sessionId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), gateway: try c.decode(ManifestGateway.self, forKey: .gateway), tunnel: try c.decode(ManifestTunnel.self, forKey: .tunnel), profile: try c.decode(ManifestProfile.self, forKey: .profile), grant: try c.decode(GrantReference.self, forKey: .grant), correlationId: try c.decode(String.self, forKey: .correlationId), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor)) } public func validate() throws { @@ -614,9 +786,10 @@ public struct ConnectionManifest: Codable, Equatable { if self.correlationId.isEmpty { throw ContractValidationError(field: "correlation_id", code: "required") } if !self.correlationId.isEmpty && self.correlationId.utf8.count < 1 { throw ContractValidationError(field: "correlation_id", code: "min_length") } if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") } + try self.selectedDescriptor.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -675,7 +848,7 @@ public struct DeviceChallenge: Codable, Equatable { if self.signatureFormat != "ed25519-domain-separated-v1" { throw ContractValidationError(field: "signature_format", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -709,7 +882,7 @@ public struct DeviceProofRequest: Codable, Equatable { if self.signature.utf8.count > 256 { throw ContractValidationError(field: "signature", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -759,7 +932,36 @@ public struct DeviceRegistrationRequest: Codable, Equatable { if self.publicKey.utf8.count > 256 { throw ContractValidationError(field: "public_key", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct DisplayLimitOverrideRequest: Codable, Equatable { + public let allowDisplayLimitOverride: Bool + public let expectedVersion: Int64 + enum CodingKeys: String, CodingKey { + case allowDisplayLimitOverride = "allow_display_limit_override" + case expectedVersion = "expected_version" + } + + public init(allowDisplayLimitOverride: Bool, expectedVersion: Int64) throws { + self.allowDisplayLimitOverride = allowDisplayLimitOverride + self.expectedVersion = expectedVersion + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(allowDisplayLimitOverride: try c.decode(Bool.self, forKey: .allowDisplayLimitOverride), expectedVersion: try c.decode(Int64.self, forKey: .expectedVersion)) + } + + public func validate() throws { + if self.expectedVersion < 1 { throw ContractValidationError(field: "expected_version", code: "minimum") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -796,7 +998,7 @@ public struct DisplayMode: Codable, Equatable { if self.fps > 240 { throw ContractValidationError(field: "fps", code: "maximum") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -804,16 +1006,19 @@ public struct EntitledPool: Codable, Equatable { public let poolId: String public let name: String public let assignmentState: String + public let qualityLimits: SessionQualityLimits enum CodingKeys: String, CodingKey { case poolId = "pool_id" case name = "name" case assignmentState = "assignment_state" + case qualityLimits = "quality_limits" } - public init(poolId: String, name: String, assignmentState: String) throws { + public init(poolId: String, name: String, assignmentState: String, qualityLimits: SessionQualityLimits) throws { self.poolId = poolId self.name = name self.assignmentState = assignmentState + self.qualityLimits = qualityLimits try validate() } @@ -821,7 +1026,7 @@ public struct EntitledPool: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(poolId: try c.decode(String.self, forKey: .poolId), name: try c.decode(String.self, forKey: .name), assignmentState: try c.decode(String.self, forKey: .assignmentState)) + try self.init(poolId: try c.decode(String.self, forKey: .poolId), name: try c.decode(String.self, forKey: .name), assignmentState: try c.decode(String.self, forKey: .assignmentState), qualityLimits: try c.decode(SessionQualityLimits.self, forKey: .qualityLimits)) } public func validate() throws { @@ -834,9 +1039,10 @@ public struct EntitledPool: Codable, Equatable { if self.assignmentState.isEmpty { throw ContractValidationError(field: "assignment_state", code: "required") } if !self.assignmentState.isEmpty && self.assignmentState.utf8.count < 1 { throw ContractValidationError(field: "assignment_state", code: "min_length") } if self.assignmentState.utf8.count > 64 { throw ContractValidationError(field: "assignment_state", code: "max_length") } + try self.qualityLimits.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -896,7 +1102,7 @@ public struct ErrorEnvelope: Codable, Equatable { for item in self.violations { try item.validate() } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -956,7 +1162,7 @@ public struct EventEnvelope: Codable, Equatable { if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -986,7 +1192,7 @@ public struct EventResume: Codable, Equatable { if self.lastSequence < 0 { throw ContractValidationError(field: "last_sequence", code: "minimum") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1020,7 +1226,7 @@ public struct FieldViolation: Codable, Equatable { if self.code.utf8.count > 64 { throw ContractValidationError(field: "code", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1069,7 +1275,7 @@ public struct GatewayClipboardAudit: Codable, Equatable { if !["forwarded", "loop", "policy", "rate", "provider", "malformed"].contains(self.reason) { throw ContractValidationError(field: "reason", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1111,7 +1317,7 @@ public struct GatewayClipboardText: Codable, Equatable { if !validBase64URL(self.loopToken) { throw ContractValidationError(field: "loop_token", code: "invalid_format") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1158,7 +1364,7 @@ public struct GatewayDrain: Codable, Equatable { if !validRFC3339UTC(self.deadline) { throw ContractValidationError(field: "deadline", code: "invalid_time") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1217,7 +1423,202 @@ public struct GatewayHeartbeat: Codable, Equatable { try self.telemetry.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayQualityAck: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + public let revision: Int64 + public let outcome: String + public let currentAppliedRevision: Int64 + public let failureCode: String? + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + case revision = "revision" + case outcome = "outcome" + case currentAppliedRevision = "current_applied_revision" + case failureCode = "failure_code" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, outcome: String, currentAppliedRevision: Int64, failureCode: String?) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + self.revision = revision + self.outcome = outcome + self.currentAppliedRevision = currentAppliedRevision + self.failureCode = failureCode + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), outcome: try c.decode(String.self, forKey: .outcome), currentAppliedRevision: try c.decode(Int64.self, forKey: .currentAppliedRevision), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if !["applied", "not_applied", "uncertain"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") } + if self.currentAppliedRevision < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } + if let value = self.failureCode { + if !value.isEmpty && value.utf8.count < 1 { throw ContractValidationError(field: "failure_code", code: "min_length") } + if value.utf8.count > 128 { throw ContractValidationError(field: "failure_code", code: "max_length") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayQualityWork: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + public let revision: Int64 + public let leaseExpiresAt: String + public let selectedDescriptor: SelectedSessionDescriptor + public let currentAppliedRevision: Int64? + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + case revision = "revision" + case leaseExpiresAt = "lease_expires_at" + case selectedDescriptor = "selected_descriptor" + case currentAppliedRevision = "current_applied_revision" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Int64?) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + self.revision = revision + self.leaseExpiresAt = leaseExpiresAt + self.selectedDescriptor = selectedDescriptor + self.currentAppliedRevision = currentAppliedRevision + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), leaseExpiresAt: try c.decode(String.self, forKey: .leaseExpiresAt), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if self.leaseExpiresAt.utf8.count > 64 { throw ContractValidationError(field: "lease_expires_at", code: "max_length") } + if !validRFC3339UTC(self.leaseExpiresAt) { throw ContractValidationError(field: "lease_expires_at", code: "invalid_time") } + try self.selectedDescriptor.validate() + if let value = self.currentAppliedRevision { + if value < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayQualityWorkRequest: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + public let revision: Int64 + public let currentAppliedRevision: Int64? + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + case revision = "revision" + case currentAppliedRevision = "current_applied_revision" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, currentAppliedRevision: Int64?) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + self.revision = revision + self.currentAppliedRevision = currentAppliedRevision + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if let value = self.currentAppliedRevision { + if value < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1310,7 +1711,170 @@ public struct GatewayRegistration: Codable, Equatable { if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: "protocol_version", code: "invalid_order") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayStopAck: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + public let outcome: String + public let failureCode: String? + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + case outcome = "outcome" + case failureCode = "failure_code" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, outcome: String, failureCode: String?) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + self.outcome = outcome + self.failureCode = failureCode + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), outcome: try c.decode(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if !["applied", "failed", "termination_unconfirmed"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") } + if let value = self.failureCode { + if !value.isEmpty && value.utf8.count < 1 { throw ContractValidationError(field: "failure_code", code: "min_length") } + if value.utf8.count > 128 { throw ContractValidationError(field: "failure_code", code: "max_length") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayStopWork: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + public let attempt: Int64 + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + case attempt = "attempt" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, attempt: Int64) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + self.attempt = attempt + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), attempt: try c.decode(Int64.self, forKey: .attempt)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.attempt < 1 { throw ContractValidationError(field: "attempt", code: "minimum") } + if self.attempt > 1 { throw ContractValidationError(field: "attempt", code: "maximum") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct GatewayStopWorkRequest: Codable, Equatable { + public let version: String + public let sessionId: String + public let gatewayId: String + public let reconnectSequence: Int64 + public let operationId: String + enum CodingKeys: String, CodingKey { + case version = "version" + case sessionId = "session_id" + case gatewayId = "gateway_id" + case reconnectSequence = "reconnect_sequence" + case operationId = "operation_id" + } + + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String) throws { + self.version = version + self.sessionId = sessionId + self.gatewayId = gatewayId + self.reconnectSequence = reconnectSequence + self.operationId = operationId + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId)) + } + + public func validate() throws { + if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") } + if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } + if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } + if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1421,7 +1985,7 @@ public struct GatewayTelemetry: Codable, Equatable { if !["unknown", "starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"].contains(self.providerState) { throw ContractValidationError(field: "provider_state", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1460,7 +2024,7 @@ public struct GrantReference: Codable, Equatable { if self.audience.utf8.count > 128 { throw ContractValidationError(field: "audience", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1500,7 +2064,7 @@ public struct LoginRequest: Codable, Equatable { if self.password.utf8.count > 1024 { throw ContractValidationError(field: "password", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1538,7 +2102,7 @@ public struct ManifestBounds: Codable, Equatable { if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: "bounds", code: "invalid_order") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1579,7 +2143,7 @@ public struct ManifestGateway: Codable, Equatable { if self.publicIdentity.utf8.count > 256 { throw ContractValidationError(field: "public_identity", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1617,7 +2181,7 @@ public struct ManifestProfile: Codable, Equatable { } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1652,7 +2216,7 @@ public struct ManifestTunnel: Codable, Equatable { for item in self.features where item.utf8.count > 64 { throw ContractValidationError(field: "features", code: "max_item_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1701,7 +2265,7 @@ public struct NativeAuthenticatedSession: Codable, Equatable { try self.nativeIdentity.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1759,7 +2323,7 @@ public struct NativeCredential: Codable, Equatable { } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1793,7 +2357,7 @@ public struct NativeSessionIdentity: Codable, Equatable { if self.deviceKeyId.utf8.count > 128 { throw ContractValidationError(field: "device_key_id", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1844,7 +2408,7 @@ public struct NativeTunnelCredential: Codable, Equatable { if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -1875,7 +2439,7 @@ public struct PageInfo: Codable, Equatable { if self.nextCursor.utf8.count > 512 { throw ContractValidationError(field: "next_cursor", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2001,7 +2565,7 @@ public struct ProviderSessionWork: Codable, Equatable { try self.clipboardPolicy.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2046,33 +2610,30 @@ public struct ProviderState: Codable, Equatable { for item in self.channels where item.utf8.count > 64 { throw ContractValidationError(field: "channels", code: "max_item_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } public struct ProviderStreamPolicy: Codable, Equatable { - public let resolutionWidth: Int64 - public let resolutionHeight: Int64 - public let fps: Int64 - public let codec: String - public let bitrateKbps: Int64 - public let audioEnabled: Bool + public let videoProfile: VideoProfile + public let audioProfile: AudioProfile + public let displayMode: DisplayMode + public let bitrateTargetKbps: Int64 + public let bitrateMaximumKbps: Int64 enum CodingKeys: String, CodingKey { - case resolutionWidth = "resolution_width" - case resolutionHeight = "resolution_height" - case fps = "fps" - case codec = "codec" - case bitrateKbps = "bitrate_kbps" - case audioEnabled = "audio_enabled" + case videoProfile = "video_profile" + case audioProfile = "audio_profile" + case displayMode = "display_mode" + case bitrateTargetKbps = "bitrate_target_kbps" + case bitrateMaximumKbps = "bitrate_maximum_kbps" } - public init(resolutionWidth: Int64, resolutionHeight: Int64, fps: Int64, codec: String, bitrateKbps: Int64, audioEnabled: Bool) throws { - self.resolutionWidth = resolutionWidth - self.resolutionHeight = resolutionHeight - self.fps = fps - self.codec = codec - self.bitrateKbps = bitrateKbps - self.audioEnabled = audioEnabled + public init(videoProfile: VideoProfile, audioProfile: AudioProfile, displayMode: DisplayMode, bitrateTargetKbps: Int64, bitrateMaximumKbps: Int64) throws { + self.videoProfile = videoProfile + self.audioProfile = audioProfile + self.displayMode = displayMode + self.bitrateTargetKbps = bitrateTargetKbps + self.bitrateMaximumKbps = bitrateMaximumKbps try validate() } @@ -2080,22 +2641,141 @@ public struct ProviderStreamPolicy: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(resolutionWidth: try c.decode(Int64.self, forKey: .resolutionWidth), resolutionHeight: try c.decode(Int64.self, forKey: .resolutionHeight), fps: try c.decode(Int64.self, forKey: .fps), codec: try c.decode(String.self, forKey: .codec), bitrateKbps: try c.decode(Int64.self, forKey: .bitrateKbps), audioEnabled: try c.decode(Bool.self, forKey: .audioEnabled)) + try self.init(videoProfile: try c.decode(VideoProfile.self, forKey: .videoProfile), audioProfile: try c.decode(AudioProfile.self, forKey: .audioProfile), displayMode: try c.decode(DisplayMode.self, forKey: .displayMode), bitrateTargetKbps: try c.decode(Int64.self, forKey: .bitrateTargetKbps), bitrateMaximumKbps: try c.decode(Int64.self, forKey: .bitrateMaximumKbps)) } public func validate() throws { - if self.resolutionWidth < 320 { throw ContractValidationError(field: "resolution_width", code: "minimum") } - if self.resolutionWidth > 16384 { throw ContractValidationError(field: "resolution_width", code: "maximum") } - if self.resolutionHeight < 200 { throw ContractValidationError(field: "resolution_height", code: "minimum") } - if self.resolutionHeight > 8640 { throw ContractValidationError(field: "resolution_height", code: "maximum") } - if self.fps < 1 { throw ContractValidationError(field: "fps", code: "minimum") } - if self.fps > 240 { throw ContractValidationError(field: "fps", code: "maximum") } - if !["H264", "HEVC", "AV1"].contains(self.codec) { throw ContractValidationError(field: "codec", code: "invalid_value") } - if self.bitrateKbps < 100 { throw ContractValidationError(field: "bitrate_kbps", code: "minimum") } - if self.bitrateKbps > 1000000 { throw ContractValidationError(field: "bitrate_kbps", code: "maximum") } + try self.videoProfile.validate() + try self.audioProfile.validate() + try self.displayMode.validate() + if self.bitrateTargetKbps < 100 { throw ContractValidationError(field: "bitrate_target_kbps", code: "minimum") } + if self.bitrateTargetKbps > 1000000 { throw ContractValidationError(field: "bitrate_target_kbps", code: "maximum") } + if self.bitrateMaximumKbps < 100 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "minimum") } + if self.bitrateMaximumKbps > 1000000 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "maximum") } + if bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: "bitrate_bounds", code: "invalid_order") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct QualityChangeOperation: Codable, Equatable { + public let operationId: String + public let sessionId: String + public let revision: Int64 + public let state: String + public let requestedBitratePreference: BitratePreference + public let effectiveBitrateKbps: Int64 + public let governingPolicyVersion: String + public let sessionVersion: Int64 + public let createdAt: String + public let deadlineAt: String + public let updatedAt: String + public let failureCode: String? + enum CodingKeys: String, CodingKey { + case operationId = "operation_id" + case sessionId = "session_id" + case revision = "revision" + case state = "state" + case requestedBitratePreference = "requested_bitrate_preference" + case effectiveBitrateKbps = "effective_bitrate_kbps" + case governingPolicyVersion = "governing_policy_version" + case sessionVersion = "session_version" + case createdAt = "created_at" + case deadlineAt = "deadline_at" + case updatedAt = "updated_at" + case failureCode = "failure_code" + } + + public init(operationId: String, sessionId: String, revision: Int64, state: String, requestedBitratePreference: BitratePreference, effectiveBitrateKbps: Int64, governingPolicyVersion: String, sessionVersion: Int64, createdAt: String, deadlineAt: String, updatedAt: String, failureCode: String?) throws { + self.operationId = operationId + self.sessionId = sessionId + self.revision = revision + self.state = state + self.requestedBitratePreference = requestedBitratePreference + self.effectiveBitrateKbps = effectiveBitrateKbps + self.governingPolicyVersion = governingPolicyVersion + self.sessionVersion = sessionVersion + self.createdAt = createdAt + self.deadlineAt = deadlineAt + self.updatedAt = updatedAt + self.failureCode = failureCode + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(operationId: try c.decode(String.self, forKey: .operationId), sessionId: try c.decode(String.self, forKey: .sessionId), revision: try c.decode(Int64.self, forKey: .revision), state: try c.decode(String.self, forKey: .state), requestedBitratePreference: try c.decode(BitratePreference.self, forKey: .requestedBitratePreference), effectiveBitrateKbps: try c.decode(Int64.self, forKey: .effectiveBitrateKbps), governingPolicyVersion: try c.decode(String.self, forKey: .governingPolicyVersion), sessionVersion: try c.decode(Int64.self, forKey: .sessionVersion), createdAt: try c.decode(String.self, forKey: .createdAt), deadlineAt: try c.decode(String.self, forKey: .deadlineAt), updatedAt: try c.decode(String.self, forKey: .updatedAt), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) + } + + public func validate() throws { + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if !["pending", "applying", "applied", "failed", "timed_out"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") } + try self.requestedBitratePreference.validate() + if self.effectiveBitrateKbps < 100 { throw ContractValidationError(field: "effective_bitrate_kbps", code: "minimum") } + if self.effectiveBitrateKbps > 1000000 { throw ContractValidationError(field: "effective_bitrate_kbps", code: "maximum") } + if self.governingPolicyVersion.isEmpty { throw ContractValidationError(field: "governing_policy_version", code: "required") } + if !self.governingPolicyVersion.isEmpty && self.governingPolicyVersion.utf8.count < 1 { throw ContractValidationError(field: "governing_policy_version", code: "min_length") } + if self.governingPolicyVersion.utf8.count > 128 { throw ContractValidationError(field: "governing_policy_version", code: "max_length") } + if self.sessionVersion < 1 { throw ContractValidationError(field: "session_version", code: "minimum") } + if self.createdAt.utf8.count > 64 { throw ContractValidationError(field: "created_at", code: "max_length") } + if !validRFC3339UTC(self.createdAt) { throw ContractValidationError(field: "created_at", code: "invalid_time") } + if self.deadlineAt.utf8.count > 64 { throw ContractValidationError(field: "deadline_at", code: "max_length") } + if !validRFC3339UTC(self.deadlineAt) { throw ContractValidationError(field: "deadline_at", code: "invalid_time") } + if self.updatedAt.utf8.count > 64 { throw ContractValidationError(field: "updated_at", code: "max_length") } + if !validRFC3339UTC(self.updatedAt) { throw ContractValidationError(field: "updated_at", code: "invalid_time") } + if let value = self.failureCode { + if !value.isEmpty && value.utf8.count < 1 { throw ContractValidationError(field: "failure_code", code: "min_length") } + if value.utf8.count > 128 { throw ContractValidationError(field: "failure_code", code: "max_length") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct QualityChangeRequest: Codable, Equatable { + public let bitratePreference: BitratePreference + public let expectedSessionVersion: Int64 + public let expectedPolicyVersion: String + enum CodingKeys: String, CodingKey { + case bitratePreference = "bitrate_preference" + case expectedSessionVersion = "expected_session_version" + case expectedPolicyVersion = "expected_policy_version" + } + + public init(bitratePreference: BitratePreference, expectedSessionVersion: Int64, expectedPolicyVersion: String) throws { + self.bitratePreference = bitratePreference + self.expectedSessionVersion = expectedSessionVersion + self.expectedPolicyVersion = expectedPolicyVersion + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(bitratePreference: try c.decode(BitratePreference.self, forKey: .bitratePreference), expectedSessionVersion: try c.decode(Int64.self, forKey: .expectedSessionVersion), expectedPolicyVersion: try c.decode(String.self, forKey: .expectedPolicyVersion)) + } + + public func validate() throws { + try self.bitratePreference.validate() + if self.expectedSessionVersion < 1 { throw ContractValidationError(field: "expected_session_version", code: "minimum") } + if self.expectedPolicyVersion.isEmpty { throw ContractValidationError(field: "expected_policy_version", code: "required") } + if !self.expectedPolicyVersion.isEmpty && self.expectedPolicyVersion.utf8.count < 1 { throw ContractValidationError(field: "expected_policy_version", code: "min_length") } + if self.expectedPolicyVersion.utf8.count > 128 { throw ContractValidationError(field: "expected_policy_version", code: "max_length") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2134,7 +2814,7 @@ public struct ReauthGrant: Codable, Equatable { if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2166,7 +2846,7 @@ public struct ReauthRequest: Codable, Equatable { if !["identity_change", "key_change", "backup_enable", "external_database_tls_disabled", "assignment_change"].contains(self.purpose) { throw ContractValidationError(field: "purpose", code: "invalid_value") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2174,16 +2854,19 @@ public struct ReconnectRequest: Codable, Equatable { public let clientDeviceId: String public let deviceKeyId: String public let expectedVersion: Int64 + public let displayRelaunchConfirmed: Bool enum CodingKeys: String, CodingKey { case clientDeviceId = "client_device_id" case deviceKeyId = "device_key_id" case expectedVersion = "expected_version" + case displayRelaunchConfirmed = "display_relaunch_confirmed" } - public init(clientDeviceId: String, deviceKeyId: String, expectedVersion: Int64) throws { + public init(clientDeviceId: String, deviceKeyId: String, expectedVersion: Int64, displayRelaunchConfirmed: Bool) throws { self.clientDeviceId = clientDeviceId self.deviceKeyId = deviceKeyId self.expectedVersion = expectedVersion + self.displayRelaunchConfirmed = displayRelaunchConfirmed try validate() } @@ -2191,7 +2874,7 @@ public struct ReconnectRequest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), expectedVersion: try c.decode(Int64.self, forKey: .expectedVersion)) + try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), expectedVersion: try c.decode(Int64.self, forKey: .expectedVersion), displayRelaunchConfirmed: try c.decode(Bool.self, forKey: .displayRelaunchConfirmed)) } public func validate() throws { @@ -2204,7 +2887,7 @@ public struct ReconnectRequest: Codable, Equatable { if self.expectedVersion < 1 { throw ContractValidationError(field: "expected_version", code: "minimum") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2238,7 +2921,7 @@ public struct RefreshRequest: Codable, Equatable { if self.refreshToken.utf8.count > 256 { throw ContractValidationError(field: "refresh_token", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2299,7 +2982,7 @@ public struct Resource: Codable, Equatable { for item in self.links { try item.validate() } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2337,7 +3020,7 @@ public struct ResourceLink: Codable, Equatable { if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2373,7 +3056,90 @@ public struct ResourceList: Codable, Equatable { try self.page.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct SelectedSessionDescriptor: Codable, Equatable { + public let videoProfile: VideoProfile + public let audioProfile: AudioProfile + public let displayMode: DisplayMode + public let bitrateTargetKbps: Int64 + public let bitrateMaximumKbps: Int64 + public let adjustment: SessionAdjustment + public let mediaTimestampBasis: String + enum CodingKeys: String, CodingKey { + case videoProfile = "video_profile" + case audioProfile = "audio_profile" + case displayMode = "display_mode" + case bitrateTargetKbps = "bitrate_target_kbps" + case bitrateMaximumKbps = "bitrate_maximum_kbps" + case adjustment = "adjustment" + case mediaTimestampBasis = "media_timestamp_basis" + } + + public init(videoProfile: VideoProfile, audioProfile: AudioProfile, displayMode: DisplayMode, bitrateTargetKbps: Int64, bitrateMaximumKbps: Int64, adjustment: SessionAdjustment, mediaTimestampBasis: String) throws { + self.videoProfile = videoProfile + self.audioProfile = audioProfile + self.displayMode = displayMode + self.bitrateTargetKbps = bitrateTargetKbps + self.bitrateMaximumKbps = bitrateMaximumKbps + self.adjustment = adjustment + self.mediaTimestampBasis = mediaTimestampBasis + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(videoProfile: try c.decode(VideoProfile.self, forKey: .videoProfile), audioProfile: try c.decode(AudioProfile.self, forKey: .audioProfile), displayMode: try c.decode(DisplayMode.self, forKey: .displayMode), bitrateTargetKbps: try c.decode(Int64.self, forKey: .bitrateTargetKbps), bitrateMaximumKbps: try c.decode(Int64.self, forKey: .bitrateMaximumKbps), adjustment: try c.decode(SessionAdjustment.self, forKey: .adjustment), mediaTimestampBasis: try c.decode(String.self, forKey: .mediaTimestampBasis)) + } + + public func validate() throws { + try self.videoProfile.validate() + try self.audioProfile.validate() + try self.displayMode.validate() + if self.bitrateTargetKbps < 100 { throw ContractValidationError(field: "bitrate_target_kbps", code: "minimum") } + if self.bitrateTargetKbps > 1000000 { throw ContractValidationError(field: "bitrate_target_kbps", code: "maximum") } + if self.bitrateMaximumKbps < 100 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "minimum") } + if self.bitrateMaximumKbps > 1000000 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "maximum") } + try self.adjustment.validate() + if self.mediaTimestampBasis != "gateway-send-wall-clock-ms" { throw ContractValidationError(field: "media_timestamp_basis", code: "invalid_value") } + if bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: "bitrate_bounds", code: "invalid_order") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct SessionAdjustment: Codable, Equatable { + public let displayReason: String + public let bitrateReason: String + enum CodingKeys: String, CodingKey { + case displayReason = "display_reason" + case bitrateReason = "bitrate_reason" + } + + public init(displayReason: String, bitrateReason: String) throws { + self.displayReason = displayReason + self.bitrateReason = bitrateReason + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(displayReason: try c.decode(String.self, forKey: .displayReason), bitrateReason: try c.decode(String.self, forKey: .bitrateReason)) + } + + public func validate() throws { + if !["none", "policy_clamp", "direct_entitlement_override"].contains(self.displayReason) { throw ContractValidationError(field: "display_reason", code: "invalid_value") } + if !["none", "policy_default", "session_ceiling_clamp", "aggregate_capacity_clamp"].contains(self.bitrateReason) { throw ContractValidationError(field: "bitrate_reason", code: "invalid_value") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2387,6 +3153,7 @@ public struct SessionAuthority: Codable, Equatable { public let capabilities: CapabilityProfile public let providerProfile: String public let providerIdentity: String + public let selectedDescriptor: SelectedSessionDescriptor enum CodingKeys: String, CodingKey { case version = "version" case sessionId = "session_id" @@ -2397,9 +3164,10 @@ public struct SessionAuthority: Codable, Equatable { case capabilities = "capabilities" case providerProfile = "provider_profile" case providerIdentity = "provider_identity" + case selectedDescriptor = "selected_descriptor" } - public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile, providerProfile: String, providerIdentity: String) throws { + public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile, providerProfile: String, providerIdentity: String, selectedDescriptor: SelectedSessionDescriptor) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId @@ -2409,6 +3177,7 @@ public struct SessionAuthority: Codable, Equatable { self.capabilities = capabilities self.providerProfile = providerProfile self.providerIdentity = providerIdentity + self.selectedDescriptor = selectedDescriptor try validate() } @@ -2416,7 +3185,7 @@ public struct SessionAuthority: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor)) } public func validate() throws { @@ -2438,9 +3207,65 @@ public struct SessionAuthority: Codable, Equatable { if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") } if !self.providerIdentity.isEmpty && self.providerIdentity.utf8.count < 1 { throw ContractValidationError(field: "provider_identity", code: "min_length") } if self.providerIdentity.utf8.count > 256 { throw ContractValidationError(field: "provider_identity", code: "max_length") } + try self.selectedDescriptor.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct SessionQualityLimits: Codable, Equatable { + public let policyVersionId: String + public let policyDisplayLimit: DisplayMode + public let selectableDisplayMaximum: DisplayMode + public let displayLimitOverride: Bool + public let bitrateMinimumKbps: Int64 + public let bitrateTargetKbps: Int64 + public let bitrateMaximumKbps: Int64 + enum CodingKeys: String, CodingKey { + case policyVersionId = "policy_version_id" + case policyDisplayLimit = "policy_display_limit" + case selectableDisplayMaximum = "selectable_display_maximum" + case displayLimitOverride = "display_limit_override" + case bitrateMinimumKbps = "bitrate_minimum_kbps" + case bitrateTargetKbps = "bitrate_target_kbps" + case bitrateMaximumKbps = "bitrate_maximum_kbps" + } + + public init(policyVersionId: String, policyDisplayLimit: DisplayMode, selectableDisplayMaximum: DisplayMode, displayLimitOverride: Bool, bitrateMinimumKbps: Int64, bitrateTargetKbps: Int64, bitrateMaximumKbps: Int64) throws { + self.policyVersionId = policyVersionId + self.policyDisplayLimit = policyDisplayLimit + self.selectableDisplayMaximum = selectableDisplayMaximum + self.displayLimitOverride = displayLimitOverride + self.bitrateMinimumKbps = bitrateMinimumKbps + self.bitrateTargetKbps = bitrateTargetKbps + self.bitrateMaximumKbps = bitrateMaximumKbps + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(policyVersionId: try c.decode(String.self, forKey: .policyVersionId), policyDisplayLimit: try c.decode(DisplayMode.self, forKey: .policyDisplayLimit), selectableDisplayMaximum: try c.decode(DisplayMode.self, forKey: .selectableDisplayMaximum), displayLimitOverride: try c.decode(Bool.self, forKey: .displayLimitOverride), bitrateMinimumKbps: try c.decode(Int64.self, forKey: .bitrateMinimumKbps), bitrateTargetKbps: try c.decode(Int64.self, forKey: .bitrateTargetKbps), bitrateMaximumKbps: try c.decode(Int64.self, forKey: .bitrateMaximumKbps)) + } + + public func validate() throws { + if self.policyVersionId.isEmpty { throw ContractValidationError(field: "policy_version_id", code: "required") } + if !self.policyVersionId.isEmpty && self.policyVersionId.utf8.count < 1 { throw ContractValidationError(field: "policy_version_id", code: "min_length") } + if self.policyVersionId.utf8.count > 128 { throw ContractValidationError(field: "policy_version_id", code: "max_length") } + try self.policyDisplayLimit.validate() + try self.selectableDisplayMaximum.validate() + if self.bitrateMinimumKbps < 100 { throw ContractValidationError(field: "bitrate_minimum_kbps", code: "minimum") } + if self.bitrateMinimumKbps > 1000000 { throw ContractValidationError(field: "bitrate_minimum_kbps", code: "maximum") } + if self.bitrateTargetKbps < 100 { throw ContractValidationError(field: "bitrate_target_kbps", code: "minimum") } + if self.bitrateTargetKbps > 1000000 { throw ContractValidationError(field: "bitrate_target_kbps", code: "maximum") } + if self.bitrateMaximumKbps < 100 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "minimum") } + if self.bitrateMaximumKbps > 1000000 { throw ContractValidationError(field: "bitrate_maximum_kbps", code: "maximum") } + if bitrateMinimumKbps > bitrateTargetKbps || bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: "bitrate_bounds", code: "invalid_order") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2449,20 +3274,26 @@ public struct SessionRequest: Codable, Equatable { public let deviceKeyId: String public let poolId: String public let idempotencyKey: String + public let videoProfiles: [VideoProfile] + public let bitratePreference: BitratePreference public let requestedDisplayMode: DisplayMode? enum CodingKeys: String, CodingKey { case clientDeviceId = "client_device_id" case deviceKeyId = "device_key_id" case poolId = "pool_id" case idempotencyKey = "idempotency_key" + case videoProfiles = "video_profiles" + case bitratePreference = "bitrate_preference" case requestedDisplayMode = "requested_display_mode" } - public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: DisplayMode?) throws { + public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, videoProfiles: [VideoProfile], bitratePreference: BitratePreference, requestedDisplayMode: DisplayMode?) throws { self.clientDeviceId = clientDeviceId self.deviceKeyId = deviceKeyId self.poolId = poolId self.idempotencyKey = idempotencyKey + self.videoProfiles = videoProfiles + self.bitratePreference = bitratePreference self.requestedDisplayMode = requestedDisplayMode try validate() } @@ -2471,7 +3302,7 @@ public struct SessionRequest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil) + try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), videoProfiles: try c.decode([VideoProfile].self, forKey: .videoProfiles), bitratePreference: try c.decode(BitratePreference.self, forKey: .bitratePreference), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil) } public func validate() throws { @@ -2487,12 +3318,17 @@ public struct SessionRequest: Codable, Equatable { if self.idempotencyKey.isEmpty { throw ContractValidationError(field: "idempotency_key", code: "required") } if !self.idempotencyKey.isEmpty && self.idempotencyKey.utf8.count < 1 { throw ContractValidationError(field: "idempotency_key", code: "min_length") } if self.idempotencyKey.utf8.count > 256 { throw ContractValidationError(field: "idempotency_key", code: "max_length") } + if self.videoProfiles.count < 1 { throw ContractValidationError(field: "video_profiles", code: "min_items") } + if self.videoProfiles.count > 12 { throw ContractValidationError(field: "video_profiles", code: "max_items") } + for (index, item) in self.videoProfiles.enumerated() where self.videoProfiles[.. Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2533,7 +3369,72 @@ public struct StableError: Codable, Equatable { if self.message.utf8.count > 512 { throw ContractValidationError(field: "message", code: "max_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct StopOperation: Codable, Equatable { + public let operationId: String + public let sessionId: String + public let state: String + public let sessionVersion: Int64 + public let createdAt: String + public let deadlineAt: String + public let updatedAt: String + public let failureCode: String? + enum CodingKeys: String, CodingKey { + case operationId = "operation_id" + case sessionId = "session_id" + case state = "state" + case sessionVersion = "session_version" + case createdAt = "created_at" + case deadlineAt = "deadline_at" + case updatedAt = "updated_at" + case failureCode = "failure_code" + } + + public init(operationId: String, sessionId: String, state: String, sessionVersion: Int64, createdAt: String, deadlineAt: String, updatedAt: String, failureCode: String?) throws { + self.operationId = operationId + self.sessionId = sessionId + self.state = state + self.sessionVersion = sessionVersion + self.createdAt = createdAt + self.deadlineAt = deadlineAt + self.updatedAt = updatedAt + self.failureCode = failureCode + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(operationId: try c.decode(String.self, forKey: .operationId), sessionId: try c.decode(String.self, forKey: .sessionId), state: try c.decode(String.self, forKey: .state), sessionVersion: try c.decode(Int64.self, forKey: .sessionVersion), createdAt: try c.decode(String.self, forKey: .createdAt), deadlineAt: try c.decode(String.self, forKey: .deadlineAt), updatedAt: try c.decode(String.self, forKey: .updatedAt), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) + } + + public func validate() throws { + if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } + if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") } + if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") } + if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") } + if !["pending", "attempting", "applied", "failed", "termination_unconfirmed"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") } + if self.sessionVersion < 1 { throw ContractValidationError(field: "session_version", code: "minimum") } + if self.createdAt.utf8.count > 64 { throw ContractValidationError(field: "created_at", code: "max_length") } + if !validRFC3339UTC(self.createdAt) { throw ContractValidationError(field: "created_at", code: "invalid_time") } + if self.deadlineAt.utf8.count > 64 { throw ContractValidationError(field: "deadline_at", code: "max_length") } + if !validRFC3339UTC(self.deadlineAt) { throw ContractValidationError(field: "deadline_at", code: "invalid_time") } + if self.updatedAt.utf8.count > 64 { throw ContractValidationError(field: "updated_at", code: "max_length") } + if !validRFC3339UTC(self.updatedAt) { throw ContractValidationError(field: "updated_at", code: "invalid_time") } + if let value = self.failureCode { + if !value.isEmpty && value.utf8.count < 1 { throw ContractValidationError(field: "failure_code", code: "min_length") } + if value.utf8.count > 128 { throw ContractValidationError(field: "failure_code", code: "max_length") } + } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2603,7 +3504,7 @@ public struct TunnelAdmissionRequest: Codable, Equatable { try self.capabilities.validate() } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2636,7 +3537,50 @@ public struct VersionNegotiation: Codable, Equatable { for item in self.features where item.utf8.count > 64 { throw ContractValidationError(field: "features", code: "max_item_length") } } - public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } + public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } +} + +public struct VideoProfile: Codable, Equatable { + public let codec: String + public let bitDepth: Int64 + public let chromaSubsampling: String + public let colorSpace: String + public let transferFunction: String + enum CodingKeys: String, CodingKey { + case codec = "codec" + case bitDepth = "bit_depth" + case chromaSubsampling = "chroma_subsampling" + case colorSpace = "color_space" + case transferFunction = "transfer_function" + } + + public init(codec: String, bitDepth: Int64, chromaSubsampling: String, colorSpace: String, transferFunction: String) throws { + self.codec = codec + self.bitDepth = bitDepth + self.chromaSubsampling = chromaSubsampling + self.colorSpace = colorSpace + self.transferFunction = transferFunction + try validate() + } + + public init(from decoder: Decoder) throws { + let all = try decoder.container(keyedBy: AnyCodingKey.self) + for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init(codec: try c.decode(String.self, forKey: .codec), bitDepth: try c.decode(Int64.self, forKey: .bitDepth), chromaSubsampling: try c.decode(String.self, forKey: .chromaSubsampling), colorSpace: try c.decode(String.self, forKey: .colorSpace), transferFunction: try c.decode(String.self, forKey: .transferFunction)) + } + + public func validate() throws { + if !["h264", "hevc", "av1"].contains(self.codec) { throw ContractValidationError(field: "codec", code: "invalid_value") } + if self.bitDepth < 8 { throw ContractValidationError(field: "bit_depth", code: "minimum") } + if self.bitDepth > 8 { throw ContractValidationError(field: "bit_depth", code: "maximum") } + if self.chromaSubsampling != "4:2:0" { throw ContractValidationError(field: "chroma_subsampling", code: "invalid_value") } + if self.colorSpace != "bt709-limited" { throw ContractValidationError(field: "color_space", code: "invalid_value") } + if self.transferFunction != "sdr" { throw ContractValidationError(field: "transfer_function", code: "invalid_value") } + } + + public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } @@ -2657,8 +3601,9 @@ public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, public extension TunnelAdmissionRequest { func deviceAdmissionTranscript() -> Data { - var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)] - fields.append(contentsOf: capabilities.clientDecode) + var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.sourceRateControl, String(capabilities.videoProfiles.count), String(capabilities.audioProfiles.count)] + for profile in capabilities.videoProfiles { fields.append(contentsOf: [profile.codec, String(profile.bitDepth), profile.chromaSubsampling, profile.colorSpace, profile.transferFunction]) } + for profile in capabilities.audioProfiles { fields.append(contentsOf: [profile.codec, String(profile.sampleRateHz), String(profile.channels), profile.channelLayout, String(profile.packetDurationMs)]) } var transcript = "versevdi/tunnel-admission/v1" for field in fields { transcript += "\(field.utf8.count):\(field)" } return Data(transcript.utf8) @@ -2669,13 +3614,15 @@ public extension CapabilityProfile { static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile { guard let selected = profiles.first else { throw ContractValidationError(field: "capabilities", code: "no_overlap") } try selected.validate() - var common = selected.clientDecode + var commonVideo = selected.videoProfiles + var commonAudio = selected.audioProfiles for profile in profiles.dropFirst() { try profile.validate() - if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: "capabilities", code: "no_overlap") } - common = common.filter { profile.clientDecode.contains($0) } - if common.isEmpty { throw ContractValidationError(field: "capabilities", code: "no_overlap") } + if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: "capabilities", code: "no_overlap") } + commonVideo = commonVideo.filter { profile.videoProfiles.contains($0) } + commonAudio = commonAudio.filter { profile.audioProfiles.contains($0) } + if commonVideo.isEmpty || commonAudio.isEmpty { throw ContractValidationError(field: "capabilities", code: "no_overlap") } } - return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, audio: selected.audio, sourceRateControl: selected.sourceRateControl, clientDecode: common) + return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, sourceRateControl: selected.sourceRateControl, videoProfiles: commonVideo, audioProfiles: commonAudio) } } diff --git a/openapi/control-v1.yaml b/openapi/control-v1.yaml index 75b6726..3477192 100644 --- a/openapi/control-v1.yaml +++ b/openapi/control-v1.yaml @@ -10,6 +10,8 @@ tags: - name: resources - name: broker - name: events + - name: session-quality + - name: gateway paths: /api/v1/auth/login: post: @@ -326,6 +328,143 @@ paths: '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} + /api/v1/session-quality-limits: + get: + tags: [session-quality] + operationId: getSessionQualityLimits + security: [{nativeBearer: []}] + responses: + '200': {description: Current global native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + /api/v1/session-quality-limits/assignments/{assignment_id}: + get: + tags: [session-quality] + operationId: getAssignmentSessionQualityLimits + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/AssignmentID'}] + responses: + '200': {description: Current assignment-specific native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /api/v1/session-quality-limits/pools/{pool_id}: + get: + tags: [session-quality] + operationId: getPoolSessionQualityLimits + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/PoolID'}] + responses: + '200': {description: Current pool-specific native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /api/v1/admin/entitlements/{entitlement_id}/display-limit-override: + patch: + tags: [session-quality] + operationId: updateEntitlementDisplayLimitOverride + security: + - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] + parameters: [{$ref: '#/components/parameters/EntitlementID'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/DisplayLimitOverrideRequest}}}} + responses: + '204': {description: Direct entitlement display override updated.} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/broker/sessions/{session_id}/quality-changes: + post: + tags: [session-quality] + operationId: createSessionQualityChange + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeRequest}}}} + responses: + '201': {description: Durable quality change created., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '200': {description: Existing idempotent quality change returned., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/broker/sessions/{session_id}/quality-changes/{operation_id}: + get: + tags: [session-quality] + operationId: getSessionQualityChange + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}] + responses: + '200': {description: Durable quality change state., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /api/v1/broker/sessions/{session_id}/stop-operations: + post: + tags: [session-quality] + operationId: createSessionStopOperation + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}] + responses: + '201': {description: Durable Stop operation created., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '200': {description: Existing idempotent Stop operation returned., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/broker/sessions/{session_id}/stop-operations/{operation_id}: + get: + tags: [session-quality] + operationId: getSessionStopOperation + security: [{nativeBearer: []}] + parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}] + responses: + '200': {description: Durable Stop operation state., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /api/v1/gateway/quality-work: + post: + tags: [gateway] + operationId: acquireGatewayQualityWork + security: [{gatewayMutualTLS: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWorkRequest}}}} + responses: + '200': {description: Leased quality work or reconciliation observation., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWork}}}} + '204': {description: No matching work is available.} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/gateway/quality-ack: + post: + tags: [gateway] + operationId: acknowledgeGatewayQualityWork + security: [{gatewayMutualTLS: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityAck}}}} + responses: + '204': {description: Quality acknowledgement committed idempotently.} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/gateway/stop-work: + post: + tags: [gateway] + operationId: acquireGatewayStopWork + security: [{gatewayMutualTLS: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWorkRequest}}}} + responses: + '200': {description: One-way non-reissued Stop work claim., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWork}}}} + '204': {description: No matching Stop work is available.} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/gateway/stop-ack: + post: + tags: [gateway] + operationId: acknowledgeGatewayStopWork + security: [{gatewayMutualTLS: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopAck}}}} + responses: + '204': {description: Stop acknowledgement committed idempotently.} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} /api/v1/events: get: tags: [events] @@ -385,6 +524,8 @@ components: type: http scheme: bearer bearerFormat: opaque + gatewayMutualTLS: + type: mutualTLS parameters: DeviceID: name: device_id @@ -396,6 +537,26 @@ components: in: path required: true schema: {type: string, minLength: 1, maxLength: 128} + AssignmentID: + name: assignment_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + PoolID: + name: pool_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + EntitlementID: + name: entitlement_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + OperationID: + name: operation_id + in: path + required: true + schema: {type: string, format: uuid} Limit: name: limit in: query diff --git a/proto/versevdi/tunnel/v1/tunnel.proto b/proto/versevdi/tunnel/v1/tunnel.proto index 6ee85b1..ec40cc7 100644 --- a/proto/versevdi/tunnel/v1/tunnel.proto +++ b/proto/versevdi/tunnel/v1/tunnel.proto @@ -54,9 +54,56 @@ message CapabilityProfile { string transport = 1; string framing = 2; string media = 3; - string audio = 4; + reserved 4; string source_rate_control = 5; - repeated string client_decode = 6; + reserved 6; + repeated VideoProfile video_profiles = 7; + repeated AudioProfile audio_profiles = 8; +} + +message VideoProfile { + string codec = 1; + uint32 bit_depth = 2; + string chroma_subsampling = 3; + string color_space = 4; + string transfer_function = 5; +} + +message AudioProfile { + string codec = 1; + uint32 sample_rate_hz = 2; + uint32 channels = 3; + string channel_layout = 4; + uint32 packet_duration_ms = 5; +} + +message DisplayMode { + uint32 resolution_width = 1; + uint32 resolution_height = 2; + uint32 fps = 3; +} + +message SessionAdjustment { + string display_reason = 1; + string bitrate_reason = 2; +} + +message SelectedSessionDescriptor { + VideoProfile video_profile = 1; + AudioProfile audio_profile = 2; + DisplayMode display_mode = 3; + uint64 bitrate_target_kbps = 4; + uint64 bitrate_maximum_kbps = 5; + SessionAdjustment adjustment = 6; + string media_timestamp_basis = 7; +} + +message ProviderStreamPolicy { + VideoProfile video_profile = 1; + AudioProfile audio_profile = 2; + DisplayMode display_mode = 3; + uint64 bitrate_target_kbps = 4; + uint64 bitrate_maximum_kbps = 5; } message GatewayRegistration { @@ -115,6 +162,7 @@ message SessionAuthority { CapabilityProfile capabilities = 7; string provider_profile = 8; string provider_identity = 9; + SelectedSessionDescriptor selected_descriptor = 10; } message ClientSessionAuthority { @@ -125,6 +173,7 @@ message ClientSessionAuthority { uint64 reconnect_sequence = 5; google.protobuf.Timestamp expires_at = 6; CapabilityProfile capabilities = 7; + SelectedSessionDescriptor selected_descriptor = 8; } message ProviderSessionWork { @@ -147,6 +196,68 @@ message ProviderSessionWork { string client_id = 17; ClipboardPolicy clipboard_policy = 18; bool provider_application_termination_allowed = 19; + ProviderStreamPolicy stream_policy = 20; +} + +message GatewayQualityWorkRequest { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; + uint64 revision = 6; + optional uint64 current_applied_revision = 7; +} + +message GatewayQualityWork { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; + uint64 revision = 6; + google.protobuf.Timestamp lease_expires_at = 7; + SelectedSessionDescriptor selected_descriptor = 8; + optional uint64 current_applied_revision = 9; +} + +message GatewayQualityAck { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; + uint64 revision = 6; + string outcome = 7; + uint64 current_applied_revision = 8; + string failure_code = 9; +} + +message GatewayStopWork { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; + uint32 attempt = 6; +} + +message GatewayStopWorkRequest { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; +} + +message GatewayStopAck { + string version = 1; + string session_id = 2; + string gateway_id = 3; + uint64 reconnect_sequence = 4; + string operation_id = 5; + string outcome = 6; + string failure_code = 7; } message ClipboardPolicy { diff --git a/registries/datagrams.json b/registries/datagrams.json index ca2c6ae..84b04b6 100644 --- a/registries/datagrams.json +++ b/registries/datagrams.json @@ -8,6 +8,9 @@ "datagrams": [ {"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024, "payload_profile": "gateway-feedback-v1"}, {"id": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048}, + {"id": "session.quality.prompt.v1", "direction": "client-to-server", "max_payload_bytes": 32, "payload_profile": "gateway-feedback-v1"}, + {"id": "session.stop.prompt.v1", "direction": "client-to-server", "max_payload_bytes": 24, "payload_profile": "gateway-feedback-v1"}, + {"id": "controller.arrival.v1", "direction": "client-to-server", "max_payload_bytes": 14, "payload_profile": "gateway-input-v1"}, {"id": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536}, {"id": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1200}, {"id": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200}, diff --git a/registries/features.json b/registries/features.json index 3024bde..dafe22c 100644 --- a/registries/features.json +++ b/registries/features.json @@ -7,6 +7,10 @@ {"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536}, {"id": "display.request.v1", "direction": "client-to-server", "max_frame_bytes": 65536}, {"id": "input.absolute.v1", "direction": "client-to-server", "max_frame_bytes": 14}, - {"id": "input.scroll.v1", "direction": "client-to-server", "max_frame_bytes": 10} + {"id": "input.scroll.v1", "direction": "client-to-server", "max_frame_bytes": 10}, + {"id": "video.profile.v1", "direction": "bidirectional", "max_frame_bytes": 65536}, + {"id": "session.quality.v1", "direction": "bidirectional", "max_frame_bytes": 32}, + {"id": "session.stop.v1", "direction": "bidirectional", "max_frame_bytes": 24}, + {"id": "controller.arrival.v1", "direction": "client-to-server", "max_frame_bytes": 14} ] } diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 5123110..ff991f6 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -64,22 +64,24 @@ "AssignedDesktop": { "type": "object", "additionalProperties": false, - "required": ["assignment_id", "pool_id", "name", "availability"], + "required": ["assignment_id", "pool_id", "name", "availability", "quality_limits"], "properties": { "assignment_id": {"type": "string", "minLength": 1, "maxLength": 128}, "pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, "name": {"type": "string", "minLength": 1, "maxLength": 256}, - "availability": {"type": "string", "minLength": 1, "maxLength": 64} + "availability": {"type": "string", "minLength": 1, "maxLength": 64}, + "quality_limits": {"$ref": "#/$defs/SessionQualityLimits"} } }, "EntitledPool": { "type": "object", "additionalProperties": false, - "required": ["pool_id", "name", "assignment_state"], + "required": ["pool_id", "name", "assignment_state", "quality_limits"], "properties": { "pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, "name": {"type": "string", "minLength": 1, "maxLength": 256}, - "assignment_state": {"type": "string", "minLength": 1, "maxLength": 64} + "assignment_state": {"type": "string", "minLength": 1, "maxLength": 64}, + "quality_limits": {"$ref": "#/$defs/SessionQualityLimits"} } }, "ResourceList": { @@ -232,32 +234,105 @@ "fps": {"type": "integer", "minimum": 1, "maximum": 240} } }, + "VideoProfile": { + "type": "object", + "additionalProperties": false, + "required": ["codec", "bit_depth", "chroma_subsampling", "color_space", "transfer_function"], + "properties": { + "codec": {"type": "string", "enum": ["h264", "hevc", "av1"]}, + "bit_depth": {"type": "integer", "minimum": 8, "maximum": 8}, + "chroma_subsampling": {"type": "string", "const": "4:2:0"}, + "color_space": {"type": "string", "const": "bt709-limited"}, + "transfer_function": {"type": "string", "const": "sdr"} + } + }, + "AudioProfile": { + "type": "object", + "additionalProperties": false, + "required": ["codec", "sample_rate_hz", "channels", "channel_layout", "packet_duration_ms"], + "properties": { + "codec": {"type": "string", "const": "opus"}, + "sample_rate_hz": {"type": "integer", "minimum": 48000, "maximum": 48000}, + "channels": {"type": "integer", "minimum": 2, "maximum": 2}, + "channel_layout": {"type": "string", "const": "stereo"}, + "packet_duration_ms": {"type": "integer", "minimum": 5, "maximum": 5} + } + }, + "BitratePreference": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": {"type": "string", "enum": ["auto", "explicit"]}, + "target_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000, "x-optional-pointer": true} + } + }, + "SessionQualityLimits": { + "type": "object", + "additionalProperties": false, + "required": ["policy_version_id", "policy_display_limit", "selectable_display_maximum", "display_limit_override", "bitrate_minimum_kbps", "bitrate_target_kbps", "bitrate_maximum_kbps"], + "properties": { + "policy_version_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "policy_display_limit": {"$ref": "#/$defs/DisplayMode"}, + "selectable_display_maximum": {"$ref": "#/$defs/DisplayMode"}, + "display_limit_override": {"type": "boolean"}, + "bitrate_minimum_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "bitrate_target_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "bitrate_maximum_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000} + } + }, + "SessionAdjustment": { + "type": "object", + "additionalProperties": false, + "required": ["display_reason", "bitrate_reason"], + "properties": { + "display_reason": {"type": "string", "enum": ["none", "policy_clamp", "direct_entitlement_override"]}, + "bitrate_reason": {"type": "string", "enum": ["none", "policy_default", "session_ceiling_clamp", "aggregate_capacity_clamp"]} + } + }, + "SelectedSessionDescriptor": { + "type": "object", + "additionalProperties": false, + "required": ["video_profile", "audio_profile", "display_mode", "bitrate_target_kbps", "bitrate_maximum_kbps", "adjustment", "media_timestamp_basis"], + "properties": { + "video_profile": {"$ref": "#/$defs/VideoProfile"}, + "audio_profile": {"$ref": "#/$defs/AudioProfile"}, + "display_mode": {"$ref": "#/$defs/DisplayMode"}, + "bitrate_target_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "bitrate_maximum_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "adjustment": {"$ref": "#/$defs/SessionAdjustment"}, + "media_timestamp_basis": {"type": "string", "const": "gateway-send-wall-clock-ms"} + } + }, "SessionRequest": { "type": "object", "additionalProperties": false, - "required": ["client_device_id", "device_key_id", "pool_id", "idempotency_key"], + "required": ["client_device_id", "device_key_id", "pool_id", "idempotency_key", "video_profiles", "bitrate_preference"], "properties": { "client_device_id": {"type": "string", "minLength": 1, "maxLength": 128}, "device_key_id": {"type": "string", "minLength": 1, "maxLength": 128}, "pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, "idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256}, + "video_profiles": {"type": "array", "minItems": 1, "maxItems": 12, "uniqueItems": true, "items": {"$ref": "#/$defs/VideoProfile"}}, + "bitrate_preference": {"$ref": "#/$defs/BitratePreference"}, "requested_display_mode": {"$ref": "#/$defs/DisplayMode"} } }, "ReconnectRequest": { "type": "object", "additionalProperties": false, - "required": ["client_device_id", "device_key_id", "expected_version"], + "required": ["client_device_id", "device_key_id", "expected_version", "display_relaunch_confirmed"], "properties": { "client_device_id": {"type": "string", "minLength": 1, "maxLength": 128}, "device_key_id": {"type": "string", "minLength": 1, "maxLength": 128}, - "expected_version": {"type": "integer", "minimum": 1} + "expected_version": {"type": "integer", "minimum": 1}, + "display_relaunch_confirmed": {"type": "boolean"} } }, "BrokerSession": { "type": "object", "additionalProperties": false, - "required": ["id", "principal_id", "pool_id", "state", "policy_snapshot", "cleanup_state", "idempotency_key", "correlation_id", "requested_at", "version"], + "required": ["id", "principal_id", "pool_id", "state", "policy_snapshot", "cleanup_state", "idempotency_key", "correlation_id", "requested_at", "version", "requested_video_profiles", "requested_bitrate_preference"], "properties": { "id": {"type": "string", "minLength": 1, "maxLength": 128}, "principal_id": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -275,7 +350,10 @@ "ended_at": {"type": "string", "format": "date-time", "maxLength": 64}, "version": {"type": "integer", "minimum": 1}, "requested_display_mode": {"$ref": "#/$defs/DisplayMode"}, - "effective_display_mode": {"$ref": "#/$defs/DisplayMode"} + "effective_display_mode": {"$ref": "#/$defs/DisplayMode"}, + "requested_video_profiles": {"type": "array", "minItems": 1, "maxItems": 12, "uniqueItems": true, "items": {"$ref": "#/$defs/VideoProfile"}}, + "requested_bitrate_preference": {"$ref": "#/$defs/BitratePreference"}, + "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"} } }, "AllocationPolicy": { @@ -297,7 +375,7 @@ "ConnectionManifest": { "type": "object", "additionalProperties": false, - "required": ["version", "purpose", "session_id", "reconnect_sequence", "gateway", "tunnel", "profile", "grant", "correlation_id"], + "required": ["version", "purpose", "session_id", "reconnect_sequence", "gateway", "tunnel", "profile", "grant", "correlation_id", "selected_descriptor"], "properties": { "version": {"type": "string", "const": "1"}, "purpose": {"type": "string", "enum": ["launch", "reconnect"]}, @@ -307,7 +385,8 @@ "tunnel": {"$ref": "#/$defs/ManifestTunnel"}, "profile": {"$ref": "#/$defs/ManifestProfile"}, "grant": {"$ref": "#/$defs/GrantReference"}, - "correlation_id": {"type": "string", "minLength": 1, "maxLength": 128} + "correlation_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"} } }, "ManifestGateway": { @@ -431,20 +510,14 @@ "CapabilityProfile": { "type": "object", "additionalProperties": false, - "required": ["transport", "framing", "media", "audio", "source_rate_control", "client_decode"], + "required": ["transport", "framing", "media", "source_rate_control", "video_profiles", "audio_profiles"], "properties": { "transport": {"type": "string", "minLength": 1, "maxLength": 64}, "framing": {"type": "string", "enum": ["datagram-v1", "datagram-v2"]}, "media": {"type": "string", "minLength": 1, "maxLength": 64}, - "audio": {"type": "string", "minLength": 1, "maxLength": 64}, "source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64}, - "client_decode": { - "type": "array", - "minItems": 1, - "maxItems": 2, - "uniqueItems": true, - "items": {"type": "string", "enum": ["h264-opus", "hevc-opus"]} - } + "video_profiles": {"type": "array", "minItems": 1, "maxItems": 12, "uniqueItems": true, "items": {"$ref": "#/$defs/VideoProfile"}}, + "audio_profiles": {"type": "array", "minItems": 1, "maxItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/AudioProfile"}} } }, "GatewayRegistration": { @@ -538,7 +611,7 @@ "SessionAuthority": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "provider_profile", "provider_identity"], + "required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "provider_profile", "provider_identity", "selected_descriptor"], "properties": { "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -548,13 +621,14 @@ "expires_at": {"type": "string", "format": "date-time", "maxLength": 64}, "capabilities": {"$ref": "#/$defs/CapabilityProfile"}, "provider_profile": {"type": "string", "enum": ["apollo"]}, - "provider_identity": {"type": "string", "minLength": 1, "maxLength": 256} + "provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}, + "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"} } }, "ClientSessionAuthority": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"], + "required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor"], "properties": { "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -562,20 +636,133 @@ "audience": {"type": "string", "minLength": 1, "maxLength": 256}, "reconnect_sequence": {"type": "integer", "minimum": 0}, "expires_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$", "maxLength": 64}, - "capabilities": {"$ref": "#/$defs/CapabilityProfile"} + "capabilities": {"$ref": "#/$defs/CapabilityProfile"}, + "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"} } }, "ProviderStreamPolicy": { "type": "object", "additionalProperties": false, - "required": ["resolution_width", "resolution_height", "fps", "codec", "bitrate_kbps", "audio_enabled"], + "required": ["video_profile", "audio_profile", "display_mode", "bitrate_target_kbps", "bitrate_maximum_kbps"], "properties": { - "resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384}, - "resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640}, - "fps": {"type": "integer", "minimum": 1, "maximum": 240}, - "codec": {"type": "string", "enum": ["H264", "HEVC", "AV1"]}, - "bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, - "audio_enabled": {"type": "boolean"} + "video_profile": {"$ref": "#/$defs/VideoProfile"}, + "audio_profile": {"$ref": "#/$defs/AudioProfile"}, + "display_mode": {"$ref": "#/$defs/DisplayMode"}, + "bitrate_target_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "bitrate_maximum_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000} + } + }, + "DisplayLimitOverrideRequest": { + "type": "object", + "additionalProperties": false, + "required": ["allow_display_limit_override", "expected_version"], + "properties": { + "allow_display_limit_override": {"type": "boolean"}, + "expected_version": {"type": "integer", "minimum": 1} + } + }, + "QualityChangeRequest": { + "type": "object", + "additionalProperties": false, + "required": ["bitrate_preference", "expected_session_version", "expected_policy_version"], + "properties": { + "bitrate_preference": {"$ref": "#/$defs/BitratePreference"}, + "expected_session_version": {"type": "integer", "minimum": 1}, + "expected_policy_version": {"type": "string", "minLength": 1, "maxLength": 128} + } + }, + "QualityChangeOperation": { + "type": "object", + "additionalProperties": false, + "required": ["operation_id", "session_id", "revision", "state", "requested_bitrate_preference", "effective_bitrate_kbps", "governing_policy_version", "session_version", "created_at", "deadline_at", "updated_at"], + "properties": { + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "revision": {"type": "integer", "minimum": 1}, + "state": {"type": "string", "enum": ["pending", "applying", "applied", "failed", "timed_out"]}, + "requested_bitrate_preference": {"$ref": "#/$defs/BitratePreference"}, + "effective_bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, + "governing_policy_version": {"type": "string", "minLength": 1, "maxLength": 128}, + "session_version": {"type": "integer", "minimum": 1}, + "created_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "deadline_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "updated_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} + } + }, + "StopOperation": { + "type": "object", + "additionalProperties": false, + "required": ["operation_id", "session_id", "state", "session_version", "created_at", "deadline_at", "updated_at"], + "properties": { + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "state": {"type": "string", "enum": ["pending", "attempting", "applied", "failed", "termination_unconfirmed"]}, + "session_version": {"type": "integer", "minimum": 1}, + "created_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "deadline_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "updated_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} + } + }, + "GatewayQualityWorkRequest": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true} + } + }, + "GatewayQualityWork": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_expires_at", "selected_descriptor"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "lease_expires_at": {"type": "string", "format": "date-time", "maxLength": 64}, "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"}, + "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true} + } + }, + "GatewayQualityAck": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "outcome", "current_applied_revision"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "outcome": {"type": "string", "enum": ["applied", "not_applied", "uncertain"]}, "current_applied_revision": {"type": "integer", "minimum": 0}, + "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} + } + }, + "GatewayStopWorkRequest": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36} + } + }, + "GatewayStopWork": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "attempt"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "attempt": {"type": "integer", "minimum": 1, "maximum": 1} + } + }, + "GatewayStopAck": { + "type": "object", "additionalProperties": false, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "outcome"], + "properties": { + "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "outcome": {"type": "string", "enum": ["applied", "failed", "termination_unconfirmed"]}, + "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} } }, "ProviderSessionWork": { diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 2c798dd..e4b9aad 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -11,6 +11,24 @@ import ( protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" ) +func rc6Video(codec string) protocol.VideoProfile { + return protocol.VideoProfile{Codec: codec, BitDepth: 8, ChromaSubsampling: "4:2:0", ColorSpace: "bt709-limited", TransferFunction: "sdr"} +} + +func rc6Audio() protocol.AudioProfile { + return protocol.AudioProfile{Codec: "opus", SampleRateHz: 48000, Channels: 2, ChannelLayout: "stereo", PacketDurationMs: 5} +} + +func rc6Capability(codecs ...string) protocol.CapabilityProfile { + videos := make([]protocol.VideoProfile, len(codecs)) + for index, codec := range codecs { videos[index] = rc6Video(codec) } + return protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", SourceRateControl: "server", VideoProfiles: videos, AudioProfiles: []protocol.AudioProfile{rc6Audio()}} +} + +func rc6Descriptor(codec string) protocol.SelectedSessionDescriptor { + return protocol.SelectedSessionDescriptor{VideoProfile: rc6Video(codec), AudioProfile: rc6Audio(), DisplayMode: protocol.DisplayMode{ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60}, BitrateTargetKbps: 20000, BitrateMaximumKbps: 40000, Adjustment: protocol.SessionAdjustment{DisplayReason: "none", BitrateReason: "none"}, MediaTimestampBasis: "gateway-send-wall-clock-ms"} +} + func TestDeviceRegistrationProofTranscriptIsCanonicalAndStrict(t *testing.T) { serverID, _ := hex.DecodeString("00112233445566778899aabbccddeeff") principalID, _ := hex.DecodeString("102132435465768798a9bacbdcedfe0f") @@ -51,7 +69,7 @@ func TestDeviceRegistrationProofTranscriptIsCanonicalAndStrict(t *testing.T) { } func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) { - valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway.control.test"},"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1"]},"profile":{"id":"standard","bounds":{"minimum_kbps":1,"target_kbps":2,"maximum_kbps":3}},"grant":{"opaque_value":"opaque-one-time-grant-value-with-at-least-43-bytes","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"correlation-1"}` + valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway.control.test"},"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1"]},"profile":{"id":"standard","bounds":{"minimum_kbps":1,"target_kbps":2,"maximum_kbps":3}},"grant":{"opaque_value":"opaque-one-time-grant-value-with-at-least-43-bytes","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"correlation-1","selected_descriptor":{"video_profile":{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"},"audio_profile":{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60},"bitrate_target_kbps":20000,"bitrate_maximum_kbps":40000,"adjustment":{"display_reason":"none","bitrate_reason":"none"},"media_timestamp_basis":"gateway-send-wall-clock-ms"}}` manifest, err := protocol.DecodeConnectionManifest([]byte(valid)) if err != nil || manifest.Gateway.ID != "gateway-1" { t.Fatalf("valid manifest = %+v, err = %v", manifest, err) @@ -83,6 +101,7 @@ func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) { request := protocol.SessionRequest{ ClientDeviceID: "device-1", DeviceKeyID: "key-1", PoolID: "pool-1", IdempotencyKey: "request-1", + VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, BitratePreference: protocol.BitratePreference{Mode: "auto"}, } legacy, err := protocol.EncodeSessionRequest(request) if err != nil { @@ -140,7 +159,7 @@ func TestSessionRequestRejectsClientPolicySnapshot(t *testing.T) { if protocol.CurrentWireVersion != "2" || protocol.NMinus1WireVersion != "1" || protocol.NMinus2WireVersion != "0" { t.Fatalf("wire compatibility = current %q, n-1 %q, n-2 %q", protocol.CurrentWireVersion, protocol.NMinus1WireVersion, protocol.NMinus2WireVersion) } - request := `{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1"}` + request := `{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1","video_profiles":[{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}],"bitrate_preference":{"mode":"auto"}}` if _, err := protocol.DecodeSessionRequest([]byte(request)); err != nil { t.Fatalf("policy-free SessionRequest rejected: %v", err) } @@ -225,7 +244,7 @@ func TestGeneratedDecodersRejectMissingRequiredFieldsAndTrailingValues(t *testin } func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) { - registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":1,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}` + registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":1,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","source_rate_control":"server","video_profiles":[{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}],"audio_profiles":[{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5}]}}` if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err != nil { t.Fatalf("valid gateway registration rejected: %v", err) } @@ -244,7 +263,7 @@ func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) { } func TestGatewayRegistrationRejectsInvertedProtocolBounds(t *testing.T) { - registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":2,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}` + registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":2,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","source_rate_control":"server","video_profiles":[{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}],"audio_profiles":[{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5}]}}` if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err == nil { t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds") } @@ -267,12 +286,12 @@ func TestGatewayHeartbeatCarriesBoundedObservedTelemetry(t *testing.T) { } func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) { - first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", SourceRateControl: "server", ClientDecode: []string{"h264-opus"}} + first := rc6Capability("h264") if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || !reflect.DeepEqual(got, first) { t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err) } second := first - second.ClientDecode = []string{"hevc-opus"} + second.VideoProfiles = []protocol.VideoProfile{rc6Video("hevc")} if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil { t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile") } @@ -280,31 +299,31 @@ func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) { func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) { gateway := protocol.CapabilityProfile{ - Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", - SourceRateControl: "server", ClientDecode: []string{"hevc-opus", "h264-opus"}, + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", + SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("hevc"), rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()}, } h264Client := gateway - h264Client.ClientDecode = []string{"h264-opus"} + h264Client.VideoProfiles = []protocol.VideoProfile{rc6Video("h264")} selected, err := protocol.IntersectCapabilityProfiles(gateway, h264Client) - if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"h264-opus"}) { + if err != nil || !reflect.DeepEqual(selected.VideoProfiles, []protocol.VideoProfile{rc6Video("h264")}) { t.Fatalf("H.264 profile intersection = %+v, %v", selected, err) } hevcClient := gateway - hevcClient.ClientDecode = []string{"hevc-opus"} + hevcClient.VideoProfiles = []protocol.VideoProfile{rc6Video("hevc")} selected, err = protocol.IntersectCapabilityProfiles(gateway, hevcClient) - if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"hevc-opus"}) { + if err != nil || !reflect.DeepEqual(selected.VideoProfiles, []protocol.VideoProfile{rc6Video("hevc")}) { t.Fatalf("HEVC profile intersection = %+v, %v", selected, err) } noOverlap := gateway - noOverlap.ClientDecode = []string{"h264-opus"} + noOverlap.VideoProfiles = []protocol.VideoProfile{rc6Video("h264")} if _, err := protocol.IntersectCapabilityProfiles(noOverlap, hevcClient); err == nil { t.Fatal("intersection accepted registered profiles without overlap") } - for _, invalid := range [][]string{{"h264-hevc-opus"}, {"h264-opus", "h264-opus"}} { + for _, invalid := range [][]protocol.VideoProfile{{{Codec: "vp9", BitDepth: 8, ChromaSubsampling: "4:2:0", ColorSpace: "bt709-limited", TransferFunction: "sdr"}}, {rc6Video("h264"), rc6Video("h264")}} { profile := gateway - profile.ClientDecode = invalid + profile.VideoProfiles = invalid if err := profile.Validate(); err == nil { - t.Fatalf("CapabilityProfile accepted invalid registered profile set %q", invalid) + t.Fatalf("CapabilityProfile accepted invalid registered profile set %+v", invalid) } } } @@ -312,7 +331,7 @@ func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) { func TestCapabilityProfileRequiresRegisteredFraming(t *testing.T) { valid := protocol.CapabilityProfile{ Transport: "quic-tls13", Framing: "datagram-v2", Media: "encoded", - Audio: "encoded", SourceRateControl: "server", ClientDecode: []string{"h264-opus"}, + SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()}, } if err := valid.Validate(); err != nil { t.Fatalf("registered datagram-v2 rejected: %v", err) @@ -329,8 +348,8 @@ func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) { Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0, ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{ - Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", - SourceRateControl: "server", ClientDecode: []string{"h264-opus"}, + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", + SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()}, }, } if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil { @@ -343,18 +362,18 @@ func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience", Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16), DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{ - Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", - SourceRateControl: "server", ClientDecode: []string{"h264-opus"}, + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", + SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()}, }, } - want := "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + strings.Repeat("g", 43) + "1:016:" + strings.Repeat("n", 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus" + want := "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + strings.Repeat("g", 43) + "1:016:" + strings.Repeat("n", 16) + "10:quic-tls1311:datagram-v17:encoded6:server1:11:14:h2641:85:4:2:013:bt709-limited3:sdr4:opus5:480001:26:stereo1:5" if got := string(request.DeviceAdmissionTranscript()); got != want { t.Fatalf("DeviceAdmissionTranscript() = %q, want %q", got, want) } } func TestSessionAuthorityRejectsProviderRoute(t *testing.T) { - valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","audience":"versevdi-gateway","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo","provider_identity":"provider-1"}` + valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","audience":"versevdi-gateway","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","source_rate_control":"server","video_profiles":[{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}],"audio_profiles":[{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5}]},"provider_profile":"apollo","provider_identity":"provider-1","selected_descriptor":{"video_profile":{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"},"audio_profile":{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60},"bitrate_target_kbps":20000,"bitrate_maximum_kbps":40000,"adjustment":{"display_reason":"none","bitrate_reason":"none"},"media_timestamp_basis":"gateway-send-wall-clock-ms"}}` if _, err := protocol.DecodeSessionAuthority([]byte(valid)); err != nil { t.Fatalf("valid session authority rejected: %v", err) } @@ -367,9 +386,10 @@ func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) { authority := protocol.ClientSessionAuthority{ Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ReconnectSequence: 2, ExpiresAt: "2099-01-01T00:00:00Z", Capabilities: protocol.CapabilityProfile{ - Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", - SourceRateControl: "server", ClientDecode: []string{"h264-opus"}, + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", + SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()}, }, + SelectedDescriptor: rc6Descriptor("h264"), } encoded, err := protocol.EncodeClientSessionAuthority(authority) if err != nil { @@ -381,7 +401,7 @@ func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) { } wantFields := map[string]bool{ "version": true, "session_id": true, "gateway_id": true, "audience": true, - "reconnect_sequence": true, "expires_at": true, "capabilities": true, + "reconnect_sequence": true, "expires_at": true, "capabilities": true, "selected_descriptor": true, } if len(fields) != len(wantFields) { t.Fatalf("encoded client authority fields = %v; want exactly %v", fields, wantFields) @@ -399,7 +419,7 @@ func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) { t.Fatalf("DecodeClientSessionAuthority() = %+v, %v; want %+v", decoded, err, authority) } - for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"} { + for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor"} { missing := make(map[string]json.RawMessage, len(fields)-1) for key, value := range fields { if key != field { @@ -439,7 +459,7 @@ func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) { } func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) { - valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}` + valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"video_profile":{"codec":"hevc","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"},"audio_profile":{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5},"display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120},"bitrate_target_kbps":40000,"bitrate_maximum_kbps":50000},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}` if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil { t.Fatalf("valid provider work rejected: %v", err) } @@ -450,10 +470,10 @@ func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) { t.Fatal("provider work accepted missing clipboard policy") } for _, invalid := range []string{ - strings.Replace(valid, `,"stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true}`, "", 1), + strings.Replace(valid, `,"stream_policy":{"video_profile":{"codec":"hevc","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"},"audio_profile":{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5},"display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120},"bitrate_target_kbps":40000,"bitrate_maximum_kbps":50000}`, "", 1), strings.Replace(valid, `"fps":120`, `"fps":241`, 1), - strings.Replace(valid, `"codec":"HEVC"`, `"codec":"VP9"`, 1), - strings.Replace(valid, `"audio_enabled":true`, `"audio_enabled":true,"unknown":false`, 1), + strings.Replace(valid, `"codec":"hevc"`, `"codec":"vp9"`, 1), + strings.Replace(valid, `"packet_duration_ms":5`, `"packet_duration_ms":5,"unknown":false`, 1), } { if _, err := protocol.DecodeProviderSessionWork([]byte(invalid)); err == nil { t.Fatalf("provider work accepted invalid stream policy: %s", invalid) diff --git a/tests/go/rc6_contract_test.go b/tests/go/rc6_contract_test.go new file mode 100644 index 0000000..3e93a08 --- /dev/null +++ b/tests/go/rc6_contract_test.go @@ -0,0 +1,140 @@ +package protocol_test + +import ( + "os" + "reflect" + "strings" + "testing" + + protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" +) + +func TestRC6GeneratedContractsHaveFrozenExactShapes(t *testing.T) { + tests := []struct { + value any + fields []string + }{ + {protocol.VideoProfile{}, []string{"Codec", "BitDepth", "ChromaSubsampling", "ColorSpace", "TransferFunction"}}, + {protocol.AudioProfile{}, []string{"Codec", "SampleRateHz", "Channels", "ChannelLayout", "PacketDurationMs"}}, + {protocol.BitratePreference{}, []string{"Mode", "TargetKbps"}}, + {protocol.SessionQualityLimits{}, []string{"PolicyVersionID", "PolicyDisplayLimit", "SelectableDisplayMaximum", "DisplayLimitOverride", "BitrateMinimumKbps", "BitrateTargetKbps", "BitrateMaximumKbps"}}, + {protocol.SessionAdjustment{}, []string{"DisplayReason", "BitrateReason"}}, + {protocol.SelectedSessionDescriptor{}, []string{"VideoProfile", "AudioProfile", "DisplayMode", "BitrateTargetKbps", "BitrateMaximumKbps", "Adjustment", "MediaTimestampBasis"}}, + } + for _, test := range tests { + typeOf := reflect.TypeOf(test.value) + if typeOf.NumField() != len(test.fields) { + t.Fatalf("%s field count = %d, want %d", typeOf.Name(), typeOf.NumField(), len(test.fields)) + } + for index, field := range test.fields { + if typeOf.Field(index).Name != field { + t.Fatalf("%s field %d = %s, want %s", typeOf.Name(), index, typeOf.Field(index).Name, field) + } + } + } +} + +func TestRC6ProfileValidationAndOrderedIntersection(t *testing.T) { + h264 := protocol.VideoProfile{Codec: "h264", BitDepth: 8, ChromaSubsampling: "4:2:0", ColorSpace: "bt709-limited", TransferFunction: "sdr"} + hevc := h264 + hevc.Codec = "hevc" + audio := protocol.AudioProfile{Codec: "opus", SampleRateHz: 48000, Channels: 2, ChannelLayout: "stereo", PacketDurationMs: 5} + first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v2", Media: "encoded", SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{h264, hevc}, AudioProfiles: []protocol.AudioProfile{audio}} + second := first + second.VideoProfiles = []protocol.VideoProfile{hevc, h264} + selected, err := protocol.IntersectCapabilityProfiles(first, second) + if err != nil || !reflect.DeepEqual(selected.VideoProfiles, []protocol.VideoProfile{h264, hevc}) { + t.Fatalf("ordered intersection = %+v, %v", selected.VideoProfiles, err) + } + duplicate := first + duplicate.VideoProfiles = []protocol.VideoProfile{h264, h264} + if duplicate.Validate() == nil { + t.Fatal("CapabilityProfile accepted duplicate video profiles") + } + invalid := h264 + invalid.BitDepth = 10 + if invalid.Validate() == nil { + t.Fatal("VideoProfile accepted a non-RC6 bit depth") + } +} + +func TestRC6StrictDecodeRejectsLegacyAndMalformedBodies(t *testing.T) { + for name, test := range map[string]struct { + payload []byte + decode func([]byte) error + }{ + "opaque capability": {[]byte(`{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}`), func(raw []byte) error { _, err := protocol.DecodeCapabilityProfile(raw); return err }}, + "old session request": {[]byte(`{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1"}`), func(raw []byte) error { _, err := protocol.DecodeSessionRequest(raw); return err }}, + "old provider policy": {[]byte(`{"resolution_width":1920,"resolution_height":1080,"fps":60,"codec":"H264","bitrate_kbps":20000,"audio_enabled":true}`), func(raw []byte) error { _, err := protocol.DecodeProviderStreamPolicy(raw); return err }}, + "unknown field": {[]byte(`{"mode":"explicit","target_kbps":1000,"unknown":true}`), func(raw []byte) error { _, err := protocol.DecodeBitratePreference(raw); return err }}, + "duplicate key": {[]byte(`{"mode":"explicit","mode":"auto","target_kbps":1000}`), func(raw []byte) error { _, err := protocol.DecodeBitratePreference(raw); return err }}, + "trailing JSON": {[]byte(`{"mode":"explicit","target_kbps":1000} {}`), func(raw []byte) error { _, err := protocol.DecodeBitratePreference(raw); return err }}, + } { + if err := test.decode(test.payload); err == nil { + t.Fatalf("RC6 accepted %s", name) + } + } +} + +func TestRC6BitratePreferenceTaggedBounds(t *testing.T) { + for _, valid := range []protocol.BitratePreference{{Mode: "auto"}, {Mode: "explicit", TargetKbps: int64Pointer(100)}, {Mode: "explicit", TargetKbps: int64Pointer(1_000_000)}} { + if err := valid.Validate(); err != nil { + t.Fatalf("valid preference %+v rejected: %v", valid, err) + } + } + for _, invalid := range []protocol.BitratePreference{{Mode: "auto", TargetKbps: int64Pointer(100)}, {Mode: "explicit"}, {Mode: "explicit", TargetKbps: int64Pointer(99)}, {Mode: "explicit", TargetKbps: int64Pointer(1_000_001)}} { + if invalid.Validate() == nil { + t.Fatalf("invalid preference %+v accepted", invalid) + } + } +} + +func TestRC6JSONFixturesAndCanonicalOperationFields(t *testing.T) { + validFixtures := map[string]func([]byte) error{ + "../../fixtures/valid/session-request.json": func(raw []byte) error { _, err := protocol.DecodeSessionRequest(raw); return err }, + "../../fixtures/valid/selected-session-descriptor.json": func(raw []byte) error { _, err := protocol.DecodeSelectedSessionDescriptor(raw); return err }, + "../../fixtures/valid/session-quality-limits.json": func(raw []byte) error { _, err := protocol.DecodeSessionQualityLimits(raw); return err }, + } + for path, decode := range validFixtures { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read valid fixture %s: %v", path, err) + } + if err := decode(raw); err != nil { + t.Fatalf("valid fixture %s rejected: %v", path, err) + } + } + invalidFixtures := map[string]func([]byte) error{ + "../../fixtures/invalid/capability-rc5-opaque.json": func(raw []byte) error { _, err := protocol.DecodeCapabilityProfile(raw); return err }, + "../../fixtures/invalid/session-request-rc5.json": func(raw []byte) error { _, err := protocol.DecodeSessionRequest(raw); return err }, + "../../fixtures/invalid/provider-stream-policy-rc5.json": func(raw []byte) error { _, err := protocol.DecodeProviderStreamPolicy(raw); return err }, + "../../fixtures/invalid/video-profile-unknown.json": func(raw []byte) error { _, err := protocol.DecodeVideoProfile(raw); return err }, + "../../fixtures/invalid/bitrate-preference-auto-target.json": func(raw []byte) error { _, err := protocol.DecodeBitratePreference(raw); return err }, + "../../fixtures/invalid/selected-session-descriptor-provider-field.json": func(raw []byte) error { _, err := protocol.DecodeSelectedSessionDescriptor(raw); return err }, + } + for path, decode := range invalidFixtures { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if decode(raw) == nil { + t.Fatalf("invalid fixture %s accepted", path) + } + } + validOperation := `{"operation_id":"12345678-1234-1234-1234-123456789abc","session_id":"session-1","revision":1,"state":"pending","requested_bitrate_preference":{"mode":"auto"},"effective_bitrate_kbps":20000,"governing_policy_version":"policy-1","session_version":1,"created_at":"2099-01-01T00:00:00Z","deadline_at":"2099-01-01T00:00:30Z","updated_at":"2099-01-01T00:00:00Z"}` + if _, err := protocol.DecodeQualityChangeOperation([]byte(validOperation)); err != nil { + t.Fatalf("valid quality operation rejected: %v", err) + } + for _, invalid := range []string{ + strings.Replace(validOperation, "123456789abc", "123456789ABC", 1), + strings.Replace(validOperation, "12345678-1234-1234-1234-123456789abc", "00000000-0000-0000-0000-000000000000", 1), + strings.Replace(validOperation, "2099-01-01T00:00:00Z", "2099-01-01T00:00:00+00:00", 1), + strings.Replace(validOperation, `,"revision":1`, "", 1), + } { + if _, err := protocol.DecodeQualityChangeOperation([]byte(invalid)); err == nil { + t.Fatalf("invalid operation accepted: %s", invalid) + } + } +} + +func int64Pointer(value int64) *int64 { return &value } diff --git a/tools/fixture_digest.py b/tools/fixture_digest.py index b4b9b97..63976ea 100644 --- a/tools/fixture_digest.py +++ b/tools/fixture_digest.py @@ -32,7 +32,17 @@ def main() -> int: expected = manifest.get("corpus_sha256") if not expected or actual != expected: raise ValueError(f"fixture corpus hash mismatch: {actual}") - print(f"Fixture corpus SHA256 {actual}") + json_paths = sorted( + path.relative_to(ROOT).as_posix() + for directory in (ROOT / "fixtures/valid", ROOT / "fixtures/invalid") + for path in directory.glob("*.json") + ) + if json_paths != manifest.get("json_files"): + raise ValueError("JSON fixture manifest file list is stale") + json_actual = digest(json_paths) + if json_actual != manifest.get("json_corpus_sha256"): + raise ValueError(f"JSON fixture corpus hash mismatch: {json_actual}") + print(f"Fixture corpus SHA256 {actual}; JSON SHA256 {json_actual}") return 0 diff --git a/tools/generate.py b/tools/generate.py index 0a751f6..e2fd48f 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -139,11 +139,15 @@ def go_validation(definition: dict[str, Any]) -> list[str]: ) if prop.get("format") == "base64url": lines.append(f"\tif v.{field} != \"\" {{ if _, err := base64.RawURLEncoding.Strict().DecodeString(v.{field}); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_format\"}}) }} }}") + if prop.get("format") == "uuid": + lines.append(f"\tif v.{field} != \"\" && !validCanonicalUUID(v.{field}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_uuid\"}}) }}") if prop.get("type") == "integer": + value = f"*v.{field}" if prop.get("x-optional-pointer") else f"v.{field}" + guard = f"v.{field} != nil && " if prop.get("x-optional-pointer") else "" if "minimum" in prop: - lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}") + lines.append(f"\tif {guard}{value} != 0 && {value} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}") if "maximum" in prop: - lines.append(f"\tif v.{field} > {prop['maximum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"maximum\"}}) }}") + lines.append(f"\tif {guard}{value} > {prop['maximum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"maximum\"}}) }}") if prop.get("type") == "array": if "minItems" in prop: lines.append(f"\tif len(v.{field}) < {prop['minItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_items\"}}) }}") @@ -159,8 +163,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]: if "enum" in items: allowed = " || ".join(f'item == "{value}"' for value in items["enum"]) lines.append(f"\tfor _, item := range v.{field} {{ if !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_item\"}}) }} }}") - if prop.get("uniqueItems") and items.get("type") == "string": - lines.append(f"\tfor index, item := range v.{field} {{ for prior := 0; prior < index; prior++ {{ if item == v.{field}[prior] {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"duplicate_item\"}}) }} }} }}") + if prop.get("uniqueItems"): + lines.append(f"\tfor index, item := range v.{field} {{ for prior := 0; prior < index; prior++ {{ if reflect.DeepEqual(item, v.{field}[prior]) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"duplicate_item\"}}) }} }} }}") item_ref = ref_name(items) if item_ref: lines.append(f"\tfor index := range v.{field} {{ if err := v.{field}[index].Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: fmt.Sprintf(\"{prop_name}[%d]\", index), Code: \"invalid_item\"}}) }} }}") @@ -173,6 +177,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]: lines.append(f"\t{validation}") if name in {"AllocationPolicy", "ManifestBounds"}: lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }") + if name == "SessionQualityLimits": + lines.append("\tif v.BitrateMinimumKbps > v.BitrateTargetKbps || v.BitrateTargetKbps > v.BitrateMaximumKbps { violations = append(violations, FieldViolation{Field: \"bitrate_bounds\", Code: \"invalid_order\"}) }") + if name in {"SelectedSessionDescriptor", "ProviderStreamPolicy"}: + lines.append("\tif v.BitrateTargetKbps > v.BitrateMaximumKbps { violations = append(violations, FieldViolation{Field: \"bitrate_bounds\", Code: \"invalid_order\"}) }") + if name == "BitratePreference": + lines.append("\tif v.Mode == \"auto\" && v.TargetKbps != nil || v.Mode == \"explicit\" && v.TargetKbps == nil { violations = append(violations, FieldViolation{Field: \"target_kbps\", Code: \"invalid_tagged_value\"}) }") if name == "GatewayRegistration": lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }") if name == "ChannelFrame": @@ -214,6 +224,30 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, "", "func (e ValidationError) Error() string { return \"protocol validation failed\" }", "", + "func validCanonicalUUID(value string) bool {", + "\tif len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' { return false }", + "\tfor index, char := range []byte(value) { if index == 8 || index == 13 || index == 18 || index == 23 { continue }; if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { return false } }", + "\treturn value != \"00000000-0000-0000-0000-000000000000\"", + "}", + "", + "func rejectDuplicateJSONKeys(data []byte) error {", + "\tdecoder := json.NewDecoder(bytes.NewReader(data))", + "\tvar scan func(json.Token) error", + "\tscan = func(token json.Token) error {", + "\t\tdelim, ok := token.(json.Delim); if !ok { return nil }", + "\t\tswitch delim {", + "\t\tcase '{':", + "\t\t\tseen := map[string]struct{}{}", + "\t\t\tfor decoder.More() { keyToken, err := decoder.Token(); if err != nil { return err }; key, ok := keyToken.(string); if !ok { return errors.New(\"invalid JSON object key\") }; if _, exists := seen[key]; exists { return errors.New(\"duplicate JSON object key\") }; seen[key] = struct{}{}; value, err := decoder.Token(); if err != nil { return err }; if err := scan(value); err != nil { return err } }", + "\t\t\t_, err := decoder.Token(); return err", + "\t\tcase '[':", + "\t\t\tfor decoder.More() { value, err := decoder.Token(); if err != nil { return err }; if err := scan(value); err != nil { return err } }; _, err := decoder.Token(); return err", + "\t\t}", + "\t\treturn nil", + "\t}", + "\ttoken, err := decoder.Token(); if err != nil { return err }; return scan(token)", + "}", + "", ] for name in sorted(defs): if name == "FieldViolation": @@ -224,7 +258,9 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, for prop_name, prop in definition.get("properties", {}).items(): tag = prop_name + (",omitempty" if prop_name not in required else "") typ = prop_type(prop, "go") - if prop_name not in required and ref_name(prop): + if prop.get("x-optional-pointer"): + typ = "*" + typ + elif prop_name not in required and ref_name(prop): typ = "*" + typ out.append(f"\t{go_field(prop_name)} {typ} `json:\"{tag}\"`") out.extend(["}", ""]) @@ -239,6 +275,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, out.append(f"func Decode{name}(data []byte) ({name}, error) {{") out.append(f"\tvar value {name}") out.append("\tif len(data) > 1024*1024 { return value, errors.New(\"protocol payload exceeds limit\") }") + out.append("\tif err := rejectDuplicateJSONKeys(data); err != nil { return value, err }") out.append("\tvar fields map[string]json.RawMessage") out.append("\tif err := json.Unmarshal(data, &fields); err != nil { return value, err }") required_fields = sorted(defs[name].get("required", [])) @@ -295,23 +332,29 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, "\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }", "\tselected := profiles[0]", "\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }", - "\tcommon := append([]string(nil), selected.ClientDecode...)", + "\tcommonVideo := append([]VideoProfile(nil), selected.VideoProfiles...)", + "\tcommonAudio := append([]AudioProfile(nil), selected.AudioProfiles...)", "\tfor _, profile := range profiles[1:] {", - "\t\tif err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.Audio != selected.Audio || profile.SourceRateControl != selected.SourceRateControl { return CapabilityProfile{}, ErrNoCapabilityOverlap }", - "\t\tnext := common[:0]", - "\t\tfor _, candidate := range common { for _, offered := range profile.ClientDecode { if candidate == offered { next = append(next, candidate); break } } }", - "\t\tcommon = next", - "\t\tif len(common) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }", + "\t\tif err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.SourceRateControl != selected.SourceRateControl { return CapabilityProfile{}, ErrNoCapabilityOverlap }", + "\t\tnextVideo := commonVideo[:0]", + "\t\tfor _, candidate := range commonVideo { for _, offered := range profile.VideoProfiles { if candidate == offered { nextVideo = append(nextVideo, candidate); break } } }", + "\t\tcommonVideo = nextVideo", + "\t\tnextAudio := commonAudio[:0]", + "\t\tfor _, candidate := range commonAudio { for _, offered := range profile.AudioProfiles { if candidate == offered { nextAudio = append(nextAudio, candidate); break } } }", + "\t\tcommonAudio = nextAudio", + "\t\tif len(commonVideo) == 0 || len(commonAudio) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }", "\t}", - "\tselected.ClientDecode = common", + "\tselected.VideoProfiles = commonVideo", + "\tselected.AudioProfiles = commonAudio", "\treturn selected, nil", "}", "", ]) out.extend([ "func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {", - "\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, fmt.Sprintf(\"%d\", len(v.Capabilities.ClientDecode))}", - "\tfields = append(fields, v.Capabilities.ClientDecode...)", + "\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.SourceRateControl, fmt.Sprintf(\"%d\", len(v.Capabilities.VideoProfiles)), fmt.Sprintf(\"%d\", len(v.Capabilities.AudioProfiles))}", + "\tfor _, profile := range v.Capabilities.VideoProfiles { fields = append(fields, profile.Codec, fmt.Sprintf(\"%d\", profile.BitDepth), profile.ChromaSubsampling, profile.ColorSpace, profile.TransferFunction) }", + "\tfor _, profile := range v.Capabilities.AudioProfiles { fields = append(fields, profile.Codec, fmt.Sprintf(\"%d\", profile.SampleRateHz), fmt.Sprintf(\"%d\", profile.Channels), profile.ChannelLayout, fmt.Sprintf(\"%d\", profile.PacketDurationMs)) }", "\tvar transcript strings.Builder", "\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")", "\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }", @@ -386,11 +429,14 @@ def rust_validation(definition: dict[str, Any]) -> list[str]: lines.append(f" {prefix}if !valid_rfc3339_utc({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_time\")); }}") if prop.get("format") == "base64url": lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}") + if prop.get("format") == "uuid": + lines.append(f" {prefix}if !valid_canonical_uuid({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_uuid\")); }}") if prop.get("type") == "integer": + numeric = f"*{value}" if prop_name not in required else value if "minimum" in prop: - lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}") + lines.append(f" {prefix}if {numeric} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}") if "maximum" in prop: - lines.append(f" {prefix}if {value} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}") + lines.append(f" {prefix}if {numeric} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}") if prop.get("type") == "array": if "minItems" in prop: lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}") @@ -406,7 +452,7 @@ def rust_validation(definition: dict[str, Any]) -> list[str]: if "enum" in items: allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"]) lines.append(f" {prefix}for item in {value}.iter() {{ if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_item\")); }} }}") - if prop.get("uniqueItems") and items.get("type") == "string": + if prop.get("uniqueItems"): lines.append(f" {prefix}for (index, item) in {value}.iter().enumerate() {{ if {value}[..index].contains(item) {{ return Err(ValidationError::new(\"{prop_name}\", \"duplicate_item\")); }} }}") item_ref = ref_name(items) if item_ref: @@ -419,6 +465,12 @@ def rust_validation(definition: dict[str, Any]) -> list[str]: name = definition["name"] if name in {"AllocationPolicy", "ManifestBounds"}: lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }") + if name == "SessionQualityLimits": + lines.append(" if self.bitrateMinimumKbps > self.bitrateTargetKbps || self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new(\"bitrate_bounds\", \"invalid_order\")); }") + if name in {"SelectedSessionDescriptor", "ProviderStreamPolicy"}: + lines.append(" if self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new(\"bitrate_bounds\", \"invalid_order\")); }") + if name == "BitratePreference": + lines.append(" if self.mode == \"auto\" && self.targetKbps.is_some() || self.mode == \"explicit\" && self.targetKbps.is_none() { return Err(ValidationError::new(\"target_kbps\", \"invalid_tagged_value\")); }") if name == "GatewayRegistration": lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }") if name == "ChannelFrame": @@ -473,6 +525,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili " let fraction = &bytes[20..bytes.len() - 1];", " bytes[19] == b'.' && !fraction.is_empty() && fraction.len() <= 9 && fraction.iter().all(u8::is_ascii_digit) && *fraction.last().unwrap() != b'0'", "}", + "fn valid_canonical_uuid(value: &str) -> bool {", + " let bytes = value.as_bytes();", + " bytes.len() == 36 && [8, 13, 18, 23].iter().all(|index| bytes[*index] == b'-') && bytes.iter().enumerate().all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) && value != \"00000000-0000-0000-0000-000000000000\"", + "}", "", ] for name in sorted(defs): @@ -515,9 +571,12 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili out.extend([ " pub fn device_admission_transcript(&self) -> Vec {", " let reconnect_sequence = self.reconnectSequence.to_string();", - " let client_decode_count = self.capabilities.clientDecode.len().to_string();", - " let mut fields = vec![self.sessionId.as_str(), self.gatewayId.as_str(), self.audience.as_str(), self.grant.as_str(), reconnect_sequence.as_str(), self.clientNonce.as_str(), self.capabilities.transport.as_str(), self.capabilities.framing.as_str(), self.capabilities.media.as_str(), self.capabilities.audio.as_str(), self.capabilities.sourceRateControl.as_str(), client_decode_count.as_str()];", - " fields.extend(self.capabilities.clientDecode.iter().map(String::as_str));", + " let video_count = self.capabilities.videoProfiles.len().to_string();", + " let audio_count = self.capabilities.audioProfiles.len().to_string();", + " let mut owned = vec![self.sessionId.clone(), self.gatewayId.clone(), self.audience.clone(), self.grant.clone(), reconnect_sequence, self.clientNonce.clone(), self.capabilities.transport.clone(), self.capabilities.framing.clone(), self.capabilities.media.clone(), self.capabilities.sourceRateControl.clone(), video_count, audio_count];", + " for profile in &self.capabilities.videoProfiles { owned.extend([profile.codec.clone(), profile.bitDepth.to_string(), profile.chromaSubsampling.clone(), profile.colorSpace.clone(), profile.transferFunction.clone()]); }", + " for profile in &self.capabilities.audioProfiles { owned.extend([profile.codec.clone(), profile.sampleRateHz.to_string(), profile.channels.to_string(), profile.channelLayout.clone(), profile.packetDurationMs.to_string()]); }", + " let fields: Vec<&str> = owned.iter().map(String::as_str).collect();", " let mut transcript = String::from(\"versevdi/tunnel-admission/v1\");", " for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }", " transcript.into_bytes()", @@ -545,9 +604,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili " selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;", " for profile in &profiles[1..] {", " profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;", - " if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }", - " selected.clientDecode.retain(|candidate| profile.clientDecode.contains(candidate));", - " if selected.clientDecode.is_empty() { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }", + " if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.sourceRateControl != selected.sourceRateControl { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }", + " selected.videoProfiles.retain(|candidate| profile.videoProfiles.contains(candidate));", + " selected.audioProfiles.retain(|candidate| profile.audioProfiles.contains(candidate));", + " if selected.videoProfiles.is_empty() || selected.audioProfiles.is_empty() { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }", " }", " Ok(selected)", "}", @@ -586,6 +646,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]: lines.append(f" {prefix}if !validRFC3339UTC({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}") if prop.get("format") == "base64url": lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}") + if prop.get("format") == "uuid": + lines.append(f" {prefix}if !validCanonicalUUID({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_uuid\") }}") if prop.get("type") == "integer": if "minimum" in prop: lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}") @@ -606,8 +668,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]: if "enum" in items: allowed = ", ".join(f'\"{item}\"' for item in items["enum"]) lines.append(f" {prefix}for item in {value} where ![{allowed}].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_item\") }}") - if prop.get("uniqueItems") and items.get("type") == "string": - lines.append(f" {prefix}if Set({value}).count != {value}.count {{ throw ContractValidationError(field: \"{prop_name}\", code: \"duplicate_item\") }}") + if prop.get("uniqueItems"): + lines.append(f" {prefix}for (index, item) in {value}.enumerated() where {value}[.. list[str]: name = definition["name"] if name in {"AllocationPolicy", "ManifestBounds"}: lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }") + if name == "SessionQualityLimits": + lines.append(" if bitrateMinimumKbps > bitrateTargetKbps || bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: \"bitrate_bounds\", code: \"invalid_order\") }") + if name in {"SelectedSessionDescriptor", "ProviderStreamPolicy"}: + lines.append(" if bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: \"bitrate_bounds\", code: \"invalid_order\") }") + if name == "BitratePreference": + lines.append(" if mode == \"auto\" && targetKbps != nil || mode == \"explicit\" && targetKbps == nil { throw ContractValidationError(field: \"target_kbps\", code: \"invalid_tagged_value\") }") if name == "GatewayRegistration": lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }") if name == "ChannelFrame": @@ -637,6 +705,65 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"', "public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }", "private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }", + "private func rejectDuplicateJSONKeys(_ data: Data) throws {", + " var index = 0", + " func skipWhitespace() { while index < data.count && [9, 10, 13, 32].contains(data[index]) { index += 1 } }", + " func parseString() throws -> String {", + " guard index < data.count, data[index] == 34 else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " let start = index", + " index += 1", + " while index < data.count {", + " if data[index] == 92 { index += 2; continue }", + " if data[index] == 34 { index += 1; return try JSONDecoder().decode(String.self, from: data[start..()", + " skipWhitespace()", + " if index < data.count, data[index] == 125 { index += 1; return }", + " while true {", + " skipWhitespace()", + " let key = try parseString()", + " guard keys.insert(key).inserted else { throw ContractValidationError(field: key, code: \"duplicate_field\") }", + " skipWhitespace()", + " guard index < data.count, data[index] == 58 else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " index += 1", + " try parseValue()", + " skipWhitespace()", + " guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " if data[index] == 125 { index += 1; return }", + " guard data[index] == 44 else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " index += 1", + " }", + " }", + " if data[index] == 91 {", + " index += 1", + " skipWhitespace()", + " if index < data.count, data[index] == 93 { index += 1; return }", + " while true {", + " try parseValue()", + " skipWhitespace()", + " guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " if data[index] == 93 { index += 1; return }", + " guard data[index] == 44 else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " index += 1", + " }", + " }", + " if data[index] == 34 { _ = try parseString(); return }", + " let start = index", + " while index < data.count && ![9, 10, 13, 32, 44, 93, 125].contains(data[index]) { index += 1 }", + " guard index > start else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", + " }", + " try parseValue()", + " skipWhitespace()", + " guard index == data.count else { throw ContractValidationError(field: \"json\", code: \"trailing_json\") }", + "}", "private func validBase64URL(_ value: String) -> Bool {", " guard !value.isEmpty, value.utf8.allSatisfy({ byte in", " (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95", @@ -663,6 +790,11 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " let fraction = bytes[20..<(bytes.count - 1)]", " return bytes[19] == 46 && !fraction.isEmpty && fraction.count <= 9 && fraction.allSatisfy { $0 >= 48 && $0 <= 57 } && fraction.last != 48", "}", + "private func validCanonicalUUID(_ value: String) -> Bool {", + " let bytes = Array(value.utf8)", + " guard bytes.count == 36, bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45, value != \"00000000-0000-0000-0000-000000000000\" else { return false }", + " return bytes.enumerated().allSatisfy { index, byte in [8, 13, 18, 23].contains(index) || (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) }", + "}", "", ] for name in sorted(defs): @@ -704,7 +836,7 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil out.append(f" try self.init({', '.join(decoded)})") out.extend([" }", "", " public func validate() throws {"]) out.extend(swift_validation(definition)) - out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""]) + out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""]) out.extend([ "public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {", " for (field, value, length) in [(\"server_id\", serverID, 16), (\"principal_id\", principalID, 16), (\"device_id\", deviceID, 16), (\"challenge\", challenge, 32)] {", @@ -723,8 +855,9 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil "", "public extension TunnelAdmissionRequest {", " func deviceAdmissionTranscript() -> Data {", - " var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]", - " fields.append(contentsOf: capabilities.clientDecode)", + " var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.sourceRateControl, String(capabilities.videoProfiles.count), String(capabilities.audioProfiles.count)]", + " for profile in capabilities.videoProfiles { fields.append(contentsOf: [profile.codec, String(profile.bitDepth), profile.chromaSubsampling, profile.colorSpace, profile.transferFunction]) }", + " for profile in capabilities.audioProfiles { fields.append(contentsOf: [profile.codec, String(profile.sampleRateHz), String(profile.channels), profile.channelLayout, String(profile.packetDurationMs)]) }", " var transcript = \"versevdi/tunnel-admission/v1\"", " for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }", " return Data(transcript.utf8)", @@ -735,14 +868,16 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {", " guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", " try selected.validate()", - " var common = selected.clientDecode", + " var commonVideo = selected.videoProfiles", + " var commonAudio = selected.audioProfiles", " for profile in profiles.dropFirst() {", " try profile.validate()", - " if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", - " common = common.filter { profile.clientDecode.contains($0) }", - " if common.isEmpty { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", + " if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", + " commonVideo = commonVideo.filter { profile.videoProfiles.contains($0) }", + " commonAudio = commonAudio.filter { profile.audioProfiles.contains($0) }", + " if commonVideo.isEmpty || commonAudio.isEmpty { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", " }", - " return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, audio: selected.audio, sourceRateControl: selected.sourceRateControl, clientDecode: common)", + " return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, sourceRateControl: selected.sourceRateControl, videoProfiles: commonVideo, audioProfiles: commonAudio)", " }", "}", "", diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index be14f64..e216d8a 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -2,6 +2,7 @@ package main import ( "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -101,6 +102,15 @@ func evaluate(version, kind, input string) string { Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}}, Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]}, CorrelationID: "correlation-1", + SelectedDescriptor: protocol.SelectedSessionDescriptor{ + VideoProfile: protocol.VideoProfile{Codec: "h264", BitDepth: 8, ChromaSubsampling: "4:2:0", ColorSpace: "bt709-limited", TransferFunction: "sdr"}, + AudioProfile: protocol.AudioProfile{Codec: "opus", SampleRateHz: 48000, Channels: 2, ChannelLayout: "stereo", PacketDurationMs: 5}, + DisplayMode: protocol.DisplayMode{ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60}, + BitrateTargetKbps: 12000, + BitrateMaximumKbps: 20000, + Adjustment: protocol.SessionAdjustment{DisplayReason: "none", BitrateReason: "none"}, + MediaTimestampBasis: "gateway-send-wall-clock-ms", + }, } if value.Validate() == nil { return "valid" @@ -123,6 +133,8 @@ func evaluate(version, kind, input string) string { value := protocol.SessionRequest{ ClientDeviceID: parts["client_device_id"], DeviceKeyID: parts["device_key_id"], PoolID: parts["pool_id"], IdempotencyKey: parts["idempotency_key"], + VideoProfiles: []protocol.VideoProfile{{Codec: "h264", BitDepth: 8, ChromaSubsampling: "4:2:0", ColorSpace: "bt709-limited", TransferFunction: "sdr"}}, + BitratePreference: protocol.BitratePreference{Mode: "auto"}, } if value.Validate() == nil { return "valid" @@ -309,6 +321,13 @@ func classifyGatewayInput(encoded string) string { if len(body) != 4 { return "invalid:length" } + case 8: + if len(body) != 8 { + return "invalid:length" + } + if body[0] > 15 || body[3] > 3 { + return "invalid:field" + } default: return "invalid:kind" } @@ -352,6 +371,22 @@ func classifyGatewayFeedback(encoded string) string { if len(body) == 0 { return "valid" } + case 4: + if len(body) != 24 { + return "invalid:length" + } + if binary.BigEndian.Uint64(body[16:]) == 0 || allZero(body[:16]) { + return "invalid:field" + } + return "valid" + case 5: + if len(body) != 16 { + return "invalid:length" + } + if allZero(body) { + return "invalid:field" + } + return "valid" default: return "invalid:type" } @@ -386,6 +421,15 @@ func classifyGatewayFeedback(encoded string) string { return "invalid:field" } +func allZero(value []byte) bool { + for _, item := range value { + if item != 0 { + return false + } + } + return true +} + func validFECStatus(body []byte) bool { if len(body) != 21 || int(body[10])<<8|int(body[11]) == 0 || int(body[14])<<8|int(body[15]) > int(body[10])<<8|int(body[11]) || int(body[16])<<8|int(body[17]) > int(body[12])<<8|int(body[13]) || body[18] > 100 || body[20] == 0 || body[19] >= body[20] { return false diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index 74a30f3..042973c 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -38,6 +38,8 @@ fn evaluate(version: &str, kind: &str, input: &str) -> &'static str { values.get("device_key_id").cloned().unwrap_or_default(), values.get("pool_id").cloned().unwrap_or_default(), values.get("idempotency_key").cloned().unwrap_or_default(), + vec![VideoProfile::new("h264".into(), 8, "4:2:0".into(), "bt709-limited".into(), "sdr".into()).unwrap()], + BitratePreference::new("auto".into(), None).unwrap(), None, ) { Ok(_) => "valid", @@ -184,6 +186,9 @@ fn classify_gateway_input(encoded: &str) -> &'static str { } 7 if body.len() == 4 => "valid", 7 => "invalid:length", + 8 if body.len() != 8 => "invalid:length", + 8 if body[0] > 15 || body[3] > 3 => "invalid:field", + 8 => "valid", _ => "invalid:kind", } } @@ -219,6 +224,12 @@ fn classify_gateway_feedback(encoded: &str) -> &'static str { 2 => "invalid:field", 3 if body.is_empty() => "valid", 3 => "invalid:length", + 4 if body.len() == 24 && body[..16].iter().any(|value| *value != 0) && body[16..24].iter().any(|value| *value != 0) => "valid", + 4 if body.len() != 24 => "invalid:length", + 4 => "invalid:field", + 5 if body.len() == 16 && body.iter().any(|value| *value != 0) => "valid", + 5 if body.len() != 16 => "invalid:length", + 5 => "invalid:field", _ => "invalid:type", }; } diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index c654eea..b0e56f1 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -26,6 +26,8 @@ func evaluate(_ version: String, _ kind: String, _ input: String) -> String { guard (try? SessionRequest( clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? "", poolId: values["pool_id"] ?? "", idempotencyKey: values["idempotency_key"] ?? "", + videoProfiles: [try! VideoProfile(codec: "h264", bitDepth: 8, chromaSubsampling: "4:2:0", colorSpace: "bt709-limited", transferFunction: "sdr")], + bitratePreference: try! BitratePreference(mode: "auto", targetKbps: nil), requestedDisplayMode: nil )) != nil else { return "invalid:required" } return "valid" @@ -136,6 +138,9 @@ func classifyGatewayInput(_ encoded: String) -> String { let height = Int(body[6]) * 256 + Int(body[7]) return width > 0 && height > 0 && x < width && y < height ? "valid" : "invalid:field" case 7: return body.count == 4 ? "valid" : "invalid:length" + case 8: + guard body.count == 8 else { return "invalid:length" } + return body[0] <= 15 && body[3] <= 3 ? "valid" : "invalid:field" default: return "invalid:kind" } } @@ -156,6 +161,12 @@ func classifyGatewayFeedback(_ encoded: String) -> String { case 2: return validFECStatus(body) ? "valid" : "invalid:field" case 3: return body.isEmpty ? "valid" : "invalid:length" + case 4: + guard body.count == 24 else { return "invalid:length" } + return body[0...15].contains(where: { $0 != 0 }) && body[16...23].contains(where: { $0 != 0 }) ? "valid" : "invalid:field" + case 5: + guard body.count == 16 else { return "invalid:length" } + return body.contains(where: { $0 != 0 }) ? "valid" : "invalid:field" default: return "invalid:type" } } diff --git a/tools/run_native_conformance.py b/tools/run_native_conformance.py index 781aec7..3721c5c 100644 --- a/tools/run_native_conformance.py +++ b/tools/run_native_conformance.py @@ -4,12 +4,17 @@ from __future__ import annotations import pathlib import subprocess import tempfile +import os ROOT = pathlib.Path(__file__).resolve().parents[1] def run(command: list[str]) -> None: - result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True) + environment = os.environ.copy() + cache_root = pathlib.Path(tempfile.gettempdir()) / "versevdi-protocol-module-cache" + environment.setdefault("CLANG_MODULE_CACHE_PATH", str(cache_root / "clang")) + environment.setdefault("SWIFT_MODULECACHE_PATH", str(cache_root / "swift")) + result = subprocess.run(command, cwd=ROOT, env=environment, text=True, capture_output=True) if result.returncode != 0: raise SystemExit(result.stdout + result.stderr) print(result.stdout.strip()) diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index 0967df3..bba4f2d 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -8,13 +8,18 @@ import re import shutil import subprocess import tempfile +import os ROOT = pathlib.Path(__file__).resolve().parents[1] def run(command: list[str], directory: pathlib.Path) -> None: - result = subprocess.run(command, cwd=directory, text=True, capture_output=True, check=False) + environment = os.environ.copy() + cache_root = pathlib.Path(tempfile.gettempdir()) / "versevdi-protocol-module-cache" + environment.setdefault("CLANG_MODULE_CACHE_PATH", str(cache_root / "clang")) + environment.setdefault("SWIFT_MODULECACHE_PATH", str(cache_root / "swift")) + result = subprocess.run(command, cwd=directory, env=environment, text=True, capture_output=True, check=False) if result.returncode != 0: raise RuntimeError("%s\n%s%s" % (" ".join(command), result.stdout, result.stderr)) @@ -58,21 +63,27 @@ def main() -> int: swift.write_text( """import Foundation +let video = try VideoProfile(codec: "h264", bitDepth: 8, chromaSubsampling: "4:2:0", colorSpace: "bt709-limited", transferFunction: "sdr") +let hevc = try VideoProfile(codec: "hevc", bitDepth: 8, chromaSubsampling: "4:2:0", colorSpace: "bt709-limited", transferFunction: "sdr") +let audio = try AudioProfile(codec: "opus", sampleRateHz: 48000, channels: 2, channelLayout: "stereo", packetDurationMs: 5) +let display = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120) +let adjustment = try SessionAdjustment(displayReason: "none", bitrateReason: "none") +let descriptor = try SelectedSessionDescriptor(videoProfile: video, audioProfile: audio, displayMode: display, bitrateTargetKbps: 40000, bitrateMaximumKbps: 50000, adjustment: adjustment, mediaTimestampBasis: "gateway-send-wall-clock-ms") let capability = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v1", media: "encoded", - audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"] + sourceRateControl: "server", videoProfiles: [video], audioProfiles: [audio] ) guard currentWireVersion == "2", nMinus1WireVersion == "1", nMinus2WireVersion == "0" else { fatalError("unexpected control wire compatibility declaration") } _ = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v2", media: "encoded", - audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"] + sourceRateControl: "server", videoProfiles: [video], audioProfiles: [audio] ) do { _ = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v3", media: "encoded", - audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"] + sourceRateControl: "server", videoProfiles: [video], audioProfiles: [audio] ) fatalError("unregistered framing was accepted") } catch { } @@ -82,7 +93,7 @@ let request = try TunnelAdmissionRequest( clientNonce: String(repeating: "n", count: 16), deviceSignature: String(repeating: "s", count: 86), capabilities: capability ) _ = request -let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + String(repeating: "g", count: 43) + "1:016:" + String(repeating: "n", count: 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus" +let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + String(repeating: "g", count: 43) + "1:016:" + String(repeating: "n", count: 16) + "10:quic-tls1311:datagram-v17:encoded6:server1:11:14:h2641:85:4:2:013:bt709-limited3:sdr4:opus5:480001:26:stereo1:5" guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else { fatalError("unexpected device admission transcript") } @@ -126,18 +137,18 @@ for (name, field, serverID, principalID, deviceID, challenge, expiry) in invalid } let incompatible = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v1", media: "encoded", - audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"] + sourceRateControl: "server", videoProfiles: [hevc], audioProfiles: [audio] ) let gatewayCapability = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v1", media: "encoded", - audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus", "h264-opus"] + sourceRateControl: "server", videoProfiles: [hevc, video], audioProfiles: [audio] ) do { guard try CapabilityProfile.intersection([capability, capability]) == capability else { fatalError("matching capability profiles did not intersect") } } catch { fatalError("matching capability profiles did not intersect") } -guard try CapabilityProfile.intersection([gatewayCapability, capability]).clientDecode == ["h264-opus"] else { +guard try CapabilityProfile.intersection([gatewayCapability, capability]).videoProfiles == [video] else { fatalError("ordered registered profile intersection changed") } do { @@ -165,17 +176,22 @@ for invalid in [ } let clientAuthority = try ClientSessionAuthority( version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience", - reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability + reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability, selectedDescriptor: descriptor ) let clientAuthorityJSON = try clientAuthority.encodeJSON() let clientAuthorityObject = try JSONSerialization.jsonObject(with: clientAuthorityJSON) as! [String: Any] guard Set(clientAuthorityObject.keys) == Set([ - "version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities" + "version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor" ]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else { fatalError("client authority was not exactly provider-free") } _ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON) -for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"] { +let duplicateCapability = Data(#"{"transport":"quic-tls13","transport":"quic-tls13","framing":"datagram-v1","media":"encoded","source_rate_control":"server","video_profiles":[{"codec":"h264","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}],"audio_profiles":[{"codec":"opus","sample_rate_hz":48000,"channels":2,"channel_layout":"stereo","packet_duration_ms":5}]}"#.utf8) +do { + _ = try CapabilityProfile.decodeJSON(duplicateCapability) + fatalError("capability accepted duplicate JSON keys") +} catch { } +for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor"] { var missing = clientAuthorityObject missing.removeValue(forKey: field) do { @@ -217,7 +233,7 @@ do { ) fatalError("invalid allocation bounds were accepted") } catch { } -let displayMode = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120) +let displayMode = display for invalid in [ { try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120) }, { try DisplayMode(resolutionWidth: 2560, resolutionHeight: 199, fps: 120) }, @@ -230,14 +246,14 @@ for invalid in [ } let policyFreeV2Request = try SessionRequest( clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1", - idempotencyKey: "request-1", requestedDisplayMode: nil + idempotencyKey: "request-1", videoProfiles: [video], bitratePreference: try BitratePreference(mode: "auto", targetKbps: nil), requestedDisplayMode: nil ).encodeJSON() guard !String(data: policyFreeV2Request, encoding: .utf8)!.contains("requested_display_mode") else { fatalError("wire-v2 request encoded an absent display mode") } let displayRequest = try SessionRequest( clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1", - idempotencyKey: "request-1", requestedDisplayMode: displayMode + idempotencyKey: "request-1", videoProfiles: [video], bitratePreference: try BitratePreference(mode: "explicit", targetKbps: 40000), requestedDisplayMode: displayMode ) guard try SessionRequest.decodeJSON(displayRequest.encodeJSON()).requestedDisplayMode == displayMode else { fatalError("display mode did not round-trip") @@ -295,14 +311,14 @@ for expiresAt in ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] { } catch { } } let streamPolicy = try ProviderStreamPolicy( - resolutionWidth: 2560, resolutionHeight: 1440, fps: 120, - codec: "HEVC", bitrateKbps: 40000, audioEnabled: true + videoProfile: hevc, audioProfile: audio, displayMode: displayMode, + bitrateTargetKbps: 40000, bitrateMaximumKbps: 50000 ) -guard streamPolicy.codec == "HEVC" else { fatalError("stream policy changed") } +guard streamPolicy.videoProfile == hevc else { fatalError("stream policy changed") } for invalid in [ - { try ProviderStreamPolicy(resolutionWidth: 319, resolutionHeight: 1440, fps: 120, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true) }, - { try ProviderStreamPolicy(resolutionWidth: 2560, resolutionHeight: 1440, fps: 241, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true) }, - { try ProviderStreamPolicy(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120, codec: "VP9", bitrateKbps: 40000, audioEnabled: true) }, + { try ProviderStreamPolicy(videoProfile: video, audioProfile: audio, displayMode: try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120), bitrateTargetKbps: 40000, bitrateMaximumKbps: 50000) }, + { try ProviderStreamPolicy(videoProfile: video, audioProfile: audio, displayMode: displayMode, bitrateTargetKbps: 50001, bitrateMaximumKbps: 50000) }, + { try ProviderStreamPolicy(videoProfile: try VideoProfile(codec: "vp9", bitDepth: 8, chromaSubsampling: "4:2:0", colorSpace: "bt709-limited", transferFunction: "sdr"), audioProfile: audio, displayMode: displayMode, bitrateTargetKbps: 40000, bitrateMaximumKbps: 50000) }, ] { do { _ = try invalid() @@ -363,17 +379,23 @@ fn main() { assert_eq!(CURRENT_WIRE_VERSION, "2"); assert_eq!(N_MINUS_1_WIRE_VERSION, "1"); assert_eq!(N_MINUS_2_WIRE_VERSION, "0"); + let video = VideoProfile::new("h264".into(), 8, "4:2:0".into(), "bt709-limited".into(), "sdr".into()).unwrap(); + let hevc = VideoProfile::new("hevc".into(), 8, "4:2:0".into(), "bt709-limited".into(), "sdr".into()).unwrap(); + let audio = AudioProfile::new("opus".into(), 48000, 2, "stereo".into(), 5).unwrap(); + let display = DisplayMode::new(2560, 1440, 120).unwrap(); + let adjustment = SessionAdjustment::new("none".into(), "none".into()).unwrap(); + let descriptor = SelectedSessionDescriptor::new(video.clone(), audio.clone(), display.clone(), 40000, 50000, adjustment, "gateway-send-wall-clock-ms".into()).unwrap(); let capabilities = CapabilityProfile::new( "quic-tls13".into(), "datagram-v1".into(), "encoded".into(), - "encoded".into(), "server".into(), vec!["h264-opus".into()], + "server".into(), vec![video.clone()], vec![audio.clone()], ).unwrap(); assert!(CapabilityProfile::new( "quic-tls13".into(), "datagram-v2".into(), "encoded".into(), - "encoded".into(), "server".into(), vec!["h264-opus".into()], + "server".into(), vec![video.clone()], vec![audio.clone()], ).is_ok()); assert!(CapabilityProfile::new( "quic-tls13".into(), "datagram-v3".into(), "encoded".into(), - "encoded".into(), "server".into(), vec!["h264-opus".into()], + "server".into(), vec![video.clone()], vec![audio.clone()], ).is_err()); let request = TunnelAdmissionRequest::new( "1".into(), "session".into(), "gateway".into(), "audience".into(), @@ -381,7 +403,7 @@ fn main() { ).unwrap(); let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:".to_string() + &"g".repeat(43) + "1:016:" + &"n".repeat(16) - + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus"; + + "10:quic-tls1311:datagram-v17:encoded6:server1:11:14:h2641:85:4:2:013:bt709-limited3:sdr4:opus5:480001:26:stereo1:5"; assert_eq!(request.device_admission_transcript(), transcript.into_bytes()); let proof_server_id = vec![1u8; 16]; let proof_principal_id = vec![2u8; 16]; @@ -423,26 +445,26 @@ fn main() { ).is_err()); let client_authority = ClientSessionAuthority::new( "1".into(), "session".into(), "gateway".into(), "audience".into(), 2, - "2099-01-01T00:00:00Z".into(), capabilities.clone(), + "2099-01-01T00:00:00Z".into(), capabilities.clone(), descriptor.clone(), ).unwrap(); assert_eq!(client_authority.sessionId(), "session"); assert_eq!(client_authority.capabilities(), &capabilities); assert!(ClientSessionAuthority::new( "1".into(), "session".into(), "gateway".into(), "audience".into(), 2, - "not-a-time".into(), capabilities.clone(), + "not-a-time".into(), capabilities.clone(), descriptor.clone(), ).is_err()); assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok()); let incompatible = CapabilityProfile::new( "quic-tls13".into(), "datagram-v1".into(), "encoded".into(), - "encoded".into(), "server".into(), vec!["hevc-opus".into()], + "server".into(), vec![hevc.clone()], vec![audio.clone()], ).unwrap(); let gateway_capability = CapabilityProfile::new( "quic-tls13".into(), "datagram-v1".into(), "encoded".into(), - "encoded".into(), "server".into(), vec!["hevc-opus".into(), "h264-opus".into()], + "server".into(), vec![hevc.clone(), video.clone()], vec![audio.clone()], ).unwrap(); assert_eq!( - intersect_capability_profiles(&[gateway_capability, capabilities.clone()]).unwrap().clientDecode(), - &vec!["h264-opus".to_string()], + intersect_capability_profiles(&[gateway_capability, capabilities.clone()]).unwrap().videoProfiles(), + &vec![video.clone()], ); assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err()); assert!(AllocationPolicy::new( @@ -454,11 +476,13 @@ fn main() { assert!(DisplayMode::new(2560, 1440, 241).is_err()); let policy_free_v2_request = SessionRequest::new( "device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(), + vec![video.clone()], BitratePreference::new("auto".into(), None).unwrap(), None, ).unwrap(); assert!(policy_free_v2_request.requestedDisplayMode().is_none()); let display_request = SessionRequest::new( "device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(), + vec![video.clone()], BitratePreference::new("explicit".into(), Some(40000)).unwrap(), Some(display_mode.clone()), ).unwrap(); assert_eq!(display_request.requestedDisplayMode(), &Some(display_mode)); @@ -486,17 +510,13 @@ fn main() { ).is_err()); } assert!(ProviderStreamPolicy::new( - 2560, 1440, 120, "HEVC".into(), 40000, true, + hevc.clone(), audio.clone(), display.clone(), 40000, 50000, ).is_ok()); + assert!(DisplayMode::new(319, 1440, 120).is_err()); assert!(ProviderStreamPolicy::new( - 319, 1440, 120, "HEVC".into(), 40000, true, - ).is_err()); - assert!(ProviderStreamPolicy::new( - 2560, 1440, 241, "HEVC".into(), 40000, true, - ).is_err()); - assert!(ProviderStreamPolicy::new( - 2560, 1440, 120, "VP9".into(), 40000, true, + video.clone(), audio.clone(), display.clone(), 50001, 50000, ).is_err()); + assert!(VideoProfile::new("vp9".into(), 8, "4:2:0".into(), "bt709-limited".into(), "sdr".into()).is_err()); assert!(GatewayTelemetry::new( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, "ready".into(), ).is_ok()); @@ -532,6 +552,7 @@ fn main() { ("reconnect_sequence", 5), ("expires_at", 6), ("capabilities", 7), + ("selected_descriptor", 8), ] actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority") if actual_protobuf_fields != expected_protobuf_fields: diff --git a/tools/validate.py b/tools/validate.py index 8ba8df6..6c1c085 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -39,6 +39,20 @@ def main() -> int: feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8")) registered_features = {entry["id"] for entry in feature_registry["features"]} assert {"control.v1", "control.v2", "display.request.v1", "input.absolute.v1", "input.scroll.v1"}.issubset(registered_features) + assert {"video.profile.v1", "session.quality.v1", "session.stop.v1", "controller.arrival.v1"}.issubset(registered_features) + + assert defs["VideoProfile"]["required"] == ["codec", "bit_depth", "chroma_subsampling", "color_space", "transfer_function"] + assert defs["AudioProfile"]["required"] == ["codec", "sample_rate_hz", "channels", "channel_layout", "packet_duration_ms"] + assert defs["CapabilityProfile"]["required"] == ["transport", "framing", "media", "source_rate_control", "video_profiles", "audio_profiles"] + assert defs["CapabilityProfile"]["properties"]["video_profiles"] == {"type": "array", "minItems": 1, "maxItems": 12, "uniqueItems": True, "items": {"$ref": "#/$defs/VideoProfile"}} + assert defs["CapabilityProfile"]["properties"]["audio_profiles"] == {"type": "array", "minItems": 1, "maxItems": 1, "uniqueItems": True, "items": {"$ref": "#/$defs/AudioProfile"}} + assert defs["SessionRequest"]["required"][-2:] == ["video_profiles", "bitrate_preference"] + assert defs["ReconnectRequest"]["required"][-1] == "display_relaunch_confirmed" + assert defs["AssignedDesktop"]["required"][-1] == "quality_limits" + assert defs["EntitledPool"]["required"][-1] == "quality_limits" + for owner in ("SessionAuthority", "ClientSessionAuthority"): + assert defs[owner]["required"][-1] == "selected_descriptor" + assert defs["ConnectionManifest"]["required"][-1] == "selected_descriptor" display_mode = defs["DisplayMode"] assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"] @@ -113,6 +127,17 @@ def main() -> int: assert "native_identity" not in native_missing_fixture tunnel_credential_fixture = json.loads((ROOT / "fixtures/valid/native-tunnel-credential.json").read_text(encoding="utf-8")) assert set(tunnel_credential_fixture) == set(tunnel_credential["required"]) + valid_fixture_contracts = { + "fixtures/valid/session-request.json": "SessionRequest", + "fixtures/valid/selected-session-descriptor.json": "SelectedSessionDescriptor", + "fixtures/valid/session-quality-limits.json": "SessionQualityLimits", + } + for relative, definition in valid_fixture_contracts.items(): + fixture = json.loads((ROOT / relative).read_text(encoding="utf-8")) + assert set(fixture) == set(defs[definition]["required"]), (relative, definition) + assert set(json.loads((ROOT / "fixtures/invalid/capability-rc5-opaque.json").read_text())) & {"audio", "client_decode"} == {"audio", "client_decode"} + assert "video_profiles" not in json.loads((ROOT / "fixtures/invalid/session-request-rc5.json").read_text()) + assert set(json.loads((ROOT / "fixtures/invalid/provider-stream-policy-rc5.json").read_text())) == {"resolution_width", "resolution_height", "fps", "codec", "bitrate_kbps", "audio_enabled"} expected_header = "id\tversion\tkind\tinput\texpected" ids = set() @@ -139,10 +164,49 @@ def main() -> int: fixture_hash.update((ROOT / relative).read_bytes()) fixture_hash.update(b"\0") assert fixture_manifest["corpus_sha256"] == fixture_hash.hexdigest() + json_fixture_paths = sorted( + path.relative_to(ROOT).as_posix() + for directory in (ROOT / "fixtures/valid", ROOT / "fixtures/invalid") + for path in directory.glob("*.json") + ) + assert fixture_manifest["json_files"] == json_fixture_paths + json_fixture_hash = hashlib.sha256() + for relative in json_fixture_paths: + json_fixture_hash.update(relative.encode("utf-8")) + json_fixture_hash.update(b"\0") + json_fixture_hash.update((ROOT / relative).read_bytes()) + json_fixture_hash.update(b"\0") + assert fixture_manifest["json_corpus_sha256"] == json_fixture_hash.hexdigest() openapi = (ROOT / "openapi/control-v1.yaml").read_text(encoding="utf-8") assert "openapi: 3.1.0" in openapi assert "/api/v1/auth/refresh:" in openapi and "/api/v1/resources:" in openapi and "/api/v1/events:" in openapi + for route in ( + "/api/v1/session-quality-limits:", + "/api/v1/session-quality-limits/assignments/{assignment_id}:", + "/api/v1/session-quality-limits/pools/{pool_id}:", + "/api/v1/admin/entitlements/{entitlement_id}/display-limit-override:", + "/api/v1/broker/sessions/{session_id}/quality-changes:", + "/api/v1/broker/sessions/{session_id}/quality-changes/{operation_id}:", + "/api/v1/broker/sessions/{session_id}/stop-operations:", + "/api/v1/broker/sessions/{session_id}/stop-operations/{operation_id}:", + "/api/v1/gateway/quality-work:", + "/api/v1/gateway/quality-ack:", + "/api/v1/gateway/stop-work:", + "/api/v1/gateway/stop-ack:", + ): + assert route in openapi, route + for operation_id in ( + "getSessionQualityLimits", "getAssignmentSessionQualityLimits", "getPoolSessionQualityLimits", + "createSessionQualityChange", "getSessionQualityChange", "createSessionStopOperation", "getSessionStopOperation", + ): + operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0] + assert "nativeBearer: []" in operation and "browserSession" not in operation, operation_id + for operation_id in ( + "acquireGatewayQualityWork", "acknowledgeGatewayQualityWork", "acquireGatewayStopWork", "acknowledgeGatewayStopWork", + ): + operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0] + assert "gatewayMutualTLS: []" in operation and "nativeBearer" not in operation and "browserSession" not in operation, operation_id assert "provider_url" not in openapi and "vm_address" not in openapi session_endpoint = openapi.split(" /api/v1/auth/session:", 1)[1].split("\n /api/", 1)[0] assert "$defs/BrowserAuthenticatedSession" in session_endpoint @@ -172,6 +236,8 @@ def main() -> int: browserCsrfCookie: [] browserCsrfHeader: [] """ + admin_override = openapi.split(" operationId: updateEntitlementDisplayLimitOverride\n", 1)[1].split(" responses:\n", 1)[0] + assert browser_requirement.removeprefix(" ") in admin_override for operation_id in ( "issueReauthenticationGrant", "logoutSession", "registerDevice", "proveDevice", "revokeDevice", "requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession", diff --git a/tools/validate_gateway_envelopes.py b/tools/validate_gateway_envelopes.py index 2d830bb..4ae2abf 100644 --- a/tools/validate_gateway_envelopes.py +++ b/tools/validate_gateway_envelopes.py @@ -44,6 +44,10 @@ def classify_input(raw: bytes) -> str: return "valid" if width and height and x < width and y < height else "invalid:field" if kind == 7: return "valid" if len(body) == 4 else "invalid:length" + if kind == 8: + if len(body) != 8: + return "invalid:length" + return "valid" if body[0] <= 15 and body[3] <= 3 else "invalid:field" return "invalid:kind" @@ -68,6 +72,14 @@ def classify_feedback(raw: bytes) -> str: return "valid" if valid_fec_status(body) else "invalid:field" if kind == 3: return "valid" if not body else "invalid:length" + if kind == 4: + if len(body) != 24: + return "invalid:length" + return "valid" if any(body[:16]) and int.from_bytes(body[16:24], "big") > 0 else "invalid:field" + if kind == 5: + if len(body) != 16: + return "invalid:length" + return "valid" if any(body) else "invalid:field" return "invalid:type" if kind in (1, 2, 3): return "invalid:direction"