diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 68b8ecf..3844f66 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -8,10 +8,11 @@ import ( "fmt" "io" "reflect" + "strings" "time" ) -const SchemaSHA256 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" +const SchemaSHA256 = "6b8631bf2b2aa12b14d0bc4d136af39a632e85b3237dc5614469ba09d93f5fca" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "1" const NMinus1WireVersion = "0" @@ -326,6 +327,7 @@ type TunnelAdmissionRequest struct { Grant string `json:"grant"` ReconnectSequence int64 `json:"reconnect_sequence"` ClientNonce string `json:"client_nonce"` + DeviceSignature string `json:"device_signature"` Capabilities CapabilityProfile `json:"capabilities"` } @@ -3923,6 +3925,15 @@ func (v TunnelAdmissionRequest) Validate() error { if len(v.ClientNonce) > 128 { violations = append(violations, FieldViolation{Field: "client_nonce", Code: "max_length"}) } + if v.DeviceSignature == "" { + violations = append(violations, FieldViolation{Field: "device_signature", Code: "required"}) + } + if len(v.DeviceSignature) < 86 && v.DeviceSignature != "" { + violations = append(violations, FieldViolation{Field: "device_signature", Code: "min_length"}) + } + if len(v.DeviceSignature) > 86 { + violations = append(violations, FieldViolation{Field: "device_signature", Code: "max_length"}) + } if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) { violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"}) } @@ -3953,6 +3964,9 @@ func DecodeTunnelAdmissionRequest(data []byte) (TunnelAdmissionRequest, error) { if raw, ok := fields["client_nonce"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "client_nonce", Code: "required"}}} } + if raw, ok := fields["device_signature"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "device_signature", Code: "required"}}} + } if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} } @@ -4073,3 +4087,13 @@ func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfi } return selected, nil } + +func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte { + fields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf("%d", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, v.Capabilities.ClientDecode} + var transcript strings.Builder + transcript.WriteString("versevdi/tunnel-admission/v1") + for _, field := range fields { + fmt.Fprintf(&transcript, "%d:%s", len(field), field) + } + return []byte(transcript.String()) +} diff --git a/gen/manifest.json b/gen/manifest.json index 94b1dfd..39e0ecd 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "2" ] }, - "generator_sha256": "88535ecf2b1c926104b10b4ab56f1bc6298e1c3e75490e36ee8581a135bcded7", + "generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab", "protocol_version": "1.0.0", - "schema_sha256": "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" + "schema_sha256": "6b8631bf2b2aa12b14d0bc4d136af39a632e85b3237dc5614469ba09d93f5fca" } diff --git a/gen/protobuf/tunnel-v1.pb b/gen/protobuf/tunnel-v1.pb index 9472b0d..06c2784 100644 Binary files a/gen/protobuf/tunnel-v1.pb and b/gen/protobuf/tunnel-v1.pb differ diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index bf2e324..def05f2 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 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"; +pub const SCHEMA_SHA256: &str = "6b8631bf2b2aa12b14d0bc4d136af39a632e85b3237dc5614469ba09d93f5fca"; 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"; @@ -1345,12 +1345,13 @@ pub struct TunnelAdmissionRequest { grant: String, reconnectSequence: i64, clientNonce: String, + deviceSignature: String, capabilities: CapabilityProfile, } impl TunnelAdmissionRequest { - pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: i64, clientNonce: String, capabilities: CapabilityProfile) -> Result { - let value = Self { version, sessionId, gatewayId, audience, grant, reconnectSequence, clientNonce, capabilities }; + pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: i64, clientNonce: String, deviceSignature: String, capabilities: CapabilityProfile) -> Result { + let value = Self { version, sessionId, gatewayId, audience, grant, reconnectSequence, clientNonce, deviceSignature, capabilities }; value.validate()?; Ok(value) } @@ -1372,6 +1373,9 @@ impl TunnelAdmissionRequest { if self.clientNonce.is_empty() { return Err(ValidationError::new("client_nonce", "required")); } if !self.clientNonce.is_empty() && self.clientNonce.len() < 16 { return Err(ValidationError::new("client_nonce", "min_length")); } if self.clientNonce.len() > 128 { return Err(ValidationError::new("client_nonce", "max_length")); } + if self.deviceSignature.is_empty() { return Err(ValidationError::new("device_signature", "required")); } + if !self.deviceSignature.is_empty() && self.deviceSignature.len() < 86 { return Err(ValidationError::new("device_signature", "min_length")); } + if self.deviceSignature.len() > 86 { return Err(ValidationError::new("device_signature", "max_length")); } self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?; Ok(()) } @@ -1382,7 +1386,15 @@ impl TunnelAdmissionRequest { pub fn grant(&self) -> &String { &self.grant } pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } pub fn clientNonce(&self) -> &String { &self.clientNonce } + pub fn deviceSignature(&self) -> &String { &self.deviceSignature } pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities } + pub fn device_admission_transcript(&self) -> Vec { + let reconnect_sequence = self.reconnectSequence.to_string(); + let fields = [&self.sessionId, &self.gatewayId, &self.audience, &self.grant, &reconnect_sequence, &self.clientNonce, &self.capabilities.transport, &self.capabilities.framing, &self.capabilities.media, &self.capabilities.audio, &self.capabilities.sourceRateControl, &self.capabilities.clientDecode]; + let mut transcript = String::from("versevdi/tunnel-admission/v1"); + for field in fields { transcript.push_str(&format!("{}:{}", field.as_bytes().len(), field)); } + transcript.into_bytes() + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 3a9f00d..0879d03 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 = "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec" +public let schemaSHA256 = "6b8631bf2b2aa12b14d0bc4d136af39a632e85b3237dc5614469ba09d93f5fca" public let currentWireVersion = "1" public let nMinus1WireVersion = "0" public let nMinus2WireVersion = "-1" @@ -1797,6 +1797,7 @@ public struct TunnelAdmissionRequest: Codable, Equatable { public let grant: String public let reconnectSequence: Int64 public let clientNonce: String + public let deviceSignature: String public let capabilities: CapabilityProfile enum CodingKeys: String, CodingKey { case version = "version" @@ -1806,10 +1807,11 @@ public struct TunnelAdmissionRequest: Codable, Equatable { case grant = "grant" case reconnectSequence = "reconnect_sequence" case clientNonce = "client_nonce" + case deviceSignature = "device_signature" case capabilities = "capabilities" } - public init(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: Int64, clientNonce: String, capabilities: CapabilityProfile) throws { + public init(version: String, sessionId: String, gatewayId: String, audience: String, grant: String, reconnectSequence: Int64, clientNonce: String, deviceSignature: String, capabilities: CapabilityProfile) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId @@ -1817,6 +1819,7 @@ public struct TunnelAdmissionRequest: Codable, Equatable { self.grant = grant self.reconnectSequence = reconnectSequence self.clientNonce = clientNonce + self.deviceSignature = deviceSignature self.capabilities = capabilities try validate() } @@ -1825,7 +1828,7 @@ public struct TunnelAdmissionRequest: 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), audience: try c.decode(String.self, forKey: .audience), grant: try c.decode(String.self, forKey: .grant), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), clientNonce: try c.decode(String.self, forKey: .clientNonce), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), grant: try c.decode(String.self, forKey: .grant), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), clientNonce: try c.decode(String.self, forKey: .clientNonce), deviceSignature: try c.decode(String.self, forKey: .deviceSignature), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities)) } public func validate() throws { @@ -1846,6 +1849,9 @@ public struct TunnelAdmissionRequest: Codable, Equatable { if self.clientNonce.isEmpty { throw ContractValidationError(field: "client_nonce", code: "required") } if !self.clientNonce.isEmpty && self.clientNonce.utf8.count < 16 { throw ContractValidationError(field: "client_nonce", code: "min_length") } if self.clientNonce.utf8.count > 128 { throw ContractValidationError(field: "client_nonce", code: "max_length") } + if self.deviceSignature.isEmpty { throw ContractValidationError(field: "device_signature", code: "required") } + if !self.deviceSignature.isEmpty && self.deviceSignature.utf8.count < 86 { throw ContractValidationError(field: "device_signature", code: "min_length") } + if self.deviceSignature.utf8.count > 86 { throw ContractValidationError(field: "device_signature", code: "max_length") } try self.capabilities.validate() } @@ -1884,6 +1890,15 @@ public struct VersionNegotiation: Codable, Equatable { public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } } +public extension TunnelAdmissionRequest { + func deviceAdmissionTranscript() -> Data { + let fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, capabilities.clientDecode] + var transcript = "versevdi/tunnel-admission/v1" + for field in fields { transcript += "\(field.utf8.count):\(field)" } + return Data(transcript.utf8) + } +} + public extension CapabilityProfile { static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile { guard let selected = profiles.first else { throw ContractValidationError(field: "capabilities", code: "no_overlap") } diff --git a/proto/versevdi/tunnel/v1/tunnel.proto b/proto/versevdi/tunnel/v1/tunnel.proto index ceab96f..2d2a5fc 100644 --- a/proto/versevdi/tunnel/v1/tunnel.proto +++ b/proto/versevdi/tunnel/v1/tunnel.proto @@ -102,6 +102,7 @@ message TunnelAdmissionRequest { uint64 reconnect_sequence = 6; string client_nonce = 7; CapabilityProfile capabilities = 8; + string device_signature = 9; } message SessionAuthority { diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 37dc463..114ed14 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -406,7 +406,7 @@ "TunnelAdmissionRequest": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "capabilities"], + "required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "device_signature", "capabilities"], "properties": { "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -415,6 +415,7 @@ "grant": {"type": "string", "minLength": 43, "maxLength": 256}, "reconnect_sequence": {"type": "integer", "minimum": 0}, "client_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, + "device_signature": {"type": "string", "minLength": 86, "maxLength": 86}, "capabilities": {"$ref": "#/$defs/CapabilityProfile"} } }, diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 0a0ae5c..1ae4668 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -74,6 +74,35 @@ func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) { } } +func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) { + request := protocol.TunnelAdmissionRequest{ + Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", + Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0, + ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{ + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", + SourceRateControl: "server", ClientDecode: "h264-opus", + }, + } + if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil { + t.Fatal("EncodeTunnelAdmissionRequest accepted an unsigned device admission") + } +} + +func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing.T) { + request := protocol.TunnelAdmissionRequest{ + Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience", + Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16), + DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{ + Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", + SourceRateControl: "server", ClientDecode: "h264-opus", + }, + } + want := "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + strings.Repeat("g", 43) + "1:016:" + strings.Repeat("n", 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server9:h264-opus" + if got := string(request.DeviceAdmissionTranscript()); got != want { + t.Fatalf("DeviceAdmissionTranscript() = %q, want %q", got, want) + } +} + func TestSessionAuthorityRejectsProviderRoute(t *testing.T) { valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","audience":"versevdi-gateway","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"},"provider_profile":"apollo","provider_identity":"provider-1"}` if _, err := protocol.DecodeSessionAuthority([]byte(valid)); err != nil { diff --git a/tools/generate.py b/tools/generate.py index 814bc94..60e6605 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -171,6 +171,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, "\"errors\"", "\"fmt\"", "\"reflect\"", + "\"strings\"", "\"time\"", ")", "", @@ -255,6 +256,16 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, "}", "", ]) + out.extend([ + "func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {", + "\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, v.Capabilities.ClientDecode}", + "\tvar transcript strings.Builder", + "\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")", + "\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }", + "\treturn []byte(transcript.String())", + "}", + "", + ]) # Use io.EOF in generated code without making every generated decoder depend on # error-string comparison; replace the deliberately compact placeholder. text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"') @@ -395,6 +406,16 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili if prop_name not in required: typ = f"Option<{typ}>" out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}") + if name == "TunnelAdmissionRequest": + out.extend([ + " pub fn device_admission_transcript(&self) -> Vec {", + " let reconnect_sequence = self.reconnectSequence.to_string();", + " let fields = [&self.sessionId, &self.gatewayId, &self.audience, &self.grant, &reconnect_sequence, &self.clientNonce, &self.capabilities.transport, &self.capabilities.framing, &self.capabilities.media, &self.capabilities.audio, &self.capabilities.sourceRateControl, &self.capabilities.clientDecode];", + " let mut transcript = String::from(\"versevdi/tunnel-admission/v1\");", + " for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }", + " transcript.into_bytes()", + " }", + ]) out.extend(["}", ""]) out.extend([ "pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result {", @@ -517,6 +538,15 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil out.extend(swift_validation(definition)) out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""]) out.extend([ + "public extension TunnelAdmissionRequest {", + " func deviceAdmissionTranscript() -> Data {", + " let fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, capabilities.clientDecode]", + " var transcript = \"versevdi/tunnel-admission/v1\"", + " for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }", + " return Data(transcript.utf8)", + " }", + "}", + "", "public extension CapabilityProfile {", " static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {", " guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }", diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index dd1fb3f..7b6c012 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -38,9 +38,13 @@ let capability = try CapabilityProfile( let request = try TunnelAdmissionRequest( version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience", grant: String(repeating: "g", count: 43), reconnectSequence: 0, - clientNonce: String(repeating: "n", count: 16), capabilities: capability + clientNonce: String(repeating: "n", count: 16), deviceSignature: String(repeating: "s", count: 86), capabilities: capability ) _ = request +let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + String(repeating: "g", count: 43) + "1:016:" + String(repeating: "n", count: 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server9:h264-opus" +guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else { + fatalError("unexpected device admission transcript") +} let incompatible = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v1", media: "encoded", audio: "encoded", sourceRateControl: "server", clientDecode: "hevc-opus" @@ -97,17 +101,25 @@ fn main() { "quic-tls13".into(), "datagram-v1".into(), "encoded".into(), "encoded".into(), "server".into(), "h264-opus".into(), ).unwrap(); + let request = TunnelAdmissionRequest::new( + "1".into(), "session".into(), "gateway".into(), "audience".into(), + "g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(), + ).unwrap(); + let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:".to_string() + + &"g".repeat(43) + "1:016:" + &"n".repeat(16) + + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server9:h264-opus"; + assert_eq!(request.device_admission_transcript(), transcript.into_bytes()); assert!(TunnelAdmissionRequest::new( "2".into(), "session".into(), "gateway".into(), "audience".into(), - "g".repeat(43), 0, "n".repeat(16), capabilities.clone(), + "g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(), ).is_err()); assert!(TunnelAdmissionRequest::new( "0".into(), "session".into(), "gateway".into(), "audience".into(), - "g".repeat(43), 0, "n".repeat(16), capabilities.clone(), + "g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(), ).is_err()); assert!(TunnelAdmissionRequest::new( "1".into(), "session".into(), "gateway".into(), "audience".into(), - "g".repeat(43), 0, "short".into(), capabilities.clone(), + "g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(), ).is_err()); assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok()); let incompatible = CapabilityProfile::new(