Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebfe07376d | ||
|
|
0ea21cd3f2 | ||
|
|
36f6edffca | ||
|
|
357e5e0dbc | ||
|
|
4a2772c053 |
@@ -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,22 @@
|
|||||||
|
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-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-feedback-type 1 gateway_feedback hex=5647463100030000 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": "69d5b12a533ff0d9786784b99aecc8a74a7ec2c6855b75c52e46ecff5bd3e6c5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,78 @@
|
|||||||
|
# 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) and `0x02` FEC
|
||||||
|
status: `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 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"]
|
||||||
}
|
}
|
||||||
|
|||||||
+582
-4
@@ -3,15 +3,17 @@ package protocol
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaSHA256 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"
|
const SchemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
const ProtocolVersion = "1.0.0"
|
const ProtocolVersion = "1.0.0"
|
||||||
const CurrentWireVersion = "1"
|
const CurrentWireVersion = "1"
|
||||||
const NMinus1WireVersion = "0"
|
const NMinus1WireVersion = "0"
|
||||||
@@ -85,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"`
|
||||||
@@ -157,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"`
|
||||||
@@ -239,6 +264,28 @@ type PageInfo struct {
|
|||||||
NextCursor string `json:"next_cursor"`
|
NextCursor string `json:"next_cursor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderSessionWork struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
GatewayID string `json:"gateway_id"`
|
||||||
|
ReconnectSequence int64 `json:"reconnect_sequence"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
ProviderProfile string `json:"provider_profile"`
|
||||||
|
ProviderIdentity string `json:"provider_identity"`
|
||||||
|
PolicyVersionID string `json:"policy_version_id"`
|
||||||
|
ApplicationID string `json:"application_id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ManagementHost string `json:"management_host"`
|
||||||
|
ManagementPort int64 `json:"management_port"`
|
||||||
|
StreamHost string `json:"stream_host"`
|
||||||
|
StreamPort int64 `json:"stream_port"`
|
||||||
|
ClientCertificatePem string `json:"client_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 {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
@@ -326,6 +373,7 @@ type TunnelAdmissionRequest struct {
|
|||||||
Grant string `json:"grant"`
|
Grant string `json:"grant"`
|
||||||
ReconnectSequence int64 `json:"reconnect_sequence"`
|
ReconnectSequence int64 `json:"reconnect_sequence"`
|
||||||
ClientNonce string `json:"client_nonce"`
|
ClientNonce string `json:"client_nonce"`
|
||||||
|
DeviceSignature string `json:"device_signature"`
|
||||||
Capabilities CapabilityProfile `json:"capabilities"`
|
Capabilities CapabilityProfile `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +969,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"})
|
||||||
}
|
}
|
||||||
@@ -963,9 +1014,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 {
|
||||||
@@ -991,6 +1039,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 == "" {
|
||||||
@@ -1912,6 +2032,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 == "" {
|
||||||
@@ -2915,6 +3223,254 @@ func EncodePageInfo(value PageInfo) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v ProviderSessionWork) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Version == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Version != "1" && v.Version != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.SessionID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) < 1 && v.SessionID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.GatewayID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) < 1 && v.GatewayID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ExpiresAt) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.ProviderProfile == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_profile", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ProviderProfile != "apollo" && v.ProviderProfile != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_profile", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.ProviderIdentity == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_identity", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ProviderIdentity) < 1 && v.ProviderIdentity != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_identity", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ProviderIdentity) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider_identity", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.PolicyVersionID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.PolicyVersionID) < 1 && v.PolicyVersionID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.PolicyVersionID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "policy_version_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ApplicationID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "application_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ApplicationID) < 1 && v.ApplicationID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "application_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ApplicationID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "application_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ClientID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ClientID) < 1 && v.ClientID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ClientID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ManagementHost == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_host", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ManagementHost) < 1 && v.ManagementHost != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_host", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ManagementHost) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_host", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ManagementPort == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_port", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ManagementPort != 0 && v.ManagementPort < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_port", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ManagementPort > 65535 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "management_port", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.StreamHost == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_host", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.StreamHost) < 1 && v.StreamHost != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_host", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.StreamHost) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_host", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.StreamPort == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_port", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.StreamPort != 0 && v.StreamPort < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_port", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.StreamPort > 65535 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "stream_port", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ClientCertificatePem == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_certificate_pem", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ClientCertificatePem) < 1 && v.ClientCertificatePem != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_certificate_pem", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ClientCertificatePem) > 32768 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_certificate_pem", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ClientPrivateKeyPem == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_private_key_pem", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ClientPrivateKeyPem) < 1 && v.ClientPrivateKeyPem != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_private_key_pem", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ClientPrivateKeyPem) > 32768 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_private_key_pem", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ServerCertificatePem == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "server_certificate_pem", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ServerCertificatePem) < 1 && v.ServerCertificatePem != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "server_certificate_pem", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ServerCertificatePem) > 32768 {
|
||||||
|
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 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
|
||||||
|
var value ProviderSessionWork
|
||||||
|
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["application_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "application_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["client_certificate_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_certificate_pem", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["client_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
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"}}}
|
||||||
|
}
|
||||||
|
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")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["management_host"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "management_host", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["management_port"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "management_port", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["policy_version_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "policy_version_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["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")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider_profile"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_profile", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["server_certificate_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "server_certificate_pem", 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["stream_host"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_host", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["stream_port"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "stream_port", 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 EncodeProviderSessionWork(value ProviderSessionWork) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v ProviderState) Validate() error {
|
func (v ProviderState) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Version == "" {
|
if v.Version == "" {
|
||||||
@@ -3923,6 +4479,15 @@ func (v TunnelAdmissionRequest) Validate() error {
|
|||||||
if len(v.ClientNonce) > 128 {
|
if len(v.ClientNonce) > 128 {
|
||||||
violations = append(violations, FieldViolation{Field: "client_nonce", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "client_nonce", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if v.DeviceSignature == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_signature", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceSignature) < 86 && v.DeviceSignature != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_signature", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceSignature) > 86 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_signature", Code: "max_length"})
|
||||||
|
}
|
||||||
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
||||||
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
||||||
}
|
}
|
||||||
@@ -3953,6 +4518,9 @@ func DecodeTunnelAdmissionRequest(data []byte) (TunnelAdmissionRequest, error) {
|
|||||||
if raw, ok := fields["client_nonce"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["client_nonce"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "client_nonce", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_nonce", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["device_signature"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "device_signature", Code: "required"}}}
|
||||||
|
}
|
||||||
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
|
||||||
}
|
}
|
||||||
@@ -4073,3 +4641,13 @@ func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfi
|
|||||||
}
|
}
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {
|
||||||
|
fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, v.Capabilities.ClientDecode}
|
||||||
|
var transcript strings.Builder
|
||||||
|
transcript.WriteString("versevdi/tunnel-admission/v1")
|
||||||
|
for _, field := range fields {
|
||||||
|
fmt.Fprintf(&transcript, "%d:%s", len(field), field)
|
||||||
|
}
|
||||||
|
return []byte(transcript.String())
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"2"
|
"2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generator_sha256": "88535ecf2b1c926104b10b4ab56f1bc6298e1c3e75490e36ee8581a135bcded7",
|
"generator_sha256": "922983e07a8ecc559771778fbf139155b14664742d9873be062880102777dccb",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"
|
"schema_sha256": "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+226
-3
@@ -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 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec";
|
pub const SCHEMA_SHA256: &str = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8";
|
||||||
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,
|
||||||
@@ -259,6 +280,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 +294,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 +661,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,
|
||||||
@@ -982,6 +1098,101 @@ impl PageInfo {
|
|||||||
pub fn nextCursor(&self) -> &String { &self.nextCursor }
|
pub fn nextCursor(&self) -> &String { &self.nextCursor }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderSessionWork {
|
||||||
|
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,
|
||||||
|
clipboardPolicy: ClipboardPolicy,
|
||||||
|
providerApplicationTerminationAllowed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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, clipboardPolicy, providerApplicationTerminationAllowed };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
|
||||||
|
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
|
||||||
|
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
|
||||||
|
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
||||||
|
if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); }
|
||||||
|
if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); }
|
||||||
|
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
||||||
|
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
||||||
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
|
||||||
|
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
|
||||||
|
if !self.providerIdentity.is_empty() && self.providerIdentity.len() < 1 { return Err(ValidationError::new("provider_identity", "min_length")); }
|
||||||
|
if self.providerIdentity.len() > 256 { return Err(ValidationError::new("provider_identity", "max_length")); }
|
||||||
|
if self.policyVersionId.is_empty() { return Err(ValidationError::new("policy_version_id", "required")); }
|
||||||
|
if !self.policyVersionId.is_empty() && self.policyVersionId.len() < 1 { return Err(ValidationError::new("policy_version_id", "min_length")); }
|
||||||
|
if self.policyVersionId.len() > 128 { return Err(ValidationError::new("policy_version_id", "max_length")); }
|
||||||
|
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.len() > 128 { return Err(ValidationError::new("application_id", "max_length")); }
|
||||||
|
if self.clientId.is_empty() { return Err(ValidationError::new("client_id", "required")); }
|
||||||
|
if !self.clientId.is_empty() && self.clientId.len() < 1 { return Err(ValidationError::new("client_id", "min_length")); }
|
||||||
|
if self.clientId.len() > 128 { return Err(ValidationError::new("client_id", "max_length")); }
|
||||||
|
if self.managementHost.is_empty() { return Err(ValidationError::new("management_host", "required")); }
|
||||||
|
if !self.managementHost.is_empty() && self.managementHost.len() < 1 { return Err(ValidationError::new("management_host", "min_length")); }
|
||||||
|
if self.managementHost.len() > 256 { return Err(ValidationError::new("management_host", "max_length")); }
|
||||||
|
if self.managementPort < 1 { return Err(ValidationError::new("management_port", "minimum")); }
|
||||||
|
if self.managementPort > 65535 { return Err(ValidationError::new("management_port", "maximum")); }
|
||||||
|
if self.streamHost.is_empty() { return Err(ValidationError::new("stream_host", "required")); }
|
||||||
|
if !self.streamHost.is_empty() && self.streamHost.len() < 1 { return Err(ValidationError::new("stream_host", "min_length")); }
|
||||||
|
if self.streamHost.len() > 256 { return Err(ValidationError::new("stream_host", "max_length")); }
|
||||||
|
if self.streamPort < 1 { return Err(ValidationError::new("stream_port", "minimum")); }
|
||||||
|
if self.streamPort > 65535 { return Err(ValidationError::new("stream_port", "maximum")); }
|
||||||
|
if self.clientCertificatePem.is_empty() { return Err(ValidationError::new("client_certificate_pem", "required")); }
|
||||||
|
if !self.clientCertificatePem.is_empty() && self.clientCertificatePem.len() < 1 { return Err(ValidationError::new("client_certificate_pem", "min_length")); }
|
||||||
|
if self.clientCertificatePem.len() > 32768 { return Err(ValidationError::new("client_certificate_pem", "max_length")); }
|
||||||
|
if self.clientPrivateKeyPem.is_empty() { return Err(ValidationError::new("client_private_key_pem", "required")); }
|
||||||
|
if !self.clientPrivateKeyPem.is_empty() && self.clientPrivateKeyPem.len() < 1 { return Err(ValidationError::new("client_private_key_pem", "min_length")); }
|
||||||
|
if self.clientPrivateKeyPem.len() > 32768 { return Err(ValidationError::new("client_private_key_pem", "max_length")); }
|
||||||
|
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.len() > 32768 { return Err(ValidationError::new("server_certificate_pem", "max_length")); }
|
||||||
|
self.clipboardPolicy.validate().map_err(|_| ValidationError::new("clipboard_policy", "invalid_object"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn version(&self) -> &String { &self.version }
|
||||||
|
pub fn sessionId(&self) -> &String { &self.sessionId }
|
||||||
|
pub fn gatewayId(&self) -> &String { &self.gatewayId }
|
||||||
|
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
|
||||||
|
pub fn expiresAt(&self) -> &String { &self.expiresAt }
|
||||||
|
pub fn providerProfile(&self) -> &String { &self.providerProfile }
|
||||||
|
pub fn providerIdentity(&self) -> &String { &self.providerIdentity }
|
||||||
|
pub fn policyVersionId(&self) -> &String { &self.policyVersionId }
|
||||||
|
pub fn applicationId(&self) -> &String { &self.applicationId }
|
||||||
|
pub fn clientId(&self) -> &String { &self.clientId }
|
||||||
|
pub fn managementHost(&self) -> &String { &self.managementHost }
|
||||||
|
pub fn managementPort(&self) -> &i64 { &self.managementPort }
|
||||||
|
pub fn streamHost(&self) -> &String { &self.streamHost }
|
||||||
|
pub fn streamPort(&self) -> &i64 { &self.streamPort }
|
||||||
|
pub fn clientCertificatePem(&self) -> &String { &self.clientCertificatePem }
|
||||||
|
pub fn clientPrivateKeyPem(&self) -> &String { &self.clientPrivateKeyPem }
|
||||||
|
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)]
|
||||||
pub struct ProviderState {
|
pub struct ProviderState {
|
||||||
version: String,
|
version: String,
|
||||||
@@ -1345,12 +1556,13 @@ pub struct TunnelAdmissionRequest {
|
|||||||
grant: String,
|
grant: String,
|
||||||
reconnectSequence: i64,
|
reconnectSequence: i64,
|
||||||
clientNonce: String,
|
clientNonce: String,
|
||||||
|
deviceSignature: String,
|
||||||
capabilities: CapabilityProfile,
|
capabilities: CapabilityProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TunnelAdmissionRequest {
|
impl TunnelAdmissionRequest {
|
||||||
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: i64, clientNonce: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
|
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: i64, clientNonce: String, deviceSignature: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
|
||||||
let value = Self { version, sessionId, gatewayId, audience, grant, reconnectSequence, clientNonce, capabilities };
|
let value = Self { version, sessionId, gatewayId, audience, grant, reconnectSequence, clientNonce, deviceSignature, capabilities };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -1372,6 +1584,9 @@ impl TunnelAdmissionRequest {
|
|||||||
if self.clientNonce.is_empty() { return Err(ValidationError::new("client_nonce", "required")); }
|
if self.clientNonce.is_empty() { return Err(ValidationError::new("client_nonce", "required")); }
|
||||||
if !self.clientNonce.is_empty() && self.clientNonce.len() < 16 { return Err(ValidationError::new("client_nonce", "min_length")); }
|
if !self.clientNonce.is_empty() && self.clientNonce.len() < 16 { return Err(ValidationError::new("client_nonce", "min_length")); }
|
||||||
if self.clientNonce.len() > 128 { return Err(ValidationError::new("client_nonce", "max_length")); }
|
if self.clientNonce.len() > 128 { return Err(ValidationError::new("client_nonce", "max_length")); }
|
||||||
|
if self.deviceSignature.is_empty() { return Err(ValidationError::new("device_signature", "required")); }
|
||||||
|
if !self.deviceSignature.is_empty() && self.deviceSignature.len() < 86 { return Err(ValidationError::new("device_signature", "min_length")); }
|
||||||
|
if self.deviceSignature.len() > 86 { return Err(ValidationError::new("device_signature", "max_length")); }
|
||||||
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1382,7 +1597,15 @@ impl TunnelAdmissionRequest {
|
|||||||
pub fn grant(&self) -> &String { &self.grant }
|
pub fn grant(&self) -> &String { &self.grant }
|
||||||
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
|
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
|
||||||
pub fn clientNonce(&self) -> &String { &self.clientNonce }
|
pub fn clientNonce(&self) -> &String { &self.clientNonce }
|
||||||
|
pub fn deviceSignature(&self) -> &String { &self.deviceSignature }
|
||||||
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
||||||
|
pub fn device_admission_transcript(&self) -> Vec<u8> {
|
||||||
|
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 mut transcript = String::from("versevdi/tunnel-admission/v1");
|
||||||
|
for field in fields { transcript.push_str(&format!("{}:{}", field.as_bytes().len(), field)); }
|
||||||
|
transcript.into_bytes()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
+279
-3
@@ -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 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"
|
public let schemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
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
|
||||||
@@ -343,6 +352,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 +360,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 +857,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
|
||||||
@@ -1310,6 +1449,128 @@ public struct PageInfo: 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 ProviderSessionWork: Codable, Equatable {
|
||||||
|
public let version: String
|
||||||
|
public let sessionId: String
|
||||||
|
public let gatewayId: String
|
||||||
|
public let reconnectSequence: Int64
|
||||||
|
public let expiresAt: String
|
||||||
|
public let providerProfile: String
|
||||||
|
public let providerIdentity: String
|
||||||
|
public let policyVersionId: String
|
||||||
|
public let applicationId: String
|
||||||
|
public let clientId: String
|
||||||
|
public let managementHost: String
|
||||||
|
public let managementPort: Int64
|
||||||
|
public let streamHost: String
|
||||||
|
public let streamPort: Int64
|
||||||
|
public let clientCertificatePem: String
|
||||||
|
public let clientPrivateKeyPem: String
|
||||||
|
public let serverCertificatePem: String
|
||||||
|
public let clipboardPolicy: ClipboardPolicy
|
||||||
|
public let providerApplicationTerminationAllowed: Bool
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version = "version"
|
||||||
|
case sessionId = "session_id"
|
||||||
|
case gatewayId = "gateway_id"
|
||||||
|
case reconnectSequence = "reconnect_sequence"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case providerProfile = "provider_profile"
|
||||||
|
case providerIdentity = "provider_identity"
|
||||||
|
case policyVersionId = "policy_version_id"
|
||||||
|
case applicationId = "application_id"
|
||||||
|
case clientId = "client_id"
|
||||||
|
case managementHost = "management_host"
|
||||||
|
case managementPort = "management_port"
|
||||||
|
case streamHost = "stream_host"
|
||||||
|
case streamPort = "stream_port"
|
||||||
|
case clientCertificatePem = "client_certificate_pem"
|
||||||
|
case clientPrivateKeyPem = "client_private_key_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, clipboardPolicy: ClipboardPolicy, providerApplicationTerminationAllowed: Bool) throws {
|
||||||
|
self.version = version
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.gatewayId = gatewayId
|
||||||
|
self.reconnectSequence = reconnectSequence
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.providerProfile = providerProfile
|
||||||
|
self.providerIdentity = providerIdentity
|
||||||
|
self.policyVersionId = policyVersionId
|
||||||
|
self.applicationId = applicationId
|
||||||
|
self.clientId = clientId
|
||||||
|
self.managementHost = managementHost
|
||||||
|
self.managementPort = managementPort
|
||||||
|
self.streamHost = streamHost
|
||||||
|
self.streamPort = streamPort
|
||||||
|
self.clientCertificatePem = clientCertificatePem
|
||||||
|
self.clientPrivateKeyPem = clientPrivateKeyPem
|
||||||
|
self.serverCertificatePem = serverCertificatePem
|
||||||
|
self.clipboardPolicy = clipboardPolicy
|
||||||
|
self.providerApplicationTerminationAllowed = providerApplicationTerminationAllowed
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), 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), clipboardPolicy: try c.decode(ClipboardPolicy.self, forKey: .clipboardPolicy), providerApplicationTerminationAllowed: try c.decode(Bool.self, forKey: .providerApplicationTerminationAllowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
|
||||||
|
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
|
||||||
|
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
|
||||||
|
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
||||||
|
if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") }
|
||||||
|
if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") }
|
||||||
|
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
||||||
|
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
||||||
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
|
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
|
if self.providerProfile != "apollo" { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
|
||||||
|
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
|
||||||
|
if !self.providerIdentity.isEmpty && self.providerIdentity.utf8.count < 1 { throw ContractValidationError(field: "provider_identity", code: "min_length") }
|
||||||
|
if self.providerIdentity.utf8.count > 256 { throw ContractValidationError(field: "provider_identity", code: "max_length") }
|
||||||
|
if self.policyVersionId.isEmpty { throw ContractValidationError(field: "policy_version_id", code: "required") }
|
||||||
|
if !self.policyVersionId.isEmpty && self.policyVersionId.utf8.count < 1 { throw ContractValidationError(field: "policy_version_id", code: "min_length") }
|
||||||
|
if self.policyVersionId.utf8.count > 128 { throw ContractValidationError(field: "policy_version_id", code: "max_length") }
|
||||||
|
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.utf8.count > 128 { throw ContractValidationError(field: "application_id", code: "max_length") }
|
||||||
|
if self.clientId.isEmpty { throw ContractValidationError(field: "client_id", code: "required") }
|
||||||
|
if !self.clientId.isEmpty && self.clientId.utf8.count < 1 { throw ContractValidationError(field: "client_id", code: "min_length") }
|
||||||
|
if self.clientId.utf8.count > 128 { throw ContractValidationError(field: "client_id", code: "max_length") }
|
||||||
|
if self.managementHost.isEmpty { throw ContractValidationError(field: "management_host", code: "required") }
|
||||||
|
if !self.managementHost.isEmpty && self.managementHost.utf8.count < 1 { throw ContractValidationError(field: "management_host", code: "min_length") }
|
||||||
|
if self.managementHost.utf8.count > 256 { throw ContractValidationError(field: "management_host", code: "max_length") }
|
||||||
|
if self.managementPort < 1 { throw ContractValidationError(field: "management_port", code: "minimum") }
|
||||||
|
if self.managementPort > 65535 { throw ContractValidationError(field: "management_port", code: "maximum") }
|
||||||
|
if self.streamHost.isEmpty { throw ContractValidationError(field: "stream_host", code: "required") }
|
||||||
|
if !self.streamHost.isEmpty && self.streamHost.utf8.count < 1 { throw ContractValidationError(field: "stream_host", code: "min_length") }
|
||||||
|
if self.streamHost.utf8.count > 256 { throw ContractValidationError(field: "stream_host", code: "max_length") }
|
||||||
|
if self.streamPort < 1 { throw ContractValidationError(field: "stream_port", code: "minimum") }
|
||||||
|
if self.streamPort > 65535 { throw ContractValidationError(field: "stream_port", code: "maximum") }
|
||||||
|
if self.clientCertificatePem.isEmpty { throw ContractValidationError(field: "client_certificate_pem", code: "required") }
|
||||||
|
if !self.clientCertificatePem.isEmpty && self.clientCertificatePem.utf8.count < 1 { throw ContractValidationError(field: "client_certificate_pem", code: "min_length") }
|
||||||
|
if self.clientCertificatePem.utf8.count > 32768 { throw ContractValidationError(field: "client_certificate_pem", code: "max_length") }
|
||||||
|
if self.clientPrivateKeyPem.isEmpty { throw ContractValidationError(field: "client_private_key_pem", code: "required") }
|
||||||
|
if !self.clientPrivateKeyPem.isEmpty && self.clientPrivateKeyPem.utf8.count < 1 { throw ContractValidationError(field: "client_private_key_pem", code: "min_length") }
|
||||||
|
if self.clientPrivateKeyPem.utf8.count > 32768 { throw ContractValidationError(field: "client_private_key_pem", code: "max_length") }
|
||||||
|
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.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 func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct ProviderState: Codable, Equatable {
|
public struct ProviderState: Codable, Equatable {
|
||||||
public let version: String
|
public let version: String
|
||||||
public let sessionId: String
|
public let sessionId: String
|
||||||
@@ -1797,6 +2058,7 @@ public struct TunnelAdmissionRequest: Codable, Equatable {
|
|||||||
public let grant: String
|
public let grant: String
|
||||||
public let reconnectSequence: Int64
|
public let reconnectSequence: Int64
|
||||||
public let clientNonce: String
|
public let clientNonce: String
|
||||||
|
public let deviceSignature: String
|
||||||
public let capabilities: CapabilityProfile
|
public let capabilities: CapabilityProfile
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case version = "version"
|
case version = "version"
|
||||||
@@ -1806,10 +2068,11 @@ public struct TunnelAdmissionRequest: Codable, Equatable {
|
|||||||
case grant = "grant"
|
case grant = "grant"
|
||||||
case reconnectSequence = "reconnect_sequence"
|
case reconnectSequence = "reconnect_sequence"
|
||||||
case clientNonce = "client_nonce"
|
case clientNonce = "client_nonce"
|
||||||
|
case deviceSignature = "device_signature"
|
||||||
case capabilities = "capabilities"
|
case capabilities = "capabilities"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: Int64, clientNonce: String, capabilities: CapabilityProfile) throws {
|
public init(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: Int64, clientNonce: String, deviceSignature: String, capabilities: CapabilityProfile) throws {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.gatewayId = gatewayId
|
self.gatewayId = gatewayId
|
||||||
@@ -1817,6 +2080,7 @@ public struct TunnelAdmissionRequest: Codable, Equatable {
|
|||||||
self.grant = grant
|
self.grant = grant
|
||||||
self.reconnectSequence = reconnectSequence
|
self.reconnectSequence = reconnectSequence
|
||||||
self.clientNonce = clientNonce
|
self.clientNonce = clientNonce
|
||||||
|
self.deviceSignature = deviceSignature
|
||||||
self.capabilities = capabilities
|
self.capabilities = capabilities
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
@@ -1825,7 +2089,7 @@ public struct TunnelAdmissionRequest: 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), audience: try c.decode(String.self, forKey: .audience), grant: try c.decode(String.self, forKey: .grant), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), clientNonce: try c.decode(String.self, forKey: .clientNonce), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), grant: try c.decode(String.self, forKey: .grant), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), clientNonce: try c.decode(String.self, forKey: .clientNonce), deviceSignature: try c.decode(String.self, forKey: .deviceSignature), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -1846,6 +2110,9 @@ public struct TunnelAdmissionRequest: Codable, Equatable {
|
|||||||
if self.clientNonce.isEmpty { throw ContractValidationError(field: "client_nonce", code: "required") }
|
if self.clientNonce.isEmpty { throw ContractValidationError(field: "client_nonce", code: "required") }
|
||||||
if !self.clientNonce.isEmpty && self.clientNonce.utf8.count < 16 { throw ContractValidationError(field: "client_nonce", code: "min_length") }
|
if !self.clientNonce.isEmpty && self.clientNonce.utf8.count < 16 { throw ContractValidationError(field: "client_nonce", code: "min_length") }
|
||||||
if self.clientNonce.utf8.count > 128 { throw ContractValidationError(field: "client_nonce", code: "max_length") }
|
if self.clientNonce.utf8.count > 128 { throw ContractValidationError(field: "client_nonce", code: "max_length") }
|
||||||
|
if self.deviceSignature.isEmpty { throw ContractValidationError(field: "device_signature", code: "required") }
|
||||||
|
if !self.deviceSignature.isEmpty && self.deviceSignature.utf8.count < 86 { throw ContractValidationError(field: "device_signature", code: "min_length") }
|
||||||
|
if self.deviceSignature.utf8.count > 86 { throw ContractValidationError(field: "device_signature", code: "max_length") }
|
||||||
try self.capabilities.validate()
|
try self.capabilities.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1884,6 +2151,15 @@ public struct VersionNegotiation: 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 extension TunnelAdmissionRequest {
|
||||||
|
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 transcript = "versevdi/tunnel-admission/v1"
|
||||||
|
for field in fields { transcript += "\(field.utf8.count):\(field)" }
|
||||||
|
return Data(transcript.utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public extension CapabilityProfile {
|
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") }
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
- [ ] 3.1 Reconcile the canonical specification, OpenSpec tasks, source
|
||||||
|
provenance, and consumer fixture digest before creating a new immutable
|
||||||
|
Protocol release candidate.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ message TunnelAdmissionRequest {
|
|||||||
uint64 reconnect_sequence = 6;
|
uint64 reconnect_sequence = 6;
|
||||||
string client_nonce = 7;
|
string client_nonce = 7;
|
||||||
CapabilityProfile capabilities = 8;
|
CapabilityProfile capabilities = 8;
|
||||||
|
string device_signature = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SessionAuthority {
|
message SessionAuthority {
|
||||||
@@ -116,6 +117,35 @@ message SessionAuthority {
|
|||||||
string provider_identity = 9;
|
string provider_identity = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ProviderSessionWork {
|
||||||
|
string version = 1;
|
||||||
|
string session_id = 2;
|
||||||
|
string gateway_id = 3;
|
||||||
|
uint64 reconnect_sequence = 4;
|
||||||
|
google.protobuf.Timestamp expires_at = 5;
|
||||||
|
string provider_profile = 6;
|
||||||
|
string provider_identity = 7;
|
||||||
|
string policy_version_id = 8;
|
||||||
|
string application_id = 9;
|
||||||
|
string management_host = 10;
|
||||||
|
uint32 management_port = 11;
|
||||||
|
string stream_host = 12;
|
||||||
|
uint32 stream_port = 13;
|
||||||
|
string client_certificate_pem = 14;
|
||||||
|
string client_private_key_pem = 15;
|
||||||
|
string server_certificate_pem = 16;
|
||||||
|
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 {
|
||||||
string version = 1;
|
string version = 1;
|
||||||
string flow_id = 2;
|
string flow_id = 2;
|
||||||
@@ -135,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,
|
||||||
@@ -406,7 +428,7 @@
|
|||||||
"TunnelAdmissionRequest": {
|
"TunnelAdmissionRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "capabilities"],
|
"required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "device_signature", "capabilities"],
|
||||||
"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},
|
||||||
@@ -415,6 +437,7 @@
|
|||||||
"grant": {"type": "string", "minLength": 43, "maxLength": 256},
|
"grant": {"type": "string", "minLength": 43, "maxLength": 256},
|
||||||
"reconnect_sequence": {"type": "integer", "minimum": 0},
|
"reconnect_sequence": {"type": "integer", "minimum": 0},
|
||||||
"client_nonce": {"type": "string", "minLength": 16, "maxLength": 128},
|
"client_nonce": {"type": "string", "minLength": 16, "maxLength": 128},
|
||||||
|
"device_signature": {"type": "string", "minLength": 86, "maxLength": 86},
|
||||||
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -434,6 +457,32 @@
|
|||||||
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ProviderSessionWork": {
|
||||||
|
"type": "object",
|
||||||
|
"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", "clipboard_policy", "provider_application_termination_allowed"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"type": "string", "const": "1"},
|
||||||
|
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"reconnect_sequence": {"type": "integer", "minimum": 0},
|
||||||
|
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
||||||
|
"provider_profile": {"type": "string", "const": "apollo"},
|
||||||
|
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
|
"policy_version_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"application_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"client_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"management_host": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
|
"management_port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||||
|
"stream_host": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
|
"stream_port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||||
|
"client_certificate_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},
|
||||||
|
"clipboard_policy": {"$ref": "#/$defs/ClipboardPolicy"},
|
||||||
|
"provider_application_termination_allowed": {"type": "boolean"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ChannelFrame": {
|
"ChannelFrame": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -461,6 +510,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,
|
||||||
|
|||||||
@@ -74,6 +74,35 @@ func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) {
|
||||||
|
request := protocol.TunnelAdmissionRequest{
|
||||||
|
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||||
|
Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0,
|
||||||
|
ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{
|
||||||
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
||||||
|
SourceRateControl: "server", ClientDecode: "h264-opus",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
|
||||||
|
t.Fatal("EncodeTunnelAdmissionRequest accepted an unsigned device admission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing.T) {
|
||||||
|
request := protocol.TunnelAdmissionRequest{
|
||||||
|
Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience",
|
||||||
|
Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16),
|
||||||
|
DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{
|
||||||
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
||||||
|
SourceRateControl: "server", ClientDecode: "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"
|
||||||
|
if got := string(request.DeviceAdmissionTranscript()); 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 {
|
||||||
@@ -83,3 +112,66 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
|||||||
t.Fatal("session authority accepted a provider route")
|
t.Fatal("session authority accepted a provider route")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
|
||||||
|
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
|
||||||
|
t.Fatalf("valid provider work rejected: %v", err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
+74
-1
@@ -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\"}}) }}")
|
||||||
@@ -167,10 +171,12 @@ 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\"",
|
||||||
"\"reflect\"",
|
"\"reflect\"",
|
||||||
|
"\"strings\"",
|
||||||
"\"time\"",
|
"\"time\"",
|
||||||
")",
|
")",
|
||||||
"",
|
"",
|
||||||
@@ -222,7 +228,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)
|
||||||
@@ -255,6 +261,16 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
])
|
])
|
||||||
|
out.extend([
|
||||||
|
"func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {",
|
||||||
|
"\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, v.Capabilities.ClientDecode}",
|
||||||
|
"\tvar transcript strings.Builder",
|
||||||
|
"\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")",
|
||||||
|
"\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }",
|
||||||
|
"\treturn []byte(transcript.String())",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
# Use io.EOF in generated code without making every generated decoder depend on
|
# Use io.EOF in generated code without making every generated decoder depend on
|
||||||
# error-string comparison; replace the deliberately compact placeholder.
|
# error-string comparison; replace the deliberately compact placeholder.
|
||||||
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
|
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
|
||||||
@@ -311,11 +327,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\")); }}")
|
||||||
@@ -358,6 +378,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]
|
||||||
@@ -395,6 +436,16 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
if prop_name not in required:
|
if prop_name not in required:
|
||||||
typ = f"Option<{typ}>"
|
typ = f"Option<{typ}>"
|
||||||
out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}")
|
out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}")
|
||||||
|
if name == "TunnelAdmissionRequest":
|
||||||
|
out.extend([
|
||||||
|
" pub fn device_admission_transcript(&self) -> Vec<u8> {",
|
||||||
|
" 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 mut transcript = String::from(\"versevdi/tunnel-admission/v1\");",
|
||||||
|
" for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }",
|
||||||
|
" transcript.into_bytes()",
|
||||||
|
" }",
|
||||||
|
])
|
||||||
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> {",
|
||||||
@@ -430,6 +481,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:
|
||||||
@@ -437,6 +490,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\") }}")
|
||||||
@@ -476,6 +531,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):
|
||||||
@@ -517,6 +581,15 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
out.extend(swift_validation(definition))
|
out.extend(swift_validation(definition))
|
||||||
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
|
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
|
||||||
out.extend([
|
out.extend([
|
||||||
|
"public extension TunnelAdmissionRequest {",
|
||||||
|
" 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 transcript = \"versevdi/tunnel-admission/v1\"",
|
||||||
|
" for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }",
|
||||||
|
" return Data(transcript.utf8)",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
"public extension CapabilityProfile {",
|
"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\") }",
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -138,11 +139,178 @@ 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"
|
||||||
|
default:
|
||||||
|
return "invalid:type"
|
||||||
|
}
|
||||||
|
return "invalid:length"
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 {
|
||||||
|
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,125 @@ 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",
|
||||||
|
_ => "invalid:type",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 {
|
||||||
|
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,104 @@ 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"
|
||||||
|
default: return "invalid:type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == 1 || kind == 2 { 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()
|
||||||
@@ -38,9 +38,13 @@ let capability = try CapabilityProfile(
|
|||||||
let request = try TunnelAdmissionRequest(
|
let request = try TunnelAdmissionRequest(
|
||||||
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
grant: String(repeating: "g", count: 43), reconnectSequence: 0,
|
grant: String(repeating: "g", count: 43), reconnectSequence: 0,
|
||||||
clientNonce: String(repeating: "n", count: 16), 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"
|
||||||
|
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
|
||||||
|
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"
|
||||||
@@ -81,6 +85,25 @@ do {
|
|||||||
)
|
)
|
||||||
fatalError("invalid allocation bounds were accepted")
|
fatalError("invalid allocation bounds were accepted")
|
||||||
} catch { }
|
} 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",
|
||||||
)
|
)
|
||||||
@@ -97,17 +120,25 @@ fn main() {
|
|||||||
"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(), "h264-opus".into(),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
let request = TunnelAdmissionRequest::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
|
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
||||||
|
).unwrap();
|
||||||
|
let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:".to_string()
|
||||||
|
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
|
||||||
|
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server9:h264-opus";
|
||||||
|
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(),
|
||||||
"g".repeat(43), 0, "n".repeat(16), capabilities.clone(),
|
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
assert!(TunnelAdmissionRequest::new(
|
assert!(TunnelAdmissionRequest::new(
|
||||||
"0".into(), "session".into(), "gateway".into(), "audience".into(),
|
"0".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "n".repeat(16), capabilities.clone(),
|
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
assert!(TunnelAdmissionRequest::new(
|
assert!(TunnelAdmissionRequest::new(
|
||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "short".into(), capabilities.clone(),
|
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
||||||
let incompatible = CapabilityProfile::new(
|
let incompatible = CapabilityProfile::new(
|
||||||
@@ -118,6 +149,19 @@ fn main() {
|
|||||||
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());
|
||||||
|
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,95 @@
|
|||||||
|
#!/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"
|
||||||
|
return "invalid:type"
|
||||||
|
if kind in (1, 2):
|
||||||
|
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