Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ea2f35c6 | ||
|
|
6e18bc9ee6 | ||
|
|
8eacc4fda9 | ||
|
|
4693102b3c | ||
|
|
afbcea62f9 | ||
|
|
b6a4f773e4 |
@@ -0,0 +1,2 @@
|
|||||||
|
id version kind input expected
|
||||||
|
device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
|
||||||
|
@@ -9,9 +9,12 @@ valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040
|
|||||||
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
|
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
|
||||||
valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
|
valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
|
||||||
valid-scroll 1 gateway_input hex=564749310704ff880078 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-idr 1 gateway_feedback hex=5647463100010000 valid
|
||||||
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
||||||
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 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-termination 1 gateway_feedback hex=564746310110000400000001 valid
|
||||||
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
|
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
|
||||||
valid-hdr 1 gateway_feedback hex=564746310112000101 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-body 1 gateway_feedback hex=5647463100030001ff invalid:length
|
||||||
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
|
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
|
||||||
invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
|
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
|
invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
|
||||||
|
|||||||
|
@@ -0,0 +1 @@
|
|||||||
|
{"mode":"auto","target_kbps":1000}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789abc","revision":7,"lease_generation":3,"outcome":"applied","current_applied_revision":6}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789ABC","revision":7,"lease_generation":3,"outcome":"unknown"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"00000000-0000-0000-0000-000000000000","revision":7,"lease_generation":3,"outcome":"unknown"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"acquisition":"poll","operation_id":"12345678-1234-1234-1234-123456789abc","revision":7}
|
||||||
@@ -26,5 +26,14 @@
|
|||||||
"audience": "versevdi-gateway"
|
"audience": "versevdi-gateway"
|
||||||
},
|
},
|
||||||
"correlation_id": "correlation-1",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"resolution_width":1920,"resolution_height":1080,"fps":60,"codec":"H264","bitrate_kbps":20000,"audio_enabled":true}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"operation_id":"12345678-1234-1234-1234-123456789abc","session_id":"session-1","revision":7,"state":"pending","requested_bitrate_preference":{"mode":"auto"},"effective_bitrate_kbps":20000,"governing_policy_version":"policy-1","session_version":2,"created_at":"2099-01-01T00:00:00.100Z","deadline_at":"2099-01-01T00:00:30Z","updated_at":"2099-01-01T00:00:00Z"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"operation_id":"12345678-1234-1234-1234-123456789abc","session_id":"session-1","revision":7,"state":"pending","requested_bitrate_preference":{"mode":"auto"},"effective_bitrate_kbps":20000,"governing_policy_version":"policy-1","session_version":2,"created_at":"2099-01-01T00:00:00+00:00","deadline_at":"2099-01-01T00:00:30Z","updated_at":"2099-01-01T00:00:00Z"}
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"codec":"vp9","bit_depth":8,"chroma_subsampling":"4:2:0","color_space":"bt709-limited","transfer_function":"sdr"}
|
||||||
+31
-1
@@ -4,11 +4,41 @@
|
|||||||
"fixtures/conformance/control-v1.tsv",
|
"fixtures/conformance/control-v1.tsv",
|
||||||
"fixtures/conformance/datagram-v1.tsv",
|
"fixtures/conformance/datagram-v1.tsv",
|
||||||
"fixtures/conformance/datagram-v2.tsv",
|
"fixtures/conformance/datagram-v2.tsv",
|
||||||
|
"fixtures/conformance/device-proof-v1.tsv",
|
||||||
"fixtures/conformance/events-v1.tsv",
|
"fixtures/conformance/events-v1.tsv",
|
||||||
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
|
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
|
||||||
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
||||||
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||||
"fixtures/conformance/tunnel-v1.tsv"
|
"fixtures/conformance/tunnel-v1.tsv"
|
||||||
],
|
],
|
||||||
"corpus_sha256": "ed69937656f395b30f520861948f82ed3c0b21ea86e9b33c7949ed09942e701d"
|
"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/gateway-quality-ack-contradictory.json",
|
||||||
|
"fixtures/invalid/gateway-quality-ack-uppercase-uuid.json",
|
||||||
|
"fixtures/invalid/gateway-quality-ack-zero-uuid.json",
|
||||||
|
"fixtures/invalid/gateway-quality-poll-coordinates.json",
|
||||||
|
"fixtures/invalid/manifest-provider-field.json",
|
||||||
|
"fixtures/invalid/native-session-missing-identity.json",
|
||||||
|
"fixtures/invalid/provider-stream-policy-rc5.json",
|
||||||
|
"fixtures/invalid/quality-operation-noncanonical-fraction.json",
|
||||||
|
"fixtures/invalid/quality-operation-offset-time.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/gateway-quality-ack-applied.json",
|
||||||
|
"fixtures/valid/gateway-quality-poll.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": "08dea278526a523d21ea4ca6270ba1e33d44dc0d701681917d7ffc6fb13053d7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789abc","revision":7,"lease_generation":3,"outcome":"applied","current_applied_revision":7}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"acquisition":"poll"}
|
||||||
@@ -25,5 +25,14 @@
|
|||||||
"expires_at": "2099-01-01T00:00:00Z",
|
"expires_at": "2099-01-01T00:00:00Z",
|
||||||
"audience": "versevdi-gateway"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
"client_device_id": "device-1",
|
"client_device_id": "device-1",
|
||||||
"device_key_id": "key-1",
|
"device_key_id": "key-1",
|
||||||
"pool_id": "pool-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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
Keyboard, mouse button, UTF-8, controller, absolute mouse, and scroll messages
|
||||||
are delivered over the gateway's reliable ordered input flow. Relative and
|
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 |
|
| 8 | N | payload | exact type-specific body |
|
||||||
|
|
||||||
The client-to-gateway types are `0x01` IDR request (empty), `0x02` FEC
|
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),
|
`frame_index` (u32), `highest_received_sequence` (u16),
|
||||||
`next_contiguous_sequence` (u16), `missing_before_highest` (u16),
|
`next_contiguous_sequence` (u16), `missing_before_highest` (u16),
|
||||||
`total_data_packets` (u16), `total_parity_packets` (u16),
|
`total_data_packets` (u16), `total_parity_packets` (u16),
|
||||||
|
|||||||
+2508
-90
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"3"
|
"3"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generator_sha256": "8a153cf1e99682d010ff91c754ef056c64aece8f8bbca0ca58f8eef2b9039119",
|
"generator_sha256": "c5756780f59a82d8f56762e52f58e22a4d6aca6648157f45e5bad6559b9deeec",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
"schema_sha256": "b9e72838b2ae02b3a47e7e2dfa03620b8580f2c98668e6eaf551ea8fb642d297"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+815
-57
File diff suppressed because it is too large
Load Diff
+1172
-116
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ tags:
|
|||||||
- name: resources
|
- name: resources
|
||||||
- name: broker
|
- name: broker
|
||||||
- name: events
|
- name: events
|
||||||
|
- name: session-quality
|
||||||
|
- name: gateway
|
||||||
paths:
|
paths:
|
||||||
/api/v1/auth/login:
|
/api/v1/auth/login:
|
||||||
post:
|
post:
|
||||||
@@ -92,6 +94,8 @@ paths:
|
|||||||
operationId: issueReauthenticationGrant
|
operationId: issueReauthenticationGrant
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -113,6 +117,8 @@ paths:
|
|||||||
operationId: logoutSession
|
operationId: logoutSession
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
- nativeBearer: []
|
- nativeBearer: []
|
||||||
responses:
|
responses:
|
||||||
'204': {description: Session revoked and browser cookies cleared.}
|
'204': {description: Session revoked and browser cookies cleared.}
|
||||||
@@ -123,6 +129,8 @@ paths:
|
|||||||
operationId: registerDevice
|
operationId: registerDevice
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -144,6 +152,8 @@ paths:
|
|||||||
operationId: proveDevice
|
operationId: proveDevice
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/DeviceID'
|
- $ref: '#/components/parameters/DeviceID'
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -167,6 +177,8 @@ paths:
|
|||||||
operationId: revokeDevice
|
operationId: revokeDevice
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/DeviceID'
|
- $ref: '#/components/parameters/DeviceID'
|
||||||
responses:
|
responses:
|
||||||
@@ -199,6 +211,8 @@ paths:
|
|||||||
description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected.
|
description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected.
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
- nativeBearer: []
|
- nativeBearer: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/IdempotencyKey'
|
- $ref: '#/components/parameters/IdempotencyKey'
|
||||||
@@ -249,6 +263,8 @@ paths:
|
|||||||
operationId: allocateBrokerSession
|
operationId: allocateBrokerSession
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
- nativeBearer: []
|
- nativeBearer: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/SessionID'
|
- $ref: '#/components/parameters/SessionID'
|
||||||
@@ -274,6 +290,8 @@ paths:
|
|||||||
operationId: reconnectBrokerSession
|
operationId: reconnectBrokerSession
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
- nativeBearer: []
|
- nativeBearer: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/SessionID'
|
- $ref: '#/components/parameters/SessionID'
|
||||||
@@ -300,6 +318,8 @@ paths:
|
|||||||
operationId: cancelBrokerSession
|
operationId: cancelBrokerSession
|
||||||
security:
|
security:
|
||||||
- browserSession: []
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
- nativeBearer: []
|
- nativeBearer: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/SessionID'
|
- $ref: '#/components/parameters/SessionID'
|
||||||
@@ -308,6 +328,151 @@ paths:
|
|||||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||||
'404': {$ref: '#/components/responses/NotFound'}
|
'404': {$ref: '#/components/responses/NotFound'}
|
||||||
'409': {$ref: '#/components/responses/Conflict'}
|
'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. Maximum JSON body: 16384 bytes.', 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. Maximum JSON body: 16384 bytes.', 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. Maximum JSON body: 16384 bytes.', 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, description: 'Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The owning principal and active device/key MUST match the subject-scoped broker session. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{nativeBearer: []}]
|
||||||
|
parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeRequest}}}}
|
||||||
|
responses:
|
||||||
|
'201': {description: 'Durable quality change created. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}}
|
||||||
|
'200': {description: 'Existing idempotent quality change returned. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The owning principal and active device/key MUST match the subject-scoped broker session and operation. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{nativeBearer: []}]
|
||||||
|
parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}]
|
||||||
|
responses:
|
||||||
|
'200': {description: 'Durable quality change state. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The owning principal and active device/key MUST match the subject-scoped broker session. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{nativeBearer: []}]
|
||||||
|
parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
responses:
|
||||||
|
'201': {description: 'Durable Stop operation created. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}}
|
||||||
|
'200': {description: 'Existing idempotent Stop operation returned. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The owning principal and active device/key MUST match the subject-scoped broker session and operation. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{nativeBearer: []}]
|
||||||
|
parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}]
|
||||||
|
responses:
|
||||||
|
'200': {description: 'Durable Stop operation state. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST match the session, gateway, reconnect sequence, and supplied operation/revision/lease generation coordinates. A `poll` acquisition omits unknown operation coordinates and discovers pending session work after a lost prompt; `prompt` and `observation` coordinates MUST match exactly. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{gatewayMutualTLS: []}]
|
||||||
|
requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWorkRequest}}}}
|
||||||
|
responses:
|
||||||
|
'200': {description: 'Leased quality work or reconciliation observation. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST exactly match the session, gateway, reconnect sequence, operation, revision, and monotonic lease generation tuple. Stale lease generations MUST be rejected. `applied` requires `current_applied_revision == revision`; `proven_prior` requires `current_applied_revision < revision`; `unknown` forbids `current_applied_revision` and makes no applied-revision assertion. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{gatewayMutualTLS: []}]
|
||||||
|
requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST match the session, gateway, reconnect sequence, and any supplied operation coordinate. A `poll` acquisition omits the unknown operation ID and discovers pending session work after a lost prompt; a `prompt` coordinate MUST match exactly. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{gatewayMutualTLS: []}]
|
||||||
|
requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWorkRequest}}}}
|
||||||
|
responses:
|
||||||
|
'200': {description: 'One-way non-reissued Stop work claim. Maximum JSON body: 16384 bytes.', 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
|
||||||
|
description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST exactly match the session, gateway, reconnect sequence, and operation tuple. Maximum JSON body: 16384 bytes.
|
||||||
|
security: [{gatewayMutualTLS: []}]
|
||||||
|
requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', 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:
|
/api/v1/events:
|
||||||
get:
|
get:
|
||||||
tags: [events]
|
tags: [events]
|
||||||
@@ -353,10 +518,22 @@ components:
|
|||||||
type: apiKey
|
type: apiKey
|
||||||
in: cookie
|
in: cookie
|
||||||
name: versevdi_session
|
name: versevdi_session
|
||||||
|
browserCsrfCookie:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: versevdi_csrf
|
||||||
|
description: Must be identical to X-CSRF-Token and is checked against Server session state.
|
||||||
|
browserCsrfHeader:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-CSRF-Token
|
||||||
|
description: Must be identical to the versevdi_csrf cookie and is checked against Server session state.
|
||||||
nativeBearer:
|
nativeBearer:
|
||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
bearerFormat: opaque
|
bearerFormat: opaque
|
||||||
|
gatewayMutualTLS:
|
||||||
|
type: mutualTLS
|
||||||
parameters:
|
parameters:
|
||||||
DeviceID:
|
DeviceID:
|
||||||
name: device_id
|
name: device_id
|
||||||
@@ -368,6 +545,26 @@ components:
|
|||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema: {type: string, minLength: 1, maxLength: 128}
|
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, pattern: '^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'}
|
||||||
Limit:
|
Limit:
|
||||||
name: limit
|
name: limit
|
||||||
in: query
|
in: query
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-12
|
||||||
|
goal: Split client-facing session authority from the provider-bearing
|
||||||
|
Server-to-gateway authority for the coordinated RC4 hard cut.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
RC3 uses one provider-bearing `SessionAuthority` for both the authenticated Server-to-gateway control plane and the gateway-to-client acknowledgement. Provider profile and identity are valid inputs to gateway provider work and release, but they are forbidden at the client boundary. Existing strict RC3 clients require those fields, so changing the client shape is intentionally incompatible.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make provider disclosure structurally impossible in the client-facing authority type.
|
||||||
|
- Preserve the provider-bound Server-to-gateway admission, work, release, and cleanup contract.
|
||||||
|
- Produce strict, matching JSON Schema, Protobuf, Go, Rust, and Swift contracts.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Supporting mixed RC3/RC4 gateway and client pairings.
|
||||||
|
- Changing `SessionAuthority`, `ProviderSessionWork`, `VERSION`, or global compatibility history.
|
||||||
|
- Adding response negotiation, optional provider fields, or permissive decoding.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Add `ClientSessionAuthority` with exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. Reusing the common validation bounds keeps the new acknowledgement session-bound without representing provider data.
|
||||||
|
2. Keep the existing provider-bearing `SessionAuthority` unchanged for Server-to-gateway operations. Deleting its provider fields would broaden the security-sensitive change into Server admission and provider-work validation.
|
||||||
|
3. Treat RC4 as a coordinated hard cut. A dual decoder would still accept the forbidden RC3 shape and is unnecessary for an unreleased candidate.
|
||||||
|
4. Use the existing generator unchanged. The JSON Schema definition is sufficient to generate strict Go, Rust, and Swift types; the matching Protobuf message uses fields 1 through 7.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [RC3 and RC4 clients are not wire-compatible] → Pin and qualify Server, gateway, and client as one exact RC4 set; retain RC3 as an immutable rollback set.
|
||||||
|
- [A future gateway could serialize the wrong authority type] → Consumer gateway tests must capture the raw acknowledgement and require `ClientSessionAuthority` with no provider-bearing keys.
|
||||||
|
- [Strict decoding rejects future additive fields] → Version a future client authority explicitly instead of weakening this v1 decoder.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Publish the verified immutable Protocol RC4 tag.
|
||||||
|
2. Repin Data, Server, and macOS to the exact RC4 commit.
|
||||||
|
3. Change gateway egress and client decoders together, then qualify the exact all-RC4 set.
|
||||||
|
4. Roll back only as the complete immutable RC3 set; do not retag or mix candidates.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None for this pre-release hard cut. Evidence of deployed RC3 coexistence would require a separate negotiated-version design and blocks this migration model.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The gateway currently serializes the provider-bearing Server-to-gateway `SessionAuthority` to clients, crossing provider identity into a client trust boundary that forbids it. RC4 must make that boundary structural before the pre-release client set is qualified.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a strict provider-free `ClientSessionAuthority` with the seven session, gateway, audience, reconnect, expiry, and capability fields shared with `SessionAuthority`.
|
||||||
|
- Keep `SessionAuthority` and `ProviderSessionWork` unchanged for the authenticated Server-to-gateway control plane.
|
||||||
|
- **BREAKING** Replace the gateway-to-client RC3 response shape with `ClientSessionAuthority` as a coordinated RC4 hard cut; no mixed RC3/RC4 compatibility is claimed.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `gateway-transport-and-admission`: Defines the distinct client-facing authority and its provider-free gateway admission boundary.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Protocol JSON Schema, tunnel Protobuf, generated Go/Rust/Swift bindings, and consumer Protocol pins advance together to `v1.0.0-phase3d-macos-rc.4`. `VERSION`, global compatibility history, and the Server-to-gateway provider authority remain unchanged.
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Client-facing authority is provider-free
|
||||||
|
The gateway-to-client acknowledgement SHALL use `ClientSessionAuthority` version `"1"` containing exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. The contract SHALL reject missing required fields, unknown fields including provider identities and routes, invalid or noncanonical expiry timestamps, and trailing JSON values.
|
||||||
|
|
||||||
|
#### Scenario: Gateway acknowledges an admitted client
|
||||||
|
- **WHEN** provider work succeeds and gateway and client capabilities intersect
|
||||||
|
- **THEN** the gateway returns a valid `ClientSessionAuthority` containing the selected capabilities and no provider-bearing field
|
||||||
|
|
||||||
|
#### Scenario: Client receives provider-bearing authority
|
||||||
|
- **WHEN** a client authority payload contains `provider_profile`, `provider_identity`, a provider route, or any unknown key
|
||||||
|
- **THEN** the strict client authority decoder rejects the payload
|
||||||
|
|
||||||
|
#### Scenario: Client receives incomplete or malformed authority
|
||||||
|
- **WHEN** a client authority omits any required binding, has an invalid expiry, or is followed by another JSON value
|
||||||
|
- **THEN** the strict client authority decoder rejects the payload
|
||||||
|
|
||||||
|
### Requirement: Server-to-gateway authority remains provider-bound
|
||||||
|
The authenticated Server-to-gateway control plane SHALL continue to use the existing provider-bearing `SessionAuthority` for admission, provider work, release, and cleanup. `SessionAuthority` and `ProviderSessionWork` fields and semantics MUST remain unchanged by this change.
|
||||||
|
|
||||||
|
#### Scenario: Gateway performs provider work
|
||||||
|
- **WHEN** the Server admits a gateway session and the gateway requests provider work
|
||||||
|
- **THEN** the original provider-bearing `SessionAuthority` continues to bind provider work and subsequent release or cleanup
|
||||||
|
|
||||||
|
### Requirement: RC4 is a coordinated hard cut
|
||||||
|
The RC4 gateway and client SHALL use `ClientSessionAuthority`; mixed RC3/RC4 gateway-client compatibility SHALL NOT be claimed. RC4 SHALL NOT add optional provider fields, a dual decoder, or response negotiation for RC3.
|
||||||
|
|
||||||
|
#### Scenario: RC4 candidate is qualified
|
||||||
|
- **WHEN** the Protocol RC4 tag is pinned by Server, gateway, and client
|
||||||
|
- **THEN** qualification uses only that exact coordinated set
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## 1. Contract and regressions
|
||||||
|
|
||||||
|
- [x] 1.1 Add RED-first Go, Swift, Rust, and Protobuf regressions for the strict provider-free authority.
|
||||||
|
- [x] 1.2 Add the exact seven-field JSON Schema and Protobuf `ClientSessionAuthority` without changing existing authority contracts.
|
||||||
|
- [x] 1.3 Regenerate Go, Rust, Swift, Protobuf, and manifest outputs using repository tooling.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
|
||||||
|
- [x] 2.1 Pass focused Go and generated-contract regressions.
|
||||||
|
- [x] 2.2 Pass strict OpenSpec validation, full `make verify`, second-generation cleanliness, and diff checks.
|
||||||
@@ -54,9 +54,56 @@ message CapabilityProfile {
|
|||||||
string transport = 1;
|
string transport = 1;
|
||||||
string framing = 2;
|
string framing = 2;
|
||||||
string media = 3;
|
string media = 3;
|
||||||
string audio = 4;
|
reserved 4;
|
||||||
string source_rate_control = 5;
|
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 {
|
message GatewayRegistration {
|
||||||
@@ -115,6 +162,18 @@ message SessionAuthority {
|
|||||||
CapabilityProfile capabilities = 7;
|
CapabilityProfile capabilities = 7;
|
||||||
string provider_profile = 8;
|
string provider_profile = 8;
|
||||||
string provider_identity = 9;
|
string provider_identity = 9;
|
||||||
|
SelectedSessionDescriptor selected_descriptor = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientSessionAuthority {
|
||||||
|
string version = 1;
|
||||||
|
string session_id = 2;
|
||||||
|
string gateway_id = 3;
|
||||||
|
string audience = 4;
|
||||||
|
uint64 reconnect_sequence = 5;
|
||||||
|
google.protobuf.Timestamp expires_at = 6;
|
||||||
|
CapabilityProfile capabilities = 7;
|
||||||
|
SelectedSessionDescriptor selected_descriptor = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ProviderSessionWork {
|
message ProviderSessionWork {
|
||||||
@@ -137,6 +196,73 @@ message ProviderSessionWork {
|
|||||||
string client_id = 17;
|
string client_id = 17;
|
||||||
ClipboardPolicy clipboard_policy = 18;
|
ClipboardPolicy clipboard_policy = 18;
|
||||||
bool provider_application_termination_allowed = 19;
|
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;
|
||||||
|
optional uint64 revision = 6;
|
||||||
|
optional uint64 current_applied_revision = 7;
|
||||||
|
string acquisition = 8;
|
||||||
|
optional uint64 lease_generation = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
uint64 lease_generation = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
optional uint64 current_applied_revision = 8;
|
||||||
|
string failure_code = 9;
|
||||||
|
uint64 lease_generation = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
string acquisition = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
message ClipboardPolicy {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"datagrams": [
|
"datagrams": [
|
||||||
{"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024, "payload_profile": "gateway-feedback-v1"},
|
{"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": "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": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536},
|
||||||
{"id": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
|
{"id": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
|
||||||
{"id": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
|
{"id": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
{"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536},
|
{"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536},
|
||||||
{"id": "display.request.v1", "direction": "client-to-server", "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.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}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-29
@@ -64,22 +64,24 @@
|
|||||||
"AssignedDesktop": {
|
"AssignedDesktop": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["assignment_id", "pool_id", "name", "availability"],
|
"required": ["assignment_id", "pool_id", "name", "availability", "quality_limits"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"assignment_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"assignment_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"name": {"type": "string", "minLength": 1, "maxLength": 256},
|
"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": {
|
"EntitledPool": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["pool_id", "name", "assignment_state"],
|
"required": ["pool_id", "name", "assignment_state", "quality_limits"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"name": {"type": "string", "minLength": 1, "maxLength": 256},
|
"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": {
|
"ResourceList": {
|
||||||
@@ -232,32 +234,106 @@
|
|||||||
"fps": {"type": "integer", "minimum": 1, "maximum": 240}
|
"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",
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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": {
|
"SessionRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256},
|
"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"}
|
"requested_display_mode": {"$ref": "#/$defs/DisplayMode"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ReconnectRequest": {
|
"ReconnectRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["client_device_id", "device_key_id", "expected_version"],
|
"required": ["client_device_id", "device_key_id", "expected_version", "display_relaunch_confirmed"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"device_key_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": {
|
"BrokerSession": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"principal_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"principal_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
@@ -275,7 +351,10 @@
|
|||||||
"ended_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
"ended_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
||||||
"version": {"type": "integer", "minimum": 1},
|
"version": {"type": "integer", "minimum": 1},
|
||||||
"requested_display_mode": {"$ref": "#/$defs/DisplayMode"},
|
"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": {
|
"AllocationPolicy": {
|
||||||
@@ -297,7 +376,7 @@
|
|||||||
"ConnectionManifest": {
|
"ConnectionManifest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"version": {"type": "string", "const": "1"},
|
"version": {"type": "string", "const": "1"},
|
||||||
"purpose": {"type": "string", "enum": ["launch", "reconnect"]},
|
"purpose": {"type": "string", "enum": ["launch", "reconnect"]},
|
||||||
@@ -307,7 +386,8 @@
|
|||||||
"tunnel": {"$ref": "#/$defs/ManifestTunnel"},
|
"tunnel": {"$ref": "#/$defs/ManifestTunnel"},
|
||||||
"profile": {"$ref": "#/$defs/ManifestProfile"},
|
"profile": {"$ref": "#/$defs/ManifestProfile"},
|
||||||
"grant": {"$ref": "#/$defs/GrantReference"},
|
"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": {
|
"ManifestGateway": {
|
||||||
@@ -431,20 +511,14 @@
|
|||||||
"CapabilityProfile": {
|
"CapabilityProfile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["transport", "framing", "media", "audio", "source_rate_control", "client_decode"],
|
"required": ["transport", "framing", "media", "source_rate_control", "video_profiles", "audio_profiles"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"transport": {"type": "string", "minLength": 1, "maxLength": 64},
|
"transport": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||||
"framing": {"type": "string", "enum": ["datagram-v1", "datagram-v2"]},
|
"framing": {"type": "string", "enum": ["datagram-v1", "datagram-v2"]},
|
||||||
"media": {"type": "string", "minLength": 1, "maxLength": 64},
|
"media": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||||
"audio": {"type": "string", "minLength": 1, "maxLength": 64},
|
|
||||||
"source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64},
|
"source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||||
"client_decode": {
|
"video_profiles": {"type": "array", "minItems": 1, "maxItems": 12, "uniqueItems": true, "items": {"$ref": "#/$defs/VideoProfile"}},
|
||||||
"type": "array",
|
"audio_profiles": {"type": "array", "minItems": 1, "maxItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/AudioProfile"}}
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 2,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": {"type": "string", "enum": ["h264-opus", "hevc-opus"]}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GatewayRegistration": {
|
"GatewayRegistration": {
|
||||||
@@ -538,7 +612,7 @@
|
|||||||
"SessionAuthority": {
|
"SessionAuthority": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"version": {"type": "string", "const": "1"},
|
"version": {"type": "string", "const": "1"},
|
||||||
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
@@ -548,20 +622,170 @@
|
|||||||
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
||||||
"capabilities": {"$ref": "#/$defs/CapabilityProfile"},
|
"capabilities": {"$ref": "#/$defs/CapabilityProfile"},
|
||||||
"provider_profile": {"type": "string", "enum": ["apollo"]},
|
"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", "selected_descriptor"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"type": "string", "const": "1"},
|
||||||
|
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"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"},
|
||||||
|
"selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ProviderStreamPolicy": {
|
"ProviderStreamPolicy": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384},
|
"video_profile": {"$ref": "#/$defs/VideoProfile"},
|
||||||
"resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640},
|
"audio_profile": {"$ref": "#/$defs/AudioProfile"},
|
||||||
"fps": {"type": "integer", "minimum": 1, "maximum": 240},
|
"display_mode": {"$ref": "#/$defs/DisplayMode"},
|
||||||
"codec": {"type": "string", "enum": ["H264", "HEVC", "AV1"]},
|
"bitrate_target_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000},
|
||||||
"bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000},
|
"bitrate_maximum_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}
|
||||||
"audio_enabled": {"type": "boolean"}
|
}
|
||||||
|
},
|
||||||
|
"DisplayLimitOverrideRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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",
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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",
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "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", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"deadline_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}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"updated_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}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"failure_code": {"type": "string", "minLength": 1, "maxLength": 128}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StopOperation": {
|
||||||
|
"type": "object",
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["operation_id", "session_id", "state", "session_version", "created_at", "deadline_at", "updated_at"],
|
||||||
|
"properties": {
|
||||||
|
"operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "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", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"deadline_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}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"updated_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}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64},
|
||||||
|
"failure_code": {"type": "string", "minLength": 1, "maxLength": 128}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayQualityWorkRequest": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"],
|
||||||
|
"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},
|
||||||
|
"acquisition": {"type": "string", "enum": ["poll", "prompt", "observation"]},
|
||||||
|
"operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36},
|
||||||
|
"revision": {"type": "integer", "minimum": 1, "x-optional-pointer": true},
|
||||||
|
"lease_generation": {"type": "integer", "minimum": 1, "x-optional-pointer": true},
|
||||||
|
"current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayQualityWork": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_generation", "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", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1},
|
||||||
|
"lease_generation": {"type": "integer", "minimum": 1},
|
||||||
|
"lease_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}(?:\\.[0-9]{0,8}[1-9])?Z$", "maxLength": 64}, "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"},
|
||||||
|
"current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayQualityAck": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"description": "Outcome invariants: applied requires current_applied_revision equal to revision; proven_prior requires current_applied_revision strictly less than revision; unknown forbids current_applied_revision and makes no applied-revision assertion.",
|
||||||
|
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_generation", "outcome"],
|
||||||
|
"oneOf": [
|
||||||
|
{"properties": {"outcome": {"const": "applied"}}, "required": ["current_applied_revision"]},
|
||||||
|
{"properties": {"outcome": {"const": "proven_prior"}}, "required": ["current_applied_revision"]},
|
||||||
|
{"properties": {"outcome": {"const": "unknown"}}, "not": {"required": ["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", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1},
|
||||||
|
"lease_generation": {"type": "integer", "minimum": 1},
|
||||||
|
"outcome": {"type": "string", "enum": ["applied", "proven_prior", "unknown"]}, "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true},
|
||||||
|
"failure_code": {"type": "string", "minLength": 1, "maxLength": 128}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayStopWorkRequest": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"],
|
||||||
|
"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},
|
||||||
|
"acquisition": {"type": "string", "enum": ["poll", "prompt"]},
|
||||||
|
"operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayStopWork": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36}, "attempt": {"type": "integer", "minimum": 1, "maximum": 1}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayStopAck": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"x-max-bytes": 16384,
|
||||||
|
"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", "pattern": "^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "minLength": 36, "maxLength": 36}, "outcome": {"type": "string", "enum": ["applied", "failed", "termination_unconfirmed"]},
|
||||||
|
"failure_code": {"type": "string", "minLength": 1, "maxLength": 128}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ProviderSessionWork": {
|
"ProviderSessionWork": {
|
||||||
|
|||||||
+163
-27
@@ -2,6 +2,8 @@ package protocol_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,8 +11,65 @@ import (
|
|||||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
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")
|
||||||
|
deviceID, _ := hex.DecodeString("ffeeddccbbaa99887766554433221100")
|
||||||
|
challenge, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||||
|
want, _ := hex.DecodeString("76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b")
|
||||||
|
|
||||||
|
got, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, 1700000000123)
|
||||||
|
if err != nil || !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("DeviceRegistrationProofTranscript() = %x, %v; want %x", got, err, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
serverID, principalID, deviceID, challenge []byte
|
||||||
|
expiry int64
|
||||||
|
field, code string
|
||||||
|
}{
|
||||||
|
{"server-short", serverID[:15], principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
|
||||||
|
{"server-long", append(append([]byte(nil), serverID...), 0), principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
|
||||||
|
{"principal-short", serverID, principalID[:15], deviceID, challenge, 0, "principal_id", "invalid_length"},
|
||||||
|
{"principal-long", serverID, append(append([]byte(nil), principalID...), 0), deviceID, challenge, 0, "principal_id", "invalid_length"},
|
||||||
|
{"device-short", serverID, principalID, deviceID[:15], challenge, 0, "device_id", "invalid_length"},
|
||||||
|
{"device-long", serverID, principalID, append(append([]byte(nil), deviceID...), 0), challenge, 0, "device_id", "invalid_length"},
|
||||||
|
{"challenge-short", serverID, principalID, deviceID, challenge[:31], 0, "challenge", "invalid_length"},
|
||||||
|
{"challenge-long", serverID, principalID, deviceID, append(append([]byte(nil), challenge...), 0), 0, "challenge", "invalid_length"},
|
||||||
|
{"negative-expiry", serverID, principalID, deviceID, challenge, -1, "expiry_unix_milliseconds", "minimum"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := protocol.DeviceRegistrationProofTranscript(test.serverID, test.principalID, test.deviceID, test.challenge, test.expiry)
|
||||||
|
validation, ok := err.(protocol.ValidationError)
|
||||||
|
if !ok || len(validation.Violations) != 1 || validation.Violations[0] != (protocol.FieldViolation{Field: test.field, Code: test.code}) {
|
||||||
|
t.Fatalf("error = %#v; want %s/%s validation error", err, test.field, test.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManifestRejectsForbiddenAndUnknownFields(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))
|
manifest, err := protocol.DecodeConnectionManifest([]byte(valid))
|
||||||
if err != nil || manifest.Gateway.ID != "gateway-1" {
|
if err != nil || manifest.Gateway.ID != "gateway-1" {
|
||||||
t.Fatalf("valid manifest = %+v, err = %v", manifest, err)
|
t.Fatalf("valid manifest = %+v, err = %v", manifest, err)
|
||||||
@@ -42,6 +101,7 @@ func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) {
|
|||||||
|
|
||||||
request := protocol.SessionRequest{
|
request := protocol.SessionRequest{
|
||||||
ClientDeviceID: "device-1", DeviceKeyID: "key-1", PoolID: "pool-1", IdempotencyKey: "request-1",
|
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)
|
legacy, err := protocol.EncodeSessionRequest(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,7 +159,7 @@ func TestSessionRequestRejectsClientPolicySnapshot(t *testing.T) {
|
|||||||
if protocol.CurrentWireVersion != "2" || protocol.NMinus1WireVersion != "1" || protocol.NMinus2WireVersion != "0" {
|
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)
|
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 {
|
if _, err := protocol.DecodeSessionRequest([]byte(request)); err != nil {
|
||||||
t.Fatalf("policy-free SessionRequest rejected: %v", err)
|
t.Fatalf("policy-free SessionRequest rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -184,7 +244,7 @@ func TestGeneratedDecodersRejectMissingRequiredFieldsAndTrailingValues(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) {
|
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 {
|
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err != nil {
|
||||||
t.Fatalf("valid gateway registration rejected: %v", err)
|
t.Fatalf("valid gateway registration rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +263,7 @@ func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayRegistrationRejectsInvertedProtocolBounds(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 {
|
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err == nil {
|
||||||
t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds")
|
t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds")
|
||||||
}
|
}
|
||||||
@@ -226,12 +286,12 @@ func TestGatewayHeartbeatCarriesBoundedObservedTelemetry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCapabilityIntersectionRejectsNoOverlap(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) {
|
if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || !reflect.DeepEqual(got, first) {
|
||||||
t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err)
|
t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err)
|
||||||
}
|
}
|
||||||
second := first
|
second := first
|
||||||
second.ClientDecode = []string{"hevc-opus"}
|
second.VideoProfiles = []protocol.VideoProfile{rc6Video("hevc")}
|
||||||
if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil {
|
if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil {
|
||||||
t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile")
|
t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile")
|
||||||
}
|
}
|
||||||
@@ -239,31 +299,31 @@ func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) {
|
|||||||
|
|
||||||
func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) {
|
func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) {
|
||||||
gateway := protocol.CapabilityProfile{
|
gateway := protocol.CapabilityProfile{
|
||||||
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded",
|
||||||
SourceRateControl: "server", ClientDecode: []string{"hevc-opus", "h264-opus"},
|
SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("hevc"), rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()},
|
||||||
}
|
}
|
||||||
h264Client := gateway
|
h264Client := gateway
|
||||||
h264Client.ClientDecode = []string{"h264-opus"}
|
h264Client.VideoProfiles = []protocol.VideoProfile{rc6Video("h264")}
|
||||||
selected, err := protocol.IntersectCapabilityProfiles(gateway, h264Client)
|
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)
|
t.Fatalf("H.264 profile intersection = %+v, %v", selected, err)
|
||||||
}
|
}
|
||||||
hevcClient := gateway
|
hevcClient := gateway
|
||||||
hevcClient.ClientDecode = []string{"hevc-opus"}
|
hevcClient.VideoProfiles = []protocol.VideoProfile{rc6Video("hevc")}
|
||||||
selected, err = protocol.IntersectCapabilityProfiles(gateway, hevcClient)
|
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)
|
t.Fatalf("HEVC profile intersection = %+v, %v", selected, err)
|
||||||
}
|
}
|
||||||
noOverlap := gateway
|
noOverlap := gateway
|
||||||
noOverlap.ClientDecode = []string{"h264-opus"}
|
noOverlap.VideoProfiles = []protocol.VideoProfile{rc6Video("h264")}
|
||||||
if _, err := protocol.IntersectCapabilityProfiles(noOverlap, hevcClient); err == nil {
|
if _, err := protocol.IntersectCapabilityProfiles(noOverlap, hevcClient); err == nil {
|
||||||
t.Fatal("intersection accepted registered profiles without overlap")
|
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 := gateway
|
||||||
profile.ClientDecode = invalid
|
profile.VideoProfiles = invalid
|
||||||
if err := profile.Validate(); err == nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +331,7 @@ func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) {
|
|||||||
func TestCapabilityProfileRequiresRegisteredFraming(t *testing.T) {
|
func TestCapabilityProfileRequiresRegisteredFraming(t *testing.T) {
|
||||||
valid := protocol.CapabilityProfile{
|
valid := protocol.CapabilityProfile{
|
||||||
Transport: "quic-tls13", Framing: "datagram-v2", Media: "encoded",
|
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 {
|
if err := valid.Validate(); err != nil {
|
||||||
t.Fatalf("registered datagram-v2 rejected: %v", err)
|
t.Fatalf("registered datagram-v2 rejected: %v", err)
|
||||||
@@ -288,8 +348,8 @@ func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) {
|
|||||||
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||||
Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0,
|
Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0,
|
||||||
ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{
|
ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{
|
||||||
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded",
|
||||||
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
|
SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
|
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
|
||||||
@@ -302,18 +362,18 @@ func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing
|
|||||||
Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience",
|
Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience",
|
||||||
Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16),
|
Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16),
|
||||||
DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{
|
DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{
|
||||||
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded",
|
||||||
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
|
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 {
|
if got := string(request.DeviceAdmissionTranscript()); got != want {
|
||||||
t.Fatalf("DeviceAdmissionTranscript() = %q, want %q", got, want)
|
t.Fatalf("DeviceAdmissionTranscript() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
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 {
|
if _, err := protocol.DecodeSessionAuthority([]byte(valid)); err != nil {
|
||||||
t.Fatalf("valid session authority rejected: %v", err)
|
t.Fatalf("valid session authority rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -322,8 +382,84 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
SourceRateControl: "server", VideoProfiles: []protocol.VideoProfile{rc6Video("h264")}, AudioProfiles: []protocol.AudioProfile{rc6Audio()},
|
||||||
|
},
|
||||||
|
SelectedDescriptor: rc6Descriptor("h264"),
|
||||||
|
}
|
||||||
|
encoded, err := protocol.EncodeClientSessionAuthority(authority)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeClientSessionAuthority() error = %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||||
|
t.Fatalf("encoded client authority is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
wantFields := map[string]bool{
|
||||||
|
"version": true, "session_id": true, "gateway_id": true, "audience": 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)
|
||||||
|
}
|
||||||
|
for field := range fields {
|
||||||
|
if !wantFields[field] {
|
||||||
|
t.Fatalf("encoded client authority contains forbidden field %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bytes.Contains(encoded, []byte("provider_")) {
|
||||||
|
t.Fatalf("encoded client authority disclosed provider data: %s", encoded)
|
||||||
|
}
|
||||||
|
decoded, err := protocol.DecodeClientSessionAuthority(encoded)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, authority) {
|
||||||
|
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", "selected_descriptor"} {
|
||||||
|
missing := make(map[string]json.RawMessage, len(fields)-1)
|
||||||
|
for key, value := range fields {
|
||||||
|
if key != field {
|
||||||
|
missing[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(missing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted missing %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"provider_profile": `"apollo"`,
|
||||||
|
"provider_identity": `"provider-1"`,
|
||||||
|
"provider_url": `"https://provider.invalid"`,
|
||||||
|
"management_host": `"provider.invalid"`,
|
||||||
|
"unknown": `true`,
|
||||||
|
} {
|
||||||
|
payload := append(append([]byte(nil), encoded[:len(encoded)-1]...), []byte(`,"`+name+`":`+value+`}`)...)
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted injected %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, expiresAt := range []string{"not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"} {
|
||||||
|
payload := bytes.Replace(encoded, []byte("2099-01-01T00:00:00Z"), []byte(expiresAt), 1)
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted expires_at %q", expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(append(encoded, []byte(" {}")...)); err == nil {
|
||||||
|
t.Fatal("DecodeClientSessionAuthority accepted trailing JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderSessionWorkIsStrictAndSessionBound(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 {
|
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
|
||||||
t.Fatalf("valid provider work rejected: %v", err)
|
t.Fatalf("valid provider work rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -334,10 +470,10 @@ func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
|
|||||||
t.Fatal("provider work accepted missing clipboard policy")
|
t.Fatal("provider work accepted missing clipboard policy")
|
||||||
}
|
}
|
||||||
for _, invalid := range []string{
|
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, `"fps":120`, `"fps":241`, 1),
|
||||||
strings.Replace(valid, `"codec":"HEVC"`, `"codec":"VP9"`, 1),
|
strings.Replace(valid, `"codec":"hevc"`, `"codec":"vp9"`, 1),
|
||||||
strings.Replace(valid, `"audio_enabled":true`, `"audio_enabled":true,"unknown":false`, 1),
|
strings.Replace(valid, `"packet_duration_ms":5`, `"packet_duration_ms":5,"unknown":false`, 1),
|
||||||
} {
|
} {
|
||||||
if _, err := protocol.DecodeProviderSessionWork([]byte(invalid)); err == nil {
|
if _, err := protocol.DecodeProviderSessionWork([]byte(invalid)); err == nil {
|
||||||
t.Fatalf("provider work accepted invalid stream policy: %s", invalid)
|
t.Fatalf("provider work accepted invalid stream policy: %s", invalid)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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 TestRC6GatewayQualityDiscoveryAndLeaseFencing(t *testing.T) {
|
||||||
|
operationID := "12345678-1234-1234-1234-123456789abc"
|
||||||
|
revision := int64(7)
|
||||||
|
leaseGeneration := int64(3)
|
||||||
|
currentRevision := int64(6)
|
||||||
|
|
||||||
|
poll := protocol.GatewayQualityWorkRequest{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2, Acquisition: "poll"}
|
||||||
|
if err := poll.Validate(); err != nil {
|
||||||
|
t.Fatalf("session-bound lost-prompt poll rejected: %v", err)
|
||||||
|
}
|
||||||
|
prompt := poll
|
||||||
|
prompt.Acquisition = "prompt"
|
||||||
|
prompt.OperationID = operationID
|
||||||
|
prompt.Revision = &revision
|
||||||
|
if err := prompt.Validate(); err != nil {
|
||||||
|
t.Fatalf("prompt-bound acquisition rejected: %v", err)
|
||||||
|
}
|
||||||
|
observation := prompt
|
||||||
|
observation.Acquisition = "observation"
|
||||||
|
observation.LeaseGeneration = &leaseGeneration
|
||||||
|
observation.CurrentAppliedRevision = ¤tRevision
|
||||||
|
if err := observation.Validate(); err != nil {
|
||||||
|
t.Fatalf("lease-bound applied-revision observation rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, invalid := range map[string]protocol.GatewayQualityWorkRequest{
|
||||||
|
"poll with unknown coordinates": prompt,
|
||||||
|
"prompt missing revision": {Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Acquisition: "prompt", OperationID: operationID},
|
||||||
|
"observation missing lease": {Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Acquisition: "observation", OperationID: operationID, Revision: &revision, CurrentAppliedRevision: ¤tRevision},
|
||||||
|
} {
|
||||||
|
if name == "poll with unknown coordinates" {
|
||||||
|
invalid.Acquisition = "poll"
|
||||||
|
}
|
||||||
|
if invalid.Validate() == nil {
|
||||||
|
t.Fatalf("accepted invalid %s: %+v", name, invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPoll := protocol.GatewayStopWorkRequest{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2, Acquisition: "poll"}
|
||||||
|
if err := stopPoll.Validate(); err != nil {
|
||||||
|
t.Fatalf("session-bound Stop poll rejected: %v", err)
|
||||||
|
}
|
||||||
|
stopPrompt := stopPoll
|
||||||
|
stopPrompt.Acquisition = "prompt"
|
||||||
|
stopPrompt.OperationID = operationID
|
||||||
|
if err := stopPrompt.Validate(); err != nil {
|
||||||
|
t.Fatalf("prompt-bound Stop acquisition rejected: %v", err)
|
||||||
|
}
|
||||||
|
stopPoll.OperationID = operationID
|
||||||
|
if stopPoll.Validate() == nil {
|
||||||
|
t.Fatal("Stop poll accepted unknown operation coordinates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRC6GatewayQualityAckHasUnambiguousObservation(t *testing.T) {
|
||||||
|
revision := int64(7)
|
||||||
|
prior := int64(6)
|
||||||
|
base := protocol.GatewayQualityAck{
|
||||||
|
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2,
|
||||||
|
OperationID: "12345678-1234-1234-1234-123456789abc", Revision: revision, LeaseGeneration: 3,
|
||||||
|
}
|
||||||
|
for _, valid := range []protocol.GatewayQualityAck{
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "applied"
|
||||||
|
value.CurrentAppliedRevision = &revision
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "proven_prior"
|
||||||
|
value.CurrentAppliedRevision = &prior
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
func() protocol.GatewayQualityAck { value := base; value.Outcome = "unknown"; return value }(),
|
||||||
|
} {
|
||||||
|
if err := valid.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid quality acknowledgement rejected: %+v: %v", valid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []protocol.GatewayQualityAck{
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "applied"
|
||||||
|
value.CurrentAppliedRevision = &prior
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "proven_prior"
|
||||||
|
value.CurrentAppliedRevision = &revision
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "unknown"
|
||||||
|
value.CurrentAppliedRevision = &prior
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
func() protocol.GatewayQualityAck {
|
||||||
|
value := base
|
||||||
|
value.Outcome = "unknown"
|
||||||
|
value.LeaseGeneration = 0
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
} {
|
||||||
|
if invalid.Validate() == nil {
|
||||||
|
t.Fatalf("contradictory or unfenced quality acknowledgement accepted: %+v", 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 },
|
||||||
|
"../../fixtures/valid/gateway-quality-poll.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityWorkRequest(raw); return err },
|
||||||
|
"../../fixtures/valid/gateway-quality-ack-applied.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(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 },
|
||||||
|
"../../fixtures/invalid/gateway-quality-poll-coordinates.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityWorkRequest(raw); return err },
|
||||||
|
"../../fixtures/invalid/gateway-quality-ack-contradictory.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err },
|
||||||
|
"../../fixtures/invalid/gateway-quality-ack-uppercase-uuid.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err },
|
||||||
|
"../../fixtures/invalid/gateway-quality-ack-zero-uuid.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err },
|
||||||
|
"../../fixtures/invalid/quality-operation-offset-time.json": func(raw []byte) error { _, err := protocol.DecodeQualityChangeOperation(raw); return err },
|
||||||
|
"../../fixtures/invalid/quality-operation-noncanonical-fraction.json": func(raw []byte) error { _, err := protocol.DecodeQualityChangeOperation(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 }
|
||||||
+11
-1
@@ -32,7 +32,17 @@ def main() -> int:
|
|||||||
expected = manifest.get("corpus_sha256")
|
expected = manifest.get("corpus_sha256")
|
||||||
if not expected or actual != expected:
|
if not expected or actual != expected:
|
||||||
raise ValueError(f"fixture corpus hash mismatch: {actual}")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+247
-33
@@ -139,11 +139,15 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
)
|
)
|
||||||
if prop.get("format") == "base64url":
|
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\"}}) }} }}")
|
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":
|
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:
|
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:
|
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 prop.get("type") == "array":
|
||||||
if "minItems" in prop:
|
if "minItems" in prop:
|
||||||
lines.append(f"\tif len(v.{field}) < {prop['minItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_items\"}}) }}")
|
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:
|
if "enum" in items:
|
||||||
allowed = " || ".join(f'item == "{value}"' for value in items["enum"])
|
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\"}}) }} }}")
|
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":
|
if prop.get("uniqueItems"):
|
||||||
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\"}}) }} }} }}")
|
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)
|
item_ref = ref_name(items)
|
||||||
if item_ref:
|
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\"}}) }} }}")
|
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,22 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f"\t{validation}")
|
lines.append(f"\t{validation}")
|
||||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
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\"}) }")
|
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 == "GatewayQualityWorkRequest":
|
||||||
|
lines.append("\tif v.Acquisition == \"poll\" && (v.OperationID != \"\" || v.Revision != nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
lines.append("\tif v.Acquisition == \"prompt\" && (v.OperationID == \"\" || v.Revision == nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
lines.append("\tif v.Acquisition == \"observation\" && (v.OperationID == \"\" || v.Revision == nil || v.LeaseGeneration == nil || v.CurrentAppliedRevision == nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
if name == "GatewayStopWorkRequest":
|
||||||
|
lines.append("\tif v.Acquisition == \"poll\" && v.OperationID != \"\" || v.Acquisition == \"prompt\" && v.OperationID == \"\" { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
if name == "GatewayQualityAck":
|
||||||
|
lines.append("\tif v.Outcome == \"applied\" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision != v.Revision) { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
lines.append("\tif v.Outcome == \"proven_prior\" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision >= v.Revision) { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }")
|
||||||
|
lines.append("\tif v.Outcome == \"unknown\" && v.CurrentAppliedRevision != nil { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }")
|
||||||
if name == "GatewayRegistration":
|
if name == "GatewayRegistration":
|
||||||
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
|
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
|
||||||
if name == "ChannelFrame":
|
if name == "ChannelFrame":
|
||||||
@@ -187,6 +207,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"",
|
"",
|
||||||
"import (",
|
"import (",
|
||||||
"\"bytes\"",
|
"\"bytes\"",
|
||||||
|
"\"encoding/binary\"",
|
||||||
"\"encoding/base64\"",
|
"\"encoding/base64\"",
|
||||||
"\"encoding/json\"",
|
"\"encoding/json\"",
|
||||||
"\"errors\"",
|
"\"errors\"",
|
||||||
@@ -213,6 +234,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 (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):
|
for name in sorted(defs):
|
||||||
if name == "FieldViolation":
|
if name == "FieldViolation":
|
||||||
@@ -223,7 +268,9 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
for prop_name, prop in definition.get("properties", {}).items():
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
tag = prop_name + (",omitempty" if prop_name not in required else "")
|
tag = prop_name + (",omitempty" if prop_name not in required else "")
|
||||||
typ = prop_type(prop, "go")
|
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
|
typ = "*" + typ
|
||||||
out.append(f"\t{go_field(prop_name)} {typ} `json:\"{tag}\"`")
|
out.append(f"\t{go_field(prop_name)} {typ} `json:\"{tag}\"`")
|
||||||
out.extend(["}", ""])
|
out.extend(["}", ""])
|
||||||
@@ -238,6 +285,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"func Decode{name}(data []byte) ({name}, error) {{")
|
||||||
out.append(f"\tvar value {name}")
|
out.append(f"\tvar value {name}")
|
||||||
out.append("\tif len(data) > 1024*1024 { return value, errors.New(\"protocol payload exceeds limit\") }")
|
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("\tvar fields map[string]json.RawMessage")
|
||||||
out.append("\tif err := json.Unmarshal(data, &fields); err != nil { return value, err }")
|
out.append("\tif err := json.Unmarshal(data, &fields); err != nil { return value, err }")
|
||||||
required_fields = sorted(defs[name].get("required", []))
|
required_fields = sorted(defs[name].get("required", []))
|
||||||
@@ -272,29 +320,51 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
out.append("}")
|
out.append("}")
|
||||||
out.append("")
|
out.append("")
|
||||||
out.extend([
|
out.extend([
|
||||||
|
"func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {",
|
||||||
|
"\tfor _, value := range []struct { field string; bytes []byte; length int }{{\"server_id\", serverID, 16}, {\"principal_id\", principalID, 16}, {\"device_id\", deviceID, 16}, {\"challenge\", challenge, 32}} {",
|
||||||
|
"\t\tif len(value.bytes) != value.length { return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: \"invalid_length\"}}} }",
|
||||||
|
"\t}",
|
||||||
|
"\tif expiryUnixMilliseconds < 0 { return nil, ValidationError{Violations: []FieldViolation{{Field: \"expiry_unix_milliseconds\", Code: \"minimum\"}}} }",
|
||||||
|
"\ttranscript := make([]byte, 0, 112)",
|
||||||
|
"\ttranscript = append(transcript, \"versevdi-device-proof-v1\"...)",
|
||||||
|
"\ttranscript = append(transcript, serverID...)",
|
||||||
|
"\ttranscript = append(transcript, principalID...)",
|
||||||
|
"\ttranscript = append(transcript, deviceID...)",
|
||||||
|
"\ttranscript = append(transcript, challenge...)",
|
||||||
|
"\tvar expiry [8]byte",
|
||||||
|
"\tbinary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))",
|
||||||
|
"\treturn append(transcript, expiry[:]...), nil",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
|
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
|
||||||
"",
|
"",
|
||||||
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
||||||
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
"\tselected := profiles[0]",
|
"\tselected := profiles[0]",
|
||||||
"\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
"\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:] {",
|
"\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\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\tnext := common[:0]",
|
"\t\tnextVideo := commonVideo[:0]",
|
||||||
"\t\tfor _, candidate := range common { for _, offered := range profile.ClientDecode { if candidate == offered { next = append(next, candidate); break } } }",
|
"\t\tfor _, candidate := range commonVideo { for _, offered := range profile.VideoProfiles { if candidate == offered { nextVideo = append(nextVideo, candidate); break } } }",
|
||||||
"\t\tcommon = next",
|
"\t\tcommonVideo = nextVideo",
|
||||||
"\t\tif len(common) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
"\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}",
|
"\t}",
|
||||||
"\tselected.ClientDecode = common",
|
"\tselected.VideoProfiles = commonVideo",
|
||||||
|
"\tselected.AudioProfiles = commonAudio",
|
||||||
"\treturn selected, nil",
|
"\treturn selected, nil",
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
])
|
])
|
||||||
out.extend([
|
out.extend([
|
||||||
"func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {",
|
"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 := []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))}",
|
||||||
"\tfields = append(fields, v.Capabilities.ClientDecode...)",
|
"\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",
|
"\tvar transcript strings.Builder",
|
||||||
"\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")",
|
"\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")",
|
||||||
"\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }",
|
"\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }",
|
||||||
@@ -369,11 +439,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\")); }}")
|
lines.append(f" {prefix}if !valid_rfc3339_utc({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_time\")); }}")
|
||||||
if prop.get("format") == "base64url":
|
if prop.get("format") == "base64url":
|
||||||
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
|
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":
|
if prop.get("type") == "integer":
|
||||||
|
numeric = f"*{value}" if prop_name not in required else value
|
||||||
if "minimum" in prop:
|
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:
|
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 prop.get("type") == "array":
|
||||||
if "minItems" in prop:
|
if "minItems" in prop:
|
||||||
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
|
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
|
||||||
@@ -389,7 +462,7 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
if "enum" in items:
|
if "enum" in items:
|
||||||
allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"])
|
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\")); }} }}")
|
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\")); }} }}")
|
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)
|
item_ref = ref_name(items)
|
||||||
if item_ref:
|
if item_ref:
|
||||||
@@ -402,6 +475,22 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
name = definition["name"]
|
name = definition["name"]
|
||||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||||
lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }")
|
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 == "GatewayQualityWorkRequest":
|
||||||
|
lines.append(" if self.acquisition == \"poll\" && (self.operationId.is_some() || self.revision.is_some() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }")
|
||||||
|
lines.append(" if self.acquisition == \"prompt\" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }")
|
||||||
|
lines.append(" if self.acquisition == \"observation\" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_none() || self.currentAppliedRevision.is_none()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }")
|
||||||
|
if name == "GatewayStopWorkRequest":
|
||||||
|
lines.append(" if self.acquisition == \"poll\" && self.operationId.is_some() || self.acquisition == \"prompt\" && self.operationId.is_none() { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }")
|
||||||
|
if name == "GatewayQualityAck":
|
||||||
|
lines.append(" if self.outcome == \"applied\" && self.currentAppliedRevision != Some(self.revision) { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }")
|
||||||
|
lines.append(" if self.outcome == \"proven_prior\" && self.currentAppliedRevision.map_or(true, |current| current >= self.revision) { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }")
|
||||||
|
lines.append(" if self.outcome == \"unknown\" && self.currentAppliedRevision.is_some() { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }")
|
||||||
if name == "GatewayRegistration":
|
if name == "GatewayRegistration":
|
||||||
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
|
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
|
||||||
if name == "ChannelFrame":
|
if name == "ChannelFrame":
|
||||||
@@ -456,6 +545,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
" let fraction = &bytes[20..bytes.len() - 1];",
|
" 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'",
|
" 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):
|
for name in sorted(defs):
|
||||||
@@ -498,9 +591,12 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
out.extend([
|
out.extend([
|
||||||
" pub fn device_admission_transcript(&self) -> Vec<u8> {",
|
" pub fn device_admission_transcript(&self) -> Vec<u8> {",
|
||||||
" let reconnect_sequence = self.reconnectSequence.to_string();",
|
" let reconnect_sequence = self.reconnectSequence.to_string();",
|
||||||
" let client_decode_count = self.capabilities.clientDecode.len().to_string();",
|
" let video_count = self.capabilities.videoProfiles.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()];",
|
" let audio_count = self.capabilities.audioProfiles.len().to_string();",
|
||||||
" fields.extend(self.capabilities.clientDecode.iter().map(String::as_str));",
|
" 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\");",
|
" let mut transcript = String::from(\"versevdi/tunnel-admission/v1\");",
|
||||||
" for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }",
|
" for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }",
|
||||||
" transcript.into_bytes()",
|
" transcript.into_bytes()",
|
||||||
@@ -508,14 +604,30 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
])
|
])
|
||||||
out.extend(["}", ""])
|
out.extend(["}", ""])
|
||||||
out.extend([
|
out.extend([
|
||||||
|
"pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result<Vec<u8>, 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\")); }",
|
||||||
|
" }",
|
||||||
|
" if expiry_unix_milliseconds < 0 { return Err(ValidationError::new(\"expiry_unix_milliseconds\", \"minimum\")); }",
|
||||||
|
" let mut transcript = Vec::with_capacity(112);",
|
||||||
|
" transcript.extend_from_slice(b\"versevdi-device-proof-v1\");",
|
||||||
|
" transcript.extend_from_slice(server_id);",
|
||||||
|
" transcript.extend_from_slice(principal_id);",
|
||||||
|
" transcript.extend_from_slice(device_id);",
|
||||||
|
" transcript.extend_from_slice(challenge);",
|
||||||
|
" transcript.extend_from_slice(&(expiry_unix_milliseconds as u64).to_be_bytes());",
|
||||||
|
" Ok(transcript)",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
||||||
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
||||||
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||||
" for profile in &profiles[1..] {",
|
" for profile in &profiles[1..] {",
|
||||||
" profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
" 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\")); }",
|
" 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.clientDecode.retain(|candidate| profile.clientDecode.contains(candidate));",
|
" selected.videoProfiles.retain(|candidate| profile.videoProfiles.contains(candidate));",
|
||||||
" if selected.clientDecode.is_empty() { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
" 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)",
|
" Ok(selected)",
|
||||||
"}",
|
"}",
|
||||||
@@ -554,6 +666,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\") }}")
|
lines.append(f" {prefix}if !validRFC3339UTC({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||||
if prop.get("format") == "base64url":
|
if prop.get("format") == "base64url":
|
||||||
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
|
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 prop.get("type") == "integer":
|
||||||
if "minimum" in prop:
|
if "minimum" in prop:
|
||||||
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
||||||
@@ -574,8 +688,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
if "enum" in items:
|
if "enum" in items:
|
||||||
allowed = ", ".join(f'\"{item}\"' for item in items["enum"])
|
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\") }}")
|
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":
|
if prop.get("uniqueItems"):
|
||||||
lines.append(f" {prefix}if Set({value}).count != {value}.count {{ throw ContractValidationError(field: \"{prop_name}\", code: \"duplicate_item\") }}")
|
lines.append(f" {prefix}for (index, item) in {value}.enumerated() where {value}[..<index].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"duplicate_item\") }}")
|
||||||
item_ref = ref_name(items)
|
item_ref = ref_name(items)
|
||||||
if item_ref:
|
if item_ref:
|
||||||
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
|
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
|
||||||
@@ -587,6 +701,22 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
name = definition["name"]
|
name = definition["name"]
|
||||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||||
lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }")
|
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 == "GatewayQualityWorkRequest":
|
||||||
|
lines.append(" if acquisition == \"poll\" && (operationId != nil || revision != nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }")
|
||||||
|
lines.append(" if acquisition == \"prompt\" && (operationId == nil || revision == nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }")
|
||||||
|
lines.append(" if acquisition == \"observation\" && (operationId == nil || revision == nil || leaseGeneration == nil || currentAppliedRevision == nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }")
|
||||||
|
if name == "GatewayStopWorkRequest":
|
||||||
|
lines.append(" if acquisition == \"poll\" && operationId != nil || acquisition == \"prompt\" && operationId == nil { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }")
|
||||||
|
if name == "GatewayQualityAck":
|
||||||
|
lines.append(" if outcome == \"applied\" && currentAppliedRevision != revision { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }")
|
||||||
|
lines.append(" if outcome == \"proven_prior\" && (currentAppliedRevision == nil || currentAppliedRevision! >= revision) { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }")
|
||||||
|
lines.append(" if outcome == \"unknown\" && currentAppliedRevision != nil { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }")
|
||||||
if name == "GatewayRegistration":
|
if name == "GatewayRegistration":
|
||||||
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
|
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
|
||||||
if name == "ChannelFrame":
|
if name == "ChannelFrame":
|
||||||
@@ -605,6 +735,67 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
||||||
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
|
"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 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 {",
|
||||||
|
" guard data.count <= 1_048_576 else { throw ContractValidationError(field: \"json\", code: \"payload_too_large\") }",
|
||||||
|
" 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..<index]) }",
|
||||||
|
" index += 1",
|
||||||
|
" }",
|
||||||
|
" throw ContractValidationError(field: \"json\", code: \"invalid_json\")",
|
||||||
|
" }",
|
||||||
|
" func parseValue(_ depth: Int) throws {",
|
||||||
|
" guard depth <= 64 else { throw ContractValidationError(field: \"json\", code: \"nesting_too_deep\") }",
|
||||||
|
" skipWhitespace()",
|
||||||
|
" guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }",
|
||||||
|
" if data[index] == 123 {",
|
||||||
|
" index += 1",
|
||||||
|
" var keys = Set<String>()",
|
||||||
|
" 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(depth + 1)",
|
||||||
|
" 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(depth + 1)",
|
||||||
|
" 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(0)",
|
||||||
|
" skipWhitespace()",
|
||||||
|
" guard index == data.count else { throw ContractValidationError(field: \"json\", code: \"trailing_json\") }",
|
||||||
|
"}",
|
||||||
"private func validBase64URL(_ value: String) -> Bool {",
|
"private func validBase64URL(_ value: String) -> Bool {",
|
||||||
" guard !value.isEmpty, value.utf8.allSatisfy({ byte in",
|
" guard !value.isEmpty, value.utf8.allSatisfy({ byte in",
|
||||||
" (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95",
|
" (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95",
|
||||||
@@ -631,6 +822,11 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
" let fraction = bytes[20..<(bytes.count - 1)]",
|
" 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",
|
" 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):
|
for name in sorted(defs):
|
||||||
@@ -672,12 +868,28 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
out.append(f" try self.init({', '.join(decoded)})")
|
out.append(f" try self.init({', '.join(decoded)})")
|
||||||
out.extend([" }", "", " public func validate() throws {"])
|
out.extend([" }", "", " public func validate() throws {"])
|
||||||
out.extend(swift_validation(definition))
|
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([
|
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)] {",
|
||||||
|
" if value.count != length { throw ContractValidationError(field: field, code: \"invalid_length\") }",
|
||||||
|
" }",
|
||||||
|
" if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: \"expiry_unix_milliseconds\", code: \"minimum\") }",
|
||||||
|
" var transcript = Data(\"versevdi-device-proof-v1\".utf8)",
|
||||||
|
" transcript.append(serverID)",
|
||||||
|
" transcript.append(principalID)",
|
||||||
|
" transcript.append(deviceID)",
|
||||||
|
" transcript.append(challenge)",
|
||||||
|
" var expiry = UInt64(expiryUnixMilliseconds).bigEndian",
|
||||||
|
" Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }",
|
||||||
|
" return transcript",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
"public extension TunnelAdmissionRequest {",
|
"public extension TunnelAdmissionRequest {",
|
||||||
" func deviceAdmissionTranscript() -> Data {",
|
" 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)]",
|
" 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)]",
|
||||||
" fields.append(contentsOf: capabilities.clientDecode)",
|
" 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\"",
|
" var transcript = \"versevdi/tunnel-admission/v1\"",
|
||||||
" for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }",
|
" for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }",
|
||||||
" return Data(transcript.utf8)",
|
" return Data(transcript.utf8)",
|
||||||
@@ -688,14 +900,16 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
|
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
|
||||||
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
" try selected.validate()",
|
" try selected.validate()",
|
||||||
" var common = selected.clientDecode",
|
" var commonVideo = selected.videoProfiles",
|
||||||
|
" var commonAudio = selected.audioProfiles",
|
||||||
" for profile in profiles.dropFirst() {",
|
" for profile in profiles.dropFirst() {",
|
||||||
" try profile.validate()",
|
" 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\") }",
|
" if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
" common = common.filter { profile.clientDecode.contains($0) }",
|
" commonVideo = commonVideo.filter { profile.videoProfiles.contains($0) }",
|
||||||
" if common.isEmpty { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
" 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)",
|
||||||
" }",
|
" }",
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -58,6 +59,20 @@ func evaluate(version, kind, input string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
|
case "device_proof_transcript":
|
||||||
|
serverID, serverErr := hex.DecodeString(parts["server_id"])
|
||||||
|
principalID, principalErr := hex.DecodeString(parts["principal_id"])
|
||||||
|
deviceID, deviceErr := hex.DecodeString(parts["device_id"])
|
||||||
|
challenge, challengeErr := hex.DecodeString(parts["challenge"])
|
||||||
|
expiry, expiryErr := strconv.ParseInt(parts["expiry_unix_ms"], 10, 64)
|
||||||
|
if serverErr != nil || principalErr != nil || deviceErr != nil || challengeErr != nil || expiryErr != nil {
|
||||||
|
return "invalid:fixture"
|
||||||
|
}
|
||||||
|
transcript, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, expiry)
|
||||||
|
if err != nil {
|
||||||
|
return "invalid:device_proof"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(transcript)
|
||||||
case "version":
|
case "version":
|
||||||
if input == "2" || input == "1" || input == "0" {
|
if input == "2" || input == "1" || input == "0" {
|
||||||
return "valid"
|
return "valid"
|
||||||
@@ -87,6 +102,15 @@ func evaluate(version, kind, input string) string {
|
|||||||
Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}},
|
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"]},
|
Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]},
|
||||||
CorrelationID: "correlation-1",
|
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 {
|
if value.Validate() == nil {
|
||||||
return "valid"
|
return "valid"
|
||||||
@@ -109,6 +133,8 @@ func evaluate(version, kind, input string) string {
|
|||||||
value := protocol.SessionRequest{
|
value := protocol.SessionRequest{
|
||||||
ClientDeviceID: parts["client_device_id"], DeviceKeyID: parts["device_key_id"],
|
ClientDeviceID: parts["client_device_id"], DeviceKeyID: parts["device_key_id"],
|
||||||
PoolID: parts["pool_id"], IdempotencyKey: parts["idempotency_key"],
|
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 {
|
if value.Validate() == nil {
|
||||||
return "valid"
|
return "valid"
|
||||||
@@ -295,6 +321,13 @@ func classifyGatewayInput(encoded string) string {
|
|||||||
if len(body) != 4 {
|
if len(body) != 4 {
|
||||||
return "invalid:length"
|
return "invalid:length"
|
||||||
}
|
}
|
||||||
|
case 8:
|
||||||
|
if len(body) != 8 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if body[0] > 15 || body[3] > 3 {
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return "invalid:kind"
|
return "invalid:kind"
|
||||||
}
|
}
|
||||||
@@ -338,6 +371,22 @@ func classifyGatewayFeedback(encoded string) string {
|
|||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return "valid"
|
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:
|
default:
|
||||||
return "invalid:type"
|
return "invalid:type"
|
||||||
}
|
}
|
||||||
@@ -372,6 +421,15 @@ func classifyGatewayFeedback(encoded string) string {
|
|||||||
return "invalid:field"
|
return "invalid:field"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allZero(value []byte) bool {
|
||||||
|
for _, item := range value {
|
||||||
|
if item != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func validFECStatus(body []byte) bool {
|
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] {
|
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
|
return false
|
||||||
|
|||||||
@@ -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("device_key_id").cloned().unwrap_or_default(),
|
||||||
values.get("pool_id").cloned().unwrap_or_default(),
|
values.get("pool_id").cloned().unwrap_or_default(),
|
||||||
values.get("idempotency_key").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,
|
None,
|
||||||
) {
|
) {
|
||||||
Ok(_) => "valid",
|
Ok(_) => "valid",
|
||||||
@@ -129,6 +131,20 @@ fn evaluate(version: &str, kind: &str, input: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evaluate_device_proof(input: &str) -> String {
|
||||||
|
let values = values(input);
|
||||||
|
let server_id = decode_hex(values.get("server_id").map(String::as_str).unwrap_or_default()).expect("server fixture hex");
|
||||||
|
let principal_id = decode_hex(values.get("principal_id").map(String::as_str).unwrap_or_default()).expect("principal fixture hex");
|
||||||
|
let device_id = decode_hex(values.get("device_id").map(String::as_str).unwrap_or_default()).expect("device fixture hex");
|
||||||
|
let challenge = decode_hex(values.get("challenge").map(String::as_str).unwrap_or_default()).expect("challenge fixture hex");
|
||||||
|
let expiry = values.get("expiry_unix_ms").expect("expiry fixture").parse::<i64>().expect("expiry integer");
|
||||||
|
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry)
|
||||||
|
.expect("valid device proof fixture")
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn classify_gateway_input(encoded: &str) -> &'static str {
|
fn classify_gateway_input(encoded: &str) -> &'static str {
|
||||||
let raw = match decode_hex(encoded) {
|
let raw = match decode_hex(encoded) {
|
||||||
Some(raw) => raw,
|
Some(raw) => raw,
|
||||||
@@ -170,6 +186,9 @@ fn classify_gateway_input(encoded: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
7 if body.len() == 4 => "valid",
|
7 if body.len() == 4 => "valid",
|
||||||
7 => "invalid:length",
|
7 => "invalid:length",
|
||||||
|
8 if body.len() != 8 => "invalid:length",
|
||||||
|
8 if body[0] > 15 || body[3] > 3 => "invalid:field",
|
||||||
|
8 => "valid",
|
||||||
_ => "invalid:kind",
|
_ => "invalid:kind",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +224,12 @@ fn classify_gateway_feedback(encoded: &str) -> &'static str {
|
|||||||
2 => "invalid:field",
|
2 => "invalid:field",
|
||||||
3 if body.is_empty() => "valid",
|
3 if body.is_empty() => "valid",
|
||||||
3 => "invalid:length",
|
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",
|
_ => "invalid:type",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -333,7 +358,11 @@ fn main() {
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
let fields: Vec<&str> = line.split('\t').collect();
|
let fields: Vec<&str> = line.split('\t').collect();
|
||||||
assert_eq!(fields.len(), 5);
|
assert_eq!(fields.len(), 5);
|
||||||
let actual = evaluate(fields[1], fields[2], fields[3]);
|
let actual = if fields[2] == "device_proof_transcript" {
|
||||||
|
evaluate_device_proof(fields[3])
|
||||||
|
} else {
|
||||||
|
evaluate(fields[1], fields[2], fields[3]).to_owned()
|
||||||
|
};
|
||||||
assert_eq!(actual, fields[4], "{}", fields[0]);
|
assert_eq!(actual, fields[4], "{}", fields[0]);
|
||||||
results.push(format!("{}\t{}", fields[0], actual));
|
results.push(format!("{}\t{}", fields[0], actual));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func evaluate(_ version: String, _ kind: String, _ input: String) -> String {
|
|||||||
guard (try? SessionRequest(
|
guard (try? SessionRequest(
|
||||||
clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? "",
|
clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? "",
|
||||||
poolId: values["pool_id"] ?? "", idempotencyKey: values["idempotency_key"] ?? "",
|
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
|
requestedDisplayMode: nil
|
||||||
)) != nil else { return "invalid:required" }
|
)) != nil else { return "invalid:required" }
|
||||||
return "valid"
|
return "valid"
|
||||||
@@ -81,6 +83,19 @@ func evaluate(_ version: String, _ kind: String, _ input: String) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func evaluateDeviceProof(_ input: String) -> String {
|
||||||
|
let values = values(input)
|
||||||
|
let serverID = Data(decodeHex(values["server_id"] ?? "")!)
|
||||||
|
let principalID = Data(decodeHex(values["principal_id"] ?? "")!)
|
||||||
|
let deviceID = Data(decodeHex(values["device_id"] ?? "")!)
|
||||||
|
let challenge = Data(decodeHex(values["challenge"] ?? "")!)
|
||||||
|
let expiry = Int64(values["expiry_unix_ms"] ?? "")!
|
||||||
|
return try! deviceRegistrationProofTranscript(
|
||||||
|
serverID: serverID, principalID: principalID, deviceID: deviceID,
|
||||||
|
challenge: challenge, expiryUnixMilliseconds: expiry
|
||||||
|
).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
func decodeHex(_ encoded: String) -> [UInt8]? {
|
func decodeHex(_ encoded: String) -> [UInt8]? {
|
||||||
let characters = Array(encoded)
|
let characters = Array(encoded)
|
||||||
guard characters.count % 2 == 0 else { return nil }
|
guard characters.count % 2 == 0 else { return nil }
|
||||||
@@ -123,6 +138,9 @@ func classifyGatewayInput(_ encoded: String) -> String {
|
|||||||
let height = Int(body[6]) * 256 + Int(body[7])
|
let height = Int(body[6]) * 256 + Int(body[7])
|
||||||
return width > 0 && height > 0 && x < width && y < height ? "valid" : "invalid:field"
|
return width > 0 && height > 0 && x < width && y < height ? "valid" : "invalid:field"
|
||||||
case 7: return body.count == 4 ? "valid" : "invalid:length"
|
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"
|
default: return "invalid:kind"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +161,12 @@ func classifyGatewayFeedback(_ encoded: String) -> String {
|
|||||||
case 2:
|
case 2:
|
||||||
return validFECStatus(body) ? "valid" : "invalid:field"
|
return validFECStatus(body) ? "valid" : "invalid:field"
|
||||||
case 3: return body.isEmpty ? "valid" : "invalid:length"
|
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"
|
default: return "invalid:type"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +253,9 @@ struct ConformanceMain {
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
|
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
|
||||||
precondition(fields.count == 5)
|
precondition(fields.count == 5)
|
||||||
let actual = evaluate(fields[1], fields[2], fields[3])
|
let actual = fields[2] == "device_proof_transcript"
|
||||||
|
? evaluateDeviceProof(fields[3])
|
||||||
|
: evaluate(fields[1], fields[2], fields[3])
|
||||||
precondition(actual == fields[4], fields[0])
|
precondition(actual == fields[4], fields[0])
|
||||||
results.append("\(fields[0])\t\(actual)")
|
results.append("\(fields[0])\t\(actual)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ from __future__ import annotations
|
|||||||
import pathlib
|
import pathlib
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def run(command: list[str]) -> None:
|
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:
|
if result.returncode != 0:
|
||||||
raise SystemExit(result.stdout + result.stderr)
|
raise SystemExit(result.stdout + result.stderr)
|
||||||
print(result.stdout.strip())
|
print(result.stdout.strip())
|
||||||
|
|||||||
@@ -4,16 +4,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def run(command: list[str], directory: pathlib.Path) -> None:
|
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:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("%s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
raise RuntimeError("%s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
@@ -24,6 +30,32 @@ def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> N
|
|||||||
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
|
|
||||||
|
def protobuf_message_fields(name: str) -> list[tuple[str, int]]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["protoc", "--decode=google.protobuf.FileDescriptorSet", "google/protobuf/descriptor.proto"],
|
||||||
|
input=(ROOT / "gen/protobuf/tunnel-v1.pb").read_bytes(),
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.decode())
|
||||||
|
lines = result.stdout.decode().splitlines()
|
||||||
|
marker = f' name: "{name}"'
|
||||||
|
try:
|
||||||
|
name_index = lines.index(marker)
|
||||||
|
start = max(index for index in range(name_index) if lines[index] == " message_type {")
|
||||||
|
except (ValueError, StopIteration) as exc:
|
||||||
|
raise RuntimeError(f"protobuf descriptor missing message {name}") from exc
|
||||||
|
depth = 0
|
||||||
|
block: list[str] = []
|
||||||
|
for line in lines[start:]:
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
block.append(line)
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
return [(field, int(number)) for field, number in re.findall(r' field \{\n name: "([^"]+)"\n number: (\d+)', "\n".join(block))]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
||||||
workspace = pathlib.Path(temporary)
|
workspace = pathlib.Path(temporary)
|
||||||
@@ -31,21 +63,42 @@ def main() -> int:
|
|||||||
swift.write_text(
|
swift.write_text(
|
||||||
"""import Foundation
|
"""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 operationId = "12345678-1234-1234-1234-123456789abc"
|
||||||
|
_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "poll", operationId: nil, revision: nil, leaseGeneration: nil, currentAppliedRevision: nil)
|
||||||
|
_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "prompt", operationId: operationId, revision: 7, leaseGeneration: nil, currentAppliedRevision: nil)
|
||||||
|
_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "observation", operationId: operationId, revision: 7, leaseGeneration: 3, currentAppliedRevision: 6)
|
||||||
|
do {
|
||||||
|
_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "poll", operationId: operationId, revision: 7, leaseGeneration: nil, currentAppliedRevision: nil)
|
||||||
|
fatalError("poll accepted unknown operation coordinates")
|
||||||
|
} catch { }
|
||||||
|
_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "applied", currentAppliedRevision: 7, failureCode: nil)
|
||||||
|
_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "proven_prior", currentAppliedRevision: 6, failureCode: nil)
|
||||||
|
_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "unknown", currentAppliedRevision: nil, failureCode: nil)
|
||||||
|
do {
|
||||||
|
_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "applied", currentAppliedRevision: 6, failureCode: nil)
|
||||||
|
fatalError("applied ack accepted a contradictory revision")
|
||||||
|
} catch { }
|
||||||
let capability = try CapabilityProfile(
|
let capability = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
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 {
|
guard currentWireVersion == "2", nMinus1WireVersion == "1", nMinus2WireVersion == "0" else {
|
||||||
fatalError("unexpected control wire compatibility declaration")
|
fatalError("unexpected control wire compatibility declaration")
|
||||||
}
|
}
|
||||||
_ = try CapabilityProfile(
|
_ = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v2", media: "encoded",
|
transport: "quic-tls13", framing: "datagram-v2", media: "encoded",
|
||||||
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
sourceRateControl: "server", videoProfiles: [video], audioProfiles: [audio]
|
||||||
)
|
)
|
||||||
do {
|
do {
|
||||||
_ = try CapabilityProfile(
|
_ = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v3", media: "encoded",
|
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")
|
fatalError("unregistered framing was accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
@@ -55,24 +108,62 @@ let request = try TunnelAdmissionRequest(
|
|||||||
clientNonce: String(repeating: "n", count: 16), deviceSignature: String(repeating: "s", count: 86), capabilities: capability
|
clientNonce: String(repeating: "n", count: 16), deviceSignature: String(repeating: "s", count: 86), capabilities: capability
|
||||||
)
|
)
|
||||||
_ = request
|
_ = 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 {
|
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
|
||||||
fatalError("unexpected device admission transcript")
|
fatalError("unexpected device admission transcript")
|
||||||
}
|
}
|
||||||
|
let proofServerID = Data(repeating: 1, count: 16)
|
||||||
|
let proofPrincipalID = Data(repeating: 2, count: 16)
|
||||||
|
let proofDeviceID = Data(repeating: 3, count: 16)
|
||||||
|
let proofChallenge = Data(repeating: 4, count: 32)
|
||||||
|
let proofTranscript = try deviceRegistrationProofTranscript(
|
||||||
|
serverID: proofServerID, principalID: proofPrincipalID, deviceID: proofDeviceID,
|
||||||
|
challenge: proofChallenge, expiryUnixMilliseconds: 1
|
||||||
|
)
|
||||||
|
guard proofTranscript.count == 112,
|
||||||
|
String(data: proofTranscript.prefix(24), encoding: .utf8) == "versevdi-device-proof-v1",
|
||||||
|
Array(proofTranscript.suffix(8)) == [0, 0, 0, 0, 0, 0, 0, 1] else {
|
||||||
|
fatalError("unexpected device registration proof transcript")
|
||||||
|
}
|
||||||
|
let invalidProofInputs: [(String, String, Data, Data, Data, Data, Int64)] = [
|
||||||
|
("server-short", "server_id", Data(repeating: 0, count: 15), proofPrincipalID, proofDeviceID, proofChallenge, 0),
|
||||||
|
("server-long", "server_id", Data(repeating: 0, count: 17), proofPrincipalID, proofDeviceID, proofChallenge, 0),
|
||||||
|
("principal-short", "principal_id", proofServerID, Data(repeating: 0, count: 15), proofDeviceID, proofChallenge, 0),
|
||||||
|
("principal-long", "principal_id", proofServerID, Data(repeating: 0, count: 17), proofDeviceID, proofChallenge, 0),
|
||||||
|
("device-short", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 15), proofChallenge, 0),
|
||||||
|
("device-long", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 17), proofChallenge, 0),
|
||||||
|
("challenge-short", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 31), 0),
|
||||||
|
("challenge-long", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 33), 0),
|
||||||
|
("negative-expiry", "expiry_unix_milliseconds", proofServerID, proofPrincipalID, proofDeviceID, proofChallenge, -1),
|
||||||
|
]
|
||||||
|
for (name, field, serverID, principalID, deviceID, challenge, expiry) in invalidProofInputs {
|
||||||
|
do {
|
||||||
|
_ = try deviceRegistrationProofTranscript(
|
||||||
|
serverID: serverID, principalID: principalID, deviceID: deviceID,
|
||||||
|
challenge: challenge, expiryUnixMilliseconds: expiry
|
||||||
|
)
|
||||||
|
fatalError("\(name) was accepted")
|
||||||
|
} catch let error as ContractValidationError {
|
||||||
|
guard error.field == field,
|
||||||
|
error.code == (field == "expiry_unix_milliseconds" ? "minimum" : "invalid_length") else {
|
||||||
|
fatalError("\(name) returned the wrong validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let incompatible = try CapabilityProfile(
|
let incompatible = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
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(
|
let gatewayCapability = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
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 {
|
do {
|
||||||
guard try CapabilityProfile.intersection([capability, capability]) == capability else {
|
guard try CapabilityProfile.intersection([capability, capability]) == capability else {
|
||||||
fatalError("matching capability profiles did not intersect")
|
fatalError("matching capability profiles did not intersect")
|
||||||
}
|
}
|
||||||
} catch { 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")
|
fatalError("ordered registered profile intersection changed")
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -98,6 +189,70 @@ for invalid in [
|
|||||||
fatalError("invalid tunnel admission request was accepted")
|
fatalError("invalid tunnel admission request was accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
let clientAuthority = try ClientSessionAuthority(
|
||||||
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
|
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", "selected_descriptor"
|
||||||
|
]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else {
|
||||||
|
fatalError("client authority was not exactly provider-free")
|
||||||
|
}
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON)
|
||||||
|
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 { }
|
||||||
|
do {
|
||||||
|
_ = try CapabilityProfile.decodeJSON(Data(repeating: 32, count: 1_048_577))
|
||||||
|
fatalError("capability accepted oversized JSON")
|
||||||
|
} catch let error as ContractValidationError {
|
||||||
|
guard error.code == "payload_too_large" else { fatalError("oversized JSON was not rejected before parsing") }
|
||||||
|
}
|
||||||
|
let deeplyNested = Data((String(repeating: "[", count: 65) + "null" + String(repeating: "]", count: 65)).utf8)
|
||||||
|
do {
|
||||||
|
_ = try CapabilityProfile.decodeJSON(deeplyNested)
|
||||||
|
fatalError("capability accepted over-deep JSON")
|
||||||
|
} catch let error as ContractValidationError {
|
||||||
|
guard error.code == "nesting_too_deep" else { fatalError("over-deep JSON was not rejected before decoding") }
|
||||||
|
}
|
||||||
|
for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor"] {
|
||||||
|
var missing = clientAuthorityObject
|
||||||
|
missing.removeValue(forKey: field)
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: missing))
|
||||||
|
fatalError("client authority accepted missing \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("provider_profile", "apollo"),
|
||||||
|
("provider_identity", "provider-1"),
|
||||||
|
("provider_url", "https://provider.invalid"),
|
||||||
|
("management_host", "provider.invalid"),
|
||||||
|
("unknown", "true"),
|
||||||
|
] {
|
||||||
|
var injected = clientAuthorityObject
|
||||||
|
injected[field] = value
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: injected))
|
||||||
|
fatalError("client authority accepted injected \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for expiresAt in ["not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
||||||
|
var invalidExpiry = clientAuthorityObject
|
||||||
|
invalidExpiry["expires_at"] = expiresAt
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: invalidExpiry))
|
||||||
|
fatalError("client authority accepted invalid expiry")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON + Data(" {}".utf8))
|
||||||
|
fatalError("client authority accepted trailing JSON")
|
||||||
|
} catch { }
|
||||||
do {
|
do {
|
||||||
_ = try AllocationPolicy(
|
_ = try AllocationPolicy(
|
||||||
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
||||||
@@ -106,7 +261,7 @@ do {
|
|||||||
)
|
)
|
||||||
fatalError("invalid allocation bounds were accepted")
|
fatalError("invalid allocation bounds were accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
let displayMode = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120)
|
let displayMode = display
|
||||||
for invalid in [
|
for invalid in [
|
||||||
{ try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120) },
|
{ try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120) },
|
||||||
{ try DisplayMode(resolutionWidth: 2560, resolutionHeight: 199, fps: 120) },
|
{ try DisplayMode(resolutionWidth: 2560, resolutionHeight: 199, fps: 120) },
|
||||||
@@ -119,14 +274,14 @@ for invalid in [
|
|||||||
}
|
}
|
||||||
let policyFreeV2Request = try SessionRequest(
|
let policyFreeV2Request = try SessionRequest(
|
||||||
clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1",
|
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()
|
).encodeJSON()
|
||||||
guard !String(data: policyFreeV2Request, encoding: .utf8)!.contains("requested_display_mode") else {
|
guard !String(data: policyFreeV2Request, encoding: .utf8)!.contains("requested_display_mode") else {
|
||||||
fatalError("wire-v2 request encoded an absent display mode")
|
fatalError("wire-v2 request encoded an absent display mode")
|
||||||
}
|
}
|
||||||
let displayRequest = try SessionRequest(
|
let displayRequest = try SessionRequest(
|
||||||
clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1",
|
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 {
|
guard try SessionRequest.decodeJSON(displayRequest.encodeJSON()).requestedDisplayMode == displayMode else {
|
||||||
fatalError("display mode did not round-trip")
|
fatalError("display mode did not round-trip")
|
||||||
@@ -184,14 +339,14 @@ for expiresAt in ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
let streamPolicy = try ProviderStreamPolicy(
|
let streamPolicy = try ProviderStreamPolicy(
|
||||||
resolutionWidth: 2560, resolutionHeight: 1440, fps: 120,
|
videoProfile: hevc, audioProfile: audio, displayMode: displayMode,
|
||||||
codec: "HEVC", bitrateKbps: 40000, audioEnabled: true
|
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 [
|
for invalid in [
|
||||||
{ try ProviderStreamPolicy(resolutionWidth: 319, resolutionHeight: 1440, fps: 120, codec: "HEVC", 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(resolutionWidth: 2560, resolutionHeight: 1440, fps: 241, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true) },
|
{ try ProviderStreamPolicy(videoProfile: video, audioProfile: audio, displayMode: displayMode, bitrateTargetKbps: 50001, bitrateMaximumKbps: 50000) },
|
||||||
{ try ProviderStreamPolicy(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120, codec: "VP9", bitrateKbps: 40000, audioEnabled: true) },
|
{ 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 {
|
do {
|
||||||
_ = try invalid()
|
_ = try invalid()
|
||||||
@@ -252,17 +407,32 @@ fn main() {
|
|||||||
assert_eq!(CURRENT_WIRE_VERSION, "2");
|
assert_eq!(CURRENT_WIRE_VERSION, "2");
|
||||||
assert_eq!(N_MINUS_1_WIRE_VERSION, "1");
|
assert_eq!(N_MINUS_1_WIRE_VERSION, "1");
|
||||||
assert_eq!(N_MINUS_2_WIRE_VERSION, "0");
|
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 operation_id = "12345678-1234-1234-1234-123456789abc".to_string();
|
||||||
|
assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "poll".into(), None, None, None, None).is_ok());
|
||||||
|
assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "prompt".into(), Some(operation_id.clone()), Some(7), None, None).is_ok());
|
||||||
|
assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "observation".into(), Some(operation_id.clone()), Some(7), Some(3), Some(6)).is_ok());
|
||||||
|
assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "poll".into(), Some(operation_id.clone()), Some(7), None, None).is_err());
|
||||||
|
assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "applied".into(), Some(7), None).is_ok());
|
||||||
|
assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "proven_prior".into(), Some(6), None).is_ok());
|
||||||
|
assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "unknown".into(), None, None).is_ok());
|
||||||
|
assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id, 7, 3, "applied".into(), Some(6), None).is_err());
|
||||||
let capabilities = CapabilityProfile::new(
|
let capabilities = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"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();
|
).unwrap();
|
||||||
assert!(CapabilityProfile::new(
|
assert!(CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v2".into(), "encoded".into(),
|
"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());
|
).is_ok());
|
||||||
assert!(CapabilityProfile::new(
|
assert!(CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v3".into(), "encoded".into(),
|
"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());
|
).is_err());
|
||||||
let request = TunnelAdmissionRequest::new(
|
let request = TunnelAdmissionRequest::new(
|
||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
@@ -270,8 +440,34 @@ fn main() {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:".to_string()
|
let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:".to_string()
|
||||||
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
|
+ &"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());
|
assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
|
||||||
|
let proof_server_id = vec![1u8; 16];
|
||||||
|
let proof_principal_id = vec![2u8; 16];
|
||||||
|
let proof_device_id = vec![3u8; 16];
|
||||||
|
let proof_challenge = vec![4u8; 32];
|
||||||
|
let proof = device_registration_proof_transcript(
|
||||||
|
&proof_server_id, &proof_principal_id, &proof_device_id, &proof_challenge, 1,
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(proof.len(), 112);
|
||||||
|
assert_eq!(&proof[..24], b"versevdi-device-proof-v1");
|
||||||
|
assert_eq!(&proof[104..], &[0, 0, 0, 0, 0, 0, 0, 1]);
|
||||||
|
for (server_id, principal_id, device_id, challenge, expiry, field, code) in [
|
||||||
|
(vec![0; 15], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
|
||||||
|
(vec![0; 17], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), vec![0; 15], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), vec![0; 17], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 15], proof_challenge.clone(), 0, "device_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 17], proof_challenge.clone(), 0, "device_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 31], 0, "challenge", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 33], 0, "challenge", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), -1, "expiry_unix_milliseconds", "minimum"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry),
|
||||||
|
Err(ValidationError::new(field, code)),
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(TunnelAdmissionRequest::new(
|
assert!(TunnelAdmissionRequest::new(
|
||||||
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
||||||
@@ -284,18 +480,28 @@ fn main() {
|
|||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
|
let client_authority = ClientSessionAuthority::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
|
||||||
|
"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(), descriptor.clone(),
|
||||||
|
).is_err());
|
||||||
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
||||||
let incompatible = CapabilityProfile::new(
|
let incompatible = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"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();
|
).unwrap();
|
||||||
let gateway_capability = CapabilityProfile::new(
|
let gateway_capability = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"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();
|
).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
intersect_capability_profiles(&[gateway_capability, capabilities.clone()]).unwrap().clientDecode(),
|
intersect_capability_profiles(&[gateway_capability, capabilities.clone()]).unwrap().videoProfiles(),
|
||||||
&vec!["h264-opus".to_string()],
|
&vec![video.clone()],
|
||||||
);
|
);
|
||||||
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
|
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
|
||||||
assert!(AllocationPolicy::new(
|
assert!(AllocationPolicy::new(
|
||||||
@@ -307,11 +513,13 @@ fn main() {
|
|||||||
assert!(DisplayMode::new(2560, 1440, 241).is_err());
|
assert!(DisplayMode::new(2560, 1440, 241).is_err());
|
||||||
let policy_free_v2_request = SessionRequest::new(
|
let policy_free_v2_request = SessionRequest::new(
|
||||||
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
|
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
|
||||||
|
vec![video.clone()], BitratePreference::new("auto".into(), None).unwrap(),
|
||||||
None,
|
None,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert!(policy_free_v2_request.requestedDisplayMode().is_none());
|
assert!(policy_free_v2_request.requestedDisplayMode().is_none());
|
||||||
let display_request = SessionRequest::new(
|
let display_request = SessionRequest::new(
|
||||||
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
|
"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()),
|
Some(display_mode.clone()),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(display_request.requestedDisplayMode(), &Some(display_mode));
|
assert_eq!(display_request.requestedDisplayMode(), &Some(display_mode));
|
||||||
@@ -339,17 +547,13 @@ fn main() {
|
|||||||
).is_err());
|
).is_err());
|
||||||
}
|
}
|
||||||
assert!(ProviderStreamPolicy::new(
|
assert!(ProviderStreamPolicy::new(
|
||||||
2560, 1440, 120, "HEVC".into(), 40000, true,
|
hevc.clone(), audio.clone(), display.clone(), 40000, 50000,
|
||||||
).is_ok());
|
).is_ok());
|
||||||
|
assert!(DisplayMode::new(319, 1440, 120).is_err());
|
||||||
assert!(ProviderStreamPolicy::new(
|
assert!(ProviderStreamPolicy::new(
|
||||||
319, 1440, 120, "HEVC".into(), 40000, true,
|
video.clone(), audio.clone(), display.clone(), 50001, 50000,
|
||||||
).is_err());
|
|
||||||
assert!(ProviderStreamPolicy::new(
|
|
||||||
2560, 1440, 241, "HEVC".into(), 40000, true,
|
|
||||||
).is_err());
|
|
||||||
assert!(ProviderStreamPolicy::new(
|
|
||||||
2560, 1440, 120, "VP9".into(), 40000, true,
|
|
||||||
).is_err());
|
).is_err());
|
||||||
|
assert!(VideoProfile::new("vp9".into(), 8, "4:2:0".into(), "bt709-limited".into(), "sdr".into()).is_err());
|
||||||
assert!(GatewayTelemetry::new(
|
assert!(GatewayTelemetry::new(
|
||||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, "ready".into(),
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, "ready".into(),
|
||||||
).is_ok());
|
).is_ok());
|
||||||
@@ -377,6 +581,68 @@ fn main() {
|
|||||||
)
|
)
|
||||||
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
||||||
run([str(workspace / "rust-contracts")], ROOT)
|
run([str(workspace / "rust-contracts")], ROOT)
|
||||||
|
expected_protobuf_messages = {
|
||||||
|
"ClientSessionAuthority": [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("audience", 4),
|
||||||
|
("reconnect_sequence", 5),
|
||||||
|
("expires_at", 6),
|
||||||
|
("capabilities", 7),
|
||||||
|
("selected_descriptor", 8),
|
||||||
|
],
|
||||||
|
"GatewayQualityWorkRequest": [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("reconnect_sequence", 4),
|
||||||
|
("operation_id", 5),
|
||||||
|
("revision", 6),
|
||||||
|
("current_applied_revision", 7),
|
||||||
|
("acquisition", 8),
|
||||||
|
("lease_generation", 9),
|
||||||
|
],
|
||||||
|
"GatewayQualityWork": [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("reconnect_sequence", 4),
|
||||||
|
("operation_id", 5),
|
||||||
|
("revision", 6),
|
||||||
|
("lease_expires_at", 7),
|
||||||
|
("selected_descriptor", 8),
|
||||||
|
("current_applied_revision", 9),
|
||||||
|
("lease_generation", 10),
|
||||||
|
],
|
||||||
|
"GatewayQualityAck": [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("reconnect_sequence", 4),
|
||||||
|
("operation_id", 5),
|
||||||
|
("revision", 6),
|
||||||
|
("outcome", 7),
|
||||||
|
("current_applied_revision", 8),
|
||||||
|
("failure_code", 9),
|
||||||
|
("lease_generation", 10),
|
||||||
|
],
|
||||||
|
"GatewayStopWorkRequest": [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("reconnect_sequence", 4),
|
||||||
|
("operation_id", 5),
|
||||||
|
("acquisition", 6),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for message, expected_protobuf_fields in expected_protobuf_messages.items():
|
||||||
|
actual_protobuf_fields = protobuf_message_fields(message)
|
||||||
|
if actual_protobuf_fields != expected_protobuf_fields:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{message} protobuf fields = {actual_protobuf_fields}; "
|
||||||
|
f"want {expected_protobuf_fields}"
|
||||||
|
)
|
||||||
rust_unknown = workspace / "unknown.rs"
|
rust_unknown = workspace / "unknown.rs"
|
||||||
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
||||||
with rust_unknown.open("a", encoding="utf-8") as output:
|
with rust_unknown.open("a", encoding="utf-8") as output:
|
||||||
|
|||||||
+181
-1
@@ -12,6 +12,17 @@ import sys
|
|||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def matches_outcome_branch(instance: dict[str, object], branch: dict[str, object]) -> bool:
|
||||||
|
required = branch.get("required", [])
|
||||||
|
if not all(field in instance for field in required):
|
||||||
|
return False
|
||||||
|
outcome = branch.get("properties", {}).get("outcome", {}).get("const")
|
||||||
|
if instance.get("outcome") != outcome:
|
||||||
|
return False
|
||||||
|
forbidden = branch.get("not", {}).get("required", [])
|
||||||
|
return not all(field in instance for field in forbidden) if forbidden else True
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
schema_path = ROOT / "schemas/control-v1.schema.json"
|
schema_path = ROOT / "schemas/control-v1.schema.json"
|
||||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
@@ -39,6 +50,65 @@ def main() -> int:
|
|||||||
feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8"))
|
feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8"))
|
||||||
registered_features = {entry["id"] for entry in feature_registry["features"]}
|
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 {"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"
|
||||||
|
quality_request = defs["GatewayQualityWorkRequest"]
|
||||||
|
assert quality_request["required"] == ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"]
|
||||||
|
assert quality_request["properties"]["acquisition"]["enum"] == ["poll", "prompt", "observation"]
|
||||||
|
assert quality_request["properties"]["lease_generation"]["minimum"] == 1
|
||||||
|
assert defs["GatewayStopWorkRequest"]["required"] == ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"]
|
||||||
|
assert defs["GatewayQualityWork"]["required"][-3:] == ["lease_generation", "lease_expires_at", "selected_descriptor"]
|
||||||
|
quality_ack = defs["GatewayQualityAck"]
|
||||||
|
assert quality_ack["required"][-3:] == ["revision", "lease_generation", "outcome"]
|
||||||
|
assert quality_ack["properties"]["outcome"]["enum"] == ["applied", "proven_prior", "unknown"]
|
||||||
|
assert quality_ack["description"] == (
|
||||||
|
"Outcome invariants: applied requires current_applied_revision equal to revision; "
|
||||||
|
"proven_prior requires current_applied_revision strictly less than revision; "
|
||||||
|
"unknown forbids current_applied_revision and makes no applied-revision assertion."
|
||||||
|
)
|
||||||
|
assert quality_ack["oneOf"] == [
|
||||||
|
{"properties": {"outcome": {"const": "applied"}}, "required": ["current_applied_revision"]},
|
||||||
|
{"properties": {"outcome": {"const": "proven_prior"}}, "required": ["current_applied_revision"]},
|
||||||
|
{"properties": {"outcome": {"const": "unknown"}}, "not": {"required": ["current_applied_revision"]}},
|
||||||
|
]
|
||||||
|
ack_branches = quality_ack["oneOf"]
|
||||||
|
for valid_ack in (
|
||||||
|
{"outcome": "applied", "current_applied_revision": 7},
|
||||||
|
{"outcome": "proven_prior", "current_applied_revision": 6},
|
||||||
|
{"outcome": "unknown"},
|
||||||
|
):
|
||||||
|
assert sum(matches_outcome_branch(valid_ack, branch) for branch in ack_branches) == 1, valid_ack
|
||||||
|
for invalid_ack in (
|
||||||
|
{"outcome": "applied"},
|
||||||
|
{"outcome": "proven_prior"},
|
||||||
|
{"outcome": "unknown", "current_applied_revision": 6},
|
||||||
|
):
|
||||||
|
assert not any(matches_outcome_branch(invalid_ack, branch) for branch in ack_branches), invalid_ack
|
||||||
|
|
||||||
|
operation_id_pattern = r"^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||||
|
canonical_time_pattern = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{0,8}[1-9])?Z$"
|
||||||
|
for definition in ("QualityChangeOperation", "StopOperation", "GatewayQualityWorkRequest", "GatewayQualityWork", "GatewayQualityAck", "GatewayStopWorkRequest", "GatewayStopWork", "GatewayStopAck"):
|
||||||
|
assert defs[definition]["properties"]["operation_id"]["pattern"] == operation_id_pattern, definition
|
||||||
|
assert defs[definition]["x-max-bytes"] == 16384, definition
|
||||||
|
for definition, fields in {
|
||||||
|
"QualityChangeOperation": ("created_at", "deadline_at", "updated_at"),
|
||||||
|
"StopOperation": ("created_at", "deadline_at", "updated_at"),
|
||||||
|
"GatewayQualityWork": ("lease_expires_at",),
|
||||||
|
}.items():
|
||||||
|
for field in fields:
|
||||||
|
assert defs[definition]["properties"][field]["pattern"] == canonical_time_pattern, (definition, field)
|
||||||
|
|
||||||
display_mode = defs["DisplayMode"]
|
display_mode = defs["DisplayMode"]
|
||||||
assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"]
|
assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"]
|
||||||
@@ -76,6 +146,14 @@ def main() -> int:
|
|||||||
assert tunnel_credential["required"] == [
|
assert tunnel_credential["required"] == [
|
||||||
"client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at"
|
"client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at"
|
||||||
]
|
]
|
||||||
|
client_authority_expiry = defs["ClientSessionAuthority"]["properties"]["expires_at"]
|
||||||
|
assert client_authority_expiry["format"] == "date-time"
|
||||||
|
client_authority_expiry_pattern = re.compile(client_authority_expiry.get("pattern", r"(?!)"))
|
||||||
|
assert client_authority_expiry_pattern.fullmatch("2099-01-01T00:00:00Z"), "client authority expiry must accept canonical UTC"
|
||||||
|
for noncanonical_expiry in ("2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"):
|
||||||
|
assert not client_authority_expiry_pattern.fullmatch(noncanonical_expiry), (
|
||||||
|
f"client authority expiry accepted noncanonical UTC {noncanonical_expiry}"
|
||||||
|
)
|
||||||
|
|
||||||
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
|
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
|
||||||
assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"]))
|
assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"]))
|
||||||
@@ -105,6 +183,17 @@ def main() -> int:
|
|||||||
assert "native_identity" not in native_missing_fixture
|
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"))
|
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"])
|
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"
|
expected_header = "id\tversion\tkind\tinput\texpected"
|
||||||
ids = set()
|
ids = set()
|
||||||
@@ -116,7 +205,9 @@ def main() -> int:
|
|||||||
assert len(fields) == 5, line
|
assert len(fields) == 5, line
|
||||||
assert fields[0] not in ids, fields[0]
|
assert fields[0] not in ids, fields[0]
|
||||||
ids.add(fields[0])
|
ids.add(fields[0])
|
||||||
assert fields[4] == "valid" or fields[4].startswith("invalid:"), line
|
assert fields[4] == "valid" or fields[4].startswith("invalid:") or (
|
||||||
|
fields[2] == "device_proof_transcript" and re.fullmatch(r"[0-9a-f]{224}", fields[4])
|
||||||
|
), line
|
||||||
|
|
||||||
fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8"))
|
fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8"))
|
||||||
assert fixture_manifest["files"] == sorted(
|
assert fixture_manifest["files"] == sorted(
|
||||||
@@ -129,10 +220,64 @@ def main() -> int:
|
|||||||
fixture_hash.update((ROOT / relative).read_bytes())
|
fixture_hash.update((ROOT / relative).read_bytes())
|
||||||
fixture_hash.update(b"\0")
|
fixture_hash.update(b"\0")
|
||||||
assert fixture_manifest["corpus_sha256"] == fixture_hash.hexdigest()
|
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")
|
openapi = (ROOT / "openapi/control-v1.yaml").read_text(encoding="utf-8")
|
||||||
assert "openapi: 3.1.0" in openapi
|
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
|
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 "certificate identity MUST match" in operation, operation_id
|
||||||
|
assert "Maximum JSON body: 16384 bytes." in operation, operation_id
|
||||||
|
quality_acquisition = openapi.split(" operationId: acquireGatewayQualityWork\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "`poll` acquisition omits unknown operation coordinates" in quality_acquisition
|
||||||
|
assert "coordinates MUST match exactly" in quality_acquisition
|
||||||
|
quality_acknowledgement = openapi.split(" operationId: acknowledgeGatewayQualityWork\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "Stale lease generations MUST be rejected" in quality_acknowledgement
|
||||||
|
assert "`applied` requires `current_applied_revision == revision`" in quality_acknowledgement
|
||||||
|
assert "`proven_prior` requires `current_applied_revision < revision`" in quality_acknowledgement
|
||||||
|
assert "`unknown` forbids `current_applied_revision`" in quality_acknowledgement
|
||||||
|
for operation_id in ("createSessionQualityChange", "getSessionQualityChange", "createSessionStopOperation", "getSessionStopOperation"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "owning principal and active device/key" in operation, operation_id
|
||||||
|
assert openapi.count("Maximum JSON body: 16384 bytes.") >= 8
|
||||||
|
assert operation_id_pattern in openapi
|
||||||
assert "provider_url" not in openapi and "vm_address" not in openapi
|
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]
|
session_endpoint = openapi.split(" /api/v1/auth/session:", 1)[1].split("\n /api/", 1)[0]
|
||||||
assert "$defs/BrowserAuthenticatedSession" in session_endpoint
|
assert "$defs/BrowserAuthenticatedSession" in session_endpoint
|
||||||
@@ -145,6 +290,41 @@ def main() -> int:
|
|||||||
assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint
|
assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint
|
||||||
assert "$defs/NativeTunnelCredential" in tunnel_endpoint
|
assert "$defs/NativeTunnelCredential" in tunnel_endpoint
|
||||||
assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint
|
assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint
|
||||||
|
csrf_schemes = """ browserCsrfCookie:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: versevdi_csrf
|
||||||
|
description: Must be identical to X-CSRF-Token and is checked against Server session state.
|
||||||
|
browserCsrfHeader:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-CSRF-Token
|
||||||
|
description: Must be identical to the versevdi_csrf cookie and is checked against Server session state.
|
||||||
|
"""
|
||||||
|
assert csrf_schemes in openapi, "missing exact browser CSRF security schemes"
|
||||||
|
browser_requirement = """ security:
|
||||||
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
|
"""
|
||||||
|
admin_override = openapi.split(" operationId: updateEntitlementDisplayLimitOverride\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert browser_requirement.removeprefix(" ") in admin_override
|
||||||
|
assert "Maximum JSON body: 16384 bytes." in admin_override
|
||||||
|
for operation_id in (
|
||||||
|
"issueReauthenticationGrant", "logoutSession", "registerDevice", "proveDevice", "revokeDevice",
|
||||||
|
"requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession",
|
||||||
|
):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert browser_requirement.removeprefix(" ") in operation, f"{operation_id}: missing browser CSRF AND requirement"
|
||||||
|
for operation_id in ("logoutSession", "requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert " browserCsrfHeader: []\n - nativeBearer: []\n" in operation, f"{operation_id}: native bearer must remain a separate OR requirement"
|
||||||
|
for operation_id in ("loginBrowserSession", "rotateNativeCredential", "issueNativeTunnelCredential"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "browserCsrf" not in operation, f"{operation_id}: excluded operation gained browser CSRF"
|
||||||
|
for operation_id in ("getAuthenticatedSession", "listResources", "getBrokerSession", "resumeUserEvents"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "browserCsrf" not in operation, f"{operation_id}: safe GET gained browser CSRF"
|
||||||
assert defs["ManifestGateway"]["properties"]["public_identity"]["description"] == (
|
assert defs["ManifestGateway"]["properties"]["public_identity"]["description"] == (
|
||||||
"Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
|
"Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
return "valid" if width and height and x < width and y < height else "invalid:field"
|
||||||
if kind == 7:
|
if kind == 7:
|
||||||
return "valid" if len(body) == 4 else "invalid:length"
|
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"
|
return "invalid:kind"
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +72,14 @@ def classify_feedback(raw: bytes) -> str:
|
|||||||
return "valid" if valid_fec_status(body) else "invalid:field"
|
return "valid" if valid_fec_status(body) else "invalid:field"
|
||||||
if kind == 3:
|
if kind == 3:
|
||||||
return "valid" if not body else "invalid:length"
|
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"
|
return "invalid:type"
|
||||||
if kind in (1, 2, 3):
|
if kind in (1, 2, 3):
|
||||||
return "invalid:direction"
|
return "invalid:direction"
|
||||||
|
|||||||
Reference in New Issue
Block a user