From 0ea21cd3f2ea198281dc18c18c67787c9d020dd2 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:24:18 +0700 Subject: [PATCH] feat(protocol): define gateway control envelopes --- Makefile | 1 + .../gateway-clipboard-audit-v1.tsv | 6 + fixtures/conformance/gateway-clipboard-v1.tsv | 6 + .../conformance/gateway-input-feedback-v1.tsv | 22 ++ fixtures/manifest.json | 5 +- frames/datagram-v1.md | 9 +- frames/gateway-clipboard-v1.md | 28 ++ frames/gateway-input-feedback-v1.md | 78 +++++ frames/registry.json | 4 +- gen/go/protocol/protocol.go | 328 +++++++++++++++++- gen/manifest.json | 2 +- gen/protobuf/control-v1.pb | Bin 8224 -> 8602 bytes gen/protobuf/tunnel-v1.pb | Bin 17309 -> 18667 bytes gen/rust/protocol.rs | 103 +++++- gen/swift/Protocol.swift | 140 +++++++- .../.openspec.yaml | 2 + .../phase3c-apollo-input-feedback/design.md | 50 +++ .../phase3c-apollo-input-feedback/proposal.md | 40 +++ .../specs/gateway-input-feedback/spec.md | 74 ++++ .../phase3c-apollo-input-feedback/tasks.md | 29 ++ proto/versevdi/control/v1/control.proto | 7 + proto/versevdi/tunnel/v1/tunnel.proto | 18 + registries/datagrams.json | 4 +- schemas/control-v1.schema.json | 41 ++- tests/go/protocol_test.go | 21 +- tools/go-conformance/main.go | 176 +++++++++- tools/native_conformance.rs | 109 ++++++ tools/native_conformance.swift | 88 ++++- tools/validate_gateway_envelopes.py | 95 +++++ 29 files changed, 1445 insertions(+), 41 deletions(-) create mode 100644 fixtures/conformance/gateway-clipboard-audit-v1.tsv create mode 100644 fixtures/conformance/gateway-clipboard-v1.tsv create mode 100644 fixtures/conformance/gateway-input-feedback-v1.tsv create mode 100644 frames/gateway-clipboard-v1.md create mode 100644 frames/gateway-input-feedback-v1.md create mode 100644 openspec/changes/phase3c-apollo-input-feedback/.openspec.yaml create mode 100644 openspec/changes/phase3c-apollo-input-feedback/design.md create mode 100644 openspec/changes/phase3c-apollo-input-feedback/proposal.md create mode 100644 openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md create mode 100644 openspec/changes/phase3c-apollo-input-feedback/tasks.md create mode 100644 tools/validate_gateway_envelopes.py diff --git a/Makefile b/Makefile index 332d0f4..b352289 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ conformance: frame-verify: $(PYTHON) tools/validate_frames.py + $(PYTHON) tools/validate_gateway_envelopes.py clean-generated: $(PYTHON) tools/generate.py --check diff --git a/fixtures/conformance/gateway-clipboard-audit-v1.tsv b/fixtures/conformance/gateway-clipboard-audit-v1.tsv new file mode 100644 index 0000000..67c3f9a --- /dev/null +++ b/fixtures/conformance/gateway-clipboard-audit-v1.tsv @@ -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 diff --git a/fixtures/conformance/gateway-clipboard-v1.tsv b/fixtures/conformance/gateway-clipboard-v1.tsv new file mode 100644 index 0000000..225007c --- /dev/null +++ b/fixtures/conformance/gateway-clipboard-v1.tsv @@ -0,0 +1,6 @@ +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 +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-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden diff --git a/fixtures/conformance/gateway-input-feedback-v1.tsv b/fixtures/conformance/gateway-input-feedback-v1.tsv new file mode 100644 index 0000000..d625639 --- /dev/null +++ b/fixtures/conformance/gateway-input-feedback-v1.tsv @@ -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 diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 87f7362..e7228fb 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -4,7 +4,10 @@ "fixtures/conformance/control-v1.tsv", "fixtures/conformance/datagram-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" ], - "corpus_sha256": "0874d39dd14c0107e602ea8909f8d53673f964c67dc9fd5fcadb8641e6ebf592" + "corpus_sha256": "92c84440bbcc08703e5465584a400b0463fea77559d41262855e778e7c3284cf" } diff --git a/frames/datagram-v1.md b/frames/datagram-v1.md index e132898..61cd8c0 100644 --- a/frames/datagram-v1.md +++ b/frames/datagram-v1.md @@ -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; 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. +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. diff --git a/frames/gateway-clipboard-v1.md b/frames/gateway-clipboard-v1.md new file mode 100644 index 0000000..251be5f --- /dev/null +++ b/frames/gateway-clipboard-v1.md @@ -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 ASCII +base64url-character 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 accepted, 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. diff --git a/frames/gateway-input-feedback-v1.md b/frames/gateway-input-feedback-v1.md new file mode 100644 index 0000000..7a50b70 --- /dev/null +++ b/frames/gateway-input-feedback-v1.md @@ -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. diff --git a/frames/registry.json b/frames/registry.json index 86b2d96..6bfca5c 100644 --- a/frames/registry.json +++ b/frames/registry.json @@ -4,12 +4,12 @@ "header_bytes": 21, "maximum_frame_bytes": 65536, "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": 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} + {"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"] } diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 30ac905..b0c050d 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -12,7 +12,7 @@ import ( "time" ) -const SchemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480" +const SchemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "1" const NMinus1WireVersion = "0" @@ -86,6 +86,13 @@ type ChannelFrame struct { 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 { Text string `json:"text"` Encoding string `json:"encoding"` @@ -158,6 +165,22 @@ type EventResume struct { 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 { Version string `json:"version"` GatewayID string `json:"gateway_id"` @@ -241,23 +264,25 @@ type PageInfo struct { } 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"` + 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 { @@ -1013,6 +1038,78 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) { 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 { var violations []FieldViolation if v.Text == "" { @@ -1934,6 +2031,189 @@ func EncodeFieldViolation(value FieldViolation) ([]byte, error) { 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 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 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"}}} + } + if raw, ok := fields["text"]; ok && len(raw) > 65536 { + return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "max_bytes"}}} + } + 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 { var violations []FieldViolation if v.Version == "" { @@ -3082,6 +3362,12 @@ func (v ProviderSessionWork) Validate() error { 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} } @@ -3109,6 +3395,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) { 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"}}} } @@ -3124,6 +3413,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) { 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"}}} } diff --git a/gen/manifest.json b/gen/manifest.json index 82590a9..aebc28f 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -14,5 +14,5 @@ }, "generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab", "protocol_version": "1.0.0", - "schema_sha256": "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480" + "schema_sha256": "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" } diff --git a/gen/protobuf/control-v1.pb b/gen/protobuf/control-v1.pb index 181606792620c207dba7572eb2698157c213c78b..472d4c9daac36605a7ede85f23f69f9f8ec3d451 100644 GIT binary patch delta 424 zcmYL>O-jQ+6o4~7t(m7nJGN=65yZAhR&Kn2(1ot0;!@HkNHAnV7*H3|h2RC;x$+1? zkKj36y73gg#7W$}?|tuk?`LoGkE=2L>}{^cFLZtUOki*x>Eu3ooM&mVR8bi(k_Rn4 z5OG>2E1jy`B}XI_m;VbXfor^SX)Wqm?t`7=D;1~tt;_0t%kmH)Q>wVp>MqG${`W3Z zTdG)8o7b;{3-K+WE4gKbn-lMctpR{?F8Ra?3C@gjBH6_DU>i8+)RJsUN3a8(3OW_p z4dLNb$c7lw&@maR8Eq87Y}RI^a$?tHPXxPJuWvdknNJ<#D0Y6^QS5xtZyaX+U}!Rg bfVxI8%z`#!m<3|5an+?WT6T+4X?6MwrkXy1 delta 43 zcmbQ`yue{Ys3g-#-p%2XZA_DsWjr`PvU9Nsu`n+bhT2O3XAnpp%zvv(+fFyi^kgS4zJ!C?rL&vLYMOC>t^#n zKK93I_3(#Esa!7(MK z`RNDMrrnxd?Y`fK4U}gag+^H5Ife3UajJ^>r04gTj>&Ms?(3l`OUTy>bUTP;VPx)@ z3?Hb4rW{ zX_c>D+{;G~WX?~9^Ns0n#w_LK8RdZQ|8Lg`y$?@4Fq0wc8x=6`S7@;oG-A|zkr$f5WDE4cU$ZzxGP6t|_ zQL0Ce?GQ2PERpy~iy({znz2CiE>ZGBu43#Gjf`3q#a|+opaNMXUA zM86#@-~v;OaF@yJVZ^e_)OXV^3tAyDVvBL8Kvv^WSIGN-tGan;7-~~EY3Z&}?70mh zjn*|vb?UKc6~sD;E-ivE7HGx-(YsDwhN~F6PFYq_+=x^a1dvq~#0J!+bqVf9V+`KN^g z7s##ycboG^6$@>1{%N(vok&d>WHkYGCsGqocZ`brpBA|AyCnMTrd+r{c3rr; zaCa%!Yj>QK&K`+Qt0tiWlZ;UJD3xY}x<@h#RX7>xzM$A|eE&0OeL*P`Vn!YNKA#;3 zV}WKzK=khOu>&!7pN}r14(EVGPOk}q3S?Cs`vH08bWm0KA5gdX>@ot!e#mDBf(v9< zO+MtaLlw&&^3i1!9g((2S?h{Zq_x2$Oi`+_?I?8% zj;YPuHdHa{n4~!@AVwY2z@W}FknT&0{cgXc0G%%>HK0!_P?4T+q##TMrp)~Zqxpn0 z1!C+8XBwzCf5n-C-~vr9KwY(&0-?U*NCOq=DGBqV0fGu-RS@z&B~R*iMH%iXb#>b< zgU+HYt4x9Hs!Y$K9ji>wq8%f{^JvE^Lm;av!*g>Bnh7V+A)ZHDM)L*V=|HGY3COPc zlrG3KcN$eJd%<^FO81gtZ*Be(otKnS*BwWy^1tGj0bwf8<_|{m6~7G-W3Tvaq{{y_ z36l~CF3{`in~5QTkKSCSKMK4 z@WiBJNH-KCszg;Is>jIMT8&1IJ2tq+nhE4?s2aLNbq(DKvc6WiXoBHhP}w`J)!3t| zG1Y0U##Eo+w+Hv@U%PP~$dMzhi)vn8w=rb+F{YqCz{m)OVn!CL>$=i4P@Asem2OKkLtpET3 diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 58995ab..094aae5 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"; +pub const SCHEMA_SHA256: &str = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"; pub const CURRENT_WIRE_VERSION: &str = "1"; pub const N_MINUS_1_WIRE_VERSION: &str = "0"; pub const N_MINUS_2_WIRE_VERSION: &str = "-1"; @@ -272,6 +272,33 @@ impl ChannelFrame { 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 { + 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)] pub struct ClipboardText { text: String, @@ -612,6 +639,71 @@ impl FieldViolation { 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 { + 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 { + 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.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")); } + 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)] pub struct GatewayDrain { version: String, @@ -1001,11 +1093,13 @@ pub struct ProviderSessionWork { 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) -> Result { - let value = Self { version, sessionId, gatewayId, reconnectSequence, expiresAt, providerProfile, providerIdentity, policyVersionId, applicationId, clientId, managementHost, managementPort, streamHost, streamPort, clientCertificatePem, clientPrivateKeyPem, serverCertificatePem }; + 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 { + 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) } @@ -1051,6 +1145,7 @@ impl ProviderSessionWork { 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 } @@ -1070,6 +1165,8 @@ impl ProviderSessionWork { 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)] diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index e41054e..c73d417 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,7 +1,7 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480" +public let schemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" public let currentWireVersion = "1" public let nMinus1WireVersion = "0" public let nMinus2WireVersion = "-1" @@ -350,6 +350,44 @@ public struct ChannelFrame: Codable, Equatable { 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 let text: String public let encoding: String @@ -809,6 +847,95 @@ public struct FieldViolation: Codable, Equatable { 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.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") } + } + + 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 let version: String public let gatewayId: String @@ -1328,6 +1455,8 @@ public struct ProviderSessionWork: Codable, Equatable { 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" @@ -1346,9 +1475,11 @@ public struct ProviderSessionWork: Codable, Equatable { 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) throws { + 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 @@ -1366,6 +1497,8 @@ public struct ProviderSessionWork: Codable, Equatable { self.clientCertificatePem = clientCertificatePem self.clientPrivateKeyPem = clientPrivateKeyPem self.serverCertificatePem = serverCertificatePem + self.clipboardPolicy = clipboardPolicy + self.providerApplicationTerminationAllowed = providerApplicationTerminationAllowed try validate() } @@ -1373,7 +1506,7 @@ public struct ProviderSessionWork: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), applicationId: try c.decode(String.self, forKey: .applicationId), clientId: try c.decode(String.self, forKey: .clientId), managementHost: try c.decode(String.self, forKey: .managementHost), managementPort: try c.decode(Int64.self, forKey: .managementPort), streamHost: try c.decode(String.self, forKey: .streamHost), streamPort: try c.decode(Int64.self, forKey: .streamPort), clientCertificatePem: try c.decode(String.self, forKey: .clientCertificatePem), clientPrivateKeyPem: try c.decode(String.self, forKey: .clientPrivateKeyPem), serverCertificatePem: try c.decode(String.self, forKey: .serverCertificatePem)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), 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 { @@ -1419,6 +1552,7 @@ public struct ProviderSessionWork: Codable, Equatable { 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) } diff --git a/openspec/changes/phase3c-apollo-input-feedback/.openspec.yaml b/openspec/changes/phase3c-apollo-input-feedback/.openspec.yaml new file mode 100644 index 0000000..f205fc7 --- /dev/null +++ b/openspec/changes/phase3c-apollo-input-feedback/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-29 diff --git a/openspec/changes/phase3c-apollo-input-feedback/design.md b/openspec/changes/phase3c-apollo-input-feedback/design.md new file mode 100644 index 0000000..e86c61c --- /dev/null +++ b/openspec/changes/phase3c-apollo-input-feedback/design.md @@ -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. diff --git a/openspec/changes/phase3c-apollo-input-feedback/proposal.md b/openspec/changes/phase3c-apollo-input-feedback/proposal.md new file mode 100644 index 0000000..ad61982 --- /dev/null +++ b/openspec/changes/phase3c-apollo-input-feedback/proposal.md @@ -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. diff --git a/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md b/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md new file mode 100644 index 0000000..e27b228 --- /dev/null +++ b/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md @@ -0,0 +1,74 @@ +## 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 bidirectional reliable control 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 bounded 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 accepts, 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: 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. diff --git a/openspec/changes/phase3c-apollo-input-feedback/tasks.md b/openspec/changes/phase3c-apollo-input-feedback/tasks.md new file mode 100644 index 0000000..c14df53 --- /dev/null +++ b/openspec/changes/phase3c-apollo-input-feedback/tasks.md @@ -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. diff --git a/proto/versevdi/control/v1/control.proto b/proto/versevdi/control/v1/control.proto index 32502b9..4b341de 100644 --- a/proto/versevdi/control/v1/control.proto +++ b/proto/versevdi/control/v1/control.proto @@ -32,3 +32,10 @@ message ClipboardText { string text = 1; string encoding = 2; } + +message GatewayClipboardText { + string direction = 1; + string text = 2; + string encoding = 3; + string loop_token = 4; +} diff --git a/proto/versevdi/tunnel/v1/tunnel.proto b/proto/versevdi/tunnel/v1/tunnel.proto index f474580..76ec416 100644 --- a/proto/versevdi/tunnel/v1/tunnel.proto +++ b/proto/versevdi/tunnel/v1/tunnel.proto @@ -135,6 +135,15 @@ message ProviderSessionWork { 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 { @@ -156,6 +165,15 @@ message ProviderState { 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 { string version = 1; string code = 2; diff --git a/registries/datagrams.json b/registries/datagrams.json index 0d08aa9..6723907 100644 --- a/registries/datagrams.json +++ b/registries/datagrams.json @@ -2,11 +2,11 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "max_frame_bytes": 65536, "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": "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} + {"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200, "payload_profile": "gateway-input-v1"} ] } diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 6cd8c66..402bfc1 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -335,6 +335,28 @@ "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", "minLength": 16, "maxLength": 128} + } + }, "VersionNegotiation": { "type": "object", "additionalProperties": false, @@ -438,7 +460,7 @@ "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"], + "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}, @@ -456,7 +478,9 @@ "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} + "server_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768}, + "clipboard_policy": {"$ref": "#/$defs/ClipboardPolicy"}, + "provider_application_termination_allowed": {"type": "boolean"} } }, "ChannelFrame": { @@ -486,6 +510,19 @@ "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": { "type": "object", "additionalProperties": false, diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 43da19e..21018af 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -114,11 +114,30 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) { } func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) { - valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate"}` + valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","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) + } + } } diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index 940e91e..52b3485 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strconv" "strings" + "unicode/utf8" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" ) @@ -87,9 +88,9 @@ func evaluate(kind, input string) string { Gateway: protocol.ManifestGateway{ ID: parts["gateway_id"], Addresses: []string{"gateway.control.test:443"}, PublicIdentity: parts["gateway_id"], }, - Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}}, - Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}}, - Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]}, + Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}}, + Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}}, + Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]}, CorrelationID: "correlation-1", } if value.Validate() == nil { @@ -118,7 +119,7 @@ func evaluate(kind, input string) string { } value := protocol.EventEnvelope{ EventID: "event-1", Sequence: sequence, Type: "broker.session.changed", Version: 1, - Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1}, + Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1}, OccurredAt: "2099-01-01T00:00:00Z", CorrelationID: parts["correlation_id"], Payload: map[string]any{}, } if payloadErr != nil || payloadBytes > 16384 { @@ -138,11 +139,178 @@ func evaluate(kind, input string) string { return "invalid:unsupported_version" case "datagram": 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: 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 { raw, err := hex.DecodeString(encoded) if err != nil { diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index 273a4f7..b8712cf 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -53,10 +53,119 @@ fn evaluate(kind: &str, input: &str) -> &'static str { } "tunnel" => "invalid:unsupported_version", "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" + if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client")) + && values.get("encoding").map(String::as_str) == Some("utf-8") + && values.get("loop_token").map_or(false, |value| (16..=128).contains(&value.len())) + && values.get("text").map_or(false, |value| value.len() <= 65536) => "valid", + "gateway_clipboard" => "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::().ok()).map_or(false, |size| size <= 65536) => "valid", + "gateway_clipboard_audit" => "invalid:clipboard_audit", _ => "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> { if input.len() % 2 != 0 { return None; diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index 8e8d639..6670a85 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -30,18 +30,100 @@ func evaluate(_ kind: String, _ input: String) -> String { if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" } return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature" 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 ["client_to_provider", "provider_to_client"].contains(values["direction"] ?? ""), values["encoding"] == "utf-8", let token = values["loop_token"], (16...128).contains(token.utf8.count), let text = values["text"], text.utf8.count <= 65536 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" } } -func classifyDatagram(_ encoded: String) -> String { +func decodeHex(_ encoded: String) -> [UInt8]? { let characters = Array(encoded) - guard characters.count % 2 == 0 else { return "invalid:hex" } + guard characters.count % 2 == 0 else { return nil } var raw: [UInt8] = [] 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) } + 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[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" } guard raw[2] == 1 else { return "invalid:unsupported_version" } diff --git a/tools/validate_gateway_envelopes.py b/tools/validate_gateway_envelopes.py new file mode 100644 index 0000000..0a14daa --- /dev/null +++ b/tools/validate_gateway_envelopes.py @@ -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()