Compare commits

...
Author SHA1 Message Date
sechmachine 4a2772c053 feat(protocol): bind tunnel admission to device proof
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m11s
2026-07-29 10:32:08 +07:00
sechmachine be709724ba feat(protocol): validate generated gateway contracts
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m14s
2026-07-29 10:03:37 +07:00
sechmachine 7c145001c0 docs(protocol): archive gateway contract 2026-07-29 07:00:02 +07:00
sechmachine 28a9aace24 docs(protocol): complete gateway candidate task 2026-07-29 06:43:34 +07:00
sechmachine b5558133e3 feat(protocol): add gateway transport authority contracts 2026-07-29 05:59:46 +07:00
24 changed files with 4756 additions and 339 deletions
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: verify generate proto-lint proto-breaking source-verify scope-verify conformance frame-verify go-test binding-compile clean-generated .PHONY: verify generate proto-lint proto-breaking source-verify scope-verify conformance frame-verify go-test binding-compile strict-contracts clean-generated
PYTHON ?= python3 PYTHON ?= python3
PROTOC ?= protoc PROTOC ?= protoc
@@ -29,6 +29,9 @@ binding-compile:
rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib
swiftc -typecheck gen/swift/Protocol.swift swiftc -typecheck gen/swift/Protocol.swift
strict-contracts:
$(PYTHON) -B tools/test_generated_contracts.py
conformance: conformance:
$(PYTHON) -B tools/fixture_digest.py $(PYTHON) -B tools/fixture_digest.py
go run ./tools/go-conformance go run ./tools/go-conformance
@@ -40,4 +43,4 @@ frame-verify:
clean-generated: clean-generated:
$(PYTHON) tools/generate.py --check $(PYTHON) tools/generate.py --check
verify: generate proto-lint proto-breaking source-verify scope-verify go-test binding-compile conformance frame-verify clean-generated verify: generate proto-lint proto-breaking source-verify scope-verify go-test binding-compile strict-contracts conformance frame-verify clean-generated
+2
View File
@@ -4,3 +4,5 @@ invalid-short 1 datagram hex=564401 invalid:truncated
invalid-version 1 datagram hex=564402010000000000000000000000000000010000 invalid:unsupported_version invalid-version 1 datagram hex=564402010000000000000000000000000000010000 invalid:unsupported_version
invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel
invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch
valid-video-empty 1 datagram hex=5644010a0000000000000000000000000000010000 valid
invalid-media-channel 1 datagram hex=5644010d0000000000000000000000000000010000 invalid:unknown_channel
1 id version kind input expected
4 invalid-version 1 datagram hex=564402010000000000000000000000000000010000 invalid:unsupported_version
5 invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel
6 invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch
7 valid-video-empty 1 datagram hex=5644010a0000000000000000000000000000010000 valid
8 invalid-media-channel 1 datagram hex=5644010d0000000000000000000000000000010000 invalid:unknown_channel
+1 -1
View File
@@ -6,5 +6,5 @@
"fixtures/conformance/events-v1.tsv", "fixtures/conformance/events-v1.tsv",
"fixtures/conformance/tunnel-v1.tsv" "fixtures/conformance/tunnel-v1.tsv"
], ],
"corpus_sha256": "c91a512dc67aa9912b31b21144be2adfeacf0dc80dd8515bd3b4a8f52977e761" "corpus_sha256": "0874d39dd14c0107e602ea8909f8d53673f964c67dc9fd5fcadb8641e6ebf592"
} }
+10 -5
View File
@@ -1,7 +1,9 @@
# VerseVDI control datagram v1 # VerseVDI control datagram v1
Phase 3A reserves a bounded control-datagram envelope. It does not forward video, Phase 3C adds bounded encoded video/audio and sequenced-input channels to the same
audio, provider traffic, VM traffic, or arbitrary binary clipboard data. transport envelope. The gateway forwards encoded bytes; it does not decode, encode,
transcode, render, or expose provider traffic. Arbitrary binary clipboard data remains
rejected.
All multi-byte integers are unsigned big-endian. The fixed header is 21 bytes: All multi-byte integers are unsigned big-endian. The fixed header is 21 bytes:
@@ -22,6 +24,9 @@ Truncated, oversized, unknown-version, unknown-channel, invalid-fragment, and
length-mismatch frames are rejected before allocation proportional to the claimed length-mismatch frames are rejected before allocation proportional to the claimed
payload. Media/provider identifiers are not registered channels. payload. Media/provider identifiers are not registered channels.
Registered Phase 3A channels are `control.ack.v1`, `control.cancel.v1`, and Registered channels are `control.ack.v1`, `control.cancel.v1`, `clipboard.text.v1`,
`clipboard.text.v1`. Clipboard payloads are UTF-8 JSON text contracts and remain `media.video.v1`, `media.audio.v1`, and `input.sequenced.v1`. Media/input frames use
subject to the 65,536-byte text limit and explicit authorization. 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
JSON text contracts and remain subject to the 65,536-byte text limit and explicit
authorization.
+5 -2
View File
@@ -6,7 +6,10 @@
"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},
{"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": 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}
], ],
"reserved_rejected": ["video", "audio", "provider", "vm", "file-transfer", "clipboard.binary"] "reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"]
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -12,7 +12,7 @@
"2" "2"
] ]
}, },
"generator_sha256": "cb975bcd42bf77641b6a0f44d5ec7a6fdba1858d6b8a865e0f04e53bad82648c", "generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab",
"protocol_version": "1.0.0", "protocol_version": "1.0.0",
"schema_sha256": "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1" "schema_sha256": "6b8631bf2b2aa12b14d0bc4d136af39a632e85b3237dc5614469ba09d93f5fca"
} }
Binary file not shown.
+1304 -130
View File
File diff suppressed because it is too large Load Diff
+1436 -188
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
## Decisions
- Use the existing generated JSON binding pipeline and add only object definitions that
consumers need now.
- Keep registration/authority messages distinct from `ConnectionManifest`; the latter is
client-facing and contains no provider route, certificate, identity, or credential.
- Encode capability domains as bounded registered strings (`transport`, `framing`, `media`,
`audio`, `source_rate_control`, `client_decode`) so unknown or empty required domains
cannot silently fall back.
- Use a fixed 21-byte big-endian datagram header with application flow IDs and a payload
limit below the path MTU; control channels 1-3 remain compatible with Phase 3A.
## Bounds and failure behavior
All arrays, strings, payloads, fragments, and timestamps are bounded by the JSON schema or
frame registry. Generated decoders reject unknown fields, trailing values, invalid versions,
and missing required fields. Provider address, RTSP, credential, and private-key names are
not added to client-facing definitions.
## Compatibility
The current wire version remains `1`; current, N-1, and N-2 declarations remain unchanged.
New identifiers are additive. Consumers must reject an unknown major or no-overlap selection
before provider launch.
@@ -0,0 +1,32 @@
## Why
Phase 3A defines gateway-only manifests and control datagrams but has no versioned
contracts for the separately deployed gateway's registration, admission authority,
capability intersection, lifecycle, or encoded relay framing. Phase 3C-G needs those
contracts frozen before Data Plane or Connection Server consumers change.
## What Changes
- Add bounded JSON bindings for gateway registration, heartbeat/drain, tunnel admission,
session-scoped authority, capability selection, channel framing, provider state, and
stable errors.
- Extend the tunnel protobuf descriptor with the same versioned control messages.
- Register only gateway-owned media/input flow identifiers; keep provider endpoints and
credentials out of client-facing manifests.
## Ownership and provenance
Protocol owns the wire contract. The contract is original VerseVDI work derived from the
Phase 3A schemas and public Apollo/Moonlight behavior recorded in the Planning Hub. No
GPL source is copied into this repository.
## Non-goals
- Provider implementation, database policy, QUIC implementation, media decoding, or client
rendering.
- Direct client-to-Apollo routing, provider endpoint exposure, or a speculative plugin ABI.
## Stop conditions
Unknown versions, malformed bounds, no capability overlap, downgrade without explicit
acknowledgement, forbidden provider fields, and oversized frames fail closed.
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Versioned gateway authority contracts
Gateway registration, heartbeat, drain, admission, capability selection, channel framing,
provider state, and stable errors SHALL use bounded versioned objects with strict decoding.
#### Scenario: Unknown or malformed gateway message
- **WHEN** a consumer receives an unknown version, missing required field, unknown field,
invalid bound, or trailing JSON value
- **THEN** it rejects the message before allocating provider or media state with a stable
validation error.
### Requirement: Gateway-only client manifest
Client-facing manifests SHALL expose only the selected gateway, tunnel/profile identifiers,
policy bounds, opaque grant, audience, expiry, session, and correlation data.
#### Scenario: Provider route injection
- **WHEN** a manifest or client-facing authority contains a provider address, RTSP URL,
certificate, pairing identity, credential, or private key field
- **THEN** schema validation rejects it and no generated client binding accepts it.
### Requirement: Explicit capability intersection
Transport, framing, media, audio, source-rate-control, and client-decode capabilities SHALL
remain separate and no-overlap or unknown-required-profile results SHALL fail closed.
#### Scenario: No capability overlap
- **WHEN** policy, gateway, provider, and client capabilities have no allowed intersection
- **THEN** negotiation returns a stable no-overlap error before provider launch or media flow.
### Requirement: Bounded encoded datagrams
Encoded media and approved sequenced input SHALL use registered application flow identifiers,
validated fixed headers, bounded fragments, and payload bytes that are not codec-transformed.
#### Scenario: Malformed or oversized datagram
- **WHEN** a datagram is truncated, has an unknown flow, invalid fragment, length mismatch,
or exceeds its registered payload limit
- **THEN** the datagram is rejected without allocation proportional to the claimed payload.
@@ -0,0 +1,5 @@
- [x] Add versioned gateway and authority definitions to the schema and tunnel descriptor.
- [x] Add registered media/audio/sequenced-input flow identifiers and positive/negative fixtures.
- [x] Regenerate Go/Rust/Swift bindings and descriptor outputs.
- [x] Run strict validation, cross-language conformance, and deterministic fixture hashing.
- [x] Freeze the local Protocol candidate commit and record its hash for consumers.
+93
View File
@@ -49,3 +49,96 @@ message ChannelOpen {
string direction = 2; string direction = 2;
uint32 maximum_frame_bytes = 3; uint32 maximum_frame_bytes = 3;
} }
message CapabilityProfile {
string transport = 1;
string framing = 2;
string media = 3;
string audio = 4;
string source_rate_control = 5;
string client_decode = 6;
}
message GatewayRegistration {
string version = 1;
string gateway_id = 2;
string instance_identity = 3;
string certificate_identity = 4;
string public_identity = 5;
string address = 6;
uint32 protocol_min_version = 7;
uint32 protocol_max_version = 8;
uint32 connection_capacity = 9;
uint64 bandwidth_capacity_kbps = 10;
repeated string features = 11;
CapabilityProfile capabilities = 12;
string provider_identity = 13;
}
message GatewayHeartbeat {
string version = 1;
string gateway_id = 2;
uint64 sequence = 3;
google.protobuf.Timestamp observed_at = 4;
uint32 active_connections = 5;
uint64 egress_kbps = 6;
string state = 7;
}
message GatewayDrain {
string version = 1;
string gateway_id = 2;
uint64 sequence = 3;
string reason = 4;
google.protobuf.Timestamp deadline = 5;
}
message TunnelAdmissionRequest {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
string grant = 5;
uint64 reconnect_sequence = 6;
string client_nonce = 7;
CapabilityProfile capabilities = 8;
string device_signature = 9;
}
message SessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
string provider_profile = 8;
string provider_identity = 9;
}
message ChannelFrame {
string version = 1;
string flow_id = 2;
uint64 sequence = 3;
uint32 flags = 4;
uint32 fragment_index = 5;
uint32 fragment_count = 6;
uint64 timestamp_ms = 7;
bytes payload = 8;
}
message ProviderState {
string version = 1;
string session_id = 2;
string state = 3;
bool cleanup_pending = 4;
repeated string channels = 5;
}
message StableError {
string version = 1;
string code = 2;
string message = 3;
bool retryable = 4;
}
+4 -1
View File
@@ -4,6 +4,9 @@
"datagrams": [ "datagrams": [
{"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024}, {"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024},
{"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.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
{"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200}
] ]
} }
+129
View File
@@ -343,6 +343,135 @@
"supported_versions": {"type": "array", "minItems": 1, "maxItems": 3, "items": {"type": "string", "maxLength": 16}}, "supported_versions": {"type": "array", "minItems": 1, "maxItems": 3, "items": {"type": "string", "maxLength": 16}},
"features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 64}} "features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 64}}
} }
},
"CapabilityProfile": {
"type": "object",
"additionalProperties": false,
"required": ["transport", "framing", "media", "audio", "source_rate_control", "client_decode"],
"properties": {
"transport": {"type": "string", "minLength": 1, "maxLength": 64},
"framing": {"type": "string", "minLength": 1, "maxLength": 64},
"media": {"type": "string", "minLength": 1, "maxLength": 64},
"audio": {"type": "string", "minLength": 1, "maxLength": 64},
"source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64},
"client_decode": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"GatewayRegistration": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "instance_identity", "certificate_identity", "public_identity", "address", "provider_identity", "protocol_min_version", "protocol_max_version", "connection_capacity", "bandwidth_capacity_kbps", "features", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"instance_identity": {"type": "string", "minLength": 1, "maxLength": 512},
"certificate_identity": {"type": "string", "minLength": 1, "maxLength": 512},
"public_identity": {"type": "string", "minLength": 1, "maxLength": 256},
"address": {"type": "string", "minLength": 1, "maxLength": 256},
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
"protocol_min_version": {"type": "integer", "minimum": 1, "maximum": 100},
"protocol_max_version": {"type": "integer", "minimum": 1, "maximum": 100},
"connection_capacity": {"type": "integer", "minimum": 1, "maximum": 1000000},
"bandwidth_capacity_kbps": {"type": "integer", "minimum": 1, "maximum": 1000000000},
"features": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 64}},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"GatewayHeartbeat": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "sequence", "observed_at", "active_connections", "egress_kbps", "state"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"sequence": {"type": "integer", "minimum": 1},
"observed_at": {"type": "string", "format": "date-time", "maxLength": 64},
"active_connections": {"type": "integer", "minimum": 0, "maximum": 1000000},
"egress_kbps": {"type": "integer", "minimum": 0, "maximum": 1000000000},
"state": {"type": "string", "enum": ["ready", "draining", "offline"]}
}
},
"GatewayDrain": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "sequence", "reason", "deadline"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"sequence": {"type": "integer", "minimum": 1},
"reason": {"type": "string", "minLength": 1, "maxLength": 256},
"deadline": {"type": "string", "format": "date-time", "maxLength": 64}
}
},
"TunnelAdmissionRequest": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "device_signature", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"grant": {"type": "string", "minLength": 43, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"client_nonce": {"type": "string", "minLength": 16, "maxLength": 128},
"device_signature": {"type": "string", "minLength": 86, "maxLength": 86},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"SessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "provider_profile", "provider_identity"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"},
"provider_profile": {"type": "string", "enum": ["apollo"]},
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"ChannelFrame": {
"type": "object",
"additionalProperties": false,
"required": ["version", "flow_id", "sequence", "flags", "fragment_index", "fragment_count", "timestamp_ms", "payload"],
"properties": {
"version": {"type": "string", "const": "1"},
"flow_id": {"type": "string", "minLength": 1, "maxLength": 64},
"sequence": {"type": "integer", "minimum": 0},
"flags": {"type": "integer", "minimum": 0, "maximum": 255},
"fragment_index": {"type": "integer", "minimum": 0, "maximum": 15},
"fragment_count": {"type": "integer", "minimum": 1, "maximum": 16},
"timestamp_ms": {"type": "integer", "minimum": 0},
"payload": {"type": "string", "maxLength": 87384, "x-max-bytes": 65536}
}
},
"ProviderState": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "state", "cleanup_pending", "channels"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"state": {"type": "string", "enum": ["starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"]},
"cleanup_pending": {"type": "boolean"},
"channels": {"type": "array", "maxItems": 8, "items": {"type": "string", "minLength": 1, "maxLength": 64}}
}
},
"StableError": {
"type": "object",
"additionalProperties": false,
"required": ["version", "code", "message", "retryable"],
"properties": {
"version": {"type": "string", "const": "1"},
"code": {"type": "string", "minLength": 1, "maxLength": 128},
"message": {"type": "string", "minLength": 1, "maxLength": 512},
"retryable": {"type": "boolean"}
}
} }
} }
} }
+77
View File
@@ -35,3 +35,80 @@ func TestGeneratedDecodersRejectMissingRequiredFieldsAndTrailingValues(t *testin
t.Fatal("DecodeErrorEnvelope accepted a missing required boolean") t.Fatal("DecodeErrorEnvelope accepted a missing required boolean")
} }
} }
func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) {
registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":1,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"}}`
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err != nil {
t.Fatalf("valid gateway registration rejected: %v", err)
}
for _, invalid := range []string{
strings.Replace(registration, `"version":"1"`, `"version":"2"`, 1),
strings.Replace(registration, `"version":"1"`, `"version":"0"`, 1),
strings.Replace(registration, `"features":["datagram.media"]`, `"features":["datagram.media"],"provider_url":"https://provider.invalid"`, 1),
} {
if _, err := protocol.DecodeGatewayRegistration([]byte(invalid)); err == nil {
t.Fatalf("invalid gateway registration accepted: %s", invalid)
}
}
if _, err := protocol.DecodeGatewayRegistration([]byte("{")); err == nil {
t.Fatal("DecodeGatewayRegistration accepted malformed JSON")
}
}
func TestGatewayRegistrationRejectsInvertedProtocolBounds(t *testing.T) {
registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":2,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"}}`
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err == nil {
t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds")
}
}
func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) {
first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", SourceRateControl: "server", ClientDecode: "h264-opus"}
if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || got != first {
t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err)
}
second := first
second.ClientDecode = "hevc-opus"
if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil {
t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile")
}
}
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) {
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 {
t.Fatalf("valid session authority rejected: %v", err)
}
if _, err := protocol.DecodeSessionAuthority([]byte(strings.Replace(valid, `"provider_identity":"provider-1"`, `"provider_identity":"provider-1","rtsp_url":"rtsp://provider.invalid"`, 1))); err == nil {
t.Fatal("session authority accepted a provider route")
}
}
+235 -5
View File
@@ -151,6 +151,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
reference = ref_name(prop) reference = ref_name(prop)
if reference: if reference:
lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}") lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}")
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }")
if name == "GatewayRegistration":
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
if name == "ChannelFrame":
lines.append("\tif v.FragmentIndex >= v.FragmentCount { violations = append(violations, FieldViolation{Field: \"fragment_index\", Code: \"invalid_order\"}) }")
return lines return lines
@@ -165,6 +171,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"\"errors\"", "\"errors\"",
"\"fmt\"", "\"fmt\"",
"\"reflect\"", "\"reflect\"",
"\"strings\"",
"\"time\"", "\"time\"",
")", ")",
"", "",
@@ -235,6 +242,30 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
out.append("\treturn json.Marshal(value)") out.append("\treturn json.Marshal(value)")
out.append("}") out.append("}")
out.append("") out.append("")
out.extend([
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
"",
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\tselected := profiles[0]",
"\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\tfor _, profile := range profiles[1:] {",
"\t\tif err := profile.Validate(); err != nil || profile != selected { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\t}",
"\treturn selected, nil",
"}",
"",
])
out.extend([
"func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {",
"\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, 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"')
@@ -272,6 +303,58 @@ def swift_type(prop: dict[str, Any]) -> str:
return "String" return "String"
def rust_validation(definition: dict[str, Any]) -> list[str]:
lines: list[str] = []
required = set(definition.get("required", []))
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
value = f"self.{field}"
if prop_name not in required:
value = f"value"
lines.append(f" if let Some(value) = &self.{field} {{")
prefix, suffix = " ", " }"
else:
prefix, suffix = "", ""
if prop.get("type") == "string":
if prop_name in required and prop.get("minLength", 0) > 0:
lines.append(f" {prefix}if {value}.is_empty() {{ return Err(ValidationError::new(\"{prop_name}\", \"required\")); }}")
if "minLength" in prop:
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
if "maxLength" in prop:
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
if "const" in prop:
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
if "enum" in prop:
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
if prop.get("type") == "integer":
if "minimum" in prop:
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
if "maximum" in prop:
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}")
if prop.get("type") == "array":
if "minItems" in prop:
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
item_ref = ref_name(prop.get("items", {}))
if item_ref:
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
reference = ref_name(prop)
if reference:
lines.append(f" {prefix}{value}.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_object\"))?;")
if suffix:
lines.append(suffix)
name = definition["name"]
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }")
if name == "GatewayRegistration":
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
if name == "ChannelFrame":
lines.append(" if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new(\"fragment_index\", \"invalid_order\")); }")
return lines
def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
out = [ out = [
"// Code generated by tools/generate.py; DO NOT EDIT.", "// Code generated by tools/generate.py; DO NOT EDIT.",
@@ -282,6 +365,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";', f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";',
"pub type JsonObject = std::collections::BTreeMap<String, String>;", "pub type JsonObject = std::collections::BTreeMap<String, String>;",
"", "",
"#[derive(Debug, Clone, PartialEq, Eq)]",
"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 } } }",
"",
] ]
for name in sorted(defs): for name in sorted(defs):
definition = defs[name] definition = defs[name]
@@ -292,11 +379,113 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
typ = rust_type(prop) typ = rust_type(prop)
if prop_name not in required: if prop_name not in required:
typ = f"Option<{typ}>" typ = f"Option<{typ}>"
out.append(f" pub {field}: {typ},") out.append(f" {field}: {typ},")
out.extend(["}", ""]) out.extend(["}", ""])
parameters: list[str] = []
assignments: list[str] = []
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
typ = rust_type(prop)
if prop_name not in required:
typ = f"Option<{typ}>"
parameters.append(f"{field}: {typ}")
assignments.append(field)
out.append(f"impl {name} {{")
out.append(f" pub fn new({', '.join(parameters)}) -> Result<Self, ValidationError> {{")
out.append(f" let value = Self {{ {', '.join(assignments)} }};")
out.append(" value.validate()?;")
out.append(" Ok(value)")
out.append(" }")
out.append(" pub fn validate(&self) -> Result<(), ValidationError> {")
out.extend(rust_validation(definition))
out.append(" Ok(())")
out.append(" }")
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
typ = rust_type(prop)
if prop_name not in required:
typ = f"Option<{typ}>"
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([
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
" let selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
" for profile in &profiles[1..] {",
" profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
" if profile != &selected { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
" }",
" Ok(selected)",
"}",
"",
])
return "\n".join(out) return "\n".join(out)
def swift_validation(definition: dict[str, Any]) -> list[str]:
lines: list[str] = []
required = set(definition.get("required", []))
for prop_name, prop in definition.get("properties", {}).items():
field = swift_field(prop_name)
value = f"self.{field}"
if prop_name not in required:
value = "value"
lines.append(f" if let value = self.{field} {{")
prefix, suffix = " ", " }"
else:
prefix, suffix = "", ""
if prop.get("type") == "string":
if prop_name in required and prop.get("minLength", 0) > 0:
lines.append(f" {prefix}if {value}.isEmpty {{ throw ContractValidationError(field: \"{prop_name}\", code: \"required\") }}")
if "minLength" in prop:
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
if "maxLength" in prop:
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
if "const" in prop:
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
if "enum" in prop:
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
if prop.get("format") == "date-time":
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
if prop.get("type") == "integer":
if "minimum" in prop:
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
if "maximum" in prop:
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"maximum\") }}")
if prop.get("type") == "array":
if "minItems" in prop:
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
item_ref = ref_name(prop.get("items", {}))
if item_ref:
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
reference = ref_name(prop)
if reference:
lines.append(f" {prefix}try {value}.validate()")
if suffix:
lines.append(suffix)
name = definition["name"]
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }")
if name == "GatewayRegistration":
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
if name == "ChannelFrame":
lines.append(" if fragmentIndex >= fragmentCount { throw ContractValidationError(field: \"fragment_index\", code: \"invalid_order\") }")
return lines
def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
out = [ out = [
"// Code generated by tools/generate.py; DO NOT EDIT.", "// Code generated by tools/generate.py; DO NOT EDIT.",
@@ -306,6 +495,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
f'public let currentWireVersion = "{compatibility["current"]}"', f'public let currentWireVersion = "{compatibility["current"]}"',
f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"', f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"',
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 }",
"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 } }",
"", "",
] ]
for name in sorted(defs): for name in sorted(defs):
@@ -320,16 +511,55 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
out.append(" enum CodingKeys: String, CodingKey {") out.append(" enum CodingKeys: String, CodingKey {")
for prop_name in definition.get("properties", {}): for prop_name in definition.get("properties", {}):
out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"") out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"")
out.extend([" }", "", " public init(from decoder: Decoder) throws {",]) parameters: list[str] = []
for prop_name, prop in definition.get("properties", {}).items():
typ = swift_type(prop)
if prop_name not in required:
typ += "?"
parameters.append(f"{swift_field(prop_name)}: {typ}")
out.extend([" }", "", f" public init({', '.join(parameters)}) throws {{"])
for prop_name in definition.get("properties", {}):
field = swift_field(prop_name)
out.append(f" self.{field} = {field}")
out.extend([" try validate()", " }", "", " public init(from decoder: Decoder) throws {"])
out.append(" let all = try decoder.container(keyedBy: AnyCodingKey.self)")
out.append(" for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: \"unknown_field\") }")
out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)") out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)")
decoded: list[str] = []
for prop_name, prop in definition.get("properties", {}).items(): for prop_name, prop in definition.get("properties", {}).items():
field = swift_field(prop_name) field = swift_field(prop_name)
typ = swift_type(prop) typ = swift_type(prop)
if prop_name in required: if prop_name in required:
out.append(f" {field} = try c.decode({typ}.self, forKey: .{field})") decoded.append(f"{field}: try c.decode({typ}.self, forKey: .{field})")
else: else:
out.append(f" {field} = try c.decodeIfPresent({typ}.self, forKey: .{field})") decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
out.extend([" }", "}", ""]) out.append(f" try self.init({', '.join(decoded)})")
out.extend([" }", "", " public func validate() throws {"])
out.extend(swift_validation(definition))
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
out.extend([
"public 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 {",
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
" try selected.validate()",
" for profile in profiles.dropFirst() {",
" try profile.validate()",
" if profile != selected { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
" }",
" return selected",
" }",
"}",
"",
])
return "\n".join(out) return "\n".join(out)
+1 -1
View File
@@ -157,7 +157,7 @@ func classifyDatagram(encoded string) string {
if raw[2] != 1 { if raw[2] != 1 {
return "invalid:unsupported_version" return "invalid:unsupported_version"
} }
limits := map[byte]int{1: 1024, 2: 2048, 3: 65515} limits := map[byte]int{1: 1024, 2: 2048, 3: 65515, 10: 1179, 11: 1179, 12: 1179}
limit, ok := limits[raw[3]] limit, ok := limits[raw[3]]
if !ok { if !ok {
return "invalid:unknown_channel" return "invalid:unknown_channel"
+1
View File
@@ -85,6 +85,7 @@ fn classify_datagram(encoded: &str) -> &'static str {
1 => 1024, 1 => 1024,
2 => 2048, 2 => 2048,
3 => 65515, 3 => 65515,
10 | 11 | 12 => 1179,
_ => return "invalid:unknown_channel", _ => return "invalid:unknown_channel",
}; };
if raw[4] != 0 { if raw[4] != 0 {
+1
View File
@@ -50,6 +50,7 @@ func classifyDatagram(_ encoded: String) -> String {
case 1: limit = 1024 case 1: limit = 1024
case 2: limit = 2048 case 2: limit = 2048
case 3: limit = 65515 case 3: limit = 65515
case 10, 11, 12: limit = 1179
default: return "invalid:unknown_channel" default: return "invalid:unknown_channel"
} }
guard raw[4] == 0 else { return "invalid:flags" } guard raw[4] == 0 else { return "invalid:flags" }
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Compile and exercise strict generated Swift and Rust gateway contracts."""
from __future__ import annotations
import pathlib
import shutil
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[1]
def run(command: list[str], directory: pathlib.Path) -> None:
result = subprocess.run(command, cwd=directory, text=True, capture_output=True, check=False)
if result.returncode != 0:
raise RuntimeError("%s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> None:
result = subprocess.run(command, cwd=directory, text=True, capture_output=True, check=False)
if result.returncode == 0 or expected not in result.stdout + result.stderr:
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
def main() -> int:
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
workspace = pathlib.Path(temporary)
swift = workspace / "main.swift"
swift.write_text(
"""import Foundation
let capability = try CapabilityProfile(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: "h264-opus"
)
let request = try TunnelAdmissionRequest(
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
grant: String(repeating: "g", count: 43), reconnectSequence: 0,
clientNonce: String(repeating: "n", count: 16), deviceSignature: String(repeating: "s", count: 86), capabilities: capability
)
_ = request
let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + String(repeating: "g", count: 43) + "1:016:" + String(repeating: "n", count: 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server9:h264-opus"
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
fatalError("unexpected device admission transcript")
}
let incompatible = try CapabilityProfile(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: "hevc-opus"
)
do {
guard try CapabilityProfile.intersection([capability, capability]) == capability else {
fatalError("matching capability profiles did not intersect")
}
} catch { fatalError("matching capability profiles did not intersect") }
do {
_ = try CapabilityProfile.intersection([capability, incompatible])
fatalError("profiles without overlap were accepted")
} catch { }
let valid = try request.encodeJSON()
var unsupported = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
unsupported["version"] = "2"
var downgrade = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
downgrade["version"] = "0"
var unknown = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
unknown["unknown"] = true
for invalid in [
try JSONSerialization.data(withJSONObject: unsupported),
try JSONSerialization.data(withJSONObject: downgrade),
try JSONSerialization.data(withJSONObject: unknown),
Data("{".utf8),
valid + Data(" {}".utf8),
] {
do {
_ = try TunnelAdmissionRequest.decodeJSON(invalid)
fatalError("invalid tunnel admission request was accepted")
} catch { }
}
do {
_ = try AllocationPolicy(
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
audience: "audience", protocolValue: "verse", protocolVersion: 1,
grantTtlSeconds: 60, reservationLeaseSeconds: 300
)
fatalError("invalid allocation bounds were accepted")
} catch { }
""",
encoding="utf-8",
)
run(["swiftc", str(ROOT / "gen/swift/Protocol.swift"), str(swift), "-o", str(workspace / "swift-contracts")], ROOT)
run([str(workspace / "swift-contracts")], ROOT)
rust = workspace / "protocol.rs"
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust)
with rust.open("a", encoding="utf-8") as output:
output.write(
"""
fn main() {
let capabilities = CapabilityProfile::new(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
"encoded".into(), "server".into(), "h264-opus".into(),
).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(
"2".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
).is_err());
assert!(TunnelAdmissionRequest::new(
"0".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
).is_err());
assert!(TunnelAdmissionRequest::new(
"1".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
).is_err());
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
let incompatible = CapabilityProfile::new(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
"encoded".into(), "server".into(), "hevc-opus".into(),
).unwrap();
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
assert!(AllocationPolicy::new(
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
).is_err());
}
"""
)
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
run([str(workspace / "rust-contracts")], ROOT)
rust_unknown = workspace / "unknown.rs"
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
with rust_unknown.open("a", encoding="utf-8") as output:
output.write("\nfn main() { let _ = CapabilityProfile { unknown: String::new() }; }\n")
run_failure(["rustc", str(rust_unknown), "-o", str(workspace / "rust-unknown")], ROOT, "no field named `unknown`")
print("Generated strict contract checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -9,7 +9,7 @@ import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
HEADER_BYTES = 21 HEADER_BYTES = 21
MAX_FRAME_BYTES = 65536 MAX_FRAME_BYTES = 65536
CHANNEL_LIMITS = {1: 1024, 2: 2048, 3: 65515} CHANNEL_LIMITS = {1: 1024, 2: 2048, 3: 65515, 10: 1179, 11: 1179, 12: 1179}
def classify(raw: bytes) -> str: def classify(raw: bytes) -> str: