Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30bb1a2fa3 | ||
|
|
021ecf425f | ||
|
|
ec15279b42 | ||
|
|
37c041e13e | ||
|
|
2e92fae27f | ||
|
|
534bb1031b | ||
|
|
e58f1c7c48 | ||
|
|
d26f8b60f8 | ||
|
|
59741761ce | ||
|
|
ebfe07376d | ||
|
|
0ea21cd3f2 |
@@ -20,6 +20,7 @@ source-verify:
|
|||||||
$(PYTHON) -B tools/fixture_digest.py
|
$(PYTHON) -B tools/fixture_digest.py
|
||||||
|
|
||||||
scope-verify:
|
scope-verify:
|
||||||
|
$(PYTHON) -B tools/test_check_scope.py
|
||||||
$(PYTHON) -B tools/check_scope.py
|
$(PYTHON) -B tools/check_scope.py
|
||||||
|
|
||||||
go-test:
|
go-test:
|
||||||
@@ -39,6 +40,7 @@ conformance:
|
|||||||
|
|
||||||
frame-verify:
|
frame-verify:
|
||||||
$(PYTHON) tools/validate_frames.py
|
$(PYTHON) tools/validate_frames.py
|
||||||
|
$(PYTHON) tools/validate_gateway_envelopes.py
|
||||||
|
|
||||||
clean-generated:
|
clean-generated:
|
||||||
$(PYTHON) tools/generate.py --check
|
$(PYTHON) tools/generate.py --check
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id version kind input expected
|
||||||
|
valid-forwarded 1 gateway_clipboard_audit direction=client_to_provider;outcome=forwarded;text_bytes=1024;reason=forwarded valid
|
||||||
|
valid-suppressed 1 gateway_clipboard_audit direction=provider_to_client;outcome=suppressed;text_bytes=12;reason=loop valid
|
||||||
|
audit-invalid-direction 1 gateway_clipboard_audit direction=bidirectional;outcome=forwarded;text_bytes=1;reason=forwarded invalid:clipboard_audit
|
||||||
|
invalid-bytes 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=65537;reason=policy invalid:clipboard_audit
|
||||||
|
invalid-content 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=1;reason=rate;text=forbidden invalid:forbidden
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
id version kind input expected
|
||||||
|
valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
|
||||||
|
valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
|
||||||
|
valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
|
||||||
|
valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
|
||||||
|
invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
|
||||||
|
invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
|
||||||
|
invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard
|
||||||
|
invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard
|
||||||
|
invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard
|
||||||
|
invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard
|
||||||
|
invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
|
||||||
|
@@ -0,0 +1,27 @@
|
|||||||
|
id version kind input expected
|
||||||
|
valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid
|
||||||
|
valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid
|
||||||
|
valid-mouse-button 1 gateway_input hex=564749310203010100 valid
|
||||||
|
valid-mouse-release 1 gateway_input hex=564749310203000100 valid
|
||||||
|
valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
|
||||||
|
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
|
||||||
|
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
|
||||||
|
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
|
||||||
|
valid-idr 1 gateway_feedback hex=5647463100010000 valid
|
||||||
|
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
||||||
|
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
|
||||||
|
valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
|
||||||
|
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
|
||||||
|
valid-hdr 1 gateway_feedback hex=564746310112000101 valid
|
||||||
|
invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
|
||||||
|
invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
|
||||||
|
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
|
||||||
|
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
|
||||||
|
invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
|
||||||
|
invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
|
||||||
|
invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction
|
||||||
|
invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length
|
||||||
|
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
|
||||||
|
invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
|
||||||
|
invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type
|
||||||
|
invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
|
||||||
|
@@ -4,7 +4,10 @@
|
|||||||
"fixtures/conformance/control-v1.tsv",
|
"fixtures/conformance/control-v1.tsv",
|
||||||
"fixtures/conformance/datagram-v1.tsv",
|
"fixtures/conformance/datagram-v1.tsv",
|
||||||
"fixtures/conformance/events-v1.tsv",
|
"fixtures/conformance/events-v1.tsv",
|
||||||
|
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
|
||||||
|
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
||||||
|
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||||
"fixtures/conformance/tunnel-v1.tsv"
|
"fixtures/conformance/tunnel-v1.tsv"
|
||||||
],
|
],
|
||||||
"corpus_sha256": "0874d39dd14c0107e602ea8909f8d53673f964c67dc9fd5fcadb8641e6ebf592"
|
"corpus_sha256": "0eb9e905e77069c0bd67182b40e7f9b1b41041d5290b0e3b2152abf83ce32eae"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,11 @@ Registered channels are `control.ack.v1`, `control.cancel.v1`, `clipboard.text.v
|
|||||||
application flow IDs 10, 11, and 12 and a path-MTU-safe payload limit of 1,179 bytes;
|
application flow IDs 10, 11, and 12 and a path-MTU-safe payload limit of 1,179 bytes;
|
||||||
larger encoded units use at most 16 validated fragments. Clipboard payloads are UTF-8
|
larger encoded units use at most 16 validated fragments. Clipboard payloads are UTF-8
|
||||||
JSON text contracts and remain subject to the 65,536-byte text limit and explicit
|
JSON text contracts and remain subject to the 65,536-byte text limit and explicit
|
||||||
authorization.
|
Server-owned direction, rate, and loop-token policy as defined in
|
||||||
|
`gateway-clipboard-v1.md`.
|
||||||
|
|
||||||
|
Within an active Phase 3C gateway session, `input.sequenced.v1` and the
|
||||||
|
bidirectional reliable `control.ack.v1` payloads additionally use the exact
|
||||||
|
provider-neutral grammars in [gateway-input-feedback-v1.md](gateway-input-feedback-v1.md).
|
||||||
|
Those grammars do not alter this datagram header or make provider traffic visible to
|
||||||
|
the Verse client.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Gateway clipboard text v1
|
||||||
|
|
||||||
|
`clipboard.text.v1` is a reliable, authenticated gateway-only `ChannelFrame`
|
||||||
|
flow. Its UTF-8 JSON payload is a `GatewayClipboardText` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"direction":"client_to_provider","text":"text","encoding":"utf-8","loop_token":"base64url-token"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction` is exact: the client may send only `client_to_provider`, and the
|
||||||
|
gateway may send only `provider_to_client`. The text contains no file name,
|
||||||
|
URL, binary value, or client-folder field and is at most the Server-owned
|
||||||
|
`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 character
|
||||||
|
canonical unpadded ASCII base64url token generated by the originating endpoint. An endpoint MUST retain
|
||||||
|
recent token/value pairs only for the bounded policy window and MUST suppress a
|
||||||
|
matching reflected value; a mismatched, malformed, expired, or replayed token
|
||||||
|
is rejected without clipboard mutation.
|
||||||
|
|
||||||
|
The gateway receives the policy only in authenticated session work. A disabled
|
||||||
|
direction, a rate above `max_updates_per_minute`, invalid UTF-8, an oversized
|
||||||
|
payload, or an unknown field fails closed. Clipboard bytes are never emitted to
|
||||||
|
provider-state, audit, telemetry, or error payloads.
|
||||||
|
|
||||||
|
For every successfully delivered, loop-suppressed, or policy/rate/provider/malformed
|
||||||
|
rejection, the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains
|
||||||
|
only the session identifier, direction, bounded text-byte count, outcome, and a
|
||||||
|
fixed reason code; it contains neither text nor loop token. The Server persists it
|
||||||
|
against the broker session using the authenticated gateway identity.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Gateway input and feedback v1
|
||||||
|
|
||||||
|
This grammar is carried only in an authenticated Phase 3C gateway session. It
|
||||||
|
is deliberately provider-neutral: it never carries provider routes,
|
||||||
|
certificates, credentials, opaque provider packets, clipboard bytes, files, or
|
||||||
|
client-folder data. It does not change the v1 datagram header or any existing
|
||||||
|
release candidate.
|
||||||
|
|
||||||
|
## `input.sequenced.v1` payload (`VGI1`)
|
||||||
|
|
||||||
|
All multibyte fields are unsigned big-endian. The payload has exactly six bytes
|
||||||
|
of header followed by the declared body:
|
||||||
|
|
||||||
|
| Offset | Size | Field | Rule |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 4 | magic | ASCII `VGI1` |
|
||||||
|
| 4 | 1 | kind | one of the kinds below |
|
||||||
|
| 5 | 1 | payload length | exact body byte count |
|
||||||
|
| 6 | N | body | exact kind-specific body |
|
||||||
|
|
||||||
|
The decoder rejects an unknown kind, non-exact length, nonzero reserved byte,
|
||||||
|
unsupported controller index, malformed UTF-8, a non-scalar UTF-8 value, or a
|
||||||
|
payload larger than the channel limit before provider translation. A false
|
||||||
|
keyboard or mouse state and a zeroed controller state are explicit releases;
|
||||||
|
they are retained by the gateway and replayed as individual provider releases
|
||||||
|
during cleanup.
|
||||||
|
|
||||||
|
| Kind | Name | Exact body |
|
||||||
|
|---:|---|---|
|
||||||
|
| `0x01` | keyboard | `state` (`0` release, `1` press), `modifiers` (one byte), nonzero `scancode` (u16). |
|
||||||
|
| `0x02` | mouse button | `state` (`0` release, `1` press), `button` (`1` through `5`), reserved `0`. |
|
||||||
|
| `0x03` | relative mouse | `delta_x` (i16), `delta_y` (i16). |
|
||||||
|
| `0x04` | UTF-8 scalar | exactly one valid UTF-8 Unicode scalar, one through four bytes. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
Keyboard, mouse button, UTF-8, and controller messages are delivered over the
|
||||||
|
gateway's reliable ordered input flow. Relative mouse is a state change, not a
|
||||||
|
pressed-state entry. The gateway maps the validated values to the provider's
|
||||||
|
separate keyboard, mouse, UTF-8, and controller control messages; it does not
|
||||||
|
forward this envelope to the provider.
|
||||||
|
|
||||||
|
## Reliable control payload (`VGF1`)
|
||||||
|
|
||||||
|
`control.ack.v1` remains the existing authenticated bidirectional reliable
|
||||||
|
control flow. Within an active gateway session, its provider-feedback payload
|
||||||
|
is the following exact envelope:
|
||||||
|
|
||||||
|
| Offset | Size | Field | Rule |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 4 | magic | ASCII `VGF1` |
|
||||||
|
| 4 | 1 | direction | `0` client-to-gateway; `1` gateway-to-client |
|
||||||
|
| 5 | 1 | type | valid only for the stated direction |
|
||||||
|
| 6 | 2 | payload length | exact payload byte count |
|
||||||
|
| 8 | N | payload | exact type-specific body |
|
||||||
|
|
||||||
|
The client-to-gateway types are `0x01` IDR request (empty), `0x02` FEC
|
||||||
|
status, and `0x03` terminal receipt (empty). FEC status contains
|
||||||
|
`frame_index` (u32), `highest_received_sequence` (u16),
|
||||||
|
`next_contiguous_sequence` (u16), `missing_before_highest` (u16),
|
||||||
|
`total_data_packets` (u16), `total_parity_packets` (u16),
|
||||||
|
`received_data_packets` (u16), `received_parity_packets` (u16),
|
||||||
|
`fec_percentage` (u8), `multi_fec_block_index` (u8), and
|
||||||
|
`multi_fec_block_count` (u8). The gateway maps this fixed 21-byte structure to
|
||||||
|
the provider's unsequenced ENet FEC delivery; it does not put it on the reliable
|
||||||
|
provider input path.
|
||||||
|
|
||||||
|
The terminal receipt is valid only from client to gateway with an exact
|
||||||
|
zero-byte payload. Session-state authorization remains a gateway responsibility;
|
||||||
|
the Protocol grammar defines only its fixed wire shape.
|
||||||
|
|
||||||
|
The gateway-to-client types are `0x10` host termination (`exit_code` u32),
|
||||||
|
`0x11` rumble (`controller` u8, `low_frequency` u16,
|
||||||
|
`high_frequency` u16), and `0x12` HDR mode (`enabled` exactly `0` or `1`). The
|
||||||
|
gateway derives these from authenticated provider control messages, normalizes
|
||||||
|
their bounded fields, and rejects all unrecognized provider feedback. The HDR
|
||||||
|
envelope intentionally carries only the negotiated mode; provider-specific HDR
|
||||||
|
metadata remains behind the gateway boundary.
|
||||||
|
|
||||||
|
Apollo's pinned `src/stream.cpp` source defines separate termination, rumble,
|
||||||
|
and HDR control structures, while Moonlight common-C's `ControlStream.c` and
|
||||||
|
`InputStream.c` separate reliable input/control from UDP media. This Verse
|
||||||
|
grammar is a new normalized contract; it does not copy either implementation or
|
||||||
|
expose its wire format.
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
"header_bytes": 21,
|
"header_bytes": 21,
|
||||||
"maximum_frame_bytes": 65536,
|
"maximum_frame_bytes": 65536,
|
||||||
"channels": [
|
"channels": [
|
||||||
{"id": 1, "name": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024},
|
{"id": 1, "name": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024, "payload_profile": "gateway-feedback-v1"},
|
||||||
{"id": 2, "name": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048},
|
{"id": 2, "name": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048},
|
||||||
{"id": 3, "name": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65515},
|
{"id": 3, "name": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65515},
|
||||||
{"id": 10, "name": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
|
{"id": 10, "name": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
|
||||||
{"id": 11, "name": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
|
{"id": 11, "name": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
|
||||||
{"id": 12, "name": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1179}
|
{"id": 12, "name": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1179, "payload_profile": "gateway-input-v1"}
|
||||||
],
|
],
|
||||||
"reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"]
|
"reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"]
|
||||||
}
|
}
|
||||||
|
|||||||
+723
-41
@@ -3,6 +3,7 @@ package protocol
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
|
const SchemaSHA256 = "3aec8dd72bdbb6b9657c8df3160252c93034c7c1032d471e01eae2ef91e47716"
|
||||||
const ProtocolVersion = "1.0.0"
|
const ProtocolVersion = "1.0.0"
|
||||||
const CurrentWireVersion = "1"
|
const CurrentWireVersion = "1"
|
||||||
const NMinus1WireVersion = "0"
|
const NMinus1WireVersion = "0"
|
||||||
@@ -67,12 +68,12 @@ type BrokerSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CapabilityProfile struct {
|
type CapabilityProfile struct {
|
||||||
Transport string `json:"transport"`
|
Transport string `json:"transport"`
|
||||||
Framing string `json:"framing"`
|
Framing string `json:"framing"`
|
||||||
Media string `json:"media"`
|
Media string `json:"media"`
|
||||||
Audio string `json:"audio"`
|
Audio string `json:"audio"`
|
||||||
SourceRateControl string `json:"source_rate_control"`
|
SourceRateControl string `json:"source_rate_control"`
|
||||||
ClientDecode string `json:"client_decode"`
|
ClientDecode []string `json:"client_decode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelFrame struct {
|
type ChannelFrame struct {
|
||||||
@@ -86,6 +87,13 @@ type ChannelFrame struct {
|
|||||||
Payload string `json:"payload"`
|
Payload string `json:"payload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClipboardPolicy struct {
|
||||||
|
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
|
||||||
|
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
|
||||||
|
MaxTextBytes int64 `json:"max_text_bytes"`
|
||||||
|
MaxUpdatesPerMinute int64 `json:"max_updates_per_minute"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClipboardText struct {
|
type ClipboardText struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Encoding string `json:"encoding"`
|
Encoding string `json:"encoding"`
|
||||||
@@ -158,6 +166,22 @@ type EventResume struct {
|
|||||||
LastSequence int64 `json:"last_sequence"`
|
LastSequence int64 `json:"last_sequence"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GatewayClipboardAudit struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
TextBytes int64 `json:"text_bytes"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayClipboardText struct {
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
LoopToken string `json:"loop_token"`
|
||||||
|
}
|
||||||
|
|
||||||
type GatewayDrain struct {
|
type GatewayDrain struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
GatewayID string `json:"gateway_id"`
|
GatewayID string `json:"gateway_id"`
|
||||||
@@ -167,13 +191,14 @@ type GatewayDrain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GatewayHeartbeat struct {
|
type GatewayHeartbeat struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
GatewayID string `json:"gateway_id"`
|
GatewayID string `json:"gateway_id"`
|
||||||
Sequence int64 `json:"sequence"`
|
Sequence int64 `json:"sequence"`
|
||||||
ObservedAt string `json:"observed_at"`
|
ObservedAt string `json:"observed_at"`
|
||||||
ActiveConnections int64 `json:"active_connections"`
|
ActiveConnections int64 `json:"active_connections"`
|
||||||
EgressKbps int64 `json:"egress_kbps"`
|
EgressKbps int64 `json:"egress_kbps"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
Telemetry GatewayTelemetry `json:"telemetry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayRegistration struct {
|
type GatewayRegistration struct {
|
||||||
@@ -192,6 +217,27 @@ type GatewayRegistration struct {
|
|||||||
Capabilities CapabilityProfile `json:"capabilities"`
|
Capabilities CapabilityProfile `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GatewayTelemetry struct {
|
||||||
|
AdmittedSessions int64 `json:"admitted_sessions"`
|
||||||
|
AdmissionRejects int64 `json:"admission_rejects"`
|
||||||
|
Reconnects int64 `json:"reconnects"`
|
||||||
|
DrainTransitions int64 `json:"drain_transitions"`
|
||||||
|
MediaDrops int64 `json:"media_drops"`
|
||||||
|
MediaPackets int64 `json:"media_packets"`
|
||||||
|
MediaBytes int64 `json:"media_bytes"`
|
||||||
|
QueueDelayMicros int64 `json:"queue_delay_micros"`
|
||||||
|
ProcessingDelayMicros int64 `json:"processing_delay_micros"`
|
||||||
|
ProcessingSamples int64 `json:"processing_samples"`
|
||||||
|
PacingDelayMicros int64 `json:"pacing_delay_micros"`
|
||||||
|
ProviderErrors int64 `json:"provider_errors"`
|
||||||
|
InputRejected int64 `json:"input_rejected"`
|
||||||
|
ControlRttMicros int64 `json:"control_rtt_micros"`
|
||||||
|
ControlJitterMicros int64 `json:"control_jitter_micros"`
|
||||||
|
ControlLossPpm int64 `json:"control_loss_ppm"`
|
||||||
|
PendingReliable int64 `json:"pending_reliable"`
|
||||||
|
ProviderState string `json:"provider_state"`
|
||||||
|
}
|
||||||
|
|
||||||
type GrantReference struct {
|
type GrantReference struct {
|
||||||
OpaqueValue string `json:"opaque_value"`
|
OpaqueValue string `json:"opaque_value"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
@@ -241,23 +287,26 @@ type PageInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProviderSessionWork struct {
|
type ProviderSessionWork struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
GatewayID string `json:"gateway_id"`
|
GatewayID string `json:"gateway_id"`
|
||||||
ReconnectSequence int64 `json:"reconnect_sequence"`
|
ReconnectSequence int64 `json:"reconnect_sequence"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
ProviderProfile string `json:"provider_profile"`
|
ProviderProfile string `json:"provider_profile"`
|
||||||
ProviderIdentity string `json:"provider_identity"`
|
ProviderIdentity string `json:"provider_identity"`
|
||||||
PolicyVersionID string `json:"policy_version_id"`
|
PolicyVersionID string `json:"policy_version_id"`
|
||||||
ApplicationID string `json:"application_id"`
|
StreamPolicy ProviderStreamPolicy `json:"stream_policy"`
|
||||||
ClientID string `json:"client_id"`
|
ApplicationID string `json:"application_id"`
|
||||||
ManagementHost string `json:"management_host"`
|
ClientID string `json:"client_id"`
|
||||||
ManagementPort int64 `json:"management_port"`
|
ManagementHost string `json:"management_host"`
|
||||||
StreamHost string `json:"stream_host"`
|
ManagementPort int64 `json:"management_port"`
|
||||||
StreamPort int64 `json:"stream_port"`
|
StreamHost string `json:"stream_host"`
|
||||||
ClientCertificatePem string `json:"client_certificate_pem"`
|
StreamPort int64 `json:"stream_port"`
|
||||||
ClientPrivateKeyPem string `json:"client_private_key_pem"`
|
ClientCertificatePem string `json:"client_certificate_pem"`
|
||||||
ServerCertificatePem string `json:"server_certificate_pem"`
|
ClientPrivateKeyPem string `json:"client_private_key_pem"`
|
||||||
|
ServerCertificatePem string `json:"server_certificate_pem"`
|
||||||
|
ClipboardPolicy ClipboardPolicy `json:"clipboard_policy"`
|
||||||
|
ProviderApplicationTerminationAllowed bool `json:"provider_application_termination_allowed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderState struct {
|
type ProviderState struct {
|
||||||
@@ -268,6 +317,15 @@ type ProviderState struct {
|
|||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderStreamPolicy struct {
|
||||||
|
ResolutionWidth int64 `json:"resolution_width"`
|
||||||
|
ResolutionHeight int64 `json:"resolution_height"`
|
||||||
|
Fps int64 `json:"fps"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
BitrateKbps int64 `json:"bitrate_kbps"`
|
||||||
|
AudioEnabled bool `json:"audio_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
type ReauthGrant struct {
|
type ReauthGrant struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Purpose string `json:"purpose"`
|
Purpose string `json:"purpose"`
|
||||||
@@ -826,14 +884,26 @@ func (v CapabilityProfile) Validate() error {
|
|||||||
if len(v.SourceRateControl) > 64 {
|
if len(v.SourceRateControl) > 64 {
|
||||||
violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "source_rate_control", Code: "max_length"})
|
||||||
}
|
}
|
||||||
if v.ClientDecode == "" {
|
if v.ClientDecode == nil {
|
||||||
violations = append(violations, FieldViolation{Field: "client_decode", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "client_decode", Code: "required"})
|
||||||
}
|
}
|
||||||
if len(v.ClientDecode) < 1 && v.ClientDecode != "" {
|
if len(v.ClientDecode) < 1 {
|
||||||
violations = append(violations, FieldViolation{Field: "client_decode", Code: "min_length"})
|
violations = append(violations, FieldViolation{Field: "client_decode", Code: "min_items"})
|
||||||
}
|
}
|
||||||
if len(v.ClientDecode) > 64 {
|
if len(v.ClientDecode) > 2 {
|
||||||
violations = append(violations, FieldViolation{Field: "client_decode", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "client_decode", Code: "max_items"})
|
||||||
|
}
|
||||||
|
for _, item := range v.ClientDecode {
|
||||||
|
if !(item == "h264-opus" || item == "hevc-opus") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_decode", Code: "invalid_item"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, item := range v.ClientDecode {
|
||||||
|
for prior := 0; prior < index; prior++ {
|
||||||
|
if item == v.ClientDecode[prior] {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_decode", Code: "duplicate_item"})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
@@ -943,6 +1013,9 @@ func (v ChannelFrame) Validate() error {
|
|||||||
if len(v.Payload) > 87384 {
|
if len(v.Payload) > 87384 {
|
||||||
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if len(v.Payload) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "payload", Code: "max_bytes"})
|
||||||
|
}
|
||||||
if v.FragmentIndex >= v.FragmentCount {
|
if v.FragmentIndex >= v.FragmentCount {
|
||||||
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
|
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
|
||||||
}
|
}
|
||||||
@@ -985,9 +1058,6 @@ func DecodeChannelFrame(data []byte) (ChannelFrame, error) {
|
|||||||
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
}
|
}
|
||||||
if raw, ok := fields["payload"]; ok && len(raw) > 65536 {
|
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "max_bytes"}}}
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
@@ -1013,6 +1083,78 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v ClipboardPolicy) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.MaxTextBytes == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.MaxTextBytes != 0 && v.MaxTextBytes < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.MaxTextBytes > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.MaxUpdatesPerMinute == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.MaxUpdatesPerMinute != 0 && v.MaxUpdatesPerMinute < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.MaxUpdatesPerMinute > 120 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeClipboardPolicy(data []byte) (ClipboardPolicy, error) {
|
||||||
|
var value ClipboardPolicy
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["client_to_provider_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_to_provider_enabled", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["max_text_bytes"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "max_text_bytes", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["max_updates_per_minute"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "max_updates_per_minute", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider_to_client_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_to_client_enabled", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeClipboardPolicy(value ClipboardPolicy) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v ClipboardText) Validate() error {
|
func (v ClipboardText) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Text == "" {
|
if v.Text == "" {
|
||||||
@@ -1934,6 +2076,194 @@ func EncodeFieldViolation(value FieldViolation) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v GatewayClipboardAudit) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Version == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Version != "1" && v.Version != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.SessionID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) < 1 && v.SessionID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Direction == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "direction", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Direction != "" && !(v.Direction == "client_to_provider" || v.Direction == "provider_to_client") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "direction", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.Outcome == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "outcome", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Outcome != "" && !(v.Outcome == "forwarded" || v.Outcome == "suppressed" || v.Outcome == "rejected") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "outcome", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.TextBytes != 0 && v.TextBytes < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text_bytes", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.TextBytes > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text_bytes", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.Reason == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reason", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Reason != "" && !(v.Reason == "forwarded" || v.Reason == "loop" || v.Reason == "policy" || v.Reason == "rate" || v.Reason == "provider" || v.Reason == "malformed") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reason", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeGatewayClipboardAudit(data []byte) (GatewayClipboardAudit, error) {
|
||||||
|
var value GatewayClipboardAudit
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["direction"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "direction", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["outcome"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "outcome", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["reason"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "reason", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["text_bytes"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "text_bytes", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeGatewayClipboardAudit(value GatewayClipboardAudit) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v GatewayClipboardText) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Direction == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "direction", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Direction != "" && !(v.Direction == "client_to_provider" || v.Direction == "provider_to_client") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "direction", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.Text == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Text) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if len(v.Text) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text", Code: "max_bytes"})
|
||||||
|
}
|
||||||
|
if v.Encoding == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "encoding", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Encoding != "utf-8" && v.Encoding != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "encoding", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.LoopToken == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.LoopToken) < 16 && v.LoopToken != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.LoopToken) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.LoopToken != "" {
|
||||||
|
if _, err := base64.RawURLEncoding.Strict().DecodeString(v.LoopToken); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "invalid_format"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeGatewayClipboardText(data []byte) (GatewayClipboardText, error) {
|
||||||
|
var value GatewayClipboardText
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["direction"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "direction", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["encoding"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "encoding", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["loop_token"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "loop_token", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["text"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeGatewayClipboardText(value GatewayClipboardText) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v GatewayDrain) Validate() error {
|
func (v GatewayDrain) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Version == "" {
|
if v.Version == "" {
|
||||||
@@ -2084,6 +2414,12 @@ func (v GatewayHeartbeat) Validate() error {
|
|||||||
if v.State != "" && !(v.State == "ready" || v.State == "draining" || v.State == "offline") {
|
if v.State != "" && !(v.State == "ready" || v.State == "draining" || v.State == "offline") {
|
||||||
violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"})
|
violations = append(violations, FieldViolation{Field: "state", Code: "invalid_value"})
|
||||||
}
|
}
|
||||||
|
if reflect.DeepEqual(v.Telemetry, GatewayTelemetry{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "telemetry", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.Telemetry.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "telemetry", Code: "invalid_object"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -2117,6 +2453,9 @@ func DecodeGatewayHeartbeat(data []byte) (GatewayHeartbeat, error) {
|
|||||||
if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "state", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["telemetry"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "telemetry", Code: "required"}}}
|
||||||
|
}
|
||||||
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
}
|
}
|
||||||
@@ -2337,6 +2676,210 @@ func EncodeGatewayRegistration(value GatewayRegistration) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v GatewayTelemetry) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.AdmittedSessions != 0 && v.AdmittedSessions < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "admitted_sessions", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.AdmittedSessions > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "admitted_sessions", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.AdmissionRejects != 0 && v.AdmissionRejects < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "admission_rejects", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.AdmissionRejects > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "admission_rejects", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.Reconnects != 0 && v.Reconnects < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reconnects", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.Reconnects > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reconnects", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.DrainTransitions != 0 && v.DrainTransitions < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "drain_transitions", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.DrainTransitions > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "drain_transitions", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.MediaDrops != 0 && v.MediaDrops < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_drops", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.MediaDrops > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_drops", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.MediaPackets != 0 && v.MediaPackets < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_packets", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.MediaPackets > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_packets", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.MediaBytes != 0 && v.MediaBytes < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_bytes", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.MediaBytes > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "media_bytes", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.QueueDelayMicros != 0 && v.QueueDelayMicros < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "queue_delay_micros", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.QueueDelayMicros > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "queue_delay_micros", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ProcessingDelayMicros != 0 && v.ProcessingDelayMicros < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "processing_delay_micros", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ProcessingDelayMicros > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "processing_delay_micros", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ProcessingSamples != 0 && v.ProcessingSamples < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "processing_samples", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ProcessingSamples > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "processing_samples", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.PacingDelayMicros != 0 && v.PacingDelayMicros < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "pacing_delay_micros", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.PacingDelayMicros > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "pacing_delay_micros", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ProviderErrors != 0 && v.ProviderErrors < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_errors", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ProviderErrors > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_errors", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.InputRejected != 0 && v.InputRejected < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "input_rejected", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.InputRejected > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "input_rejected", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ControlRttMicros != 0 && v.ControlRttMicros < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_rtt_micros", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ControlRttMicros > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_rtt_micros", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ControlJitterMicros != 0 && v.ControlJitterMicros < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_jitter_micros", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ControlJitterMicros > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_jitter_micros", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ControlLossPpm != 0 && v.ControlLossPpm < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_loss_ppm", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ControlLossPpm > 1000000 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "control_loss_ppm", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.PendingReliable != 0 && v.PendingReliable < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "pending_reliable", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.PendingReliable > 9223372036854775807 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "pending_reliable", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ProviderState == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_state", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ProviderState != "" && !(v.ProviderState == "unknown" || v.ProviderState == "starting" || v.ProviderState == "ready" || v.ProviderState == "disconnected" || v.ProviderState == "terminating" || v.ProviderState == "terminated" || v.ProviderState == "cleanup_pending" || v.ProviderState == "failed") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_state", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeGatewayTelemetry(data []byte) (GatewayTelemetry, error) {
|
||||||
|
var value GatewayTelemetry
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["admission_rejects"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "admission_rejects", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["admitted_sessions"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "admitted_sessions", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["control_jitter_micros"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "control_jitter_micros", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["control_loss_ppm"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "control_loss_ppm", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["control_rtt_micros"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "control_rtt_micros", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["drain_transitions"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "drain_transitions", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["input_rejected"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "input_rejected", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["media_bytes"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "media_bytes", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["media_drops"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "media_drops", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["media_packets"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "media_packets", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["pacing_delay_micros"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "pacing_delay_micros", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["pending_reliable"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "pending_reliable", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["processing_delay_micros"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "processing_delay_micros", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["processing_samples"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "processing_samples", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider_errors"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_errors", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider_state"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_state", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["queue_delay_micros"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "queue_delay_micros", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["reconnects"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnects", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeGatewayTelemetry(value GatewayTelemetry) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v GrantReference) Validate() error {
|
func (v GrantReference) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.OpaqueValue == "" {
|
if v.OpaqueValue == "" {
|
||||||
@@ -3001,6 +3544,12 @@ func (v ProviderSessionWork) Validate() error {
|
|||||||
if len(v.PolicyVersionID) > 128 {
|
if len(v.PolicyVersionID) > 128 {
|
||||||
violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if reflect.DeepEqual(v.StreamPolicy, ProviderStreamPolicy{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_policy", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.StreamPolicy.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_policy", Code: "invalid_object"})
|
||||||
|
}
|
||||||
if v.ApplicationID == "" {
|
if v.ApplicationID == "" {
|
||||||
violations = append(violations, FieldViolation{Field: "application_id", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "application_id", Code: "required"})
|
||||||
}
|
}
|
||||||
@@ -3082,6 +3631,12 @@ func (v ProviderSessionWork) Validate() error {
|
|||||||
if len(v.ServerCertificatePem) > 32768 {
|
if len(v.ServerCertificatePem) > 32768 {
|
||||||
violations = append(violations, FieldViolation{Field: "server_certificate_pem", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "server_certificate_pem", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if reflect.DeepEqual(v.ClipboardPolicy, ClipboardPolicy{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "clipboard_policy", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.ClipboardPolicy.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "clipboard_policy", Code: "invalid_object"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -3109,6 +3664,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
|
|||||||
if raw, ok := fields["client_private_key_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["client_private_key_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "client_private_key_pem", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_private_key_pem", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["clipboard_policy"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "clipboard_policy", Code: "required"}}}
|
||||||
|
}
|
||||||
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
||||||
}
|
}
|
||||||
@@ -3124,6 +3682,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
|
|||||||
if raw, ok := fields["policy_version_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["policy_version_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "policy_version_id", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "policy_version_id", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["provider_application_termination_allowed"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_application_termination_allowed", Code: "required"}}}
|
||||||
|
}
|
||||||
if raw, ok := fields["provider_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["provider_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}}
|
||||||
}
|
}
|
||||||
@@ -3142,6 +3703,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
|
|||||||
if raw, ok := fields["stream_host"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["stream_host"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_host", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_host", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["stream_policy"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_policy", Code: "required"}}}
|
||||||
|
}
|
||||||
if raw, ok := fields["stream_port"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["stream_port"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_port", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_port", Code: "required"}}}
|
||||||
}
|
}
|
||||||
@@ -3257,6 +3821,108 @@ func EncodeProviderState(value ProviderState) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v ProviderStreamPolicy) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.ResolutionWidth == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ResolutionWidth != 0 && v.ResolutionWidth < 320 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionWidth > 16384 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight != 0 && v.ResolutionHeight < 200 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight > 8640 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.Fps == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Fps != 0 && v.Fps < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.Fps > 240 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.Codec == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "codec", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Codec != "" && !(v.Codec == "H264" || v.Codec == "HEVC" || v.Codec == "AV1") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "codec", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.BitrateKbps == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.BitrateKbps != 0 && v.BitrateKbps < 100 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.BitrateKbps > 1000000 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "bitrate_kbps", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeProviderStreamPolicy(data []byte) (ProviderStreamPolicy, error) {
|
||||||
|
var value ProviderStreamPolicy
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["audio_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "audio_enabled", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["bitrate_kbps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "bitrate_kbps", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["codec"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "codec", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["fps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "fps", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["resolution_height"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_height", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["resolution_width"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_width", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeProviderStreamPolicy(value ProviderStreamPolicy) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v ReauthGrant) Validate() error {
|
func (v ReauthGrant) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Token == "" {
|
if v.Token == "" {
|
||||||
@@ -4336,16 +5002,32 @@ func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfi
|
|||||||
if err := selected.Validate(); err != nil {
|
if err := selected.Validate(); err != nil {
|
||||||
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
}
|
}
|
||||||
|
common := append([]string(nil), selected.ClientDecode...)
|
||||||
for _, profile := range profiles[1:] {
|
for _, profile := range profiles[1:] {
|
||||||
if err := profile.Validate(); err != nil || profile != selected {
|
if err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.Audio != selected.Audio || profile.SourceRateControl != selected.SourceRateControl {
|
||||||
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
|
}
|
||||||
|
next := common[:0]
|
||||||
|
for _, candidate := range common {
|
||||||
|
for _, offered := range profile.ClientDecode {
|
||||||
|
if candidate == offered {
|
||||||
|
next = append(next, candidate)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
common = next
|
||||||
|
if len(common) == 0 {
|
||||||
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selected.ClientDecode = common
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {
|
func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {
|
||||||
fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, v.Capabilities.ClientDecode}
|
fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, fmt.Sprintf("%d", len(v.Capabilities.ClientDecode))}
|
||||||
|
fields = append(fields, v.Capabilities.ClientDecode...)
|
||||||
var transcript strings.Builder
|
var transcript strings.Builder
|
||||||
transcript.WriteString("versevdi/tunnel-admission/v1")
|
transcript.WriteString("versevdi/tunnel-admission/v1")
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"2"
|
"2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab",
|
"generator_sha256": "00fdba050eb924a54dd3d63aac0a38560341b675f0de4e3e9631ee895057a9b6",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
|
"schema_sha256": "3aec8dd72bdbb6b9657c8df3160252c93034c7c1032d471e01eae2ef91e47716"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+268
-14
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
pub const SCHEMA_SHA256: &str = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480";
|
pub const SCHEMA_SHA256: &str = "3aec8dd72bdbb6b9657c8df3160252c93034c7c1032d471e01eae2ef91e47716";
|
||||||
pub const CURRENT_WIRE_VERSION: &str = "1";
|
pub const CURRENT_WIRE_VERSION: &str = "1";
|
||||||
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
|
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
|
||||||
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
|
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
|
||||||
@@ -10,6 +10,27 @@ pub type JsonObject = std::collections::BTreeMap<String, String>;
|
|||||||
pub struct ValidationError { pub field: &'static str, pub code: &'static str }
|
pub struct ValidationError { pub field: &'static str, pub code: &'static str }
|
||||||
impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }
|
impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }
|
||||||
|
|
||||||
|
fn base64url_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'A'..=b'Z' => Some(value - b'A'),
|
||||||
|
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||||
|
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||||
|
b'-' => Some(62),
|
||||||
|
b'_' => Some(63),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn valid_base64_url(value: &str) -> bool {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }
|
||||||
|
match bytes.len() % 4 {
|
||||||
|
0 => true,
|
||||||
|
2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,
|
||||||
|
3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AllocationPolicy {
|
pub struct AllocationPolicy {
|
||||||
minimumKbps: i64,
|
minimumKbps: i64,
|
||||||
@@ -189,11 +210,11 @@ pub struct CapabilityProfile {
|
|||||||
media: String,
|
media: String,
|
||||||
audio: String,
|
audio: String,
|
||||||
sourceRateControl: String,
|
sourceRateControl: String,
|
||||||
clientDecode: String,
|
clientDecode: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CapabilityProfile {
|
impl CapabilityProfile {
|
||||||
pub fn new(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: String) -> Result<Self, ValidationError> {
|
pub fn new(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: Vec<String>) -> Result<Self, ValidationError> {
|
||||||
let value = Self { transport, framing, media, audio, sourceRateControl, clientDecode };
|
let value = Self { transport, framing, media, audio, sourceRateControl, clientDecode };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
@@ -214,9 +235,10 @@ impl CapabilityProfile {
|
|||||||
if self.sourceRateControl.is_empty() { return Err(ValidationError::new("source_rate_control", "required")); }
|
if self.sourceRateControl.is_empty() { return Err(ValidationError::new("source_rate_control", "required")); }
|
||||||
if !self.sourceRateControl.is_empty() && self.sourceRateControl.len() < 1 { return Err(ValidationError::new("source_rate_control", "min_length")); }
|
if !self.sourceRateControl.is_empty() && self.sourceRateControl.len() < 1 { return Err(ValidationError::new("source_rate_control", "min_length")); }
|
||||||
if self.sourceRateControl.len() > 64 { return Err(ValidationError::new("source_rate_control", "max_length")); }
|
if self.sourceRateControl.len() > 64 { return Err(ValidationError::new("source_rate_control", "max_length")); }
|
||||||
if self.clientDecode.is_empty() { return Err(ValidationError::new("client_decode", "required")); }
|
if self.clientDecode.len() < 1 { return Err(ValidationError::new("client_decode", "min_items")); }
|
||||||
if !self.clientDecode.is_empty() && self.clientDecode.len() < 1 { return Err(ValidationError::new("client_decode", "min_length")); }
|
if self.clientDecode.len() > 2 { return Err(ValidationError::new("client_decode", "max_items")); }
|
||||||
if self.clientDecode.len() > 64 { return Err(ValidationError::new("client_decode", "max_length")); }
|
for item in self.clientDecode.iter() { if item != "h264-opus" && item != "hevc-opus" { return Err(ValidationError::new("client_decode", "invalid_item")); } }
|
||||||
|
for (index, item) in self.clientDecode.iter().enumerate() { if self.clientDecode[..index].contains(item) { return Err(ValidationError::new("client_decode", "duplicate_item")); } }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn transport(&self) -> &String { &self.transport }
|
pub fn transport(&self) -> &String { &self.transport }
|
||||||
@@ -224,7 +246,7 @@ impl CapabilityProfile {
|
|||||||
pub fn media(&self) -> &String { &self.media }
|
pub fn media(&self) -> &String { &self.media }
|
||||||
pub fn audio(&self) -> &String { &self.audio }
|
pub fn audio(&self) -> &String { &self.audio }
|
||||||
pub fn sourceRateControl(&self) -> &String { &self.sourceRateControl }
|
pub fn sourceRateControl(&self) -> &String { &self.sourceRateControl }
|
||||||
pub fn clientDecode(&self) -> &String { &self.clientDecode }
|
pub fn clientDecode(&self) -> &Vec<String> { &self.clientDecode }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -259,6 +281,7 @@ impl ChannelFrame {
|
|||||||
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
|
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
|
||||||
if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); }
|
if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); }
|
||||||
if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); }
|
if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); }
|
||||||
|
if self.payload.as_bytes().len() > 65536 { return Err(ValidationError::new("payload", "max_bytes")); }
|
||||||
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
|
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -272,6 +295,33 @@ impl ChannelFrame {
|
|||||||
pub fn payload(&self) -> &String { &self.payload }
|
pub fn payload(&self) -> &String { &self.payload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClipboardPolicy {
|
||||||
|
clientToProviderEnabled: bool,
|
||||||
|
providerToClientEnabled: bool,
|
||||||
|
maxTextBytes: i64,
|
||||||
|
maxUpdatesPerMinute: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardPolicy {
|
||||||
|
pub fn new(clientToProviderEnabled: bool, providerToClientEnabled: bool, maxTextBytes: i64, maxUpdatesPerMinute: i64) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { clientToProviderEnabled, providerToClientEnabled, maxTextBytes, maxUpdatesPerMinute };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.maxTextBytes < 1 { return Err(ValidationError::new("max_text_bytes", "minimum")); }
|
||||||
|
if self.maxTextBytes > 65536 { return Err(ValidationError::new("max_text_bytes", "maximum")); }
|
||||||
|
if self.maxUpdatesPerMinute < 1 { return Err(ValidationError::new("max_updates_per_minute", "minimum")); }
|
||||||
|
if self.maxUpdatesPerMinute > 120 { return Err(ValidationError::new("max_updates_per_minute", "maximum")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn clientToProviderEnabled(&self) -> &bool { &self.clientToProviderEnabled }
|
||||||
|
pub fn providerToClientEnabled(&self) -> &bool { &self.providerToClientEnabled }
|
||||||
|
pub fn maxTextBytes(&self) -> &i64 { &self.maxTextBytes }
|
||||||
|
pub fn maxUpdatesPerMinute(&self) -> &i64 { &self.maxUpdatesPerMinute }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClipboardText {
|
pub struct ClipboardText {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -612,6 +662,73 @@ impl FieldViolation {
|
|||||||
pub fn code(&self) -> &String { &self.code }
|
pub fn code(&self) -> &String { &self.code }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GatewayClipboardAudit {
|
||||||
|
version: String,
|
||||||
|
sessionId: String,
|
||||||
|
direction: String,
|
||||||
|
outcome: String,
|
||||||
|
textBytes: i64,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayClipboardAudit {
|
||||||
|
pub fn new(version: String, sessionId: String, direction: String, outcome: String, textBytes: i64, reason: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { version, sessionId, direction, outcome, textBytes, reason };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
|
||||||
|
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
|
||||||
|
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
|
||||||
|
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
||||||
|
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
|
||||||
|
if self.outcome != "forwarded" && self.outcome != "suppressed" && self.outcome != "rejected" { return Err(ValidationError::new("outcome", "invalid_value")); }
|
||||||
|
if self.textBytes < 0 { return Err(ValidationError::new("text_bytes", "minimum")); }
|
||||||
|
if self.textBytes > 65536 { return Err(ValidationError::new("text_bytes", "maximum")); }
|
||||||
|
if self.reason != "forwarded" && self.reason != "loop" && self.reason != "policy" && self.reason != "rate" && self.reason != "provider" && self.reason != "malformed" { return Err(ValidationError::new("reason", "invalid_value")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn version(&self) -> &String { &self.version }
|
||||||
|
pub fn sessionId(&self) -> &String { &self.sessionId }
|
||||||
|
pub fn direction(&self) -> &String { &self.direction }
|
||||||
|
pub fn outcome(&self) -> &String { &self.outcome }
|
||||||
|
pub fn textBytes(&self) -> &i64 { &self.textBytes }
|
||||||
|
pub fn reason(&self) -> &String { &self.reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GatewayClipboardText {
|
||||||
|
direction: String,
|
||||||
|
text: String,
|
||||||
|
encoding: String,
|
||||||
|
loopToken: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayClipboardText {
|
||||||
|
pub fn new(direction: String, text: String, encoding: String, loopToken: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { direction, text, encoding, loopToken };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
|
||||||
|
if self.text.len() > 65536 { return Err(ValidationError::new("text", "max_length")); }
|
||||||
|
if self.text.as_bytes().len() > 65536 { return Err(ValidationError::new("text", "max_bytes")); }
|
||||||
|
if self.encoding != "utf-8" { return Err(ValidationError::new("encoding", "invalid_value")); }
|
||||||
|
if self.loopToken.is_empty() { return Err(ValidationError::new("loop_token", "required")); }
|
||||||
|
if !self.loopToken.is_empty() && self.loopToken.len() < 16 { return Err(ValidationError::new("loop_token", "min_length")); }
|
||||||
|
if self.loopToken.len() > 128 { return Err(ValidationError::new("loop_token", "max_length")); }
|
||||||
|
if !valid_base64_url(self.loopToken.as_str()) { return Err(ValidationError::new("loop_token", "invalid_format")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn direction(&self) -> &String { &self.direction }
|
||||||
|
pub fn text(&self) -> &String { &self.text }
|
||||||
|
pub fn encoding(&self) -> &String { &self.encoding }
|
||||||
|
pub fn loopToken(&self) -> &String { &self.loopToken }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct GatewayDrain {
|
pub struct GatewayDrain {
|
||||||
version: String,
|
version: String,
|
||||||
@@ -655,11 +772,12 @@ pub struct GatewayHeartbeat {
|
|||||||
activeConnections: i64,
|
activeConnections: i64,
|
||||||
egressKbps: i64,
|
egressKbps: i64,
|
||||||
state: String,
|
state: String,
|
||||||
|
telemetry: GatewayTelemetry,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayHeartbeat {
|
impl GatewayHeartbeat {
|
||||||
pub fn new(version: String, gatewayId: String, sequence: i64, observedAt: String, activeConnections: i64, egressKbps: i64, state: String) -> Result<Self, ValidationError> {
|
pub fn new(version: String, gatewayId: String, sequence: i64, observedAt: String, activeConnections: i64, egressKbps: i64, state: String, telemetry: GatewayTelemetry) -> Result<Self, ValidationError> {
|
||||||
let value = Self { version, gatewayId, sequence, observedAt, activeConnections, egressKbps, state };
|
let value = Self { version, gatewayId, sequence, observedAt, activeConnections, egressKbps, state, telemetry };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -675,6 +793,7 @@ impl GatewayHeartbeat {
|
|||||||
if self.egressKbps < 0 { return Err(ValidationError::new("egress_kbps", "minimum")); }
|
if self.egressKbps < 0 { return Err(ValidationError::new("egress_kbps", "minimum")); }
|
||||||
if self.egressKbps > 1000000000 { return Err(ValidationError::new("egress_kbps", "maximum")); }
|
if self.egressKbps > 1000000000 { return Err(ValidationError::new("egress_kbps", "maximum")); }
|
||||||
if self.state != "ready" && self.state != "draining" && self.state != "offline" { return Err(ValidationError::new("state", "invalid_value")); }
|
if self.state != "ready" && self.state != "draining" && self.state != "offline" { return Err(ValidationError::new("state", "invalid_value")); }
|
||||||
|
self.telemetry.validate().map_err(|_| ValidationError::new("telemetry", "invalid_object"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn version(&self) -> &String { &self.version }
|
pub fn version(&self) -> &String { &self.version }
|
||||||
@@ -684,6 +803,7 @@ impl GatewayHeartbeat {
|
|||||||
pub fn activeConnections(&self) -> &i64 { &self.activeConnections }
|
pub fn activeConnections(&self) -> &i64 { &self.activeConnections }
|
||||||
pub fn egressKbps(&self) -> &i64 { &self.egressKbps }
|
pub fn egressKbps(&self) -> &i64 { &self.egressKbps }
|
||||||
pub fn state(&self) -> &String { &self.state }
|
pub fn state(&self) -> &String { &self.state }
|
||||||
|
pub fn telemetry(&self) -> &GatewayTelemetry { &self.telemetry }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -757,6 +877,92 @@ impl GatewayRegistration {
|
|||||||
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GatewayTelemetry {
|
||||||
|
admittedSessions: i64,
|
||||||
|
admissionRejects: i64,
|
||||||
|
reconnects: i64,
|
||||||
|
drainTransitions: i64,
|
||||||
|
mediaDrops: i64,
|
||||||
|
mediaPackets: i64,
|
||||||
|
mediaBytes: i64,
|
||||||
|
queueDelayMicros: i64,
|
||||||
|
processingDelayMicros: i64,
|
||||||
|
processingSamples: i64,
|
||||||
|
pacingDelayMicros: i64,
|
||||||
|
providerErrors: i64,
|
||||||
|
inputRejected: i64,
|
||||||
|
controlRttMicros: i64,
|
||||||
|
controlJitterMicros: i64,
|
||||||
|
controlLossPpm: i64,
|
||||||
|
pendingReliable: i64,
|
||||||
|
providerState: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayTelemetry {
|
||||||
|
pub fn new(admittedSessions: i64, admissionRejects: i64, reconnects: i64, drainTransitions: i64, mediaDrops: i64, mediaPackets: i64, mediaBytes: i64, queueDelayMicros: i64, processingDelayMicros: i64, processingSamples: i64, pacingDelayMicros: i64, providerErrors: i64, inputRejected: i64, controlRttMicros: i64, controlJitterMicros: i64, controlLossPpm: i64, pendingReliable: i64, providerState: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { admittedSessions, admissionRejects, reconnects, drainTransitions, mediaDrops, mediaPackets, mediaBytes, queueDelayMicros, processingDelayMicros, processingSamples, pacingDelayMicros, providerErrors, inputRejected, controlRttMicros, controlJitterMicros, controlLossPpm, pendingReliable, providerState };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.admittedSessions < 0 { return Err(ValidationError::new("admitted_sessions", "minimum")); }
|
||||||
|
if self.admittedSessions > 9223372036854775807 { return Err(ValidationError::new("admitted_sessions", "maximum")); }
|
||||||
|
if self.admissionRejects < 0 { return Err(ValidationError::new("admission_rejects", "minimum")); }
|
||||||
|
if self.admissionRejects > 9223372036854775807 { return Err(ValidationError::new("admission_rejects", "maximum")); }
|
||||||
|
if self.reconnects < 0 { return Err(ValidationError::new("reconnects", "minimum")); }
|
||||||
|
if self.reconnects > 9223372036854775807 { return Err(ValidationError::new("reconnects", "maximum")); }
|
||||||
|
if self.drainTransitions < 0 { return Err(ValidationError::new("drain_transitions", "minimum")); }
|
||||||
|
if self.drainTransitions > 9223372036854775807 { return Err(ValidationError::new("drain_transitions", "maximum")); }
|
||||||
|
if self.mediaDrops < 0 { return Err(ValidationError::new("media_drops", "minimum")); }
|
||||||
|
if self.mediaDrops > 9223372036854775807 { return Err(ValidationError::new("media_drops", "maximum")); }
|
||||||
|
if self.mediaPackets < 0 { return Err(ValidationError::new("media_packets", "minimum")); }
|
||||||
|
if self.mediaPackets > 9223372036854775807 { return Err(ValidationError::new("media_packets", "maximum")); }
|
||||||
|
if self.mediaBytes < 0 { return Err(ValidationError::new("media_bytes", "minimum")); }
|
||||||
|
if self.mediaBytes > 9223372036854775807 { return Err(ValidationError::new("media_bytes", "maximum")); }
|
||||||
|
if self.queueDelayMicros < 0 { return Err(ValidationError::new("queue_delay_micros", "minimum")); }
|
||||||
|
if self.queueDelayMicros > 9223372036854775807 { return Err(ValidationError::new("queue_delay_micros", "maximum")); }
|
||||||
|
if self.processingDelayMicros < 0 { return Err(ValidationError::new("processing_delay_micros", "minimum")); }
|
||||||
|
if self.processingDelayMicros > 9223372036854775807 { return Err(ValidationError::new("processing_delay_micros", "maximum")); }
|
||||||
|
if self.processingSamples < 0 { return Err(ValidationError::new("processing_samples", "minimum")); }
|
||||||
|
if self.processingSamples > 9223372036854775807 { return Err(ValidationError::new("processing_samples", "maximum")); }
|
||||||
|
if self.pacingDelayMicros < 0 { return Err(ValidationError::new("pacing_delay_micros", "minimum")); }
|
||||||
|
if self.pacingDelayMicros > 9223372036854775807 { return Err(ValidationError::new("pacing_delay_micros", "maximum")); }
|
||||||
|
if self.providerErrors < 0 { return Err(ValidationError::new("provider_errors", "minimum")); }
|
||||||
|
if self.providerErrors > 9223372036854775807 { return Err(ValidationError::new("provider_errors", "maximum")); }
|
||||||
|
if self.inputRejected < 0 { return Err(ValidationError::new("input_rejected", "minimum")); }
|
||||||
|
if self.inputRejected > 9223372036854775807 { return Err(ValidationError::new("input_rejected", "maximum")); }
|
||||||
|
if self.controlRttMicros < 0 { return Err(ValidationError::new("control_rtt_micros", "minimum")); }
|
||||||
|
if self.controlRttMicros > 9223372036854775807 { return Err(ValidationError::new("control_rtt_micros", "maximum")); }
|
||||||
|
if self.controlJitterMicros < 0 { return Err(ValidationError::new("control_jitter_micros", "minimum")); }
|
||||||
|
if self.controlJitterMicros > 9223372036854775807 { return Err(ValidationError::new("control_jitter_micros", "maximum")); }
|
||||||
|
if self.controlLossPpm < 0 { return Err(ValidationError::new("control_loss_ppm", "minimum")); }
|
||||||
|
if self.controlLossPpm > 1000000 { return Err(ValidationError::new("control_loss_ppm", "maximum")); }
|
||||||
|
if self.pendingReliable < 0 { return Err(ValidationError::new("pending_reliable", "minimum")); }
|
||||||
|
if self.pendingReliable > 9223372036854775807 { return Err(ValidationError::new("pending_reliable", "maximum")); }
|
||||||
|
if self.providerState != "unknown" && self.providerState != "starting" && self.providerState != "ready" && self.providerState != "disconnected" && self.providerState != "terminating" && self.providerState != "terminated" && self.providerState != "cleanup_pending" && self.providerState != "failed" { return Err(ValidationError::new("provider_state", "invalid_value")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn admittedSessions(&self) -> &i64 { &self.admittedSessions }
|
||||||
|
pub fn admissionRejects(&self) -> &i64 { &self.admissionRejects }
|
||||||
|
pub fn reconnects(&self) -> &i64 { &self.reconnects }
|
||||||
|
pub fn drainTransitions(&self) -> &i64 { &self.drainTransitions }
|
||||||
|
pub fn mediaDrops(&self) -> &i64 { &self.mediaDrops }
|
||||||
|
pub fn mediaPackets(&self) -> &i64 { &self.mediaPackets }
|
||||||
|
pub fn mediaBytes(&self) -> &i64 { &self.mediaBytes }
|
||||||
|
pub fn queueDelayMicros(&self) -> &i64 { &self.queueDelayMicros }
|
||||||
|
pub fn processingDelayMicros(&self) -> &i64 { &self.processingDelayMicros }
|
||||||
|
pub fn processingSamples(&self) -> &i64 { &self.processingSamples }
|
||||||
|
pub fn pacingDelayMicros(&self) -> &i64 { &self.pacingDelayMicros }
|
||||||
|
pub fn providerErrors(&self) -> &i64 { &self.providerErrors }
|
||||||
|
pub fn inputRejected(&self) -> &i64 { &self.inputRejected }
|
||||||
|
pub fn controlRttMicros(&self) -> &i64 { &self.controlRttMicros }
|
||||||
|
pub fn controlJitterMicros(&self) -> &i64 { &self.controlJitterMicros }
|
||||||
|
pub fn controlLossPpm(&self) -> &i64 { &self.controlLossPpm }
|
||||||
|
pub fn pendingReliable(&self) -> &i64 { &self.pendingReliable }
|
||||||
|
pub fn providerState(&self) -> &String { &self.providerState }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct GrantReference {
|
pub struct GrantReference {
|
||||||
opaqueValue: String,
|
opaqueValue: String,
|
||||||
@@ -992,6 +1198,7 @@ pub struct ProviderSessionWork {
|
|||||||
providerProfile: String,
|
providerProfile: String,
|
||||||
providerIdentity: String,
|
providerIdentity: String,
|
||||||
policyVersionId: String,
|
policyVersionId: String,
|
||||||
|
streamPolicy: ProviderStreamPolicy,
|
||||||
applicationId: String,
|
applicationId: String,
|
||||||
clientId: String,
|
clientId: String,
|
||||||
managementHost: String,
|
managementHost: String,
|
||||||
@@ -1001,11 +1208,13 @@ pub struct ProviderSessionWork {
|
|||||||
clientCertificatePem: String,
|
clientCertificatePem: String,
|
||||||
clientPrivateKeyPem: String,
|
clientPrivateKeyPem: String,
|
||||||
serverCertificatePem: String,
|
serverCertificatePem: String,
|
||||||
|
clipboardPolicy: ClipboardPolicy,
|
||||||
|
providerApplicationTerminationAllowed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderSessionWork {
|
impl ProviderSessionWork {
|
||||||
pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: i64, streamHost: String, streamPort: i64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String) -> Result<Self, ValidationError> {
|
pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, streamPolicy: ProviderStreamPolicy, applicationId: String, clientId: String, managementHost: String, managementPort: i64, streamHost: String, streamPort: i64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String, clipboardPolicy: ClipboardPolicy, providerApplicationTerminationAllowed: bool) -> Result<Self, ValidationError> {
|
||||||
let value = Self { version, sessionId, gatewayId, reconnectSequence, expiresAt, providerProfile, providerIdentity, policyVersionId, applicationId, clientId, managementHost, managementPort, streamHost, streamPort, clientCertificatePem, clientPrivateKeyPem, serverCertificatePem };
|
let value = Self { version, sessionId, gatewayId, reconnectSequence, expiresAt, providerProfile, providerIdentity, policyVersionId, streamPolicy, applicationId, clientId, managementHost, managementPort, streamHost, streamPort, clientCertificatePem, clientPrivateKeyPem, serverCertificatePem, clipboardPolicy, providerApplicationTerminationAllowed };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1235,7 @@ impl ProviderSessionWork {
|
|||||||
if self.policyVersionId.is_empty() { return Err(ValidationError::new("policy_version_id", "required")); }
|
if self.policyVersionId.is_empty() { return Err(ValidationError::new("policy_version_id", "required")); }
|
||||||
if !self.policyVersionId.is_empty() && self.policyVersionId.len() < 1 { return Err(ValidationError::new("policy_version_id", "min_length")); }
|
if !self.policyVersionId.is_empty() && self.policyVersionId.len() < 1 { return Err(ValidationError::new("policy_version_id", "min_length")); }
|
||||||
if self.policyVersionId.len() > 128 { return Err(ValidationError::new("policy_version_id", "max_length")); }
|
if self.policyVersionId.len() > 128 { return Err(ValidationError::new("policy_version_id", "max_length")); }
|
||||||
|
self.streamPolicy.validate().map_err(|_| ValidationError::new("stream_policy", "invalid_object"))?;
|
||||||
if self.applicationId.is_empty() { return Err(ValidationError::new("application_id", "required")); }
|
if self.applicationId.is_empty() { return Err(ValidationError::new("application_id", "required")); }
|
||||||
if !self.applicationId.is_empty() && self.applicationId.len() < 1 { return Err(ValidationError::new("application_id", "min_length")); }
|
if !self.applicationId.is_empty() && self.applicationId.len() < 1 { return Err(ValidationError::new("application_id", "min_length")); }
|
||||||
if self.applicationId.len() > 128 { return Err(ValidationError::new("application_id", "max_length")); }
|
if self.applicationId.len() > 128 { return Err(ValidationError::new("application_id", "max_length")); }
|
||||||
@@ -1051,6 +1261,7 @@ impl ProviderSessionWork {
|
|||||||
if self.serverCertificatePem.is_empty() { return Err(ValidationError::new("server_certificate_pem", "required")); }
|
if self.serverCertificatePem.is_empty() { return Err(ValidationError::new("server_certificate_pem", "required")); }
|
||||||
if !self.serverCertificatePem.is_empty() && self.serverCertificatePem.len() < 1 { return Err(ValidationError::new("server_certificate_pem", "min_length")); }
|
if !self.serverCertificatePem.is_empty() && self.serverCertificatePem.len() < 1 { return Err(ValidationError::new("server_certificate_pem", "min_length")); }
|
||||||
if self.serverCertificatePem.len() > 32768 { return Err(ValidationError::new("server_certificate_pem", "max_length")); }
|
if self.serverCertificatePem.len() > 32768 { return Err(ValidationError::new("server_certificate_pem", "max_length")); }
|
||||||
|
self.clipboardPolicy.validate().map_err(|_| ValidationError::new("clipboard_policy", "invalid_object"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn version(&self) -> &String { &self.version }
|
pub fn version(&self) -> &String { &self.version }
|
||||||
@@ -1061,6 +1272,7 @@ impl ProviderSessionWork {
|
|||||||
pub fn providerProfile(&self) -> &String { &self.providerProfile }
|
pub fn providerProfile(&self) -> &String { &self.providerProfile }
|
||||||
pub fn providerIdentity(&self) -> &String { &self.providerIdentity }
|
pub fn providerIdentity(&self) -> &String { &self.providerIdentity }
|
||||||
pub fn policyVersionId(&self) -> &String { &self.policyVersionId }
|
pub fn policyVersionId(&self) -> &String { &self.policyVersionId }
|
||||||
|
pub fn streamPolicy(&self) -> &ProviderStreamPolicy { &self.streamPolicy }
|
||||||
pub fn applicationId(&self) -> &String { &self.applicationId }
|
pub fn applicationId(&self) -> &String { &self.applicationId }
|
||||||
pub fn clientId(&self) -> &String { &self.clientId }
|
pub fn clientId(&self) -> &String { &self.clientId }
|
||||||
pub fn managementHost(&self) -> &String { &self.managementHost }
|
pub fn managementHost(&self) -> &String { &self.managementHost }
|
||||||
@@ -1070,6 +1282,8 @@ impl ProviderSessionWork {
|
|||||||
pub fn clientCertificatePem(&self) -> &String { &self.clientCertificatePem }
|
pub fn clientCertificatePem(&self) -> &String { &self.clientCertificatePem }
|
||||||
pub fn clientPrivateKeyPem(&self) -> &String { &self.clientPrivateKeyPem }
|
pub fn clientPrivateKeyPem(&self) -> &String { &self.clientPrivateKeyPem }
|
||||||
pub fn serverCertificatePem(&self) -> &String { &self.serverCertificatePem }
|
pub fn serverCertificatePem(&self) -> &String { &self.serverCertificatePem }
|
||||||
|
pub fn clipboardPolicy(&self) -> &ClipboardPolicy { &self.clipboardPolicy }
|
||||||
|
pub fn providerApplicationTerminationAllowed(&self) -> &bool { &self.providerApplicationTerminationAllowed }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -1103,6 +1317,42 @@ impl ProviderState {
|
|||||||
pub fn channels(&self) -> &Vec<String> { &self.channels }
|
pub fn channels(&self) -> &Vec<String> { &self.channels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderStreamPolicy {
|
||||||
|
resolutionWidth: i64,
|
||||||
|
resolutionHeight: i64,
|
||||||
|
fps: i64,
|
||||||
|
codec: String,
|
||||||
|
bitrateKbps: i64,
|
||||||
|
audioEnabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderStreamPolicy {
|
||||||
|
pub fn new(resolutionWidth: i64, resolutionHeight: i64, fps: i64, codec: String, bitrateKbps: i64, audioEnabled: bool) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { resolutionWidth, resolutionHeight, fps, codec, bitrateKbps, audioEnabled };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.resolutionWidth < 320 { return Err(ValidationError::new("resolution_width", "minimum")); }
|
||||||
|
if self.resolutionWidth > 16384 { return Err(ValidationError::new("resolution_width", "maximum")); }
|
||||||
|
if self.resolutionHeight < 200 { return Err(ValidationError::new("resolution_height", "minimum")); }
|
||||||
|
if self.resolutionHeight > 8640 { return Err(ValidationError::new("resolution_height", "maximum")); }
|
||||||
|
if self.fps < 1 { return Err(ValidationError::new("fps", "minimum")); }
|
||||||
|
if self.fps > 240 { return Err(ValidationError::new("fps", "maximum")); }
|
||||||
|
if self.codec != "H264" && self.codec != "HEVC" && self.codec != "AV1" { return Err(ValidationError::new("codec", "invalid_value")); }
|
||||||
|
if self.bitrateKbps < 100 { return Err(ValidationError::new("bitrate_kbps", "minimum")); }
|
||||||
|
if self.bitrateKbps > 1000000 { return Err(ValidationError::new("bitrate_kbps", "maximum")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn resolutionWidth(&self) -> &i64 { &self.resolutionWidth }
|
||||||
|
pub fn resolutionHeight(&self) -> &i64 { &self.resolutionHeight }
|
||||||
|
pub fn fps(&self) -> &i64 { &self.fps }
|
||||||
|
pub fn codec(&self) -> &String { &self.codec }
|
||||||
|
pub fn bitrateKbps(&self) -> &i64 { &self.bitrateKbps }
|
||||||
|
pub fn audioEnabled(&self) -> &bool { &self.audioEnabled }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ReauthGrant {
|
pub struct ReauthGrant {
|
||||||
token: String,
|
token: String,
|
||||||
@@ -1480,7 +1730,9 @@ impl TunnelAdmissionRequest {
|
|||||||
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
||||||
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 fields = [&self.sessionId, &self.gatewayId, &self.audience, &self.grant, &reconnect_sequence, &self.clientNonce, &self.capabilities.transport, &self.capabilities.framing, &self.capabilities.media, &self.capabilities.audio, &self.capabilities.sourceRateControl, &self.capabilities.clientDecode];
|
let client_decode_count = self.capabilities.clientDecode.len().to_string();
|
||||||
|
let mut fields = vec![self.sessionId.as_str(), self.gatewayId.as_str(), self.audience.as_str(), self.grant.as_str(), reconnect_sequence.as_str(), self.clientNonce.as_str(), self.capabilities.transport.as_str(), self.capabilities.framing.as_str(), self.capabilities.media.as_str(), self.capabilities.audio.as_str(), self.capabilities.sourceRateControl.as_str(), client_decode_count.as_str()];
|
||||||
|
fields.extend(self.capabilities.clientDecode.iter().map(String::as_str));
|
||||||
let 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()
|
||||||
@@ -1510,11 +1762,13 @@ impl VersionNegotiation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {
|
pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {
|
||||||
let 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 != &selected { return Err(ValidationError::new("capabilities", "no_overlap")); }
|
if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { return Err(ValidationError::new("capabilities", "no_overlap")); }
|
||||||
|
selected.clientDecode.retain(|candidate| profile.clientDecode.contains(candidate));
|
||||||
|
if selected.clientDecode.is_empty() { return Err(ValidationError::new("capabilities", "no_overlap")); }
|
||||||
}
|
}
|
||||||
Ok(selected)
|
Ok(selected)
|
||||||
}
|
}
|
||||||
|
|||||||
+333
-14
@@ -1,12 +1,21 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
import Foundation
|
import Foundation
|
||||||
public typealias JSONObject = [String: String]
|
public typealias JSONObject = [String: String]
|
||||||
public let schemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
|
public let schemaSHA256 = "3aec8dd72bdbb6b9657c8df3160252c93034c7c1032d471e01eae2ef91e47716"
|
||||||
public let currentWireVersion = "1"
|
public let currentWireVersion = "1"
|
||||||
public let nMinus1WireVersion = "0"
|
public let nMinus1WireVersion = "0"
|
||||||
public let nMinus2WireVersion = "-1"
|
public let nMinus2WireVersion = "-1"
|
||||||
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 validBase64URL(_ value: String) -> Bool {
|
||||||
|
guard !value.isEmpty, value.utf8.allSatisfy({ byte in
|
||||||
|
(byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95
|
||||||
|
}) else { return false }
|
||||||
|
let padding = String(repeating: "=", count: (4 - value.utf8.count % 4) % 4)
|
||||||
|
let standard = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + padding
|
||||||
|
guard let decoded = Data(base64Encoded: standard) else { return false }
|
||||||
|
return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value
|
||||||
|
}
|
||||||
|
|
||||||
public struct AllocationPolicy: Codable, Equatable {
|
public struct AllocationPolicy: Codable, Equatable {
|
||||||
public let minimumKbps: Int64
|
public let minimumKbps: Int64
|
||||||
@@ -238,7 +247,7 @@ public struct CapabilityProfile: Codable, Equatable {
|
|||||||
public let media: String
|
public let media: String
|
||||||
public let audio: String
|
public let audio: String
|
||||||
public let sourceRateControl: String
|
public let sourceRateControl: String
|
||||||
public let clientDecode: String
|
public let clientDecode: [String]
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case transport = "transport"
|
case transport = "transport"
|
||||||
case framing = "framing"
|
case framing = "framing"
|
||||||
@@ -248,7 +257,7 @@ public struct CapabilityProfile: Codable, Equatable {
|
|||||||
case clientDecode = "client_decode"
|
case clientDecode = "client_decode"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: String) throws {
|
public init(transport: String, framing: String, media: String, audio: String, sourceRateControl: String, clientDecode: [String]) throws {
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.framing = framing
|
self.framing = framing
|
||||||
self.media = media
|
self.media = media
|
||||||
@@ -262,7 +271,7 @@ public struct CapabilityProfile: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(transport: try c.decode(String.self, forKey: .transport), framing: try c.decode(String.self, forKey: .framing), media: try c.decode(String.self, forKey: .media), audio: try c.decode(String.self, forKey: .audio), sourceRateControl: try c.decode(String.self, forKey: .sourceRateControl), clientDecode: try c.decode(String.self, forKey: .clientDecode))
|
try self.init(transport: try c.decode(String.self, forKey: .transport), framing: try c.decode(String.self, forKey: .framing), media: try c.decode(String.self, forKey: .media), audio: try c.decode(String.self, forKey: .audio), sourceRateControl: try c.decode(String.self, forKey: .sourceRateControl), clientDecode: try c.decode([String].self, forKey: .clientDecode))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -281,9 +290,10 @@ public struct CapabilityProfile: Codable, Equatable {
|
|||||||
if self.sourceRateControl.isEmpty { throw ContractValidationError(field: "source_rate_control", code: "required") }
|
if self.sourceRateControl.isEmpty { throw ContractValidationError(field: "source_rate_control", code: "required") }
|
||||||
if !self.sourceRateControl.isEmpty && self.sourceRateControl.utf8.count < 1 { throw ContractValidationError(field: "source_rate_control", code: "min_length") }
|
if !self.sourceRateControl.isEmpty && self.sourceRateControl.utf8.count < 1 { throw ContractValidationError(field: "source_rate_control", code: "min_length") }
|
||||||
if self.sourceRateControl.utf8.count > 64 { throw ContractValidationError(field: "source_rate_control", code: "max_length") }
|
if self.sourceRateControl.utf8.count > 64 { throw ContractValidationError(field: "source_rate_control", code: "max_length") }
|
||||||
if self.clientDecode.isEmpty { throw ContractValidationError(field: "client_decode", code: "required") }
|
if self.clientDecode.count < 1 { throw ContractValidationError(field: "client_decode", code: "min_items") }
|
||||||
if !self.clientDecode.isEmpty && self.clientDecode.utf8.count < 1 { throw ContractValidationError(field: "client_decode", code: "min_length") }
|
if self.clientDecode.count > 2 { throw ContractValidationError(field: "client_decode", code: "max_items") }
|
||||||
if self.clientDecode.utf8.count > 64 { throw ContractValidationError(field: "client_decode", code: "max_length") }
|
for item in self.clientDecode where !["h264-opus", "hevc-opus"].contains(item) { throw ContractValidationError(field: "client_decode", code: "invalid_item") }
|
||||||
|
if Set(self.clientDecode).count != self.clientDecode.count { throw ContractValidationError(field: "client_decode", code: "duplicate_item") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -343,6 +353,7 @@ public struct ChannelFrame: Codable, Equatable {
|
|||||||
if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") }
|
if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") }
|
||||||
if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") }
|
if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") }
|
||||||
if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") }
|
if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") }
|
||||||
|
if self.payload.utf8.count > 65536 { throw ContractValidationError(field: "payload", code: "max_bytes") }
|
||||||
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
|
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +361,44 @@ public struct ChannelFrame: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClipboardPolicy: Codable, Equatable {
|
||||||
|
public let clientToProviderEnabled: Bool
|
||||||
|
public let providerToClientEnabled: Bool
|
||||||
|
public let maxTextBytes: Int64
|
||||||
|
public let maxUpdatesPerMinute: Int64
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientToProviderEnabled = "client_to_provider_enabled"
|
||||||
|
case providerToClientEnabled = "provider_to_client_enabled"
|
||||||
|
case maxTextBytes = "max_text_bytes"
|
||||||
|
case maxUpdatesPerMinute = "max_updates_per_minute"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(clientToProviderEnabled: Bool, providerToClientEnabled: Bool, maxTextBytes: Int64, maxUpdatesPerMinute: Int64) throws {
|
||||||
|
self.clientToProviderEnabled = clientToProviderEnabled
|
||||||
|
self.providerToClientEnabled = providerToClientEnabled
|
||||||
|
self.maxTextBytes = maxTextBytes
|
||||||
|
self.maxUpdatesPerMinute = maxUpdatesPerMinute
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(clientToProviderEnabled: try c.decode(Bool.self, forKey: .clientToProviderEnabled), providerToClientEnabled: try c.decode(Bool.self, forKey: .providerToClientEnabled), maxTextBytes: try c.decode(Int64.self, forKey: .maxTextBytes), maxUpdatesPerMinute: try c.decode(Int64.self, forKey: .maxUpdatesPerMinute))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.maxTextBytes < 1 { throw ContractValidationError(field: "max_text_bytes", code: "minimum") }
|
||||||
|
if self.maxTextBytes > 65536 { throw ContractValidationError(field: "max_text_bytes", code: "maximum") }
|
||||||
|
if self.maxUpdatesPerMinute < 1 { throw ContractValidationError(field: "max_updates_per_minute", code: "minimum") }
|
||||||
|
if self.maxUpdatesPerMinute > 120 { throw ContractValidationError(field: "max_updates_per_minute", code: "maximum") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClipboardText: Codable, Equatable {
|
public struct ClipboardText: Codable, Equatable {
|
||||||
public let text: String
|
public let text: String
|
||||||
public let encoding: String
|
public let encoding: String
|
||||||
@@ -809,6 +858,97 @@ public struct FieldViolation: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct GatewayClipboardAudit: Codable, Equatable {
|
||||||
|
public let version: String
|
||||||
|
public let sessionId: String
|
||||||
|
public let direction: String
|
||||||
|
public let outcome: String
|
||||||
|
public let textBytes: Int64
|
||||||
|
public let reason: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version = "version"
|
||||||
|
case sessionId = "session_id"
|
||||||
|
case direction = "direction"
|
||||||
|
case outcome = "outcome"
|
||||||
|
case textBytes = "text_bytes"
|
||||||
|
case reason = "reason"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(version: String, sessionId: String, direction: String, outcome: String, textBytes: Int64, reason: String) throws {
|
||||||
|
self.version = version
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.direction = direction
|
||||||
|
self.outcome = outcome
|
||||||
|
self.textBytes = textBytes
|
||||||
|
self.reason = reason
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), direction: try c.decode(String.self, forKey: .direction), outcome: try c.decode(String.self, forKey: .outcome), textBytes: try c.decode(Int64.self, forKey: .textBytes), reason: try c.decode(String.self, forKey: .reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
|
||||||
|
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
|
||||||
|
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
|
||||||
|
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
||||||
|
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
|
||||||
|
if !["forwarded", "suppressed", "rejected"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") }
|
||||||
|
if self.textBytes < 0 { throw ContractValidationError(field: "text_bytes", code: "minimum") }
|
||||||
|
if self.textBytes > 65536 { throw ContractValidationError(field: "text_bytes", code: "maximum") }
|
||||||
|
if !["forwarded", "loop", "policy", "rate", "provider", "malformed"].contains(self.reason) { throw ContractValidationError(field: "reason", code: "invalid_value") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GatewayClipboardText: Codable, Equatable {
|
||||||
|
public let direction: String
|
||||||
|
public let text: String
|
||||||
|
public let encoding: String
|
||||||
|
public let loopToken: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case direction = "direction"
|
||||||
|
case text = "text"
|
||||||
|
case encoding = "encoding"
|
||||||
|
case loopToken = "loop_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(direction: String, text: String, encoding: String, loopToken: String) throws {
|
||||||
|
self.direction = direction
|
||||||
|
self.text = text
|
||||||
|
self.encoding = encoding
|
||||||
|
self.loopToken = loopToken
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(direction: try c.decode(String.self, forKey: .direction), text: try c.decode(String.self, forKey: .text), encoding: try c.decode(String.self, forKey: .encoding), loopToken: try c.decode(String.self, forKey: .loopToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
|
||||||
|
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_length") }
|
||||||
|
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_bytes") }
|
||||||
|
if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") }
|
||||||
|
if self.loopToken.isEmpty { throw ContractValidationError(field: "loop_token", code: "required") }
|
||||||
|
if !self.loopToken.isEmpty && self.loopToken.utf8.count < 16 { throw ContractValidationError(field: "loop_token", code: "min_length") }
|
||||||
|
if self.loopToken.utf8.count > 128 { throw ContractValidationError(field: "loop_token", code: "max_length") }
|
||||||
|
if !validBase64URL(self.loopToken) { throw ContractValidationError(field: "loop_token", code: "invalid_format") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct GatewayDrain: Codable, Equatable {
|
public struct GatewayDrain: Codable, Equatable {
|
||||||
public let version: String
|
public let version: String
|
||||||
public let gatewayId: String
|
public let gatewayId: String
|
||||||
@@ -864,6 +1004,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
public let activeConnections: Int64
|
public let activeConnections: Int64
|
||||||
public let egressKbps: Int64
|
public let egressKbps: Int64
|
||||||
public let state: String
|
public let state: String
|
||||||
|
public let telemetry: GatewayTelemetry
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case version = "version"
|
case version = "version"
|
||||||
case gatewayId = "gateway_id"
|
case gatewayId = "gateway_id"
|
||||||
@@ -872,9 +1013,10 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
case activeConnections = "active_connections"
|
case activeConnections = "active_connections"
|
||||||
case egressKbps = "egress_kbps"
|
case egressKbps = "egress_kbps"
|
||||||
case state = "state"
|
case state = "state"
|
||||||
|
case telemetry = "telemetry"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(version: String, gatewayId: String, sequence: Int64, observedAt: String, activeConnections: Int64, egressKbps: Int64, state: String) throws {
|
public init(version: String, gatewayId: String, sequence: Int64, observedAt: String, activeConnections: Int64, egressKbps: Int64, state: String, telemetry: GatewayTelemetry) throws {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.gatewayId = gatewayId
|
self.gatewayId = gatewayId
|
||||||
self.sequence = sequence
|
self.sequence = sequence
|
||||||
@@ -882,6 +1024,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
self.activeConnections = activeConnections
|
self.activeConnections = activeConnections
|
||||||
self.egressKbps = egressKbps
|
self.egressKbps = egressKbps
|
||||||
self.state = state
|
self.state = state
|
||||||
|
self.telemetry = telemetry
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,7 +1032,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(version: try c.decode(String.self, forKey: .version), gatewayId: try c.decode(String.self, forKey: .gatewayId), sequence: try c.decode(Int64.self, forKey: .sequence), observedAt: try c.decode(String.self, forKey: .observedAt), activeConnections: try c.decode(Int64.self, forKey: .activeConnections), egressKbps: try c.decode(Int64.self, forKey: .egressKbps), state: try c.decode(String.self, forKey: .state))
|
try self.init(version: try c.decode(String.self, forKey: .version), gatewayId: try c.decode(String.self, forKey: .gatewayId), sequence: try c.decode(Int64.self, forKey: .sequence), observedAt: try c.decode(String.self, forKey: .observedAt), activeConnections: try c.decode(Int64.self, forKey: .activeConnections), egressKbps: try c.decode(Int64.self, forKey: .egressKbps), state: try c.decode(String.self, forKey: .state), telemetry: try c.decode(GatewayTelemetry.self, forKey: .telemetry))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -905,6 +1048,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
if self.egressKbps < 0 { throw ContractValidationError(field: "egress_kbps", code: "minimum") }
|
if self.egressKbps < 0 { throw ContractValidationError(field: "egress_kbps", code: "minimum") }
|
||||||
if self.egressKbps > 1000000000 { throw ContractValidationError(field: "egress_kbps", code: "maximum") }
|
if self.egressKbps > 1000000000 { throw ContractValidationError(field: "egress_kbps", code: "maximum") }
|
||||||
if !["ready", "draining", "offline"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") }
|
if !["ready", "draining", "offline"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") }
|
||||||
|
try self.telemetry.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1002,6 +1146,117 @@ public struct GatewayRegistration: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct GatewayTelemetry: Codable, Equatable {
|
||||||
|
public let admittedSessions: Int64
|
||||||
|
public let admissionRejects: Int64
|
||||||
|
public let reconnects: Int64
|
||||||
|
public let drainTransitions: Int64
|
||||||
|
public let mediaDrops: Int64
|
||||||
|
public let mediaPackets: Int64
|
||||||
|
public let mediaBytes: Int64
|
||||||
|
public let queueDelayMicros: Int64
|
||||||
|
public let processingDelayMicros: Int64
|
||||||
|
public let processingSamples: Int64
|
||||||
|
public let pacingDelayMicros: Int64
|
||||||
|
public let providerErrors: Int64
|
||||||
|
public let inputRejected: Int64
|
||||||
|
public let controlRttMicros: Int64
|
||||||
|
public let controlJitterMicros: Int64
|
||||||
|
public let controlLossPpm: Int64
|
||||||
|
public let pendingReliable: Int64
|
||||||
|
public let providerState: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case admittedSessions = "admitted_sessions"
|
||||||
|
case admissionRejects = "admission_rejects"
|
||||||
|
case reconnects = "reconnects"
|
||||||
|
case drainTransitions = "drain_transitions"
|
||||||
|
case mediaDrops = "media_drops"
|
||||||
|
case mediaPackets = "media_packets"
|
||||||
|
case mediaBytes = "media_bytes"
|
||||||
|
case queueDelayMicros = "queue_delay_micros"
|
||||||
|
case processingDelayMicros = "processing_delay_micros"
|
||||||
|
case processingSamples = "processing_samples"
|
||||||
|
case pacingDelayMicros = "pacing_delay_micros"
|
||||||
|
case providerErrors = "provider_errors"
|
||||||
|
case inputRejected = "input_rejected"
|
||||||
|
case controlRttMicros = "control_rtt_micros"
|
||||||
|
case controlJitterMicros = "control_jitter_micros"
|
||||||
|
case controlLossPpm = "control_loss_ppm"
|
||||||
|
case pendingReliable = "pending_reliable"
|
||||||
|
case providerState = "provider_state"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(admittedSessions: Int64, admissionRejects: Int64, reconnects: Int64, drainTransitions: Int64, mediaDrops: Int64, mediaPackets: Int64, mediaBytes: Int64, queueDelayMicros: Int64, processingDelayMicros: Int64, processingSamples: Int64, pacingDelayMicros: Int64, providerErrors: Int64, inputRejected: Int64, controlRttMicros: Int64, controlJitterMicros: Int64, controlLossPpm: Int64, pendingReliable: Int64, providerState: String) throws {
|
||||||
|
self.admittedSessions = admittedSessions
|
||||||
|
self.admissionRejects = admissionRejects
|
||||||
|
self.reconnects = reconnects
|
||||||
|
self.drainTransitions = drainTransitions
|
||||||
|
self.mediaDrops = mediaDrops
|
||||||
|
self.mediaPackets = mediaPackets
|
||||||
|
self.mediaBytes = mediaBytes
|
||||||
|
self.queueDelayMicros = queueDelayMicros
|
||||||
|
self.processingDelayMicros = processingDelayMicros
|
||||||
|
self.processingSamples = processingSamples
|
||||||
|
self.pacingDelayMicros = pacingDelayMicros
|
||||||
|
self.providerErrors = providerErrors
|
||||||
|
self.inputRejected = inputRejected
|
||||||
|
self.controlRttMicros = controlRttMicros
|
||||||
|
self.controlJitterMicros = controlJitterMicros
|
||||||
|
self.controlLossPpm = controlLossPpm
|
||||||
|
self.pendingReliable = pendingReliable
|
||||||
|
self.providerState = providerState
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(admittedSessions: try c.decode(Int64.self, forKey: .admittedSessions), admissionRejects: try c.decode(Int64.self, forKey: .admissionRejects), reconnects: try c.decode(Int64.self, forKey: .reconnects), drainTransitions: try c.decode(Int64.self, forKey: .drainTransitions), mediaDrops: try c.decode(Int64.self, forKey: .mediaDrops), mediaPackets: try c.decode(Int64.self, forKey: .mediaPackets), mediaBytes: try c.decode(Int64.self, forKey: .mediaBytes), queueDelayMicros: try c.decode(Int64.self, forKey: .queueDelayMicros), processingDelayMicros: try c.decode(Int64.self, forKey: .processingDelayMicros), processingSamples: try c.decode(Int64.self, forKey: .processingSamples), pacingDelayMicros: try c.decode(Int64.self, forKey: .pacingDelayMicros), providerErrors: try c.decode(Int64.self, forKey: .providerErrors), inputRejected: try c.decode(Int64.self, forKey: .inputRejected), controlRttMicros: try c.decode(Int64.self, forKey: .controlRttMicros), controlJitterMicros: try c.decode(Int64.self, forKey: .controlJitterMicros), controlLossPpm: try c.decode(Int64.self, forKey: .controlLossPpm), pendingReliable: try c.decode(Int64.self, forKey: .pendingReliable), providerState: try c.decode(String.self, forKey: .providerState))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.admittedSessions < 0 { throw ContractValidationError(field: "admitted_sessions", code: "minimum") }
|
||||||
|
if self.admittedSessions > 9223372036854775807 { throw ContractValidationError(field: "admitted_sessions", code: "maximum") }
|
||||||
|
if self.admissionRejects < 0 { throw ContractValidationError(field: "admission_rejects", code: "minimum") }
|
||||||
|
if self.admissionRejects > 9223372036854775807 { throw ContractValidationError(field: "admission_rejects", code: "maximum") }
|
||||||
|
if self.reconnects < 0 { throw ContractValidationError(field: "reconnects", code: "minimum") }
|
||||||
|
if self.reconnects > 9223372036854775807 { throw ContractValidationError(field: "reconnects", code: "maximum") }
|
||||||
|
if self.drainTransitions < 0 { throw ContractValidationError(field: "drain_transitions", code: "minimum") }
|
||||||
|
if self.drainTransitions > 9223372036854775807 { throw ContractValidationError(field: "drain_transitions", code: "maximum") }
|
||||||
|
if self.mediaDrops < 0 { throw ContractValidationError(field: "media_drops", code: "minimum") }
|
||||||
|
if self.mediaDrops > 9223372036854775807 { throw ContractValidationError(field: "media_drops", code: "maximum") }
|
||||||
|
if self.mediaPackets < 0 { throw ContractValidationError(field: "media_packets", code: "minimum") }
|
||||||
|
if self.mediaPackets > 9223372036854775807 { throw ContractValidationError(field: "media_packets", code: "maximum") }
|
||||||
|
if self.mediaBytes < 0 { throw ContractValidationError(field: "media_bytes", code: "minimum") }
|
||||||
|
if self.mediaBytes > 9223372036854775807 { throw ContractValidationError(field: "media_bytes", code: "maximum") }
|
||||||
|
if self.queueDelayMicros < 0 { throw ContractValidationError(field: "queue_delay_micros", code: "minimum") }
|
||||||
|
if self.queueDelayMicros > 9223372036854775807 { throw ContractValidationError(field: "queue_delay_micros", code: "maximum") }
|
||||||
|
if self.processingDelayMicros < 0 { throw ContractValidationError(field: "processing_delay_micros", code: "minimum") }
|
||||||
|
if self.processingDelayMicros > 9223372036854775807 { throw ContractValidationError(field: "processing_delay_micros", code: "maximum") }
|
||||||
|
if self.processingSamples < 0 { throw ContractValidationError(field: "processing_samples", code: "minimum") }
|
||||||
|
if self.processingSamples > 9223372036854775807 { throw ContractValidationError(field: "processing_samples", code: "maximum") }
|
||||||
|
if self.pacingDelayMicros < 0 { throw ContractValidationError(field: "pacing_delay_micros", code: "minimum") }
|
||||||
|
if self.pacingDelayMicros > 9223372036854775807 { throw ContractValidationError(field: "pacing_delay_micros", code: "maximum") }
|
||||||
|
if self.providerErrors < 0 { throw ContractValidationError(field: "provider_errors", code: "minimum") }
|
||||||
|
if self.providerErrors > 9223372036854775807 { throw ContractValidationError(field: "provider_errors", code: "maximum") }
|
||||||
|
if self.inputRejected < 0 { throw ContractValidationError(field: "input_rejected", code: "minimum") }
|
||||||
|
if self.inputRejected > 9223372036854775807 { throw ContractValidationError(field: "input_rejected", code: "maximum") }
|
||||||
|
if self.controlRttMicros < 0 { throw ContractValidationError(field: "control_rtt_micros", code: "minimum") }
|
||||||
|
if self.controlRttMicros > 9223372036854775807 { throw ContractValidationError(field: "control_rtt_micros", code: "maximum") }
|
||||||
|
if self.controlJitterMicros < 0 { throw ContractValidationError(field: "control_jitter_micros", code: "minimum") }
|
||||||
|
if self.controlJitterMicros > 9223372036854775807 { throw ContractValidationError(field: "control_jitter_micros", code: "maximum") }
|
||||||
|
if self.controlLossPpm < 0 { throw ContractValidationError(field: "control_loss_ppm", code: "minimum") }
|
||||||
|
if self.controlLossPpm > 1000000 { throw ContractValidationError(field: "control_loss_ppm", code: "maximum") }
|
||||||
|
if self.pendingReliable < 0 { throw ContractValidationError(field: "pending_reliable", code: "minimum") }
|
||||||
|
if self.pendingReliable > 9223372036854775807 { throw ContractValidationError(field: "pending_reliable", code: "maximum") }
|
||||||
|
if !["unknown", "starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"].contains(self.providerState) { throw ContractValidationError(field: "provider_state", code: "invalid_value") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct GrantReference: Codable, Equatable {
|
public struct GrantReference: Codable, Equatable {
|
||||||
public let opaqueValue: String
|
public let opaqueValue: String
|
||||||
public let expiresAt: String
|
public let expiresAt: String
|
||||||
@@ -1319,6 +1574,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
public let providerProfile: String
|
public let providerProfile: String
|
||||||
public let providerIdentity: String
|
public let providerIdentity: String
|
||||||
public let policyVersionId: String
|
public let policyVersionId: String
|
||||||
|
public let streamPolicy: ProviderStreamPolicy
|
||||||
public let applicationId: String
|
public let applicationId: String
|
||||||
public let clientId: String
|
public let clientId: String
|
||||||
public let managementHost: String
|
public let managementHost: String
|
||||||
@@ -1328,6 +1584,8 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
public let clientCertificatePem: String
|
public let clientCertificatePem: String
|
||||||
public let clientPrivateKeyPem: String
|
public let clientPrivateKeyPem: String
|
||||||
public let serverCertificatePem: String
|
public let serverCertificatePem: String
|
||||||
|
public let clipboardPolicy: ClipboardPolicy
|
||||||
|
public let providerApplicationTerminationAllowed: Bool
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case version = "version"
|
case version = "version"
|
||||||
case sessionId = "session_id"
|
case sessionId = "session_id"
|
||||||
@@ -1337,6 +1595,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
case providerProfile = "provider_profile"
|
case providerProfile = "provider_profile"
|
||||||
case providerIdentity = "provider_identity"
|
case providerIdentity = "provider_identity"
|
||||||
case policyVersionId = "policy_version_id"
|
case policyVersionId = "policy_version_id"
|
||||||
|
case streamPolicy = "stream_policy"
|
||||||
case applicationId = "application_id"
|
case applicationId = "application_id"
|
||||||
case clientId = "client_id"
|
case clientId = "client_id"
|
||||||
case managementHost = "management_host"
|
case managementHost = "management_host"
|
||||||
@@ -1346,9 +1605,11 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
case clientCertificatePem = "client_certificate_pem"
|
case clientCertificatePem = "client_certificate_pem"
|
||||||
case clientPrivateKeyPem = "client_private_key_pem"
|
case clientPrivateKeyPem = "client_private_key_pem"
|
||||||
case serverCertificatePem = "server_certificate_pem"
|
case serverCertificatePem = "server_certificate_pem"
|
||||||
|
case clipboardPolicy = "clipboard_policy"
|
||||||
|
case providerApplicationTerminationAllowed = "provider_application_termination_allowed"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: Int64, streamHost: String, streamPort: Int64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String) throws {
|
public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, streamPolicy: ProviderStreamPolicy, applicationId: String, clientId: String, managementHost: String, managementPort: Int64, streamHost: String, streamPort: Int64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String, clipboardPolicy: ClipboardPolicy, providerApplicationTerminationAllowed: Bool) throws {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.gatewayId = gatewayId
|
self.gatewayId = gatewayId
|
||||||
@@ -1357,6 +1618,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
self.providerProfile = providerProfile
|
self.providerProfile = providerProfile
|
||||||
self.providerIdentity = providerIdentity
|
self.providerIdentity = providerIdentity
|
||||||
self.policyVersionId = policyVersionId
|
self.policyVersionId = policyVersionId
|
||||||
|
self.streamPolicy = streamPolicy
|
||||||
self.applicationId = applicationId
|
self.applicationId = applicationId
|
||||||
self.clientId = clientId
|
self.clientId = clientId
|
||||||
self.managementHost = managementHost
|
self.managementHost = managementHost
|
||||||
@@ -1366,6 +1628,8 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
self.clientCertificatePem = clientCertificatePem
|
self.clientCertificatePem = clientCertificatePem
|
||||||
self.clientPrivateKeyPem = clientPrivateKeyPem
|
self.clientPrivateKeyPem = clientPrivateKeyPem
|
||||||
self.serverCertificatePem = serverCertificatePem
|
self.serverCertificatePem = serverCertificatePem
|
||||||
|
self.clipboardPolicy = clipboardPolicy
|
||||||
|
self.providerApplicationTerminationAllowed = providerApplicationTerminationAllowed
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,7 +1637,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), applicationId: try c.decode(String.self, forKey: .applicationId), clientId: try c.decode(String.self, forKey: .clientId), managementHost: try c.decode(String.self, forKey: .managementHost), managementPort: try c.decode(Int64.self, forKey: .managementPort), streamHost: try c.decode(String.self, forKey: .streamHost), streamPort: try c.decode(Int64.self, forKey: .streamPort), clientCertificatePem: try c.decode(String.self, forKey: .clientCertificatePem), clientPrivateKeyPem: try c.decode(String.self, forKey: .clientPrivateKeyPem), serverCertificatePem: try c.decode(String.self, forKey: .serverCertificatePem))
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), streamPolicy: try c.decode(ProviderStreamPolicy.self, forKey: .streamPolicy), applicationId: try c.decode(String.self, forKey: .applicationId), clientId: try c.decode(String.self, forKey: .clientId), managementHost: try c.decode(String.self, forKey: .managementHost), managementPort: try c.decode(Int64.self, forKey: .managementPort), streamHost: try c.decode(String.self, forKey: .streamHost), streamPort: try c.decode(Int64.self, forKey: .streamPort), clientCertificatePem: try c.decode(String.self, forKey: .clientCertificatePem), clientPrivateKeyPem: try c.decode(String.self, forKey: .clientPrivateKeyPem), serverCertificatePem: try c.decode(String.self, forKey: .serverCertificatePem), clipboardPolicy: try c.decode(ClipboardPolicy.self, forKey: .clipboardPolicy), providerApplicationTerminationAllowed: try c.decode(Bool.self, forKey: .providerApplicationTerminationAllowed))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -1394,6 +1658,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
if self.policyVersionId.isEmpty { throw ContractValidationError(field: "policy_version_id", code: "required") }
|
if self.policyVersionId.isEmpty { throw ContractValidationError(field: "policy_version_id", code: "required") }
|
||||||
if !self.policyVersionId.isEmpty && self.policyVersionId.utf8.count < 1 { throw ContractValidationError(field: "policy_version_id", code: "min_length") }
|
if !self.policyVersionId.isEmpty && self.policyVersionId.utf8.count < 1 { throw ContractValidationError(field: "policy_version_id", code: "min_length") }
|
||||||
if self.policyVersionId.utf8.count > 128 { throw ContractValidationError(field: "policy_version_id", code: "max_length") }
|
if self.policyVersionId.utf8.count > 128 { throw ContractValidationError(field: "policy_version_id", code: "max_length") }
|
||||||
|
try self.streamPolicy.validate()
|
||||||
if self.applicationId.isEmpty { throw ContractValidationError(field: "application_id", code: "required") }
|
if self.applicationId.isEmpty { throw ContractValidationError(field: "application_id", code: "required") }
|
||||||
if !self.applicationId.isEmpty && self.applicationId.utf8.count < 1 { throw ContractValidationError(field: "application_id", code: "min_length") }
|
if !self.applicationId.isEmpty && self.applicationId.utf8.count < 1 { throw ContractValidationError(field: "application_id", code: "min_length") }
|
||||||
if self.applicationId.utf8.count > 128 { throw ContractValidationError(field: "application_id", code: "max_length") }
|
if self.applicationId.utf8.count > 128 { throw ContractValidationError(field: "application_id", code: "max_length") }
|
||||||
@@ -1419,6 +1684,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
if self.serverCertificatePem.isEmpty { throw ContractValidationError(field: "server_certificate_pem", code: "required") }
|
if self.serverCertificatePem.isEmpty { throw ContractValidationError(field: "server_certificate_pem", code: "required") }
|
||||||
if !self.serverCertificatePem.isEmpty && self.serverCertificatePem.utf8.count < 1 { throw ContractValidationError(field: "server_certificate_pem", code: "min_length") }
|
if !self.serverCertificatePem.isEmpty && self.serverCertificatePem.utf8.count < 1 { throw ContractValidationError(field: "server_certificate_pem", code: "min_length") }
|
||||||
if self.serverCertificatePem.utf8.count > 32768 { throw ContractValidationError(field: "server_certificate_pem", code: "max_length") }
|
if self.serverCertificatePem.utf8.count > 32768 { throw ContractValidationError(field: "server_certificate_pem", code: "max_length") }
|
||||||
|
try self.clipboardPolicy.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1468,6 +1734,55 @@ public struct ProviderState: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ProviderStreamPolicy: Codable, Equatable {
|
||||||
|
public let resolutionWidth: Int64
|
||||||
|
public let resolutionHeight: Int64
|
||||||
|
public let fps: Int64
|
||||||
|
public let codec: String
|
||||||
|
public let bitrateKbps: Int64
|
||||||
|
public let audioEnabled: Bool
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case resolutionWidth = "resolution_width"
|
||||||
|
case resolutionHeight = "resolution_height"
|
||||||
|
case fps = "fps"
|
||||||
|
case codec = "codec"
|
||||||
|
case bitrateKbps = "bitrate_kbps"
|
||||||
|
case audioEnabled = "audio_enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(resolutionWidth: Int64, resolutionHeight: Int64, fps: Int64, codec: String, bitrateKbps: Int64, audioEnabled: Bool) throws {
|
||||||
|
self.resolutionWidth = resolutionWidth
|
||||||
|
self.resolutionHeight = resolutionHeight
|
||||||
|
self.fps = fps
|
||||||
|
self.codec = codec
|
||||||
|
self.bitrateKbps = bitrateKbps
|
||||||
|
self.audioEnabled = audioEnabled
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(resolutionWidth: try c.decode(Int64.self, forKey: .resolutionWidth), resolutionHeight: try c.decode(Int64.self, forKey: .resolutionHeight), fps: try c.decode(Int64.self, forKey: .fps), codec: try c.decode(String.self, forKey: .codec), bitrateKbps: try c.decode(Int64.self, forKey: .bitrateKbps), audioEnabled: try c.decode(Bool.self, forKey: .audioEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.resolutionWidth < 320 { throw ContractValidationError(field: "resolution_width", code: "minimum") }
|
||||||
|
if self.resolutionWidth > 16384 { throw ContractValidationError(field: "resolution_width", code: "maximum") }
|
||||||
|
if self.resolutionHeight < 200 { throw ContractValidationError(field: "resolution_height", code: "minimum") }
|
||||||
|
if self.resolutionHeight > 8640 { throw ContractValidationError(field: "resolution_height", code: "maximum") }
|
||||||
|
if self.fps < 1 { throw ContractValidationError(field: "fps", code: "minimum") }
|
||||||
|
if self.fps > 240 { throw ContractValidationError(field: "fps", code: "maximum") }
|
||||||
|
if !["H264", "HEVC", "AV1"].contains(self.codec) { throw ContractValidationError(field: "codec", code: "invalid_value") }
|
||||||
|
if self.bitrateKbps < 100 { throw ContractValidationError(field: "bitrate_kbps", code: "minimum") }
|
||||||
|
if self.bitrateKbps > 1000000 { throw ContractValidationError(field: "bitrate_kbps", code: "maximum") }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct ReauthGrant: Codable, Equatable {
|
public struct ReauthGrant: Codable, Equatable {
|
||||||
public let token: String
|
public let token: String
|
||||||
public let purpose: String
|
public let purpose: String
|
||||||
@@ -2007,7 +2322,8 @@ public struct VersionNegotiation: Codable, Equatable {
|
|||||||
|
|
||||||
public extension TunnelAdmissionRequest {
|
public extension TunnelAdmissionRequest {
|
||||||
func deviceAdmissionTranscript() -> Data {
|
func deviceAdmissionTranscript() -> Data {
|
||||||
let fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, capabilities.clientDecode]
|
var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]
|
||||||
|
fields.append(contentsOf: capabilities.clientDecode)
|
||||||
var 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)
|
||||||
@@ -2018,10 +2334,13 @@ public extension CapabilityProfile {
|
|||||||
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
|
||||||
for profile in profiles.dropFirst() {
|
for profile in profiles.dropFirst() {
|
||||||
try profile.validate()
|
try profile.validate()
|
||||||
if profile != selected { throw ContractValidationError(field: "capabilities", code: "no_overlap") }
|
if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: "capabilities", code: "no_overlap") }
|
||||||
|
common = common.filter { profile.clientDecode.contains($0) }
|
||||||
|
if common.isEmpty { throw ContractValidationError(field: "capabilities", code: "no_overlap") }
|
||||||
}
|
}
|
||||||
return selected
|
return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, audio: selected.audio, sourceRateControl: selected.sourceRateControl, clientDecode: common)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The registered `input.sequenced.v1` flow has a payload bound but no typed
|
||||||
|
payload grammar. RC5 also has no provider-to-client envelope for host
|
||||||
|
termination, rumble, or HDR feedback. The Data Plane must translate these
|
||||||
|
states to the provider without exposing Apollo packet formats or credentials.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Define fixed, bounded, endian-explicit gateway payloads for keyboard, mouse,
|
||||||
|
UTF-8 text, and controller state.
|
||||||
|
- Define a reliable control envelope for provider feedback and termination.
|
||||||
|
- Define an explicit release operation for every pressed key/button/controller.
|
||||||
|
- Keep provider packet encodings and endpoint details internal to the Data Plane.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Touch, pen, motion, file transfer, binary clipboard, or provider-specific
|
||||||
|
packets.
|
||||||
|
- Changing RC5, moving an existing tag, or making the Connection Server parse
|
||||||
|
streaming input.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Define a new binary payload grammar beneath the existing registered flows.
|
||||||
|
This avoids changing the authenticated tunnel header while removing the
|
||||||
|
untyped `device`/opaque-payload ambiguity. JSON was rejected because input is
|
||||||
|
latency-sensitive and fixed binary bounds are simpler to validate before
|
||||||
|
allocation.
|
||||||
|
- Input events use explicit event kinds and fixed payload lengths except UTF-8
|
||||||
|
text, which is limited to one valid Unicode scalar value. This permits exact
|
||||||
|
provider translation and deterministic cleanup.
|
||||||
|
- Provider feedback and termination use the existing bidirectional reliable
|
||||||
|
control channel with a distinct magic, direction, type, and length. A new
|
||||||
|
channel was rejected because the existing channel is already authenticated,
|
||||||
|
reliable, and versioned.
|
||||||
|
- Protocol contents and fixtures are finalized before a new immutable release
|
||||||
|
candidate is created. RC5 remains an unchanged dependency for current
|
||||||
|
consumers until they explicitly adopt the new revision.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [New client adoption is required] -> retain RC5 unchanged and publish an
|
||||||
|
explicit capability/version mismatch before any provider allocation.
|
||||||
|
- [Provider feedback can be high rate] -> allow only termination, rumble, and
|
||||||
|
HDR payload types with fixed maximum sizes; other types reject.
|
||||||
|
- [Pressed-state loss during disconnect] -> gateway records accepted presses
|
||||||
|
and emits typed releases during cleanup before provider disconnect.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The RC5 tunnel register identifies a bounded sequenced-input flow but does not
|
||||||
|
define typed input state or provider-to-client feedback. The native Apollo
|
||||||
|
adapter cannot safely translate keyboard, mouse, UTF-8, controller, termination,
|
||||||
|
rumble, or HDR state from an untyped payload.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Define a typed, versioned gateway input envelope for keyboard, mouse, UTF-8,
|
||||||
|
and controller state.
|
||||||
|
- Define bounded provider-feedback and provider-termination envelopes on the
|
||||||
|
existing reliable control direction.
|
||||||
|
- Define a separate typed clipboard envelope and Server-owned per-session
|
||||||
|
direction, size, and rate policy so the gateway can prevent reflected loops
|
||||||
|
without exposing provider management material.
|
||||||
|
- Define release semantics so gateway cleanup can emit real provider key/button
|
||||||
|
releases without a synthetic provider command.
|
||||||
|
- Preserve RC5 unchanged; this change requires a new immutable Protocol version
|
||||||
|
after its fixtures and consumers are final.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `gateway-input-feedback`: Typed Phase 3C gateway input, provider feedback,
|
||||||
|
termination, and release envelopes.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Protocol control and datagram registries, schemas, fixtures, and generated
|
||||||
|
Go/Rust/Swift bindings.
|
||||||
|
- Data Plane gateway input/clipboard translation and host-feedback forwarding.
|
||||||
|
- Connection Server mints only immutable clipboard policy in authenticated
|
||||||
|
provider work; it neither receives clipboard bytes nor inspects provider
|
||||||
|
packet payloads.
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Typed sequenced input envelope
|
||||||
|
The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte
|
||||||
|
event kind, and one-byte payload length. It SHALL contain exactly one bounded
|
||||||
|
keyboard, mouse-button, relative-mouse, UTF-8 scalar, or controller-state
|
||||||
|
event. False keyboard/mouse state and zeroed controller state are explicit
|
||||||
|
releases. Multibyte integer fields SHALL be big-endian. Unknown kinds,
|
||||||
|
length mismatches, malformed UTF-8, unsupported controller indices, and
|
||||||
|
reserved fields SHALL be rejected before provider translation.
|
||||||
|
|
||||||
|
#### Scenario: Keyboard state change
|
||||||
|
- **WHEN** a client sends a valid keyboard press or release envelope
|
||||||
|
- **THEN** the gateway forwards the corresponding typed provider input on its
|
||||||
|
reliable keyboard channel and records the pressed state for cleanup.
|
||||||
|
|
||||||
|
#### Scenario: Invalid input envelope
|
||||||
|
- **WHEN** a client sends an envelope with an unknown event kind, invalid
|
||||||
|
length, malformed UTF-8 scalar, or nonzero reserved field
|
||||||
|
- **THEN** the gateway rejects it without sending provider input or changing
|
||||||
|
pressed state.
|
||||||
|
|
||||||
|
### Requirement: Explicit input release
|
||||||
|
The typed input envelope SHALL represent release of each keyboard key,
|
||||||
|
mouse button, and controller state. Gateway cleanup SHALL send a typed release
|
||||||
|
for every accepted pressed state before provider disconnect; it SHALL NOT use
|
||||||
|
an implementation-specific release-all provider command.
|
||||||
|
|
||||||
|
#### Scenario: Tunnel cleanup with pressed input
|
||||||
|
- **WHEN** a tunnel closes after accepted pressed keyboard, mouse, or
|
||||||
|
controller input
|
||||||
|
- **THEN** the gateway emits the corresponding individual provider release
|
||||||
|
packets reliably before starting provider disconnect.
|
||||||
|
|
||||||
|
### Requirement: Bounded provider feedback control envelope
|
||||||
|
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
|
||||||
|
with a direction byte, type byte, big-endian payload length, and exact payload
|
||||||
|
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
||||||
|
gateway to the client; only IDR and FEC/loss feedback SHALL be valid from the
|
||||||
|
client to the gateway. The envelope SHALL contain no provider address,
|
||||||
|
certificate, credential, or opaque provider packet.
|
||||||
|
|
||||||
|
#### Scenario: Host termination forwarding
|
||||||
|
- **WHEN** the Apollo adapter receives an authenticated host termination
|
||||||
|
packet
|
||||||
|
- **THEN** the gateway forwards a bounded `VGF1` termination envelope over
|
||||||
|
reliable Verse control and reports the provider state separately.
|
||||||
|
|
||||||
|
#### Scenario: Unauthorized or malformed feedback
|
||||||
|
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length,
|
||||||
|
or contains a forbidden provider field
|
||||||
|
- **THEN** the gateway rejects it without forwarding or provider mutation.
|
||||||
|
|
||||||
|
### Requirement: Policy-bound text clipboard envelope
|
||||||
|
The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text
|
||||||
|
envelope with exact direction and a 16--128 character canonical unpadded ASCII
|
||||||
|
base64url loop token. The Server SHALL mint
|
||||||
|
the enabled directions, maximum text bytes, and maximum updates per minute in
|
||||||
|
authenticated provider work. The gateway SHALL reject disabled direction,
|
||||||
|
unknown fields, files, file URLs, client folders, binary data, malformed UTF-8,
|
||||||
|
oversized values, rates above policy, and reflected/replayed loop tokens. It
|
||||||
|
SHALL not put clipboard content, provider routes, or credentials in telemetry,
|
||||||
|
audit, state, or errors.
|
||||||
|
|
||||||
|
#### Scenario: Clipboard audit metadata
|
||||||
|
- **WHEN** the gateway successfully delivers, suppresses, or rejects a clipboard update
|
||||||
|
- **THEN** it sends an authenticated Server audit record with only direction,
|
||||||
|
bounded byte count, outcome, and a fixed reason; it never includes text or
|
||||||
|
the loop token.
|
||||||
|
|
||||||
|
#### Scenario: Clipboard delivery failure
|
||||||
|
- **WHEN** provider-to-client control delivery fails
|
||||||
|
- **THEN** the gateway does not report the update as forwarded.
|
||||||
|
|
||||||
|
#### Scenario: Reflected clipboard value
|
||||||
|
- **WHEN** a client-originated text value returns from the provider with the
|
||||||
|
matching retained token/value pair
|
||||||
|
- **THEN** the gateway suppresses the reflected update without a second
|
||||||
|
provider mutation or client delivery.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## 1. Contract and fixtures
|
||||||
|
|
||||||
|
- [x] 1.1 Define the exact typed input and provider-feedback binary layouts in
|
||||||
|
the canonical frame documentation and registries.
|
||||||
|
- [x] 1.2 Add positive and negative fixed-byte conformance fixtures for every
|
||||||
|
input, release, feedback, termination, reserved, and malformed case.
|
||||||
|
- [x] 1.3 Update only source Protocol artifacts, regenerate bindings and the
|
||||||
|
manifest, and prove no generated drift.
|
||||||
|
- [x] 1.4 Define policy-bound clipboard direction/rate/loop-token envelopes and
|
||||||
|
positive/negative deterministic conformance fixtures without adding provider
|
||||||
|
fields to a client-facing frame.
|
||||||
|
|
||||||
|
## 2. Consumer qualification
|
||||||
|
|
||||||
|
- [x] 2.1 Run Protocol validation and the Go, Rust, and Swift conformance
|
||||||
|
consumers against the new fixtures.
|
||||||
|
- [x] 2.2 Advance the Data Plane to the final immutable Protocol revision and
|
||||||
|
translate only the typed envelopes to provider control packets.
|
||||||
|
- [x] 2.3 Add deterministic host-feedback forwarding and input-release tests
|
||||||
|
without provider endpoint or credential disclosure.
|
||||||
|
- [x] 2.4 Advance the Data Plane and Connection Server to the final clipboard
|
||||||
|
contract and prove disabled direction, malformed/oversized text, rate, loop,
|
||||||
|
and file/binary rejection against the authenticated provider path.
|
||||||
|
|
||||||
|
## 3. Freeze
|
||||||
|
|
||||||
|
- [x] 3.1 Reconcile the canonical specification, OpenSpec tasks, source
|
||||||
|
provenance, and consumer fixture digest before creating a new immutable
|
||||||
|
Protocol release candidate.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-30
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
QUIC stream writes complete before peer receipt. The previous gateway avoided losing its last control frame by waiting for client-owned connection closure; immediate gateway closure reproduced event loss. `control.ack.v1` is already reliable and bidirectional, so no new flow is needed.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Represent peer receipt of the one terminal control event.
|
||||||
|
- Keep the message bounded, direction-specific, and independent of provider data.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- General acknowledgements, retries, lifecycle state, or provider transport semantics.
|
||||||
|
- Changes to JSON schemas or generated bindings.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Assign client-direction `VGF1` type `0x03` with an empty payload to terminal receipt.
|
||||||
|
- Permit it only after a terminal event; the Data Plane enforces session state and deadline.
|
||||||
|
- Preserve all existing message bytes and meanings.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Older clients do not send the receipt] → The gateway closes at its bounded receipt deadline; compatibility does not transfer tunnel ownership back to the client.
|
||||||
|
- [A stale receipt is replayed] → The gateway rejects receipts outside the single awaiting-terminal state.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
A public independent QUIC regression proved that closing the gateway connection immediately after writing the reliable terminal event can discard that event, while waiting for the client to close leaves session ownership with the client. The registered bidirectional control flow needs one bounded receipt semantic so the gateway can close only after observed delivery or a fixed receipt deadline.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a client-to-gateway terminal receipt to the existing bounded `VGF1` envelope on `control.ack.v1`.
|
||||||
|
- Keep the receipt payload empty and valid only while one terminal event is awaiting receipt.
|
||||||
|
- Preserve the existing IDR, FEC, termination, rumble, and HDR meanings.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `gateway-input-feedback`: Permit the narrowly scoped terminal receipt in the existing reliable control envelope.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
This changes the immutable Protocol semantics consumed by the GPLv3 Data Plane and independent Verse clients. It adds no schema field, dependency, provider address, credential, generic acknowledgement framework, or provider-facing message.
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bounded provider feedback control envelope
|
||||||
|
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
|
||||||
|
with a direction byte, type byte, big-endian payload length, and exact payload
|
||||||
|
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
||||||
|
gateway to the client. Only IDR, FEC/loss feedback, and an empty terminal receipt
|
||||||
|
SHALL be valid from the client to the gateway. The terminal receipt SHALL be
|
||||||
|
valid only while the same session awaits receipt of its one terminal event and
|
||||||
|
MUST NOT be forwarded to the provider. The envelope SHALL contain no provider
|
||||||
|
address, certificate, credential, or opaque provider packet.
|
||||||
|
|
||||||
|
#### Scenario: Host termination forwarding
|
||||||
|
- **WHEN** the Apollo adapter receives an authenticated host termination packet
|
||||||
|
- **THEN** the gateway forwards a bounded `VGF1` termination envelope over reliable Verse control and reports the provider state separately
|
||||||
|
|
||||||
|
#### Scenario: Terminal event receipt
|
||||||
|
- **WHEN** a client receives the reliable typed terminal event
|
||||||
|
- **THEN** it sends the empty terminal receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
|
||||||
|
|
||||||
|
#### Scenario: Unauthorized or malformed feedback
|
||||||
|
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
|
||||||
|
- **THEN** the gateway rejects it without forwarding or provider mutation
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
## 1. Contract
|
||||||
|
|
||||||
|
- [x] 1.1 Define the empty client-direction terminal receipt on `control.ack.v1`
|
||||||
|
- [x] 1.2 Run complete Protocol verification and deterministic generation checks
|
||||||
|
|
||||||
|
## 2. Immutable release
|
||||||
|
|
||||||
|
- [x] 2.1 Publish one new never-reused immutable Protocol version after final contract verification
|
||||||
|
- [x] 2.2 Resolve the version from separate empty consumer caches and record exact checksums
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The Data Plane already collects process-wide atomic counters and gauges. Only active connections and egress Kbps cross the authenticated heartbeat boundary.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Carry the existing low-cardinality observations with explicit units.
|
||||||
|
- Bound every numeric field and enumerate provider state.
|
||||||
|
- Keep registration capacity distinct from measured traffic.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Add session, route, endpoint, credential, label, or payload fields.
|
||||||
|
- Define a new telemetry transport.
|
||||||
|
- Publish a Protocol version.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Nest the values in required `GatewayTelemetry` so heartbeat telemetry is one strict atomic contract.
|
||||||
|
- Use cumulative counters and microsecond delay totals plus one processing sample per complete provider media unit; consumers can derive rates/averages without losing raw observations.
|
||||||
|
- Define queue delay as residence in the bounded provider queue, processing as active recovery/framing/QUIC work excluding queue and scheduler waits, and pacing as scheduler wait only.
|
||||||
|
- Derive measured egress from transmitted-byte deltas over monotonic elapsed time; configured capacity remains registration data.
|
||||||
|
- Keep loss as parts per million and provider state as a bounded enum.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Cumulative counters approach signed integer limits] → Bound at signed 64-bit and saturate consumer conversions.
|
||||||
|
- [New required object breaks RC6] → Test locally and publish only under separate immutable-version authorization.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Regenerate all bindings locally, update both consumers through the temporary workspace, and stop at the immutable publication boundary.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Gateway heartbeat currently carries active sessions and one egress rate but cannot transport the required observed process and provider-path telemetry.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add one required bounded low-cardinality telemetry object to authenticated gateway heartbeat.
|
||||||
|
- Cover counters, delay totals/samples, control RTT/loss/jitter, pending reliability, reconnects, and provider state.
|
||||||
|
- Keep configured capacity exclusively in registration and measured egress in heartbeat.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `gateway-heartbeat-telemetry`: Authenticated heartbeats carry bounded observed gateway telemetry without routes, sessions, credentials, or payload data.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The control-v1 schema, generated Go/Rust/Swift bindings, conformance checks, and both unpublished consumers require coordinated local updates. RC6 remains unchanged.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Heartbeat carries observed gateway telemetry
|
||||||
|
Every authenticated `GatewayHeartbeat` SHALL carry the bounded process-level counters, delay totals and samples, control RTT/loss/jitter, pending reliable work, reconnect count, and provider state defined by `GatewayTelemetry`.
|
||||||
|
|
||||||
|
#### Scenario: Valid telemetry heartbeat
|
||||||
|
- **WHEN** a gateway reports its current observed snapshot
|
||||||
|
- **THEN** Go, Rust, and Swift bindings accept the same bounded low-cardinality values and units
|
||||||
|
|
||||||
|
### Requirement: Heartbeat telemetry excludes sensitive dimensions
|
||||||
|
Heartbeat telemetry MUST reject unknown fields and MUST NOT include session, route, endpoint, credential, label, or payload values.
|
||||||
|
|
||||||
|
#### Scenario: Secret or high-cardinality field is attempted
|
||||||
|
- **WHEN** a heartbeat contains an unregistered session, route, endpoint, credential, or payload field
|
||||||
|
- **THEN** strict contract validation rejects it before authenticated transport
|
||||||
|
|
||||||
|
### Requirement: Delay and egress observations have one canonical meaning
|
||||||
|
Queue delay SHALL measure provider-queue residence, processing delay SHALL measure active gateway recovery/framing/QUIC work excluding queue and pacing, and pacing delay SHALL measure scheduler waiting only. Processing samples SHALL count complete provider media units rather than Verse fragments. Measured egress SHALL derive from transmitted-byte deltas over monotonic elapsed time and MUST NOT be copied from configured capacity.
|
||||||
|
|
||||||
|
#### Scenario: One provider unit becomes multiple Verse frames
|
||||||
|
- **WHEN** one complete provider unit waits in the queue, traverses gateway processing, waits for pacing, and fragments into multiple Verse frames
|
||||||
|
- **THEN** each delay total includes only its defined interval and the heartbeat advances processing samples exactly once
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## 1. Contract
|
||||||
|
|
||||||
|
- [x] 1.1 Add bounded GatewayTelemetry to every heartbeat
|
||||||
|
- [x] 1.2 Add Go, Rust, and Swift strict conformance checks
|
||||||
|
- [x] 1.3 Regenerate bindings and prove deterministic output
|
||||||
|
- [x] 1.4 Specify queue, processing, pacing, sample, and measured-egress semantics
|
||||||
|
|
||||||
|
## 2. Consumer Boundary
|
||||||
|
|
||||||
|
- [x] 2.1 Verify local Data Plane and Server consumers through a temporary workspace
|
||||||
|
- [x] 2.2 Publish one new never-reused immutable Protocol version under separate authorization
|
||||||
|
- [x] 2.3 Resolve from empty caches and pin exact checksums in both consumers
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The Server resolves an immutable stream-policy version, but RC6 provider work carries only its identifier. The Data Plane consequently cannot distinguish the authorized settings from local defaults.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Carry only the effective launch settings required by the provider boundary.
|
||||||
|
- Express client decode support as an ordered set of existing registered profiles.
|
||||||
|
- Generate the same ordered registered-profile intersection for every consumer.
|
||||||
|
- Generate identical validation from the canonical schema for all bindings.
|
||||||
|
- Preserve the policy-version identifier for audit correlation.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Publish or mutate RC6.
|
||||||
|
- Add a provider-specific token grammar or generic capability framework.
|
||||||
|
- Expose provider work or policy internals to Verse clients.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Use one required nested `ProviderStreamPolicy` value in `ProviderSessionWork`; this keeps the policy settings atomic and avoids repeating validation.
|
||||||
|
- Carry the Server-selected target bitrate rather than all policy bounds because Apollo ANNOUNCE consumes one configured bitrate.
|
||||||
|
- Permit canonical `H264`, `HEVC`, and `AV1` values in the contract. A provider implementation must reject values it cannot honor rather than silently downgrade them.
|
||||||
|
- Carry `audio_enabled` even though the current Apollo path cannot truthfully disable audio; the Data Plane must fail closed for that combination.
|
||||||
|
- Change `client_decode` from one opaque string to a non-empty ordered unique array of registered profile identifiers. Preference belongs to the first peer's order.
|
||||||
|
- Generate `IntersectCapabilityProfiles` from the canonical schema so Protocol, Server, and Data Plane do not maintain separate interpretations.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [New required field breaks RC6 consumers] → Publish only under a separately authorized new immutable version and pin both consumers after empty-cache resolution.
|
||||||
|
- [Provider capabilities differ] → Validate the effective policy against the selected provider before readiness.
|
||||||
|
- [Peers advertise no common registered profile] → Reject admission instead of inventing a combined token or silently downgrading.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Regenerate and verify bindings locally, update both consumers through a temporary workspace only, then stop at the publication boundary. RC6 remains unchanged.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Provider work identifies an immutable stream-policy version but omits the effective settings, allowing a gateway to launch Apollo with unrelated hard-coded media parameters.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add the effective resolution, frame rate, codec, selected bitrate, and audio policy to authenticated provider work.
|
||||||
|
- Represent decode support as an ordered set of registered profiles and generate one canonical intersection operation for consumers.
|
||||||
|
- Require generated Go, Rust, and Swift bindings to validate the same bounded stream-policy contract.
|
||||||
|
- Keep the new contract unpublished until a new immutable Protocol version is separately authorized.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `provider-stream-policy`: Authenticated provider work carries the exact effective stream policy consumed by the provider launch, and registered peers negotiate that policy through the shared ordered profile intersection.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The control-v1 schema, generated bindings, conformance fixtures, and downstream Server and Data Plane consumers require coordinated local updates. RC6 remains immutable and unchanged.
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Provider work carries the effective stream policy
|
||||||
|
Authenticated `ProviderSessionWork` SHALL carry the immutable policy version and its effective resolution, frame rate, codec, target bitrate, and audio-enabled decision.
|
||||||
|
|
||||||
|
#### Scenario: Gateway receives an effective policy
|
||||||
|
- **WHEN** the Server issues provider work for an admitted session
|
||||||
|
- **THEN** the work identifies the policy version and includes the effective bounded stream-policy values
|
||||||
|
|
||||||
|
### Requirement: Stream-policy bindings share one strict contract
|
||||||
|
Generated Go, Rust, and Swift bindings MUST reject missing, unknown, out-of-range, or unsupported stream-policy wire values according to the canonical schema.
|
||||||
|
|
||||||
|
#### Scenario: Invalid policy is rejected consistently
|
||||||
|
- **WHEN** provider work contains an unknown codec or a value outside the canonical bounds
|
||||||
|
- **THEN** every generated binding rejects the work before it can reach provider setup
|
||||||
|
|
||||||
|
### Requirement: Decode capabilities use registered ordered profiles
|
||||||
|
`CapabilityProfile.client_decode` SHALL be a non-empty ordered unique set containing only registered `h264-opus` and `hevc-opus` profile identifiers. It MUST NOT encode multiple capabilities in an opaque private token.
|
||||||
|
|
||||||
|
#### Scenario: Independent peer advertises one registered profile
|
||||||
|
- **WHEN** an independent peer advertises one registered decode profile
|
||||||
|
- **THEN** canonical validation accepts that profile without requiring a combined private token
|
||||||
|
|
||||||
|
### Requirement: Consumers share one ordered registered-profile intersection
|
||||||
|
Generated Protocol behavior SHALL select common registered profiles in the first peer's preference order. Provider consumers SHALL separately reject the resulting intersection when it cannot honor the immutable stream policy.
|
||||||
|
|
||||||
|
#### Scenario: Policy-compatible profile overlaps
|
||||||
|
- **WHEN** the gateway advertises HEVC then H.264 and the client advertises only H.264
|
||||||
|
- **THEN** the shared intersection selects `h264-opus`
|
||||||
|
|
||||||
|
#### Scenario: No policy-compatible profile overlaps
|
||||||
|
- **WHEN** peers have no registered common profile
|
||||||
|
- **THEN** the shared intersection rejects admission without inventing a private combined token
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## 1. Contract
|
||||||
|
|
||||||
|
- [x] 1.1 Add bounded effective stream policy to ProviderSessionWork
|
||||||
|
- [x] 1.2 Add Go, Rust, and Swift conformance coverage
|
||||||
|
- [x] 1.3 Regenerate bindings and prove deterministic output
|
||||||
|
- [x] 1.4 Replace the opaque decode token with an ordered unique set of registered profiles
|
||||||
|
- [x] 1.5 Generate and cross-check canonical ordered registered-profile intersection behavior
|
||||||
|
|
||||||
|
## 2. Consumer Boundary
|
||||||
|
|
||||||
|
- [x] 2.1 Verify local Server and Data Plane consumers through a temporary workspace
|
||||||
|
- [x] 2.2 Publish one new never-reused immutable Protocol version under separate authorization
|
||||||
|
- [x] 2.3 Resolve from empty caches and pin exact checksums in both consumers
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-30
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
RC8's canonical requirement names a terminal receipt, but its frame grammar, fixed fixture, and all Protocol validators reject the exact receipt accepted by Data Plane. The existing VGF1 envelope and generated conformance machinery already cover the required boundary.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make one fixed empty client-direction type `0x03` receipt valid in every Protocol validator.
|
||||||
|
- Preserve all existing VGF1 direction, type, size, and payload validation.
|
||||||
|
- Make independent clients able to construct the receipt from Protocol-owned evidence.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No new envelope, feedback framework, transport, or acknowledgement protocol.
|
||||||
|
- No change to gateway-to-client termination, rumble, HDR, client IDR, or FEC payloads.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Extend the existing VGF1 grammar and fixed TSV corpus; generated and native validators remain consumers of that single contract.
|
||||||
|
- Reserve type `0x03` only for client direction with a zero-length payload. Direction and exact-length checks remain prior trust-boundary requirements.
|
||||||
|
- Publish the verified change as a new immutable version; RC8 remains unchanged.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [A validator diverges from the fixed corpus] → Require normalized cross-language conformance in `make verify`.
|
||||||
|
- [Receipt handling is accepted outside terminal state] → Keep state authorization in Data Plane; Protocol validates only the wire shape.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The canonical terminal-receipt requirement contradicts the fixed VGF1 grammar and every generated validator, so RC8 cannot provide an executable cross-language contract for gateway-owned terminal closure.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Define client-direction VGF1 type `0x03` as an empty terminal receipt in the existing frame grammar.
|
||||||
|
- Add fixed conformance vectors for valid receipt handling and invalid direction, body, truncation, length, and unknown-type cases.
|
||||||
|
- Generate consistent Go, Rust, Swift, and Python validation behavior from the Protocol source.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `gateway-input-feedback`: Make the already-required terminal receipt executable and cross-language conformant without weakening other VGF1 validation.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Protocol frame documentation, conformance fixtures, generators, generated bindings, native validator tools, and immutable Protocol consumers. RC8 remains unchanged and a new immutable Protocol version is required.
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bounded provider feedback control envelope
|
||||||
|
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
|
||||||
|
with a direction byte, type byte, big-endian payload length, and exact payload
|
||||||
|
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
||||||
|
gateway to the client. Only IDR, FEC/loss feedback, and client-direction type
|
||||||
|
`0x03` with an empty payload as terminal receipt SHALL be valid from the client
|
||||||
|
to the gateway. The fixed conformance corpus and every generated or native
|
||||||
|
Protocol validator SHALL accept that exact receipt and reject unknown types,
|
||||||
|
wrong direction, nonempty receipt bodies, truncation, and length mismatch. The
|
||||||
|
terminal receipt SHALL be valid only while the same session awaits receipt of
|
||||||
|
its one terminal event and MUST NOT be forwarded to the provider. The envelope
|
||||||
|
SHALL contain no provider address, certificate, credential, or opaque provider
|
||||||
|
packet.
|
||||||
|
|
||||||
|
#### Scenario: Host termination forwarding
|
||||||
|
- **WHEN** the Apollo adapter receives an authenticated host termination packet
|
||||||
|
- **THEN** the gateway forwards a bounded `VGF1` termination envelope over reliable Verse control and reports the provider state separately
|
||||||
|
|
||||||
|
#### Scenario: Terminal event receipt
|
||||||
|
- **WHEN** a client receives the reliable typed terminal event
|
||||||
|
- **THEN** it sends the fixed empty client-direction type `0x03` receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
|
||||||
|
|
||||||
|
#### Scenario: Unauthorized or malformed feedback
|
||||||
|
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
|
||||||
|
- **THEN** the gateway rejects it without forwarding or provider mutation
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## 1. Red Conformance
|
||||||
|
|
||||||
|
- [x] 1.1 Add fixed valid and invalid terminal-receipt vectors and prove Python, Go, Rust, and Swift reject the required valid vector
|
||||||
|
- [x] 1.2 Add a Data Plane independent-client regression that consumes the Protocol fixed vector rather than the production encoder
|
||||||
|
|
||||||
|
## 2. Contract Repair
|
||||||
|
|
||||||
|
- [x] 2.1 Update the VGF1 grammar and Protocol validator sources for the exact empty client-direction type `0x03` receipt
|
||||||
|
- [x] 2.2 Regenerate bindings normally and prove deterministic generation has no drift
|
||||||
|
- [x] 2.3 Run complete Protocol verification and strict OpenSpec validation
|
||||||
|
|
||||||
|
## 3. Immutable Release
|
||||||
|
|
||||||
|
- [ ] 3.1 Verify the next version is unused locally and remotely, publish one immutable annotated tag, and verify its object and peeled commit
|
||||||
|
- [ ] 3.2 Resolve the version from separate empty caches and pin exact fetched checksums in Data Plane and Connection Server
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# gateway-heartbeat-telemetry Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define the bounded, low-cardinality gateway observations carried by authenticated heartbeats and their canonical units and exclusions.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Heartbeat carries observed gateway telemetry
|
||||||
|
Every authenticated `GatewayHeartbeat` SHALL carry the bounded process-level counters, delay totals and samples, control RTT/loss/jitter, pending reliable work, reconnect count, and provider state defined by `GatewayTelemetry`.
|
||||||
|
|
||||||
|
#### Scenario: Valid telemetry heartbeat
|
||||||
|
- **WHEN** a gateway reports its current observed snapshot
|
||||||
|
- **THEN** Go, Rust, and Swift bindings accept the same bounded low-cardinality values and units
|
||||||
|
|
||||||
|
### Requirement: Heartbeat telemetry excludes sensitive dimensions
|
||||||
|
Heartbeat telemetry MUST reject unknown fields and MUST NOT include session, route, endpoint, credential, label, or payload values.
|
||||||
|
|
||||||
|
#### Scenario: Secret or high-cardinality field is attempted
|
||||||
|
- **WHEN** a heartbeat contains an unregistered session, route, endpoint, credential, or payload field
|
||||||
|
- **THEN** strict contract validation rejects it before authenticated transport
|
||||||
|
|
||||||
|
### Requirement: Delay and egress observations have one canonical meaning
|
||||||
|
Queue delay SHALL measure provider-queue residence, processing delay SHALL measure active gateway recovery/framing/QUIC work excluding queue and pacing, and pacing delay SHALL measure scheduler waiting only. Processing samples SHALL count complete provider media units rather than Verse fragments. Measured egress SHALL derive from transmitted-byte deltas over monotonic elapsed time and MUST NOT be copied from configured capacity.
|
||||||
|
|
||||||
|
#### Scenario: One provider unit becomes multiple Verse frames
|
||||||
|
- **WHEN** one complete provider unit waits in the queue, traverses gateway processing, waits for pacing, and fragments into multiple Verse frames
|
||||||
|
- **THEN** each delay total includes only its defined interval and the heartbeat advances processing samples exactly once
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# gateway-input-feedback Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define the provider-neutral typed input, feedback, and text-clipboard envelopes
|
||||||
|
used across the authenticated Verse gateway boundary.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Typed sequenced input envelope
|
||||||
|
The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte
|
||||||
|
event kind, and one-byte payload length. It SHALL contain exactly one bounded
|
||||||
|
keyboard, mouse-button, relative-mouse, UTF-8 scalar, or controller-state
|
||||||
|
event. False keyboard/mouse state and zeroed controller state are explicit
|
||||||
|
releases. Multibyte integer fields SHALL be big-endian. Unknown kinds,
|
||||||
|
length mismatches, malformed UTF-8, unsupported controller indices, and
|
||||||
|
reserved fields SHALL be rejected before provider translation.
|
||||||
|
|
||||||
|
#### Scenario: Keyboard state change
|
||||||
|
- **WHEN** a client sends a valid keyboard press or release envelope
|
||||||
|
- **THEN** the gateway forwards the corresponding typed provider input on its
|
||||||
|
reliable keyboard channel and records the pressed state for cleanup.
|
||||||
|
|
||||||
|
#### Scenario: Invalid input envelope
|
||||||
|
- **WHEN** a client sends an envelope with an unknown event kind, invalid
|
||||||
|
length, malformed UTF-8 scalar, or nonzero reserved field
|
||||||
|
- **THEN** the gateway rejects it without sending provider input or changing
|
||||||
|
pressed state.
|
||||||
|
|
||||||
|
### Requirement: Explicit input release
|
||||||
|
The typed input envelope SHALL represent release of each keyboard key,
|
||||||
|
mouse button, and controller state. Gateway cleanup SHALL send a typed release
|
||||||
|
for every accepted pressed state before provider disconnect; it SHALL NOT use
|
||||||
|
an implementation-specific release-all provider command.
|
||||||
|
|
||||||
|
#### Scenario: Tunnel cleanup with pressed input
|
||||||
|
- **WHEN** a tunnel closes after accepted pressed keyboard, mouse, or
|
||||||
|
controller input
|
||||||
|
- **THEN** the gateway emits the corresponding individual provider release
|
||||||
|
packets reliably before starting provider disconnect.
|
||||||
|
|
||||||
|
### Requirement: Bounded provider feedback control envelope
|
||||||
|
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
|
||||||
|
with a direction byte, type byte, big-endian payload length, and exact payload
|
||||||
|
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
||||||
|
gateway to the client. Only IDR, FEC/loss feedback, and an empty terminal receipt
|
||||||
|
SHALL be valid from the client to the gateway. The terminal receipt SHALL be
|
||||||
|
valid only while the same session awaits receipt of its one terminal event and
|
||||||
|
MUST NOT be forwarded to the provider. The envelope SHALL contain no provider
|
||||||
|
address, certificate, credential, or opaque provider packet.
|
||||||
|
|
||||||
|
#### Scenario: Host termination forwarding
|
||||||
|
- **WHEN** the Apollo adapter receives an authenticated host termination packet
|
||||||
|
- **THEN** the gateway forwards a bounded `VGF1` termination envelope over reliable Verse control and reports the provider state separately
|
||||||
|
|
||||||
|
#### Scenario: Terminal event receipt
|
||||||
|
- **WHEN** a client receives the reliable typed terminal event
|
||||||
|
- **THEN** it sends the empty terminal receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
|
||||||
|
|
||||||
|
#### Scenario: Unauthorized or malformed feedback
|
||||||
|
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
|
||||||
|
- **THEN** the gateway rejects it without forwarding or provider mutation
|
||||||
|
|
||||||
|
### Requirement: Policy-bound text clipboard envelope
|
||||||
|
The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text
|
||||||
|
envelope with exact direction and a 16--128 character canonical unpadded ASCII
|
||||||
|
base64url loop token. The Server SHALL mint
|
||||||
|
the enabled directions, maximum text bytes, and maximum updates per minute in
|
||||||
|
authenticated provider work. The gateway SHALL reject disabled direction,
|
||||||
|
unknown fields, files, file URLs, client folders, binary data, malformed UTF-8,
|
||||||
|
oversized values, rates above policy, and reflected/replayed loop tokens. It
|
||||||
|
SHALL not put clipboard content, provider routes, or credentials in telemetry,
|
||||||
|
audit, state, or errors.
|
||||||
|
|
||||||
|
#### Scenario: Clipboard audit metadata
|
||||||
|
- **WHEN** the gateway successfully delivers, suppresses, or rejects a clipboard update
|
||||||
|
- **THEN** it sends an authenticated Server audit record with only direction,
|
||||||
|
bounded byte count, outcome, and a fixed reason; it never includes text or
|
||||||
|
the loop token.
|
||||||
|
|
||||||
|
#### Scenario: Clipboard delivery failure
|
||||||
|
- **WHEN** provider-to-client control delivery fails
|
||||||
|
- **THEN** the gateway does not report the update as forwarded.
|
||||||
|
|
||||||
|
#### Scenario: Reflected clipboard value
|
||||||
|
- **WHEN** a client-originated text value returns from the provider with the
|
||||||
|
matching retained token/value pair
|
||||||
|
- **THEN** the gateway suppresses the reflected update without a second
|
||||||
|
provider mutation or client delivery.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# provider-stream-policy Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Define immutable provider stream-policy fields and the registered ordered decode-profile intersection shared by all generated bindings.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Provider work carries the effective stream policy
|
||||||
|
Authenticated `ProviderSessionWork` SHALL carry the immutable policy version and its effective resolution, frame rate, codec, target bitrate, and audio-enabled decision.
|
||||||
|
|
||||||
|
#### Scenario: Gateway receives an effective policy
|
||||||
|
- **WHEN** the Server issues provider work for an admitted session
|
||||||
|
- **THEN** the work identifies the policy version and includes the effective bounded stream-policy values
|
||||||
|
|
||||||
|
### Requirement: Stream-policy bindings share one strict contract
|
||||||
|
Generated Go, Rust, and Swift bindings MUST reject missing, unknown, out-of-range, or unsupported stream-policy wire values according to the canonical schema.
|
||||||
|
|
||||||
|
#### Scenario: Invalid policy is rejected consistently
|
||||||
|
- **WHEN** provider work contains an unknown codec or a value outside the canonical bounds
|
||||||
|
- **THEN** every generated binding rejects the work before it can reach provider setup
|
||||||
|
|
||||||
|
### Requirement: Decode capabilities use registered ordered profiles
|
||||||
|
`CapabilityProfile.client_decode` SHALL be a non-empty ordered unique set containing only registered `h264-opus` and `hevc-opus` profile identifiers. It MUST NOT encode multiple capabilities in an opaque private token.
|
||||||
|
|
||||||
|
#### Scenario: Independent peer advertises one registered profile
|
||||||
|
- **WHEN** an independent peer advertises one registered decode profile
|
||||||
|
- **THEN** canonical validation accepts that profile without requiring a combined private token
|
||||||
|
|
||||||
|
### Requirement: Consumers share one ordered registered-profile intersection
|
||||||
|
Generated Protocol behavior SHALL select common registered profiles in the first peer's preference order. Provider consumers SHALL separately reject the resulting intersection when it cannot honor the immutable stream policy.
|
||||||
|
|
||||||
|
#### Scenario: Policy-compatible profile overlaps
|
||||||
|
- **WHEN** the gateway advertises HEVC then H.264 and the client advertises only H.264
|
||||||
|
- **THEN** the shared intersection selects `h264-opus`
|
||||||
|
|
||||||
|
#### Scenario: No policy-compatible profile overlaps
|
||||||
|
- **WHEN** peers have no registered common profile
|
||||||
|
- **THEN** the shared intersection rejects admission without inventing a private combined token
|
||||||
@@ -32,3 +32,10 @@ message ClipboardText {
|
|||||||
string text = 1;
|
string text = 1;
|
||||||
string encoding = 2;
|
string encoding = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GatewayClipboardText {
|
||||||
|
string direction = 1;
|
||||||
|
string text = 2;
|
||||||
|
string encoding = 3;
|
||||||
|
string loop_token = 4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ message CapabilityProfile {
|
|||||||
string media = 3;
|
string media = 3;
|
||||||
string audio = 4;
|
string audio = 4;
|
||||||
string source_rate_control = 5;
|
string source_rate_control = 5;
|
||||||
string client_decode = 6;
|
repeated string client_decode = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GatewayRegistration {
|
message GatewayRegistration {
|
||||||
@@ -135,6 +135,15 @@ message ProviderSessionWork {
|
|||||||
string client_private_key_pem = 15;
|
string client_private_key_pem = 15;
|
||||||
string server_certificate_pem = 16;
|
string server_certificate_pem = 16;
|
||||||
string client_id = 17;
|
string client_id = 17;
|
||||||
|
ClipboardPolicy clipboard_policy = 18;
|
||||||
|
bool provider_application_termination_allowed = 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClipboardPolicy {
|
||||||
|
bool client_to_provider_enabled = 1;
|
||||||
|
bool provider_to_client_enabled = 2;
|
||||||
|
uint32 max_text_bytes = 3;
|
||||||
|
uint32 max_updates_per_minute = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ChannelFrame {
|
message ChannelFrame {
|
||||||
@@ -156,6 +165,15 @@ message ProviderState {
|
|||||||
repeated string channels = 5;
|
repeated string channels = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GatewayClipboardAudit {
|
||||||
|
string version = 1;
|
||||||
|
string session_id = 2;
|
||||||
|
string direction = 3;
|
||||||
|
string outcome = 4;
|
||||||
|
uint32 text_bytes = 5;
|
||||||
|
string reason = 6;
|
||||||
|
}
|
||||||
|
|
||||||
message StableError {
|
message StableError {
|
||||||
string version = 1;
|
string version = 1;
|
||||||
string code = 2;
|
string code = 2;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"max_frame_bytes": 65536,
|
"max_frame_bytes": 65536,
|
||||||
"datagrams": [
|
"datagrams": [
|
||||||
{"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024},
|
{"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": "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},
|
||||||
{"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200}
|
{"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200, "payload_profile": "gateway-input-v1"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,28 @@
|
|||||||
"encoding": {"type": "string", "const": "utf-8"}
|
"encoding": {"type": "string", "const": "utf-8"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ClipboardPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["client_to_provider_enabled", "provider_to_client_enabled", "max_text_bytes", "max_updates_per_minute"],
|
||||||
|
"properties": {
|
||||||
|
"client_to_provider_enabled": {"type": "boolean"},
|
||||||
|
"provider_to_client_enabled": {"type": "boolean"},
|
||||||
|
"max_text_bytes": {"type": "integer", "minimum": 1, "maximum": 65536},
|
||||||
|
"max_updates_per_minute": {"type": "integer", "minimum": 1, "maximum": 120}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GatewayClipboardText": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["direction", "text", "encoding", "loop_token"],
|
||||||
|
"properties": {
|
||||||
|
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
|
||||||
|
"text": {"type": "string", "maxLength": 65536, "x-max-bytes": 65536},
|
||||||
|
"encoding": {"type": "string", "const": "utf-8"},
|
||||||
|
"loop_token": {"type": "string", "format": "base64url", "minLength": 16, "maxLength": 128}
|
||||||
|
}
|
||||||
|
},
|
||||||
"VersionNegotiation": {
|
"VersionNegotiation": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -354,7 +376,13 @@
|
|||||||
"media": {"type": "string", "minLength": 1, "maxLength": 64},
|
"media": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||||
"audio": {"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": {"type": "string", "minLength": 1, "maxLength": 64}
|
"client_decode": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 2,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {"type": "string", "enum": ["h264-opus", "hevc-opus"]}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GatewayRegistration": {
|
"GatewayRegistration": {
|
||||||
@@ -377,10 +405,35 @@
|
|||||||
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GatewayTelemetry": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["admitted_sessions", "admission_rejects", "reconnects", "drain_transitions", "media_drops", "media_packets", "media_bytes", "queue_delay_micros", "processing_delay_micros", "processing_samples", "pacing_delay_micros", "provider_errors", "input_rejected", "control_rtt_micros", "control_jitter_micros", "control_loss_ppm", "pending_reliable", "provider_state"],
|
||||||
|
"properties": {
|
||||||
|
"admitted_sessions": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"admission_rejects": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"reconnects": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"drain_transitions": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"media_drops": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"media_packets": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"media_bytes": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"queue_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"processing_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"processing_samples": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"pacing_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"provider_errors": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"input_rejected": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"control_rtt_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"control_jitter_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"control_loss_ppm": {"type": "integer", "minimum": 0, "maximum": 1000000},
|
||||||
|
"pending_reliable": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
|
||||||
|
"provider_state": {"type": "string", "enum": ["unknown", "starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"]}
|
||||||
|
}
|
||||||
|
},
|
||||||
"GatewayHeartbeat": {
|
"GatewayHeartbeat": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["version", "gateway_id", "sequence", "observed_at", "active_connections", "egress_kbps", "state"],
|
"required": ["version", "gateway_id", "sequence", "observed_at", "active_connections", "egress_kbps", "state", "telemetry"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"version": {"type": "string", "const": "1"},
|
"version": {"type": "string", "const": "1"},
|
||||||
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
@@ -388,7 +441,8 @@
|
|||||||
"observed_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
"observed_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
||||||
"active_connections": {"type": "integer", "minimum": 0, "maximum": 1000000},
|
"active_connections": {"type": "integer", "minimum": 0, "maximum": 1000000},
|
||||||
"egress_kbps": {"type": "integer", "minimum": 0, "maximum": 1000000000},
|
"egress_kbps": {"type": "integer", "minimum": 0, "maximum": 1000000000},
|
||||||
"state": {"type": "string", "enum": ["ready", "draining", "offline"]}
|
"state": {"type": "string", "enum": ["ready", "draining", "offline"]},
|
||||||
|
"telemetry": {"$ref": "#/$defs/GatewayTelemetry"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GatewayDrain": {
|
"GatewayDrain": {
|
||||||
@@ -435,10 +489,23 @@
|
|||||||
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ProviderStreamPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["resolution_width", "resolution_height", "fps", "codec", "bitrate_kbps", "audio_enabled"],
|
||||||
|
"properties": {
|
||||||
|
"resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384},
|
||||||
|
"resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640},
|
||||||
|
"fps": {"type": "integer", "minimum": 1, "maximum": 240},
|
||||||
|
"codec": {"type": "string", "enum": ["H264", "HEVC", "AV1"]},
|
||||||
|
"bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000},
|
||||||
|
"audio_enabled": {"type": "boolean"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ProviderSessionWork": {
|
"ProviderSessionWork": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "expires_at", "provider_profile", "provider_identity", "policy_version_id", "application_id", "client_id", "management_host", "management_port", "stream_host", "stream_port", "client_certificate_pem", "client_private_key_pem", "server_certificate_pem"],
|
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "expires_at", "provider_profile", "provider_identity", "policy_version_id", "stream_policy", "application_id", "client_id", "management_host", "management_port", "stream_host", "stream_port", "client_certificate_pem", "client_private_key_pem", "server_certificate_pem", "clipboard_policy", "provider_application_termination_allowed"],
|
||||||
"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},
|
||||||
@@ -448,6 +515,7 @@
|
|||||||
"provider_profile": {"type": "string", "const": "apollo"},
|
"provider_profile": {"type": "string", "const": "apollo"},
|
||||||
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
|
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
"policy_version_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"policy_version_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"stream_policy": {"$ref": "#/$defs/ProviderStreamPolicy"},
|
||||||
"application_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"application_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"client_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
"client_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"management_host": {"type": "string", "minLength": 1, "maxLength": 256},
|
"management_host": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
@@ -456,7 +524,9 @@
|
|||||||
"stream_port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
"stream_port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||||
"client_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
|
"client_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
|
||||||
"client_private_key_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
|
"client_private_key_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
|
||||||
"server_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768}
|
"server_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
|
||||||
|
"clipboard_policy": {"$ref": "#/$defs/ClipboardPolicy"},
|
||||||
|
"provider_application_termination_allowed": {"type": "boolean"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ChannelFrame": {
|
"ChannelFrame": {
|
||||||
@@ -486,6 +556,19 @@
|
|||||||
"channels": {"type": "array", "maxItems": 8, "items": {"type": "string", "minLength": 1, "maxLength": 64}}
|
"channels": {"type": "array", "maxItems": 8, "items": {"type": "string", "minLength": 1, "maxLength": 64}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GatewayClipboardAudit": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "session_id", "direction", "outcome", "text_bytes", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"type": "string", "const": "1"},
|
||||||
|
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
|
||||||
|
"outcome": {"type": "string", "enum": ["forwarded", "suppressed", "rejected"]},
|
||||||
|
"text_bytes": {"type": "integer", "minimum": 0, "maximum": 65536},
|
||||||
|
"reason": {"type": "string", "enum": ["forwarded", "loop", "policy", "rate", "provider", "malformed"]}
|
||||||
|
}
|
||||||
|
},
|
||||||
"StableError": {
|
"StableError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
+121
-10
@@ -1,6 +1,7 @@
|
|||||||
package protocol_test
|
package protocol_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -37,7 +38,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","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}`
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -56,31 +57,78 @@ 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","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}`
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayHeartbeatCarriesBoundedObservedTelemetry(t *testing.T) {
|
||||||
|
valid := `{"version":"1","gateway_id":"gateway-1","sequence":1,"observed_at":"2099-01-01T00:00:00Z","active_connections":1,"egress_kbps":64,"state":"ready","telemetry":{"admitted_sessions":2,"admission_rejects":3,"reconnects":4,"drain_transitions":5,"media_drops":6,"media_packets":7,"media_bytes":8000,"queue_delay_micros":9,"processing_delay_micros":10,"processing_samples":11,"pacing_delay_micros":12,"provider_errors":13,"input_rejected":14,"control_rtt_micros":15,"control_jitter_micros":16,"control_loss_ppm":17,"pending_reliable":18,"provider_state":"ready"}}`
|
||||||
|
if _, err := protocol.DecodeGatewayHeartbeat([]byte(valid)); err != nil {
|
||||||
|
t.Fatalf("valid gateway heartbeat rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(valid, `,"telemetry":{`, `,"session_id":"forbidden","telemetry":{`, 1),
|
||||||
|
strings.Replace(valid, `"control_loss_ppm":17`, `"control_loss_ppm":1000001`, 1),
|
||||||
|
strings.Replace(valid, `"provider_state":"ready"`, `"provider_state":"provider.example:47984"`, 1),
|
||||||
|
} {
|
||||||
|
if _, err := protocol.DecodeGatewayHeartbeat([]byte(invalid)); err == nil {
|
||||||
|
t.Fatalf("invalid gateway heartbeat accepted: %s", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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: "h264-opus"}
|
first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", SourceRateControl: "server", ClientDecode: []string{"h264-opus"}}
|
||||||
if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || 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 = "hevc-opus"
|
second.ClientDecode = []string{"hevc-opus"}
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) {
|
||||||
|
gateway := protocol.CapabilityProfile{
|
||||||
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
||||||
|
SourceRateControl: "server", ClientDecode: []string{"hevc-opus", "h264-opus"},
|
||||||
|
}
|
||||||
|
h264Client := gateway
|
||||||
|
h264Client.ClientDecode = []string{"h264-opus"}
|
||||||
|
selected, err := protocol.IntersectCapabilityProfiles(gateway, h264Client)
|
||||||
|
if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"h264-opus"}) {
|
||||||
|
t.Fatalf("H.264 profile intersection = %+v, %v", selected, err)
|
||||||
|
}
|
||||||
|
hevcClient := gateway
|
||||||
|
hevcClient.ClientDecode = []string{"hevc-opus"}
|
||||||
|
selected, err = protocol.IntersectCapabilityProfiles(gateway, hevcClient)
|
||||||
|
if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"hevc-opus"}) {
|
||||||
|
t.Fatalf("HEVC profile intersection = %+v, %v", selected, err)
|
||||||
|
}
|
||||||
|
noOverlap := gateway
|
||||||
|
noOverlap.ClientDecode = []string{"h264-opus"}
|
||||||
|
if _, err := protocol.IntersectCapabilityProfiles(noOverlap, hevcClient); err == nil {
|
||||||
|
t.Fatal("intersection accepted registered profiles without overlap")
|
||||||
|
}
|
||||||
|
for _, invalid := range [][]string{{"h264-hevc-opus"}, {"h264-opus", "h264-opus"}} {
|
||||||
|
profile := gateway
|
||||||
|
profile.ClientDecode = invalid
|
||||||
|
if err := profile.Validate(); err == nil {
|
||||||
|
t.Fatalf("CapabilityProfile accepted invalid registered profile set %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) {
|
func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) {
|
||||||
request := protocol.TunnelAdmissionRequest{
|
request := protocol.TunnelAdmissionRequest{
|
||||||
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", Audio: "encoded",
|
||||||
SourceRateControl: "server", ClientDecode: "h264-opus",
|
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
|
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
|
||||||
@@ -94,17 +142,17 @@ func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing
|
|||||||
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", Audio: "encoded",
|
||||||
SourceRateControl: "server", ClientDecode: "h264-opus",
|
SourceRateControl: "server", ClientDecode: []string{"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:encoded7:encoded6:server9: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:encoded7:encoded6:server1:19:h264-opus"
|
||||||
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","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo","provider_identity":"provider-1"}`
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -114,11 +162,74 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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","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"}`
|
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}`
|
||||||
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)
|
||||||
}
|
}
|
||||||
if _, err := protocol.DecodeProviderSessionWork([]byte(strings.Replace(valid, `"application_id":"42"`, `"application_id":"42","management_password":"forbidden"`, 1))); err == nil {
|
if _, err := protocol.DecodeProviderSessionWork([]byte(strings.Replace(valid, `"application_id":"42"`, `"application_id":"42","management_password":"forbidden"`, 1))); err == nil {
|
||||||
t.Fatal("provider work accepted a management credential")
|
t.Fatal("provider work accepted a management credential")
|
||||||
}
|
}
|
||||||
|
if _, err := protocol.DecodeProviderSessionWork([]byte(strings.Replace(valid, `,"clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30}`, "", 1))); err == nil {
|
||||||
|
t.Fatal("provider work accepted missing clipboard policy")
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(valid, `,"stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true}`, "", 1),
|
||||||
|
strings.Replace(valid, `"fps":120`, `"fps":241`, 1),
|
||||||
|
strings.Replace(valid, `"codec":"HEVC"`, `"codec":"VP9"`, 1),
|
||||||
|
strings.Replace(valid, `"audio_enabled":true`, `"audio_enabled":true,"unknown":false`, 1),
|
||||||
|
} {
|
||||||
|
if _, err := protocol.DecodeProviderSessionWork([]byte(invalid)); err == nil {
|
||||||
|
t.Fatalf("provider work accepted invalid stream policy: %s", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayClipboardAuditIsMetadataOnlyAndStrict(t *testing.T) {
|
||||||
|
valid := `{"version":"1","session_id":"session-1","direction":"client_to_provider","outcome":"rejected","text_bytes":64,"reason":"rate"}`
|
||||||
|
if _, err := protocol.DecodeGatewayClipboardAudit([]byte(valid)); err != nil {
|
||||||
|
t.Fatalf("valid clipboard audit rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(valid, `"reason":"rate"`, `"reason":"text"`, 1),
|
||||||
|
strings.Replace(valid, `"text_bytes":64`, `"text_bytes":65537`, 1),
|
||||||
|
strings.Replace(valid, `"reason":"rate"`, `"reason":"rate","text":"forbidden"`, 1),
|
||||||
|
} {
|
||||||
|
if _, err := protocol.DecodeGatewayClipboardAudit([]byte(invalid)); err == nil {
|
||||||
|
t.Fatalf("invalid clipboard audit accepted: %s", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayClipboardTextMeasuresDecodedUTF8Bytes(t *testing.T) {
|
||||||
|
for name, text := range map[string]string{
|
||||||
|
"ascii-boundary": strings.Repeat("a", 65536),
|
||||||
|
"utf8-boundary": strings.Repeat("é", 32768),
|
||||||
|
"escape-heavy": strings.Repeat(`"`, 32768),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
value := protocol.GatewayClipboardText{
|
||||||
|
Direction: "client_to_provider",
|
||||||
|
Text: text,
|
||||||
|
Encoding: "utf-8",
|
||||||
|
LoopToken: "abcdefghijklmnop",
|
||||||
|
}
|
||||||
|
encoded, err := protocol.EncodeGatewayClipboardText(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeGatewayClipboardText() error = %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := protocol.DecodeGatewayClipboardText(encoded)
|
||||||
|
if err != nil || decoded.Text != text {
|
||||||
|
t.Fatalf("DecodeGatewayClipboardText() = %d bytes, %v", len(decoded.Text), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tooLarge := protocol.GatewayClipboardText{
|
||||||
|
Direction: "client_to_provider",
|
||||||
|
Text: strings.Repeat("a", 65537),
|
||||||
|
Encoding: "utf-8",
|
||||||
|
LoopToken: "abcdefghijklmnop",
|
||||||
|
}
|
||||||
|
if _, err := protocol.EncodeGatewayClipboardText(tooLarge); err == nil {
|
||||||
|
t.Fatal("EncodeGatewayClipboardText() accepted 65,537 decoded UTF-8 bytes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-15
@@ -30,6 +30,9 @@ SECRET_PATTERNS = (
|
|||||||
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
||||||
re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"),
|
re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"),
|
||||||
)
|
)
|
||||||
|
ALLOWED_SECRET_PROPERTIES = {
|
||||||
|
("ProviderSessionWork", "client_private_key_pem"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fail(message: str) -> None:
|
def fail(message: str) -> None:
|
||||||
@@ -53,19 +56,36 @@ def check_generated_provenance() -> None:
|
|||||||
def check_manifest_schema() -> None:
|
def check_manifest_schema() -> None:
|
||||||
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
|
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
|
||||||
definitions = schema.get("$defs", {})
|
definitions = schema.get("$defs", {})
|
||||||
for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"):
|
for name, definition in definitions.items():
|
||||||
properties = definitions.get(name, {}).get("properties", {})
|
for field in definition.get("properties", {}):
|
||||||
forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties))
|
if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS):
|
||||||
if forbidden:
|
if (name, field) not in ALLOWED_SECRET_PROPERTIES:
|
||||||
fail(f"{name} exposes forbidden wire fields: {forbidden}")
|
fail(f"{name} exposes forbidden wire field {field}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_proto_boundaries(path: pathlib.Path) -> None:
|
||||||
|
message = ""
|
||||||
|
depth = 0
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = re.match(r"\s*message\s+([A-Za-z0-9_]+)\s*\{", line)
|
||||||
|
if match and depth == 0:
|
||||||
|
message = match.group(1)
|
||||||
|
if any(field in line.lower() for field in FORBIDDEN_WIRE_FIELDS):
|
||||||
|
allowed = (
|
||||||
|
message == "ProviderSessionWork"
|
||||||
|
and re.fullmatch(r"\s*string\s+client_private_key_pem\s*=\s*[0-9]+;\s*", line)
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
fail(f"{path.relative_to(ROOT)} exposes forbidden wire field in {message or 'file scope'}")
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
if depth == 0:
|
||||||
|
message = ""
|
||||||
|
|
||||||
|
|
||||||
def check_text_boundaries() -> None:
|
def check_text_boundaries() -> None:
|
||||||
paths = [
|
paths = [
|
||||||
ROOT / "openapi/control-v1.yaml",
|
ROOT / "openapi/control-v1.yaml",
|
||||||
ROOT / "schemas/control-v1.schema.json",
|
|
||||||
ROOT / "proto/versevdi/control/v1/control.proto",
|
ROOT / "proto/versevdi/control/v1/control.proto",
|
||||||
ROOT / "proto/versevdi/tunnel/v1/tunnel.proto",
|
|
||||||
ROOT / "frames/datagram-v1.md",
|
ROOT / "frames/datagram-v1.md",
|
||||||
ROOT / "frames/registry.json",
|
ROOT / "frames/registry.json",
|
||||||
ROOT / "registries/features.json",
|
ROOT / "registries/features.json",
|
||||||
@@ -77,14 +97,7 @@ def check_text_boundaries() -> None:
|
|||||||
if field in text:
|
if field in text:
|
||||||
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
|
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
|
||||||
|
|
||||||
generated_paths = list((ROOT / "gen").rglob("*"))
|
check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto")
|
||||||
for path in generated_paths:
|
|
||||||
if not path.is_file() or path.name == "manifest.json" or path.suffix in {".pb", ".binpb"}:
|
|
||||||
continue
|
|
||||||
text = path.read_text(encoding="utf-8").lower()
|
|
||||||
for field in FORBIDDEN_WIRE_FIELDS:
|
|
||||||
if field in text:
|
|
||||||
fail(f"generated output {path.relative_to(ROOT)} contains forbidden wire field {field}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_secret_canaries() -> None:
|
def check_secret_canaries() -> None:
|
||||||
|
|||||||
+88
-12
@@ -125,6 +125,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}")
|
lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}")
|
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f"\tif len(v.{field}) > {prop['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_bytes\"}}) }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
|
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
@@ -135,6 +137,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
'\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }'
|
'\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }'
|
||||||
% (field, field, field, prop_name)
|
% (field, field, field, prop_name)
|
||||||
)
|
)
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f"\tif v.{field} != \"\" {{ if _, err := base64.RawURLEncoding.Strict().DecodeString(v.{field}); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_format\"}}) }} }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
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 v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}")
|
||||||
@@ -145,7 +149,13 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
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\"}}) }}")
|
||||||
if "maxItems" in prop:
|
if "maxItems" in prop:
|
||||||
lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}")
|
lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}")
|
||||||
item_ref = ref_name(prop.get("items", {}))
|
items = prop.get("items", {})
|
||||||
|
if "enum" in items:
|
||||||
|
allowed = " || ".join(f'item == "{value}"' for value in items["enum"])
|
||||||
|
lines.append(f"\tfor _, item := range v.{field} {{ if !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_item\"}}) }} }}")
|
||||||
|
if prop.get("uniqueItems") and items.get("type") == "string":
|
||||||
|
lines.append(f"\tfor index, item := range v.{field} {{ for prior := 0; prior < index; prior++ {{ if item == v.{field}[prior] {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"duplicate_item\"}}) }} }} }}")
|
||||||
|
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\"}}) }} }}")
|
||||||
reference = ref_name(prop)
|
reference = ref_name(prop)
|
||||||
@@ -167,6 +177,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"",
|
"",
|
||||||
"import (",
|
"import (",
|
||||||
"\"bytes\"",
|
"\"bytes\"",
|
||||||
|
"\"encoding/base64\"",
|
||||||
"\"encoding/json\"",
|
"\"encoding/json\"",
|
||||||
"\"errors\"",
|
"\"errors\"",
|
||||||
"\"fmt\"",
|
"\"fmt\"",
|
||||||
@@ -223,7 +234,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
% (prop_name, prop_name)
|
% (prop_name, prop_name)
|
||||||
)
|
)
|
||||||
for prop_name, prop in defs[name].get("properties", {}).items():
|
for prop_name, prop in defs[name].get("properties", {}).items():
|
||||||
if "x-max-bytes" in prop:
|
if "x-max-bytes" in prop and prop.get("type") != "string":
|
||||||
out.append(
|
out.append(
|
||||||
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }'
|
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }'
|
||||||
% (prop_name, prop["x-max-bytes"], prop_name)
|
% (prop_name, prop["x-max-bytes"], prop_name)
|
||||||
@@ -249,16 +260,23 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"\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...)",
|
||||||
"\tfor _, profile := range profiles[1:] {",
|
"\tfor _, profile := range profiles[1:] {",
|
||||||
"\t\tif err := profile.Validate(); err != nil || profile != selected { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
"\t\tif err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.Audio != selected.Audio || profile.SourceRateControl != selected.SourceRateControl { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
|
"\t\tnext := common[:0]",
|
||||||
|
"\t\tfor _, candidate := range common { for _, offered := range profile.ClientDecode { if candidate == offered { next = append(next, candidate); break } } }",
|
||||||
|
"\t\tcommon = next",
|
||||||
|
"\t\tif len(common) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
"\t}",
|
"\t}",
|
||||||
|
"\tselected.ClientDecode = common",
|
||||||
"\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, 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.Audio, v.Capabilities.SourceRateControl, fmt.Sprintf(\"%d\", len(v.Capabilities.ClientDecode))}",
|
||||||
|
"\tfields = append(fields, v.Capabilities.ClientDecode...)",
|
||||||
"\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) }",
|
||||||
@@ -322,11 +340,15 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.as_bytes().len() > {prop['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_bytes\")); }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||||
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
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 {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
|
||||||
@@ -337,7 +359,13 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
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\")); }}")
|
||||||
if "maxItems" in prop:
|
if "maxItems" in prop:
|
||||||
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
||||||
item_ref = ref_name(prop.get("items", {}))
|
items = prop.get("items", {})
|
||||||
|
if "enum" in items:
|
||||||
|
allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"])
|
||||||
|
lines.append(f" {prefix}for item in {value}.iter() {{ if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_item\")); }} }}")
|
||||||
|
if prop.get("uniqueItems") and items.get("type") == "string":
|
||||||
|
lines.append(f" {prefix}for (index, item) in {value}.iter().enumerate() {{ if {value}[..index].contains(item) {{ return Err(ValidationError::new(\"{prop_name}\", \"duplicate_item\")); }} }}")
|
||||||
|
item_ref = ref_name(items)
|
||||||
if item_ref:
|
if item_ref:
|
||||||
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
|
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
|
||||||
reference = ref_name(prop)
|
reference = ref_name(prop)
|
||||||
@@ -369,6 +397,27 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
||||||
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
||||||
"",
|
"",
|
||||||
|
"fn base64url_value(value: u8) -> Option<u8> {",
|
||||||
|
" match value {",
|
||||||
|
" b'A'..=b'Z' => Some(value - b'A'),",
|
||||||
|
" b'a'..=b'z' => Some(value - b'a' + 26),",
|
||||||
|
" b'0'..=b'9' => Some(value - b'0' + 52),",
|
||||||
|
" b'-' => Some(62),",
|
||||||
|
" b'_' => Some(63),",
|
||||||
|
" _ => None,",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"fn valid_base64_url(value: &str) -> bool {",
|
||||||
|
" let bytes = value.as_bytes();",
|
||||||
|
" if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }",
|
||||||
|
" match bytes.len() % 4 {",
|
||||||
|
" 0 => true,",
|
||||||
|
" 2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,",
|
||||||
|
" 3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,",
|
||||||
|
" _ => false,",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
definition = defs[name]
|
definition = defs[name]
|
||||||
@@ -410,7 +459,9 @@ 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 fields = [&self.sessionId, &self.gatewayId, &self.audience, &self.grant, &reconnect_sequence, &self.clientNonce, &self.capabilities.transport, &self.capabilities.framing, &self.capabilities.media, &self.capabilities.audio, &self.capabilities.sourceRateControl, &self.capabilities.clientDecode];",
|
" let client_decode_count = self.capabilities.clientDecode.len().to_string();",
|
||||||
|
" let mut fields = vec![self.sessionId.as_str(), self.gatewayId.as_str(), self.audience.as_str(), self.grant.as_str(), reconnect_sequence.as_str(), self.clientNonce.as_str(), self.capabilities.transport.as_str(), self.capabilities.framing.as_str(), self.capabilities.media.as_str(), self.capabilities.audio.as_str(), self.capabilities.sourceRateControl.as_str(), client_decode_count.as_str()];",
|
||||||
|
" fields.extend(self.capabilities.clientDecode.iter().map(String::as_str));",
|
||||||
" let 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()",
|
||||||
@@ -419,11 +470,13 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
out.extend(["}", ""])
|
out.extend(["}", ""])
|
||||||
out.extend([
|
out.extend([
|
||||||
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
||||||
" let 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 != &selected { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
" if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
||||||
|
" selected.clientDecode.retain(|candidate| profile.clientDecode.contains(candidate));",
|
||||||
|
" if selected.clientDecode.is_empty() { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
||||||
" }",
|
" }",
|
||||||
" Ok(selected)",
|
" Ok(selected)",
|
||||||
"}",
|
"}",
|
||||||
@@ -451,6 +504,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.utf8.count > {prop['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_bytes\") }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
@@ -458,6 +513,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
if prop.get("format") == "date-time":
|
if prop.get("format") == "date-time":
|
||||||
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
|
||||||
if prop.get("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\") }}")
|
||||||
@@ -468,7 +525,13 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
|
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
|
||||||
if "maxItems" in prop:
|
if "maxItems" in prop:
|
||||||
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
||||||
item_ref = ref_name(prop.get("items", {}))
|
items = prop.get("items", {})
|
||||||
|
if "enum" in items:
|
||||||
|
allowed = ", ".join(f'\"{item}\"' for item in items["enum"])
|
||||||
|
lines.append(f" {prefix}for item in {value} where ![{allowed}].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_item\") }}")
|
||||||
|
if prop.get("uniqueItems") and items.get("type") == "string":
|
||||||
|
lines.append(f" {prefix}if Set({value}).count != {value}.count {{ throw ContractValidationError(field: \"{prop_name}\", code: \"duplicate_item\") }}")
|
||||||
|
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() }}")
|
||||||
reference = ref_name(prop)
|
reference = ref_name(prop)
|
||||||
@@ -497,6 +560,15 @@ 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 validBase64URL(_ value: String) -> Bool {",
|
||||||
|
" guard !value.isEmpty, value.utf8.allSatisfy({ byte in",
|
||||||
|
" (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95",
|
||||||
|
" }) else { return false }",
|
||||||
|
" let padding = String(repeating: \"=\", count: (4 - value.utf8.count % 4) % 4)",
|
||||||
|
" let standard = value.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\") + padding",
|
||||||
|
" guard let decoded = Data(base64Encoded: standard) else { return false }",
|
||||||
|
" return decoded.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\") == value",
|
||||||
|
"}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
@@ -540,7 +612,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
out.extend([
|
out.extend([
|
||||||
"public extension TunnelAdmissionRequest {",
|
"public extension TunnelAdmissionRequest {",
|
||||||
" func deviceAdmissionTranscript() -> Data {",
|
" func deviceAdmissionTranscript() -> Data {",
|
||||||
" let fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, capabilities.clientDecode]",
|
" var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]",
|
||||||
|
" fields.append(contentsOf: capabilities.clientDecode)",
|
||||||
" var 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)",
|
||||||
@@ -551,11 +624,14 @@ 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",
|
||||||
" for profile in profiles.dropFirst() {",
|
" for profile in profiles.dropFirst() {",
|
||||||
" try profile.validate()",
|
" try profile.validate()",
|
||||||
" if profile != selected { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
" if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
|
" common = common.filter { profile.clientDecode.contains($0) }",
|
||||||
|
" if common.isEmpty { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
" }",
|
" }",
|
||||||
" return selected",
|
" return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, audio: selected.audio, sourceRateControl: selected.sourceRateControl, clientDecode: common)",
|
||||||
" }",
|
" }",
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||||
)
|
)
|
||||||
@@ -87,9 +88,9 @@ func evaluate(kind, input string) string {
|
|||||||
Gateway: protocol.ManifestGateway{
|
Gateway: protocol.ManifestGateway{
|
||||||
ID: parts["gateway_id"], Addresses: []string{"gateway.control.test:443"}, PublicIdentity: parts["gateway_id"],
|
ID: parts["gateway_id"], Addresses: []string{"gateway.control.test:443"}, PublicIdentity: parts["gateway_id"],
|
||||||
},
|
},
|
||||||
Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}},
|
Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}},
|
||||||
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",
|
||||||
}
|
}
|
||||||
if value.Validate() == nil {
|
if value.Validate() == nil {
|
||||||
@@ -118,7 +119,7 @@ func evaluate(kind, input string) string {
|
|||||||
}
|
}
|
||||||
value := protocol.EventEnvelope{
|
value := protocol.EventEnvelope{
|
||||||
EventID: "event-1", Sequence: sequence, Type: "broker.session.changed", Version: 1,
|
EventID: "event-1", Sequence: sequence, Type: "broker.session.changed", Version: 1,
|
||||||
Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1},
|
Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1},
|
||||||
OccurredAt: "2099-01-01T00:00:00Z", CorrelationID: parts["correlation_id"], Payload: map[string]any{},
|
OccurredAt: "2099-01-01T00:00:00Z", CorrelationID: parts["correlation_id"], Payload: map[string]any{},
|
||||||
}
|
}
|
||||||
if payloadErr != nil || payloadBytes > 16384 {
|
if payloadErr != nil || payloadBytes > 16384 {
|
||||||
@@ -138,11 +139,182 @@ func evaluate(kind, input string) string {
|
|||||||
return "invalid:unsupported_version"
|
return "invalid:unsupported_version"
|
||||||
case "datagram":
|
case "datagram":
|
||||||
return classifyDatagram(parts["hex"])
|
return classifyDatagram(parts["hex"])
|
||||||
|
case "gateway_input":
|
||||||
|
return classifyGatewayInput(parts["hex"])
|
||||||
|
case "gateway_feedback":
|
||||||
|
return classifyGatewayFeedback(parts["hex"])
|
||||||
|
case "gateway_clipboard":
|
||||||
|
if _, hasFile := parts["file"]; hasFile {
|
||||||
|
return "invalid:forbidden"
|
||||||
|
}
|
||||||
|
value := protocol.GatewayClipboardText{Direction: parts["direction"], Text: parts["text"], Encoding: parts["encoding"], LoopToken: parts["loop_token"]}
|
||||||
|
if value.Validate() != nil {
|
||||||
|
return "invalid:clipboard"
|
||||||
|
}
|
||||||
|
return "valid"
|
||||||
|
case "gateway_clipboard_audit":
|
||||||
|
if _, hasText := parts["text"]; hasText {
|
||||||
|
return "invalid:forbidden"
|
||||||
|
}
|
||||||
|
textBytes, err := strconv.ParseInt(parts["text_bytes"], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "invalid:clipboard_audit"
|
||||||
|
}
|
||||||
|
value := protocol.GatewayClipboardAudit{Version: "1", SessionID: "fixture-session", Direction: parts["direction"], Outcome: parts["outcome"], TextBytes: textBytes, Reason: parts["reason"]}
|
||||||
|
if value.Validate() != nil {
|
||||||
|
return "invalid:clipboard_audit"
|
||||||
|
}
|
||||||
|
return "valid"
|
||||||
default:
|
default:
|
||||||
return "invalid:unknown_kind"
|
return "invalid:unknown_kind"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeGatewayHex(encoded string) ([]byte, string) {
|
||||||
|
raw, err := hex.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "invalid:hex"
|
||||||
|
}
|
||||||
|
return raw, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyGatewayInput(encoded string) string {
|
||||||
|
raw, invalid := decodeGatewayHex(encoded)
|
||||||
|
if invalid != "" {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
if len(raw) < 6 {
|
||||||
|
return "invalid:truncated"
|
||||||
|
}
|
||||||
|
if string(raw[:4]) != "VGI1" {
|
||||||
|
return "invalid:magic"
|
||||||
|
}
|
||||||
|
kind, length := raw[4], int(raw[5])
|
||||||
|
if len(raw) != 6+length {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
body := raw[6:]
|
||||||
|
switch kind {
|
||||||
|
case 1:
|
||||||
|
if len(body) != 4 || body[0] > 1 || (body[2] == 0 && body[3] == 0) {
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if len(body) != 3 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if body[0] > 1 || body[1] < 1 || body[1] > 5 {
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
|
if body[2] != 0 {
|
||||||
|
return "invalid:reserved"
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if len(body) != 4 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
if len(body) < 1 || len(body) > 4 || !utf8.Valid(body) || utf8.RuneCount(body) != 1 {
|
||||||
|
return "invalid:utf8"
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if len(body) != 17 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if body[0] > 15 {
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
|
if body[1] == 0 && body[2] == 0 {
|
||||||
|
for _, value := range body[3:] {
|
||||||
|
if value != 0 {
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "invalid:kind"
|
||||||
|
}
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyGatewayFeedback(encoded string) string {
|
||||||
|
raw, invalid := decodeGatewayHex(encoded)
|
||||||
|
if invalid != "" {
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
if len(raw) < 8 {
|
||||||
|
return "invalid:truncated"
|
||||||
|
}
|
||||||
|
if string(raw[:4]) != "VGF1" {
|
||||||
|
return "invalid:magic"
|
||||||
|
}
|
||||||
|
direction, kind := raw[4], raw[5]
|
||||||
|
if len(raw) != 8+(int(raw[6])<<8)+int(raw[7]) {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if direction != 0 && direction != 1 {
|
||||||
|
return "invalid:direction"
|
||||||
|
}
|
||||||
|
body := raw[8:]
|
||||||
|
if direction == 0 {
|
||||||
|
if kind >= 0x10 && kind <= 0x12 {
|
||||||
|
return "invalid:direction"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case 1:
|
||||||
|
if len(body) == 0 {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if validFECStatus(body) {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
return "invalid:field"
|
||||||
|
case 3:
|
||||||
|
if len(body) == 0 {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "invalid:type"
|
||||||
|
}
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 || kind == 3 {
|
||||||
|
return "invalid:direction"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case 0x10:
|
||||||
|
if len(body) == 4 {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
return "invalid:length"
|
||||||
|
case 0x11:
|
||||||
|
if len(body) != 5 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if body[0] <= 15 {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
case 0x12:
|
||||||
|
if len(body) != 1 {
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if body[0] <= 1 {
|
||||||
|
return "valid"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "invalid:type"
|
||||||
|
}
|
||||||
|
return "invalid:field"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFECStatus(body []byte) bool {
|
||||||
|
if len(body) != 21 || int(body[10])<<8|int(body[11]) == 0 || int(body[14])<<8|int(body[15]) > int(body[10])<<8|int(body[11]) || int(body[16])<<8|int(body[17]) > int(body[12])<<8|int(body[13]) || body[18] > 100 || body[20] == 0 || body[19] >= body[20] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func classifyDatagram(encoded string) string {
|
func classifyDatagram(encoded string) string {
|
||||||
raw, err := hex.DecodeString(encoded)
|
raw, err := hex.DecodeString(encoded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -53,10 +53,127 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
"tunnel" => "invalid:unsupported_version",
|
"tunnel" => "invalid:unsupported_version",
|
||||||
"datagram" => classify_datagram(values.get("hex").map(String::as_str).unwrap_or_default()),
|
"datagram" => classify_datagram(values.get("hex").map(String::as_str).unwrap_or_default()),
|
||||||
|
"gateway_input" => classify_gateway_input(values.get("hex").map(String::as_str).unwrap_or_default()),
|
||||||
|
"gateway_feedback" => classify_gateway_feedback(values.get("hex").map(String::as_str).unwrap_or_default()),
|
||||||
|
"gateway_clipboard" if values.contains_key("file") => "invalid:forbidden",
|
||||||
|
"gateway_clipboard" => match (
|
||||||
|
values.get("direction"),
|
||||||
|
values.get("text"),
|
||||||
|
values.get("encoding"),
|
||||||
|
values.get("loop_token"),
|
||||||
|
) {
|
||||||
|
(Some(direction), Some(text), Some(encoding), Some(token))
|
||||||
|
if GatewayClipboardText::new(
|
||||||
|
direction.clone(), text.clone(), encoding.clone(), token.clone(),
|
||||||
|
).is_ok() => "valid",
|
||||||
|
_ => "invalid:clipboard",
|
||||||
|
},
|
||||||
|
"gateway_clipboard_audit" if values.contains_key("text") => "invalid:forbidden",
|
||||||
|
"gateway_clipboard_audit"
|
||||||
|
if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
|
||||||
|
&& matches!(values.get("outcome").map(String::as_str), Some("forwarded") | Some("suppressed") | Some("rejected"))
|
||||||
|
&& matches!(values.get("reason").map(String::as_str), Some("forwarded") | Some("loop") | Some("policy") | Some("rate") | Some("provider") | Some("malformed"))
|
||||||
|
&& values.get("text_bytes").and_then(|value| value.parse::<usize>().ok()).map_or(false, |size| size <= 65536) => "valid",
|
||||||
|
"gateway_clipboard_audit" => "invalid:clipboard_audit",
|
||||||
_ => "invalid:unknown_kind",
|
_ => "invalid:unknown_kind",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn classify_gateway_input(encoded: &str) -> &'static str {
|
||||||
|
let raw = match decode_hex(encoded) {
|
||||||
|
Some(raw) => raw,
|
||||||
|
None => return "invalid:hex",
|
||||||
|
};
|
||||||
|
if raw.len() < 6 {
|
||||||
|
return "invalid:truncated";
|
||||||
|
}
|
||||||
|
if raw[0..4] != *b"VGI1" {
|
||||||
|
return "invalid:magic";
|
||||||
|
}
|
||||||
|
let kind = raw[4];
|
||||||
|
let body = &raw[6..];
|
||||||
|
if body.len() != raw[5] as usize {
|
||||||
|
return "invalid:length";
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
1 if body.len() == 4 && body[0] <= 1 && (body[2] != 0 || body[3] != 0) => "valid",
|
||||||
|
1 => "invalid:field",
|
||||||
|
2 if body.len() != 3 => "invalid:length",
|
||||||
|
2 if body[0] > 1 || !(1..=5).contains(&body[1]) => "invalid:field",
|
||||||
|
2 if body[2] != 0 => "invalid:reserved",
|
||||||
|
2 => "valid",
|
||||||
|
3 if body.len() == 4 => "valid",
|
||||||
|
3 => "invalid:length",
|
||||||
|
4 if (1..=4).contains(&body.len()) && std::str::from_utf8(body).ok().map_or(false, |value| value.chars().count() == 1) => "valid",
|
||||||
|
4 => "invalid:utf8",
|
||||||
|
5 if body.len() != 17 => "invalid:length",
|
||||||
|
5 if body[0] > 15 => "invalid:field",
|
||||||
|
5 if body[1] == 0 && body[2] == 0 && body[3..].iter().any(|value| *value != 0) => "invalid:field",
|
||||||
|
5 => "valid",
|
||||||
|
_ => "invalid:kind",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_gateway_feedback(encoded: &str) -> &'static str {
|
||||||
|
let raw = match decode_hex(encoded) {
|
||||||
|
Some(raw) => raw,
|
||||||
|
None => return "invalid:hex",
|
||||||
|
};
|
||||||
|
if raw.len() < 8 {
|
||||||
|
return "invalid:truncated";
|
||||||
|
}
|
||||||
|
if raw[0..4] != *b"VGF1" {
|
||||||
|
return "invalid:magic";
|
||||||
|
}
|
||||||
|
let direction = raw[4];
|
||||||
|
let kind = raw[5];
|
||||||
|
let body = &raw[8..];
|
||||||
|
if body.len() != ((raw[6] as usize) << 8 | raw[7] as usize) {
|
||||||
|
return "invalid:length";
|
||||||
|
}
|
||||||
|
if direction > 1 {
|
||||||
|
return "invalid:direction";
|
||||||
|
}
|
||||||
|
if direction == 0 {
|
||||||
|
if (0x10..=0x12).contains(&kind) {
|
||||||
|
return "invalid:direction";
|
||||||
|
}
|
||||||
|
return match kind {
|
||||||
|
1 if body.is_empty() => "valid",
|
||||||
|
1 => "invalid:length",
|
||||||
|
2 if valid_fec_status(body) => "valid",
|
||||||
|
2 => "invalid:field",
|
||||||
|
3 if body.is_empty() => "valid",
|
||||||
|
3 => "invalid:length",
|
||||||
|
_ => "invalid:type",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 || kind == 3 {
|
||||||
|
return "invalid:direction";
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
0x10 if body.len() == 4 => "valid",
|
||||||
|
0x10 => "invalid:length",
|
||||||
|
0x11 if body.len() != 5 => "invalid:length",
|
||||||
|
0x11 if body[0] <= 15 => "valid",
|
||||||
|
0x11 => "invalid:field",
|
||||||
|
0x12 if body.len() != 1 => "invalid:length",
|
||||||
|
0x12 if body[0] <= 1 => "valid",
|
||||||
|
0x12 => "invalid:field",
|
||||||
|
_ => "invalid:type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_fec_status(body: &[u8]) -> bool {
|
||||||
|
body.len() == 21
|
||||||
|
&& ((body[10] as u16) << 8 | body[11] as u16) > 0
|
||||||
|
&& ((body[14] as u16) << 8 | body[15] as u16) <= ((body[10] as u16) << 8 | body[11] as u16)
|
||||||
|
&& ((body[16] as u16) << 8 | body[17] as u16) <= ((body[12] as u16) << 8 | body[13] as u16)
|
||||||
|
&& body[18] <= 100
|
||||||
|
&& body[20] > 0
|
||||||
|
&& body[19] < body[20]
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_hex(input: &str) -> Option<Vec<u8>> {
|
fn decode_hex(input: &str) -> Option<Vec<u8>> {
|
||||||
if input.len() % 2 != 0 {
|
if input.len() % 2 != 0 {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -30,18 +30,105 @@ func evaluate(_ kind: String, _ input: String) -> String {
|
|||||||
if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" }
|
if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" }
|
||||||
return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature"
|
return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature"
|
||||||
case "datagram": return classifyDatagram(values["hex"] ?? "")
|
case "datagram": return classifyDatagram(values["hex"] ?? "")
|
||||||
|
case "gateway_input": return classifyGatewayInput(values["hex"] ?? "")
|
||||||
|
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
|
||||||
|
case "gateway_clipboard":
|
||||||
|
if values["file"] != nil { return "invalid:forbidden" }
|
||||||
|
guard let direction = values["direction"], let text = values["text"],
|
||||||
|
let encoding = values["encoding"], let token = values["loop_token"],
|
||||||
|
(try? GatewayClipboardText(
|
||||||
|
direction: direction, text: text, encoding: encoding, loopToken: token
|
||||||
|
)) != nil else { return "invalid:clipboard" }
|
||||||
|
return "valid"
|
||||||
|
case "gateway_clipboard_audit":
|
||||||
|
if values["text"] != nil { return "invalid:forbidden" }
|
||||||
|
guard ["client_to_provider", "provider_to_client"].contains(values["direction"] ?? ""), ["forwarded", "suppressed", "rejected"].contains(values["outcome"] ?? ""), ["forwarded", "loop", "policy", "rate", "provider", "malformed"].contains(values["reason"] ?? ""), let textBytes = Int(values["text_bytes"] ?? ""), (0...65536).contains(textBytes) else { return "invalid:clipboard_audit" }
|
||||||
|
return "valid"
|
||||||
default: return "invalid:unknown_kind"
|
default: return "invalid:unknown_kind"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func classifyDatagram(_ encoded: String) -> String {
|
func decodeHex(_ encoded: String) -> [UInt8]? {
|
||||||
let characters = Array(encoded)
|
let characters = Array(encoded)
|
||||||
guard characters.count % 2 == 0 else { return "invalid:hex" }
|
guard characters.count % 2 == 0 else { return nil }
|
||||||
var raw: [UInt8] = []
|
var raw: [UInt8] = []
|
||||||
for index in stride(from: 0, to: characters.count, by: 2) {
|
for index in stride(from: 0, to: characters.count, by: 2) {
|
||||||
guard let byte = UInt8(String(characters[index...index + 1]), radix: 16) else { return "invalid:hex" }
|
guard let byte = UInt8(String(characters[index...index + 1]), radix: 16) else { return nil }
|
||||||
raw.append(byte)
|
raw.append(byte)
|
||||||
}
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyGatewayInput(_ encoded: String) -> String {
|
||||||
|
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
|
||||||
|
guard raw.count >= 6 else { return "invalid:truncated" }
|
||||||
|
guard Array(raw[0..<4]) == Array("VGI1".utf8) else { return "invalid:magic" }
|
||||||
|
let kind = raw[4]
|
||||||
|
let body = Array(raw.dropFirst(6))
|
||||||
|
guard body.count == Int(raw[5]) else { return "invalid:length" }
|
||||||
|
switch kind {
|
||||||
|
case 1:
|
||||||
|
return body.count == 4 && body[0] <= 1 && (body[2] != 0 || body[3] != 0) ? "valid" : "invalid:field"
|
||||||
|
case 2:
|
||||||
|
guard body.count == 3 else { return "invalid:length" }
|
||||||
|
guard body[0] <= 1 && (1...5).contains(body[1]) else { return "invalid:field" }
|
||||||
|
return body[2] == 0 ? "valid" : "invalid:reserved"
|
||||||
|
case 3: return body.count == 4 ? "valid" : "invalid:length"
|
||||||
|
case 4:
|
||||||
|
guard (1...4).contains(body.count), let scalar = String(bytes: body, encoding: .utf8), scalar.unicodeScalars.count == 1 else { return "invalid:utf8" }
|
||||||
|
return "valid"
|
||||||
|
case 5:
|
||||||
|
guard body.count == 17 else { return "invalid:length" }
|
||||||
|
guard body[0] <= 15 else { return "invalid:field" }
|
||||||
|
guard body[1] != 0 || body[2] != 0 || body.dropFirst(3).allSatisfy({ $0 == 0 }) else { return "invalid:field" }
|
||||||
|
return "valid"
|
||||||
|
default: return "invalid:kind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyGatewayFeedback(_ encoded: String) -> String {
|
||||||
|
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
|
||||||
|
guard raw.count >= 8 else { return "invalid:truncated" }
|
||||||
|
guard Array(raw[0..<4]) == Array("VGF1".utf8) else { return "invalid:magic" }
|
||||||
|
let direction = raw[4]
|
||||||
|
let kind = raw[5]
|
||||||
|
let body = Array(raw.dropFirst(8))
|
||||||
|
guard body.count == Int(raw[6]) * 256 + Int(raw[7]) else { return "invalid:length" }
|
||||||
|
guard direction <= 1 else { return "invalid:direction" }
|
||||||
|
if direction == 0 {
|
||||||
|
if (0x10...0x12).contains(kind) { return "invalid:direction" }
|
||||||
|
switch kind {
|
||||||
|
case 1: return body.isEmpty ? "valid" : "invalid:length"
|
||||||
|
case 2:
|
||||||
|
return validFECStatus(body) ? "valid" : "invalid:field"
|
||||||
|
case 3: return body.isEmpty ? "valid" : "invalid:length"
|
||||||
|
default: return "invalid:type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 || kind == 3 { return "invalid:direction" }
|
||||||
|
switch kind {
|
||||||
|
case 0x10: return body.count == 4 ? "valid" : "invalid:length"
|
||||||
|
case 0x11:
|
||||||
|
guard body.count == 5 else { return "invalid:length" }
|
||||||
|
return body[0] <= 15 ? "valid" : "invalid:field"
|
||||||
|
case 0x12:
|
||||||
|
guard body.count == 1 else { return "invalid:length" }
|
||||||
|
return body[0] <= 1 ? "valid" : "invalid:field"
|
||||||
|
default: return "invalid:type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFECStatus(_ body: [UInt8]) -> Bool {
|
||||||
|
guard body.count == 21 else { return false }
|
||||||
|
let totalData = Int(body[10]) * 256 + Int(body[11])
|
||||||
|
let totalParity = Int(body[12]) * 256 + Int(body[13])
|
||||||
|
let receivedData = Int(body[14]) * 256 + Int(body[15])
|
||||||
|
let receivedParity = Int(body[16]) * 256 + Int(body[17])
|
||||||
|
return totalData > 0 && receivedData <= totalData && receivedParity <= totalParity && body[18] <= 100 && body[20] > 0 && body[19] < body[20]
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyDatagram(_ encoded: String) -> String {
|
||||||
|
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
|
||||||
guard raw.count >= 21 else { return "invalid:truncated" }
|
guard raw.count >= 21 else { return "invalid:truncated" }
|
||||||
guard raw[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" }
|
guard raw[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" }
|
||||||
guard raw[2] == 1 else { return "invalid:unsupported_version" }
|
guard raw[2] == 1 else { return "invalid:unsupported_version" }
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ def main() -> int:
|
|||||||
temp = pathlib.Path(directory)
|
temp = pathlib.Path(directory)
|
||||||
rust_bin = temp / "rust-conformance"
|
rust_bin = temp / "rust-conformance"
|
||||||
swift_bin = temp / "swift-conformance"
|
swift_bin = temp / "swift-conformance"
|
||||||
run(["rustc", "tools/native_conformance.rs", "-O", "-o", str(rust_bin)])
|
rust_source = temp / "main.rs"
|
||||||
|
rust_source.write_text(
|
||||||
|
(ROOT / "gen/rust/protocol.rs").read_text(encoding="utf-8")
|
||||||
|
+ "\n"
|
||||||
|
+ (ROOT / "tools/native_conformance.rs").read_text(encoding="utf-8"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
run(["rustc", str(rust_source), "-O", "-o", str(rust_bin)])
|
||||||
run([str(rust_bin)])
|
run([str(rust_bin)])
|
||||||
main_source = temp / "main.swift"
|
main_source = temp / "main.swift"
|
||||||
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")
|
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused contract-boundary regressions for check_scope.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import check_scope
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_PATHS = (
|
||||||
|
"openapi/control-v1.yaml",
|
||||||
|
"proto/versevdi/control/v1/control.proto",
|
||||||
|
"proto/versevdi/tunnel/v1/tunnel.proto",
|
||||||
|
"frames/datagram-v1.md",
|
||||||
|
"frames/registry.json",
|
||||||
|
"registries/features.json",
|
||||||
|
"registries/datagrams.json",
|
||||||
|
)
|
||||||
|
PROVIDER_WORK_PROTO = """
|
||||||
|
message ProviderSessionWork {
|
||||||
|
string client_private_key_pem = 15;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def schema_with_private_key(owner: str) -> dict[str, object]:
|
||||||
|
definitions = {
|
||||||
|
name: {"type": "object", "properties": {}}
|
||||||
|
for name in (
|
||||||
|
"ConnectionManifest",
|
||||||
|
"ManifestGateway",
|
||||||
|
"ManifestTunnel",
|
||||||
|
"ManifestProfile",
|
||||||
|
"ManifestBounds",
|
||||||
|
"GrantReference",
|
||||||
|
"ProviderSessionWork",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
definitions[owner]["properties"] = {
|
||||||
|
"client_private_key_pem": {"type": "string"},
|
||||||
|
}
|
||||||
|
return {"$defs": definitions}
|
||||||
|
|
||||||
|
|
||||||
|
def run_scope(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
schema_path = root / "schemas/control-v1.schema.json"
|
||||||
|
schema_path.parent.mkdir(parents=True)
|
||||||
|
schema_path.write_text(json.dumps(schema), encoding="utf-8")
|
||||||
|
for relative in TEXT_PATHS:
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
default = PROVIDER_WORK_PROTO if relative == "proto/versevdi/tunnel/v1/tunnel.proto" else ""
|
||||||
|
path.write_text((overrides or {}).get(relative, default), encoding="utf-8")
|
||||||
|
(root / "gen").mkdir()
|
||||||
|
|
||||||
|
original_root = check_scope.ROOT
|
||||||
|
check_scope.ROOT = root
|
||||||
|
try:
|
||||||
|
check_scope.check_manifest_schema()
|
||||||
|
check_scope.check_text_boundaries()
|
||||||
|
finally:
|
||||||
|
check_scope.ROOT = original_root
|
||||||
|
|
||||||
|
|
||||||
|
def expect_rejected(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
|
||||||
|
try:
|
||||||
|
run_scope(schema, overrides)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
raise AssertionError("client-visible private-key material was accepted")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
run_scope(schema_with_private_key("ProviderSessionWork"))
|
||||||
|
expect_rejected(schema_with_private_key("ConnectionManifest"))
|
||||||
|
expect_rejected(schema_with_private_key("ManifestProfile"))
|
||||||
|
expect_rejected(
|
||||||
|
schema_with_private_key("ProviderSessionWork"),
|
||||||
|
{
|
||||||
|
"proto/versevdi/tunnel/v1/tunnel.proto": """
|
||||||
|
message ConnectionManifest {
|
||||||
|
string client_private_key_pem = 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect_rejected(
|
||||||
|
schema_with_private_key("ProviderSessionWork"),
|
||||||
|
{"frames/datagram-v1.md": "client_private_key_pem"},
|
||||||
|
)
|
||||||
|
print("Protocol contract-aware scope regression passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -33,7 +33,7 @@ def main() -> int:
|
|||||||
|
|
||||||
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"
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
||||||
)
|
)
|
||||||
let request = try TunnelAdmissionRequest(
|
let request = try TunnelAdmissionRequest(
|
||||||
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
@@ -41,19 +41,26 @@ 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:server9: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:encoded7:encoded6:server1:19:h264-opus"
|
||||||
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 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"
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
|
||||||
|
)
|
||||||
|
let gatewayCapability = try CapabilityProfile(
|
||||||
|
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
||||||
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus", "h264-opus"]
|
||||||
)
|
)
|
||||||
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 {
|
||||||
|
fatalError("ordered registered profile intersection changed")
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
_ = try CapabilityProfile.intersection([capability, incompatible])
|
_ = try CapabilityProfile.intersection([capability, incompatible])
|
||||||
fatalError("profiles without overlap were accepted")
|
fatalError("profiles without overlap were accepted")
|
||||||
@@ -85,6 +92,60 @@ do {
|
|||||||
)
|
)
|
||||||
fatalError("invalid allocation bounds were accepted")
|
fatalError("invalid allocation bounds were accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
|
let streamPolicy = try ProviderStreamPolicy(
|
||||||
|
resolutionWidth: 2560, resolutionHeight: 1440, fps: 120,
|
||||||
|
codec: "HEVC", bitrateKbps: 40000, audioEnabled: true
|
||||||
|
)
|
||||||
|
guard streamPolicy.codec == "HEVC" else { fatalError("stream policy changed") }
|
||||||
|
for invalid in [
|
||||||
|
{ try ProviderStreamPolicy(resolutionWidth: 319, resolutionHeight: 1440, fps: 120, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true) },
|
||||||
|
{ try ProviderStreamPolicy(resolutionWidth: 2560, resolutionHeight: 1440, fps: 241, codec: "HEVC", bitrateKbps: 40000, audioEnabled: true) },
|
||||||
|
{ try ProviderStreamPolicy(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120, codec: "VP9", bitrateKbps: 40000, audioEnabled: true) },
|
||||||
|
] {
|
||||||
|
do {
|
||||||
|
_ = try invalid()
|
||||||
|
fatalError("invalid stream policy was accepted")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
let telemetry = try GatewayTelemetry(
|
||||||
|
admittedSessions: 1, admissionRejects: 2, reconnects: 3, drainTransitions: 4,
|
||||||
|
mediaDrops: 5, mediaPackets: 6, mediaBytes: 7, queueDelayMicros: 8,
|
||||||
|
processingDelayMicros: 9, processingSamples: 10, pacingDelayMicros: 11,
|
||||||
|
providerErrors: 12, inputRejected: 13, controlRttMicros: 14,
|
||||||
|
controlJitterMicros: 15, controlLossPpm: 16, pendingReliable: 17,
|
||||||
|
providerState: "ready"
|
||||||
|
)
|
||||||
|
guard telemetry.mediaBytes == 7 else { fatalError("gateway telemetry changed") }
|
||||||
|
do {
|
||||||
|
_ = try GatewayTelemetry(
|
||||||
|
admittedSessions: 1, admissionRejects: 2, reconnects: 3, drainTransitions: 4,
|
||||||
|
mediaDrops: 5, mediaPackets: 6, mediaBytes: 7, queueDelayMicros: 8,
|
||||||
|
processingDelayMicros: 9, processingSamples: 10, pacingDelayMicros: 11,
|
||||||
|
providerErrors: 12, inputRejected: 13, controlRttMicros: 14,
|
||||||
|
controlJitterMicros: 15, controlLossPpm: 1000001, pendingReliable: 17,
|
||||||
|
providerState: "ready"
|
||||||
|
)
|
||||||
|
fatalError("invalid gateway telemetry was accepted")
|
||||||
|
} catch { }
|
||||||
|
for text in [
|
||||||
|
String(repeating: "a", count: 65536),
|
||||||
|
String(repeating: "é", count: 32768),
|
||||||
|
String(repeating: "\\\"", count: 32768),
|
||||||
|
] {
|
||||||
|
let clipboard = try GatewayClipboardText(
|
||||||
|
direction: "client_to_provider", text: text, encoding: "utf-8",
|
||||||
|
loopToken: "abcdefghijklmnop"
|
||||||
|
)
|
||||||
|
let decoded = try GatewayClipboardText.decodeJSON(clipboard.encodeJSON())
|
||||||
|
guard decoded.text == text else { fatalError("clipboard text changed during round-trip") }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try GatewayClipboardText(
|
||||||
|
direction: "client_to_provider", text: String(repeating: "a", count: 65537),
|
||||||
|
encoding: "utf-8", loopToken: "abcdefghijklmnop"
|
||||||
|
)
|
||||||
|
fatalError("oversized clipboard text was accepted")
|
||||||
|
} catch { }
|
||||||
""",
|
""",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -99,7 +160,7 @@ do {
|
|||||||
fn main() {
|
fn main() {
|
||||||
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(), "h264-opus".into(),
|
"encoded".into(), "server".into(), vec!["h264-opus".into()],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let request = TunnelAdmissionRequest::new(
|
let request = TunnelAdmissionRequest::new(
|
||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
@@ -107,7 +168,7 @@ 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:server9:h264-opus";
|
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
|
||||||
assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
|
assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
|
||||||
assert!(TunnelAdmissionRequest::new(
|
assert!(TunnelAdmissionRequest::new(
|
||||||
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
@@ -124,12 +185,54 @@ fn main() {
|
|||||||
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(), "hevc-opus".into(),
|
"encoded".into(), "server".into(), vec!["hevc-opus".into()],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
let gateway_capability = CapabilityProfile::new(
|
||||||
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
|
"encoded".into(), "server".into(), vec!["hevc-opus".into(), "h264-opus".into()],
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
intersect_capability_profiles(&[gateway_capability, capabilities.clone()]).unwrap().clientDecode(),
|
||||||
|
&vec!["h264-opus".to_string()],
|
||||||
|
);
|
||||||
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
|
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
|
||||||
assert!(AllocationPolicy::new(
|
assert!(AllocationPolicy::new(
|
||||||
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
||||||
).is_err());
|
).is_err());
|
||||||
|
assert!(ProviderStreamPolicy::new(
|
||||||
|
2560, 1440, 120, "HEVC".into(), 40000, true,
|
||||||
|
).is_ok());
|
||||||
|
assert!(ProviderStreamPolicy::new(
|
||||||
|
319, 1440, 120, "HEVC".into(), 40000, true,
|
||||||
|
).is_err());
|
||||||
|
assert!(ProviderStreamPolicy::new(
|
||||||
|
2560, 1440, 241, "HEVC".into(), 40000, true,
|
||||||
|
).is_err());
|
||||||
|
assert!(ProviderStreamPolicy::new(
|
||||||
|
2560, 1440, 120, "VP9".into(), 40000, true,
|
||||||
|
).is_err());
|
||||||
|
assert!(GatewayTelemetry::new(
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, "ready".into(),
|
||||||
|
).is_ok());
|
||||||
|
assert!(GatewayTelemetry::new(
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1000001, 17, "ready".into(),
|
||||||
|
).is_err());
|
||||||
|
assert!(GatewayTelemetry::new(
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, "provider.example:47984".into(),
|
||||||
|
).is_err());
|
||||||
|
for text in [
|
||||||
|
"a".repeat(65536),
|
||||||
|
"é".repeat(32768),
|
||||||
|
"\\\"".repeat(32768),
|
||||||
|
] {
|
||||||
|
assert!(GatewayClipboardText::new(
|
||||||
|
"client_to_provider".into(), text, "utf-8".into(), "abcdefghijklmnop".into(),
|
||||||
|
).is_ok());
|
||||||
|
}
|
||||||
|
assert!(GatewayClipboardText::new(
|
||||||
|
"client_to_provider".into(), "a".repeat(65537), "utf-8".into(),
|
||||||
|
"abcdefghijklmnop".into(),
|
||||||
|
).is_err());
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate fixed-byte Phase 3C gateway input and feedback envelopes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import binascii
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_input(raw: bytes) -> str:
|
||||||
|
if len(raw) < 6:
|
||||||
|
return "invalid:truncated"
|
||||||
|
if raw[:4] != b"VGI1":
|
||||||
|
return "invalid:magic"
|
||||||
|
kind, length = raw[4], raw[5]
|
||||||
|
if len(raw) != 6 + length:
|
||||||
|
return "invalid:length"
|
||||||
|
body = raw[6:]
|
||||||
|
if kind == 1:
|
||||||
|
return "valid" if len(body) == 4 and body[0] <= 1 and body[2:4] != b"\0\0" else "invalid:field"
|
||||||
|
if kind == 2:
|
||||||
|
return "valid" if len(body) == 3 and body[0] <= 1 and 1 <= body[1] <= 5 and body[2] == 0 else "invalid:reserved"
|
||||||
|
if kind == 3:
|
||||||
|
return "valid" if len(body) == 4 else "invalid:length"
|
||||||
|
if kind == 4:
|
||||||
|
try:
|
||||||
|
decoded = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "invalid:utf8"
|
||||||
|
return "valid" if 1 <= len(body) <= 4 and len(decoded) == 1 else "invalid:utf8"
|
||||||
|
if kind == 5:
|
||||||
|
if len(body) != 17:
|
||||||
|
return "invalid:length"
|
||||||
|
if body[0] > 15:
|
||||||
|
return "invalid:field"
|
||||||
|
active_mask = int.from_bytes(body[1:3], "big")
|
||||||
|
return "valid" if active_mask or not any(body[3:]) else "invalid:field"
|
||||||
|
return "invalid:kind"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_feedback(raw: bytes) -> str:
|
||||||
|
if len(raw) < 8:
|
||||||
|
return "invalid:truncated"
|
||||||
|
if raw[:4] != b"VGF1":
|
||||||
|
return "invalid:magic"
|
||||||
|
direction, kind = raw[4], raw[5]
|
||||||
|
length = int.from_bytes(raw[6:8], "big")
|
||||||
|
if len(raw) != 8 + length:
|
||||||
|
return "invalid:length"
|
||||||
|
if direction not in (0, 1):
|
||||||
|
return "invalid:direction"
|
||||||
|
body = raw[8:]
|
||||||
|
if direction == 0:
|
||||||
|
if kind in (0x10, 0x11, 0x12):
|
||||||
|
return "invalid:direction"
|
||||||
|
if kind == 1:
|
||||||
|
return "valid" if not body else "invalid:length"
|
||||||
|
if kind == 2:
|
||||||
|
return "valid" if valid_fec_status(body) else "invalid:field"
|
||||||
|
if kind == 3:
|
||||||
|
return "valid" if not body else "invalid:length"
|
||||||
|
return "invalid:type"
|
||||||
|
if kind in (1, 2, 3):
|
||||||
|
return "invalid:direction"
|
||||||
|
if kind == 0x10:
|
||||||
|
return "valid" if len(body) == 4 else "invalid:length"
|
||||||
|
if kind == 0x11:
|
||||||
|
return "valid" if len(body) == 5 and body[0] <= 15 else "invalid:field"
|
||||||
|
if kind == 0x12:
|
||||||
|
return "valid" if len(body) == 1 and body[0] <= 1 else "invalid:field"
|
||||||
|
return "invalid:type"
|
||||||
|
|
||||||
|
|
||||||
|
def valid_fec_status(body: bytes) -> bool:
|
||||||
|
return len(body) == 21 and int.from_bytes(body[10:12], "big") > 0 and int.from_bytes(body[14:16], "big") <= int.from_bytes(body[10:12], "big") and int.from_bytes(body[16:18], "big") <= int.from_bytes(body[12:14], "big") and body[18] <= 100 and body[20] > 0 and body[19] < body[20]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
lines = (ROOT / "fixtures/conformance/gateway-input-feedback-v1.tsv").read_text(encoding="utf-8").splitlines()
|
||||||
|
assert lines[0] == "id\tversion\tkind\tinput\texpected"
|
||||||
|
for line in lines[1:]:
|
||||||
|
identifier, version, kind, input_value, expected = line.split("\t")
|
||||||
|
assert version == "1"
|
||||||
|
try:
|
||||||
|
raw = binascii.unhexlify(input_value.removeprefix("hex="))
|
||||||
|
except binascii.Error:
|
||||||
|
actual = "invalid:hex"
|
||||||
|
else:
|
||||||
|
actual = classify_input(raw) if kind == "gateway_input" else classify_feedback(raw)
|
||||||
|
assert actual == expected, f"{identifier}: {actual} != {expected}"
|
||||||
|
print("Gateway input/feedback envelope validation passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user