Protocol: split client session authority
This commit is contained in:
+127
-1
@@ -14,7 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
const SchemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
|
||||||
const ProtocolVersion = "1.0.0"
|
const ProtocolVersion = "1.0.0"
|
||||||
const CurrentWireVersion = "2"
|
const CurrentWireVersion = "2"
|
||||||
const NMinus1WireVersion = "1"
|
const NMinus1WireVersion = "1"
|
||||||
@@ -97,6 +97,16 @@ type ChannelFrame struct {
|
|||||||
Payload string `json:"payload"`
|
Payload string `json:"payload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientSessionAuthority struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
GatewayID string `json:"gateway_id"`
|
||||||
|
Audience string `json:"audience"`
|
||||||
|
ReconnectSequence int64 `json:"reconnect_sequence"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Capabilities CapabilityProfile `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClipboardPolicy struct {
|
type ClipboardPolicy struct {
|
||||||
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
|
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
|
||||||
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
|
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
|
||||||
@@ -1233,6 +1243,122 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v ClientSessionAuthority) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Version == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Version != "1" && v.Version != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.SessionID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) < 1 && v.SessionID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.GatewayID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) < 1 && v.GatewayID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Audience == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Audience) < 1 && v.Audience != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Audience) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ExpiresAt) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.Capabilities.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) {
|
||||||
|
var value ClientSessionAuthority
|
||||||
|
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["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", 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["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 EncodeClientSessionAuthority(value ClientSessionAuthority) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v ClipboardPolicy) Validate() error {
|
func (v ClipboardPolicy) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.MaxTextBytes == 0 {
|
if v.MaxTextBytes == 0 {
|
||||||
|
|||||||
+1
-1
@@ -14,5 +14,5 @@
|
|||||||
},
|
},
|
||||||
"generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
|
"generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
"schema_sha256": "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+44
-1
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
pub const SCHEMA_SHA256: &str = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f";
|
pub const SCHEMA_SHA256: &str = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1";
|
||||||
pub const CURRENT_WIRE_VERSION: &str = "2";
|
pub const CURRENT_WIRE_VERSION: &str = "2";
|
||||||
pub const N_MINUS_1_WIRE_VERSION: &str = "1";
|
pub const N_MINUS_1_WIRE_VERSION: &str = "1";
|
||||||
pub const N_MINUS_2_WIRE_VERSION: &str = "0";
|
pub const N_MINUS_2_WIRE_VERSION: &str = "0";
|
||||||
@@ -353,6 +353,49 @@ impl ChannelFrame {
|
|||||||
pub fn payload(&self) -> &String { &self.payload }
|
pub fn payload(&self) -> &String { &self.payload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClientSessionAuthority {
|
||||||
|
version: String,
|
||||||
|
sessionId: String,
|
||||||
|
gatewayId: String,
|
||||||
|
audience: String,
|
||||||
|
reconnectSequence: i64,
|
||||||
|
expiresAt: String,
|
||||||
|
capabilities: CapabilityProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientSessionAuthority {
|
||||||
|
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
|
||||||
|
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
|
||||||
|
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
|
||||||
|
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
||||||
|
if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); }
|
||||||
|
if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); }
|
||||||
|
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
||||||
|
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
|
||||||
|
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
|
||||||
|
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
|
||||||
|
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
||||||
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
|
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn version(&self) -> &String { &self.version }
|
||||||
|
pub fn sessionId(&self) -> &String { &self.sessionId }
|
||||||
|
pub fn gatewayId(&self) -> &String { &self.gatewayId }
|
||||||
|
pub fn audience(&self) -> &String { &self.audience }
|
||||||
|
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
|
||||||
|
pub fn expiresAt(&self) -> &String { &self.expiresAt }
|
||||||
|
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClipboardPolicy {
|
pub struct ClipboardPolicy {
|
||||||
clientToProviderEnabled: bool,
|
clientToProviderEnabled: bool,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
import Foundation
|
import Foundation
|
||||||
public typealias JSONObject = [String: String]
|
public typealias JSONObject = [String: String]
|
||||||
public let schemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
public let schemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
|
||||||
public let currentWireVersion = "2"
|
public let currentWireVersion = "2"
|
||||||
public let nMinus1WireVersion = "1"
|
public let nMinus1WireVersion = "1"
|
||||||
public let nMinus2WireVersion = "0"
|
public let nMinus2WireVersion = "0"
|
||||||
@@ -433,6 +433,63 @@ public struct ChannelFrame: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClientSessionAuthority: Codable, Equatable {
|
||||||
|
public let version: String
|
||||||
|
public let sessionId: String
|
||||||
|
public let gatewayId: String
|
||||||
|
public let audience: String
|
||||||
|
public let reconnectSequence: Int64
|
||||||
|
public let expiresAt: String
|
||||||
|
public let capabilities: CapabilityProfile
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version = "version"
|
||||||
|
case sessionId = "session_id"
|
||||||
|
case gatewayId = "gateway_id"
|
||||||
|
case audience = "audience"
|
||||||
|
case reconnectSequence = "reconnect_sequence"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case capabilities = "capabilities"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile) throws {
|
||||||
|
self.version = version
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.gatewayId = gatewayId
|
||||||
|
self.audience = audience
|
||||||
|
self.reconnectSequence = reconnectSequence
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.capabilities = capabilities
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
|
||||||
|
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
|
||||||
|
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
|
||||||
|
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
||||||
|
if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") }
|
||||||
|
if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") }
|
||||||
|
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
||||||
|
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
|
||||||
|
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
|
||||||
|
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
|
||||||
|
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
||||||
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
|
try self.capabilities.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClipboardPolicy: Codable, Equatable {
|
public struct ClipboardPolicy: Codable, Equatable {
|
||||||
public let clientToProviderEnabled: Bool
|
public let clientToProviderEnabled: Bool
|
||||||
public let providerToClientEnabled: Bool
|
public let providerToClientEnabled: Bool
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-12
|
||||||
|
goal: Split client-facing session authority from the provider-bearing
|
||||||
|
Server-to-gateway authority for the coordinated RC4 hard cut.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
RC3 uses one provider-bearing `SessionAuthority` for both the authenticated Server-to-gateway control plane and the gateway-to-client acknowledgement. Provider profile and identity are valid inputs to gateway provider work and release, but they are forbidden at the client boundary. Existing strict RC3 clients require those fields, so changing the client shape is intentionally incompatible.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make provider disclosure structurally impossible in the client-facing authority type.
|
||||||
|
- Preserve the provider-bound Server-to-gateway admission, work, release, and cleanup contract.
|
||||||
|
- Produce strict, matching JSON Schema, Protobuf, Go, Rust, and Swift contracts.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Supporting mixed RC3/RC4 gateway and client pairings.
|
||||||
|
- Changing `SessionAuthority`, `ProviderSessionWork`, `VERSION`, or global compatibility history.
|
||||||
|
- Adding response negotiation, optional provider fields, or permissive decoding.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Add `ClientSessionAuthority` with exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. Reusing the common validation bounds keeps the new acknowledgement session-bound without representing provider data.
|
||||||
|
2. Keep the existing provider-bearing `SessionAuthority` unchanged for Server-to-gateway operations. Deleting its provider fields would broaden the security-sensitive change into Server admission and provider-work validation.
|
||||||
|
3. Treat RC4 as a coordinated hard cut. A dual decoder would still accept the forbidden RC3 shape and is unnecessary for an unreleased candidate.
|
||||||
|
4. Use the existing generator unchanged. The JSON Schema definition is sufficient to generate strict Go, Rust, and Swift types; the matching Protobuf message uses fields 1 through 7.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [RC3 and RC4 clients are not wire-compatible] → Pin and qualify Server, gateway, and client as one exact RC4 set; retain RC3 as an immutable rollback set.
|
||||||
|
- [A future gateway could serialize the wrong authority type] → Consumer gateway tests must capture the raw acknowledgement and require `ClientSessionAuthority` with no provider-bearing keys.
|
||||||
|
- [Strict decoding rejects future additive fields] → Version a future client authority explicitly instead of weakening this v1 decoder.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Publish the verified immutable Protocol RC4 tag.
|
||||||
|
2. Repin Data, Server, and macOS to the exact RC4 commit.
|
||||||
|
3. Change gateway egress and client decoders together, then qualify the exact all-RC4 set.
|
||||||
|
4. Roll back only as the complete immutable RC3 set; do not retag or mix candidates.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None for this pre-release hard cut. Evidence of deployed RC3 coexistence would require a separate negotiated-version design and blocks this migration model.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The gateway currently serializes the provider-bearing Server-to-gateway `SessionAuthority` to clients, crossing provider identity into a client trust boundary that forbids it. RC4 must make that boundary structural before the pre-release client set is qualified.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a strict provider-free `ClientSessionAuthority` with the seven session, gateway, audience, reconnect, expiry, and capability fields shared with `SessionAuthority`.
|
||||||
|
- Keep `SessionAuthority` and `ProviderSessionWork` unchanged for the authenticated Server-to-gateway control plane.
|
||||||
|
- **BREAKING** Replace the gateway-to-client RC3 response shape with `ClientSessionAuthority` as a coordinated RC4 hard cut; no mixed RC3/RC4 compatibility is claimed.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `gateway-transport-and-admission`: Defines the distinct client-facing authority and its provider-free gateway admission boundary.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Protocol JSON Schema, tunnel Protobuf, generated Go/Rust/Swift bindings, and consumer Protocol pins advance together to `v1.0.0-phase3d-macos-rc.4`. `VERSION`, global compatibility history, and the Server-to-gateway provider authority remain unchanged.
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Client-facing authority is provider-free
|
||||||
|
The gateway-to-client acknowledgement SHALL use `ClientSessionAuthority` version `"1"` containing exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. The contract SHALL reject missing required fields, unknown fields including provider identities and routes, invalid or noncanonical expiry timestamps, and trailing JSON values.
|
||||||
|
|
||||||
|
#### Scenario: Gateway acknowledges an admitted client
|
||||||
|
- **WHEN** provider work succeeds and gateway and client capabilities intersect
|
||||||
|
- **THEN** the gateway returns a valid `ClientSessionAuthority` containing the selected capabilities and no provider-bearing field
|
||||||
|
|
||||||
|
#### Scenario: Client receives provider-bearing authority
|
||||||
|
- **WHEN** a client authority payload contains `provider_profile`, `provider_identity`, a provider route, or any unknown key
|
||||||
|
- **THEN** the strict client authority decoder rejects the payload
|
||||||
|
|
||||||
|
#### Scenario: Client receives incomplete or malformed authority
|
||||||
|
- **WHEN** a client authority omits any required binding, has an invalid expiry, or is followed by another JSON value
|
||||||
|
- **THEN** the strict client authority decoder rejects the payload
|
||||||
|
|
||||||
|
### Requirement: Server-to-gateway authority remains provider-bound
|
||||||
|
The authenticated Server-to-gateway control plane SHALL continue to use the existing provider-bearing `SessionAuthority` for admission, provider work, release, and cleanup. `SessionAuthority` and `ProviderSessionWork` fields and semantics MUST remain unchanged by this change.
|
||||||
|
|
||||||
|
#### Scenario: Gateway performs provider work
|
||||||
|
- **WHEN** the Server admits a gateway session and the gateway requests provider work
|
||||||
|
- **THEN** the original provider-bearing `SessionAuthority` continues to bind provider work and subsequent release or cleanup
|
||||||
|
|
||||||
|
### Requirement: RC4 is a coordinated hard cut
|
||||||
|
The RC4 gateway and client SHALL use `ClientSessionAuthority`; mixed RC3/RC4 gateway-client compatibility SHALL NOT be claimed. RC4 SHALL NOT add optional provider fields, a dual decoder, or response negotiation for RC3.
|
||||||
|
|
||||||
|
#### Scenario: RC4 candidate is qualified
|
||||||
|
- **WHEN** the Protocol RC4 tag is pinned by Server, gateway, and client
|
||||||
|
- **THEN** qualification uses only that exact coordinated set
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## 1. Contract and regressions
|
||||||
|
|
||||||
|
- [x] 1.1 Add RED-first Go, Swift, Rust, and Protobuf regressions for the strict provider-free authority.
|
||||||
|
- [x] 1.2 Add the exact seven-field JSON Schema and Protobuf `ClientSessionAuthority` without changing existing authority contracts.
|
||||||
|
- [x] 1.3 Regenerate Go, Rust, Swift, Protobuf, and manifest outputs using repository tooling.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
|
||||||
|
- [x] 2.1 Pass focused Go and generated-contract regressions.
|
||||||
|
- [x] 2.2 Pass strict OpenSpec validation, full `make verify`, second-generation cleanliness, and diff checks.
|
||||||
@@ -117,6 +117,16 @@ message SessionAuthority {
|
|||||||
string provider_identity = 9;
|
string provider_identity = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ClientSessionAuthority {
|
||||||
|
string version = 1;
|
||||||
|
string session_id = 2;
|
||||||
|
string gateway_id = 3;
|
||||||
|
string audience = 4;
|
||||||
|
uint64 reconnect_sequence = 5;
|
||||||
|
google.protobuf.Timestamp expires_at = 6;
|
||||||
|
CapabilityProfile capabilities = 7;
|
||||||
|
}
|
||||||
|
|
||||||
message ProviderSessionWork {
|
message ProviderSessionWork {
|
||||||
string version = 1;
|
string version = 1;
|
||||||
string session_id = 2;
|
string session_id = 2;
|
||||||
|
|||||||
@@ -551,6 +551,20 @@
|
|||||||
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ClientSessionAuthority": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"type": "string", "const": "1"},
|
||||||
|
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
|
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||||
|
"reconnect_sequence": {"type": "integer", "minimum": 0},
|
||||||
|
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
|
||||||
|
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ProviderStreamPolicy": {
|
"ProviderStreamPolicy": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package protocol_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -362,6 +363,81 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) {
|
||||||
|
authority := protocol.ClientSessionAuthority{
|
||||||
|
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||||
|
ReconnectSequence: 2, ExpiresAt: "2099-01-01T00:00:00Z", Capabilities: protocol.CapabilityProfile{
|
||||||
|
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
|
||||||
|
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
encoded, err := protocol.EncodeClientSessionAuthority(authority)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeClientSessionAuthority() error = %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||||
|
t.Fatalf("encoded client authority is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
wantFields := map[string]bool{
|
||||||
|
"version": true, "session_id": true, "gateway_id": true, "audience": true,
|
||||||
|
"reconnect_sequence": true, "expires_at": true, "capabilities": true,
|
||||||
|
}
|
||||||
|
if len(fields) != len(wantFields) {
|
||||||
|
t.Fatalf("encoded client authority fields = %v; want exactly %v", fields, wantFields)
|
||||||
|
}
|
||||||
|
for field := range fields {
|
||||||
|
if !wantFields[field] {
|
||||||
|
t.Fatalf("encoded client authority contains forbidden field %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bytes.Contains(encoded, []byte("provider_")) {
|
||||||
|
t.Fatalf("encoded client authority disclosed provider data: %s", encoded)
|
||||||
|
}
|
||||||
|
decoded, err := protocol.DecodeClientSessionAuthority(encoded)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, authority) {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority() = %+v, %v; want %+v", decoded, err, authority)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"} {
|
||||||
|
missing := make(map[string]json.RawMessage, len(fields)-1)
|
||||||
|
for key, value := range fields {
|
||||||
|
if key != field {
|
||||||
|
missing[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(missing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted missing %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"provider_profile": `"apollo"`,
|
||||||
|
"provider_identity": `"provider-1"`,
|
||||||
|
"provider_url": `"https://provider.invalid"`,
|
||||||
|
"management_host": `"provider.invalid"`,
|
||||||
|
"unknown": `true`,
|
||||||
|
} {
|
||||||
|
payload := append(append([]byte(nil), encoded[:len(encoded)-1]...), []byte(`,"`+name+`":`+value+`}`)...)
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted injected %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, expiresAt := range []string{"not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"} {
|
||||||
|
payload := bytes.Replace(encoded, []byte("2099-01-01T00:00:00Z"), []byte(expiresAt), 1)
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
|
||||||
|
t.Fatalf("DecodeClientSessionAuthority accepted expires_at %q", expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := protocol.DecodeClientSessionAuthority(append(encoded, []byte(" {}")...)); err == nil {
|
||||||
|
t.Fatal("DecodeClientSessionAuthority accepted trailing JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
|
func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
|
||||||
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
|
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
|
||||||
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
|
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -24,6 +25,32 @@ def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> N
|
|||||||
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
|
|
||||||
|
def protobuf_message_fields(name: str) -> list[tuple[str, int]]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["protoc", "--decode=google.protobuf.FileDescriptorSet", "google/protobuf/descriptor.proto"],
|
||||||
|
input=(ROOT / "gen/protobuf/tunnel-v1.pb").read_bytes(),
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.decode())
|
||||||
|
lines = result.stdout.decode().splitlines()
|
||||||
|
marker = f' name: "{name}"'
|
||||||
|
try:
|
||||||
|
name_index = lines.index(marker)
|
||||||
|
start = max(index for index in range(name_index) if lines[index] == " message_type {")
|
||||||
|
except (ValueError, StopIteration) as exc:
|
||||||
|
raise RuntimeError(f"protobuf descriptor missing message {name}") from exc
|
||||||
|
depth = 0
|
||||||
|
block: list[str] = []
|
||||||
|
for line in lines[start:]:
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
block.append(line)
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
return [(field, int(number)) for field, number in re.findall(r' field \{\n name: "([^"]+)"\n number: (\d+)', "\n".join(block))]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
||||||
workspace = pathlib.Path(temporary)
|
workspace = pathlib.Path(temporary)
|
||||||
@@ -136,6 +163,52 @@ for invalid in [
|
|||||||
fatalError("invalid tunnel admission request was accepted")
|
fatalError("invalid tunnel admission request was accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
let clientAuthority = try ClientSessionAuthority(
|
||||||
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
|
reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability
|
||||||
|
)
|
||||||
|
let clientAuthorityJSON = try clientAuthority.encodeJSON()
|
||||||
|
let clientAuthorityObject = try JSONSerialization.jsonObject(with: clientAuthorityJSON) as! [String: Any]
|
||||||
|
guard Set(clientAuthorityObject.keys) == Set([
|
||||||
|
"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"
|
||||||
|
]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else {
|
||||||
|
fatalError("client authority was not exactly provider-free")
|
||||||
|
}
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON)
|
||||||
|
for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"] {
|
||||||
|
var missing = clientAuthorityObject
|
||||||
|
missing.removeValue(forKey: field)
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: missing))
|
||||||
|
fatalError("client authority accepted missing \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("provider_profile", "apollo"),
|
||||||
|
("provider_identity", "provider-1"),
|
||||||
|
("provider_url", "https://provider.invalid"),
|
||||||
|
("management_host", "provider.invalid"),
|
||||||
|
("unknown", "true"),
|
||||||
|
] {
|
||||||
|
var injected = clientAuthorityObject
|
||||||
|
injected[field] = value
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: injected))
|
||||||
|
fatalError("client authority accepted injected \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for expiresAt in ["not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
||||||
|
var invalidExpiry = clientAuthorityObject
|
||||||
|
invalidExpiry["expires_at"] = expiresAt
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: invalidExpiry))
|
||||||
|
fatalError("client authority accepted invalid expiry")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON + Data(" {}".utf8))
|
||||||
|
fatalError("client authority accepted trailing JSON")
|
||||||
|
} catch { }
|
||||||
do {
|
do {
|
||||||
_ = try AllocationPolicy(
|
_ = try AllocationPolicy(
|
||||||
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
||||||
@@ -348,6 +421,16 @@ fn main() {
|
|||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
|
let client_authority = ClientSessionAuthority::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
|
||||||
|
"2099-01-01T00:00:00Z".into(), capabilities.clone(),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(client_authority.sessionId(), "session");
|
||||||
|
assert_eq!(client_authority.capabilities(), &capabilities);
|
||||||
|
assert!(ClientSessionAuthority::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
|
||||||
|
"not-a-time".into(), capabilities.clone(),
|
||||||
|
).is_err());
|
||||||
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
||||||
let incompatible = CapabilityProfile::new(
|
let incompatible = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
@@ -441,6 +524,21 @@ fn main() {
|
|||||||
)
|
)
|
||||||
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
||||||
run([str(workspace / "rust-contracts")], ROOT)
|
run([str(workspace / "rust-contracts")], ROOT)
|
||||||
|
expected_protobuf_fields = [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("audience", 4),
|
||||||
|
("reconnect_sequence", 5),
|
||||||
|
("expires_at", 6),
|
||||||
|
("capabilities", 7),
|
||||||
|
]
|
||||||
|
actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority")
|
||||||
|
if actual_protobuf_fields != expected_protobuf_fields:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ClientSessionAuthority protobuf fields = {actual_protobuf_fields}; "
|
||||||
|
f"want {expected_protobuf_fields}"
|
||||||
|
)
|
||||||
rust_unknown = workspace / "unknown.rs"
|
rust_unknown = workspace / "unknown.rs"
|
||||||
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
||||||
with rust_unknown.open("a", encoding="utf-8") as output:
|
with rust_unknown.open("a", encoding="utf-8") as output:
|
||||||
|
|||||||
Reference in New Issue
Block a user