feat(protocol): validate generated gateway contracts
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: verify generate proto-lint proto-breaking source-verify scope-verify conformance frame-verify go-test binding-compile clean-generated
|
.PHONY: verify generate proto-lint proto-breaking source-verify scope-verify conformance frame-verify go-test binding-compile strict-contracts clean-generated
|
||||||
|
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -29,6 +29,9 @@ binding-compile:
|
|||||||
rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib
|
rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib
|
||||||
swiftc -typecheck gen/swift/Protocol.swift
|
swiftc -typecheck gen/swift/Protocol.swift
|
||||||
|
|
||||||
|
strict-contracts:
|
||||||
|
$(PYTHON) -B tools/test_generated_contracts.py
|
||||||
|
|
||||||
conformance:
|
conformance:
|
||||||
$(PYTHON) -B tools/fixture_digest.py
|
$(PYTHON) -B tools/fixture_digest.py
|
||||||
go run ./tools/go-conformance
|
go run ./tools/go-conformance
|
||||||
@@ -40,4 +43,4 @@ frame-verify:
|
|||||||
clean-generated:
|
clean-generated:
|
||||||
$(PYTHON) tools/generate.py --check
|
$(PYTHON) tools/generate.py --check
|
||||||
|
|
||||||
verify: generate proto-lint proto-breaking source-verify scope-verify go-test binding-compile conformance frame-verify clean-generated
|
verify: generate proto-lint proto-breaking source-verify scope-verify go-test binding-compile strict-contracts conformance frame-verify clean-generated
|
||||||
|
|||||||
@@ -414,6 +414,9 @@ func (v AllocationPolicy) Validate() error {
|
|||||||
if v.ReservationLeaseSeconds > 3600 {
|
if v.ReservationLeaseSeconds > 3600 {
|
||||||
violations = append(violations, FieldViolation{Field: "reservation_lease_seconds", Code: "maximum"})
|
violations = append(violations, FieldViolation{Field: "reservation_lease_seconds", Code: "maximum"})
|
||||||
}
|
}
|
||||||
|
if v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps {
|
||||||
|
violations = append(violations, FieldViolation{Field: "bounds", Code: "invalid_order"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -918,6 +921,9 @@ func (v ChannelFrame) Validate() error {
|
|||||||
if len(v.Payload) > 87384 {
|
if len(v.Payload) > 87384 {
|
||||||
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if v.FragmentIndex >= v.FragmentCount {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -2227,6 +2233,9 @@ func (v GatewayRegistration) Validate() error {
|
|||||||
if err := v.Capabilities.Validate(); err != nil {
|
if err := v.Capabilities.Validate(); err != nil {
|
||||||
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
|
||||||
}
|
}
|
||||||
|
if v.ProtocolMinVersion > v.ProtocolMaxVersion {
|
||||||
|
violations = append(violations, FieldViolation{Field: "protocol_version", Code: "invalid_order"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -2484,6 +2493,9 @@ func (v ManifestBounds) Validate() error {
|
|||||||
if v.MaximumKbps > 100000000 {
|
if v.MaximumKbps > 100000000 {
|
||||||
violations = append(violations, FieldViolation{Field: "maximum_kbps", Code: "maximum"})
|
violations = append(violations, FieldViolation{Field: "maximum_kbps", Code: "maximum"})
|
||||||
}
|
}
|
||||||
|
if v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps {
|
||||||
|
violations = append(violations, FieldViolation{Field: "bounds", Code: "invalid_order"})
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -4043,3 +4055,21 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||||
|
|
||||||
|
func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
|
}
|
||||||
|
selected := profiles[0]
|
||||||
|
if err := selected.Validate(); err != nil {
|
||||||
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
|
}
|
||||||
|
for _, profile := range profiles[1:] {
|
||||||
|
if err := profile.Validate(); err != nil || profile != selected {
|
||||||
|
return CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"2"
|
"2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generator_sha256": "cb975bcd42bf77641b6a0f44d5ec7a6fdba1858d6b8a865e0f04e53bad82648c",
|
"generator_sha256": "88535ecf2b1c926104b10b4ab56f1bc6298e1c3e75490e36ee8581a135bcded7",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"
|
"schema_sha256": "b8a69785112bb94d45f47c2250ca59d0bde47e3667b8ad89c9b0e2c4cfb25aec"
|
||||||
}
|
}
|
||||||
|
|||||||
+1255
-194
File diff suppressed because it is too large
Load Diff
+1227
-270
File diff suppressed because it is too large
Load Diff
@@ -43,12 +43,35 @@ func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, invalid := range []string{
|
for _, invalid := range []string{
|
||||||
strings.Replace(registration, `"version":"1"`, `"version":"2"`, 1),
|
strings.Replace(registration, `"version":"1"`, `"version":"2"`, 1),
|
||||||
|
strings.Replace(registration, `"version":"1"`, `"version":"0"`, 1),
|
||||||
strings.Replace(registration, `"features":["datagram.media"]`, `"features":["datagram.media"],"provider_url":"https://provider.invalid"`, 1),
|
strings.Replace(registration, `"features":["datagram.media"]`, `"features":["datagram.media"],"provider_url":"https://provider.invalid"`, 1),
|
||||||
} {
|
} {
|
||||||
if _, err := protocol.DecodeGatewayRegistration([]byte(invalid)); err == nil {
|
if _, err := protocol.DecodeGatewayRegistration([]byte(invalid)); err == nil {
|
||||||
t.Fatalf("invalid gateway registration accepted: %s", invalid)
|
t.Fatalf("invalid gateway registration accepted: %s", invalid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := protocol.DecodeGatewayRegistration([]byte("{")); err == nil {
|
||||||
|
t.Fatal("DecodeGatewayRegistration accepted malformed JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayRegistrationRejectsInvertedProtocolBounds(t *testing.T) {
|
||||||
|
registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":2,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":"h264-opus"}}`
|
||||||
|
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err == nil {
|
||||||
|
t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) {
|
||||||
|
first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", SourceRateControl: "server", ClientDecode: "h264-opus"}
|
||||||
|
if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || got != first {
|
||||||
|
t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
second := first
|
||||||
|
second.ClientDecode = "hevc-opus"
|
||||||
|
if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil {
|
||||||
|
t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
||||||
|
|||||||
+205
-5
@@ -151,6 +151,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
reference = ref_name(prop)
|
reference = ref_name(prop)
|
||||||
if reference:
|
if reference:
|
||||||
lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}")
|
lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}")
|
||||||
|
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||||
|
lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }")
|
||||||
|
if name == "GatewayRegistration":
|
||||||
|
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
|
||||||
|
if name == "ChannelFrame":
|
||||||
|
lines.append("\tif v.FragmentIndex >= v.FragmentCount { violations = append(violations, FieldViolation{Field: \"fragment_index\", Code: \"invalid_order\"}) }")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -235,6 +241,20 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
out.append("\treturn json.Marshal(value)")
|
out.append("\treturn json.Marshal(value)")
|
||||||
out.append("}")
|
out.append("}")
|
||||||
out.append("")
|
out.append("")
|
||||||
|
out.extend([
|
||||||
|
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
|
||||||
|
"",
|
||||||
|
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
||||||
|
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
|
"\tselected := profiles[0]",
|
||||||
|
"\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
|
"\tfor _, profile := range profiles[1:] {",
|
||||||
|
"\t\tif err := profile.Validate(); err != nil || profile != selected { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||||
|
"\t}",
|
||||||
|
"\treturn selected, nil",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
# Use io.EOF in generated code without making every generated decoder depend on
|
# Use io.EOF in generated code without making every generated decoder depend on
|
||||||
# error-string comparison; replace the deliberately compact placeholder.
|
# error-string comparison; replace the deliberately compact placeholder.
|
||||||
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
|
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
|
||||||
@@ -272,6 +292,58 @@ def swift_type(prop: dict[str, Any]) -> str:
|
|||||||
return "String"
|
return "String"
|
||||||
|
|
||||||
|
|
||||||
|
def rust_validation(definition: dict[str, Any]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
required = set(definition.get("required", []))
|
||||||
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
|
field = rust_field(prop_name)
|
||||||
|
value = f"self.{field}"
|
||||||
|
if prop_name not in required:
|
||||||
|
value = f"value"
|
||||||
|
lines.append(f" if let Some(value) = &self.{field} {{")
|
||||||
|
prefix, suffix = " ", " }"
|
||||||
|
else:
|
||||||
|
prefix, suffix = "", ""
|
||||||
|
if prop.get("type") == "string":
|
||||||
|
if prop_name in required and prop.get("minLength", 0) > 0:
|
||||||
|
lines.append(f" {prefix}if {value}.is_empty() {{ return Err(ValidationError::new(\"{prop_name}\", \"required\")); }}")
|
||||||
|
if "minLength" in prop:
|
||||||
|
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
||||||
|
if "maxLength" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
||||||
|
if "const" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
|
if "enum" in prop:
|
||||||
|
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||||
|
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
|
if prop.get("type") == "integer":
|
||||||
|
if "minimum" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
|
||||||
|
if "maximum" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}")
|
||||||
|
if prop.get("type") == "array":
|
||||||
|
if "minItems" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
|
||||||
|
if "maxItems" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
||||||
|
item_ref = ref_name(prop.get("items", {}))
|
||||||
|
if item_ref:
|
||||||
|
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
|
||||||
|
reference = ref_name(prop)
|
||||||
|
if reference:
|
||||||
|
lines.append(f" {prefix}{value}.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_object\"))?;")
|
||||||
|
if suffix:
|
||||||
|
lines.append(suffix)
|
||||||
|
name = definition["name"]
|
||||||
|
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||||
|
lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }")
|
||||||
|
if name == "GatewayRegistration":
|
||||||
|
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
|
||||||
|
if name == "ChannelFrame":
|
||||||
|
lines.append(" if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new(\"fragment_index\", \"invalid_order\")); }")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
||||||
out = [
|
out = [
|
||||||
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
||||||
@@ -282,6 +354,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";',
|
f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";',
|
||||||
"pub type JsonObject = std::collections::BTreeMap<String, String>;",
|
"pub type JsonObject = std::collections::BTreeMap<String, String>;",
|
||||||
"",
|
"",
|
||||||
|
"#[derive(Debug, Clone, PartialEq, Eq)]",
|
||||||
|
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
||||||
|
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
definition = defs[name]
|
definition = defs[name]
|
||||||
@@ -292,11 +368,103 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
typ = rust_type(prop)
|
typ = rust_type(prop)
|
||||||
if prop_name not in required:
|
if prop_name not in required:
|
||||||
typ = f"Option<{typ}>"
|
typ = f"Option<{typ}>"
|
||||||
out.append(f" pub {field}: {typ},")
|
out.append(f" {field}: {typ},")
|
||||||
out.extend(["}", ""])
|
out.extend(["}", ""])
|
||||||
|
parameters: list[str] = []
|
||||||
|
assignments: list[str] = []
|
||||||
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
|
field = rust_field(prop_name)
|
||||||
|
typ = rust_type(prop)
|
||||||
|
if prop_name not in required:
|
||||||
|
typ = f"Option<{typ}>"
|
||||||
|
parameters.append(f"{field}: {typ}")
|
||||||
|
assignments.append(field)
|
||||||
|
out.append(f"impl {name} {{")
|
||||||
|
out.append(f" pub fn new({', '.join(parameters)}) -> Result<Self, ValidationError> {{")
|
||||||
|
out.append(f" let value = Self {{ {', '.join(assignments)} }};")
|
||||||
|
out.append(" value.validate()?;")
|
||||||
|
out.append(" Ok(value)")
|
||||||
|
out.append(" }")
|
||||||
|
out.append(" pub fn validate(&self) -> Result<(), ValidationError> {")
|
||||||
|
out.extend(rust_validation(definition))
|
||||||
|
out.append(" Ok(())")
|
||||||
|
out.append(" }")
|
||||||
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
|
field = rust_field(prop_name)
|
||||||
|
typ = rust_type(prop)
|
||||||
|
if prop_name not in required:
|
||||||
|
typ = f"Option<{typ}>"
|
||||||
|
out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}")
|
||||||
|
out.extend(["}", ""])
|
||||||
|
out.extend([
|
||||||
|
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
||||||
|
" let selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
||||||
|
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||||
|
" for profile in &profiles[1..] {",
|
||||||
|
" profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||||
|
" if profile != &selected { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
||||||
|
" }",
|
||||||
|
" Ok(selected)",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def swift_validation(definition: dict[str, Any]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
required = set(definition.get("required", []))
|
||||||
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
|
field = swift_field(prop_name)
|
||||||
|
value = f"self.{field}"
|
||||||
|
if prop_name not in required:
|
||||||
|
value = "value"
|
||||||
|
lines.append(f" if let value = self.{field} {{")
|
||||||
|
prefix, suffix = " ", " }"
|
||||||
|
else:
|
||||||
|
prefix, suffix = "", ""
|
||||||
|
if prop.get("type") == "string":
|
||||||
|
if prop_name in required and prop.get("minLength", 0) > 0:
|
||||||
|
lines.append(f" {prefix}if {value}.isEmpty {{ throw ContractValidationError(field: \"{prop_name}\", code: \"required\") }}")
|
||||||
|
if "minLength" in prop:
|
||||||
|
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
||||||
|
if "maxLength" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
||||||
|
if "const" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
|
if "enum" in prop:
|
||||||
|
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
|
||||||
|
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
|
if prop.get("format") == "date-time":
|
||||||
|
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||||
|
if prop.get("type") == "integer":
|
||||||
|
if "minimum" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
||||||
|
if "maximum" in prop:
|
||||||
|
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"maximum\") }}")
|
||||||
|
if prop.get("type") == "array":
|
||||||
|
if "minItems" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
|
||||||
|
if "maxItems" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
||||||
|
item_ref = ref_name(prop.get("items", {}))
|
||||||
|
if item_ref:
|
||||||
|
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
|
||||||
|
reference = ref_name(prop)
|
||||||
|
if reference:
|
||||||
|
lines.append(f" {prefix}try {value}.validate()")
|
||||||
|
if suffix:
|
||||||
|
lines.append(suffix)
|
||||||
|
name = definition["name"]
|
||||||
|
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||||
|
lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }")
|
||||||
|
if name == "GatewayRegistration":
|
||||||
|
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
|
||||||
|
if name == "ChannelFrame":
|
||||||
|
lines.append(" if fragmentIndex >= fragmentCount { throw ContractValidationError(field: \"fragment_index\", code: \"invalid_order\") }")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
||||||
out = [
|
out = [
|
||||||
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
||||||
@@ -306,6 +474,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
f'public let currentWireVersion = "{compatibility["current"]}"',
|
f'public let currentWireVersion = "{compatibility["current"]}"',
|
||||||
f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"',
|
f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"',
|
||||||
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
||||||
|
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
|
||||||
|
"private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
@@ -320,16 +490,46 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
out.append(" enum CodingKeys: String, CodingKey {")
|
out.append(" enum CodingKeys: String, CodingKey {")
|
||||||
for prop_name in definition.get("properties", {}):
|
for prop_name in definition.get("properties", {}):
|
||||||
out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"")
|
out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"")
|
||||||
out.extend([" }", "", " public init(from decoder: Decoder) throws {",])
|
parameters: list[str] = []
|
||||||
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
|
typ = swift_type(prop)
|
||||||
|
if prop_name not in required:
|
||||||
|
typ += "?"
|
||||||
|
parameters.append(f"{swift_field(prop_name)}: {typ}")
|
||||||
|
out.extend([" }", "", f" public init({', '.join(parameters)}) throws {{"])
|
||||||
|
for prop_name in definition.get("properties", {}):
|
||||||
|
field = swift_field(prop_name)
|
||||||
|
out.append(f" self.{field} = {field}")
|
||||||
|
out.extend([" try validate()", " }", "", " public init(from decoder: Decoder) throws {"])
|
||||||
|
out.append(" let all = try decoder.container(keyedBy: AnyCodingKey.self)")
|
||||||
|
out.append(" for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: \"unknown_field\") }")
|
||||||
out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)")
|
out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)")
|
||||||
|
decoded: list[str] = []
|
||||||
for prop_name, prop in definition.get("properties", {}).items():
|
for prop_name, prop in definition.get("properties", {}).items():
|
||||||
field = swift_field(prop_name)
|
field = swift_field(prop_name)
|
||||||
typ = swift_type(prop)
|
typ = swift_type(prop)
|
||||||
if prop_name in required:
|
if prop_name in required:
|
||||||
out.append(f" {field} = try c.decode({typ}.self, forKey: .{field})")
|
decoded.append(f"{field}: try c.decode({typ}.self, forKey: .{field})")
|
||||||
else:
|
else:
|
||||||
out.append(f" {field} = try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
||||||
out.extend([" }", "}", ""])
|
out.append(f" try self.init({', '.join(decoded)})")
|
||||||
|
out.extend([" }", "", " public func validate() throws {"])
|
||||||
|
out.extend(swift_validation(definition))
|
||||||
|
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
|
||||||
|
out.extend([
|
||||||
|
"public extension CapabilityProfile {",
|
||||||
|
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
|
||||||
|
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
|
" try selected.validate()",
|
||||||
|
" for profile in profiles.dropFirst() {",
|
||||||
|
" try profile.validate()",
|
||||||
|
" if profile != selected { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||||
|
" }",
|
||||||
|
" return selected",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compile and exercise strict generated Swift and Rust gateway contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], directory: pathlib.Path) -> None:
|
||||||
|
result = subprocess.run(command, cwd=directory, text=True, capture_output=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("%s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
|
|
||||||
|
def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> None:
|
||||||
|
result = subprocess.run(command, cwd=directory, text=True, capture_output=True, check=False)
|
||||||
|
if result.returncode == 0 or expected not in result.stdout + result.stderr:
|
||||||
|
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
||||||
|
workspace = pathlib.Path(temporary)
|
||||||
|
swift = workspace / "main.swift"
|
||||||
|
swift.write_text(
|
||||||
|
"""import Foundation
|
||||||
|
|
||||||
|
let capability = try CapabilityProfile(
|
||||||
|
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
||||||
|
audio: "encoded", sourceRateControl: "server", clientDecode: "h264-opus"
|
||||||
|
)
|
||||||
|
let request = try TunnelAdmissionRequest(
|
||||||
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
|
grant: String(repeating: "g", count: 43), reconnectSequence: 0,
|
||||||
|
clientNonce: String(repeating: "n", count: 16), capabilities: capability
|
||||||
|
)
|
||||||
|
_ = request
|
||||||
|
let incompatible = try CapabilityProfile(
|
||||||
|
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
||||||
|
audio: "encoded", sourceRateControl: "server", clientDecode: "hevc-opus"
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
guard try CapabilityProfile.intersection([capability, capability]) == capability else {
|
||||||
|
fatalError("matching capability profiles did not intersect")
|
||||||
|
}
|
||||||
|
} catch { fatalError("matching capability profiles did not intersect") }
|
||||||
|
do {
|
||||||
|
_ = try CapabilityProfile.intersection([capability, incompatible])
|
||||||
|
fatalError("profiles without overlap were accepted")
|
||||||
|
} catch { }
|
||||||
|
let valid = try request.encodeJSON()
|
||||||
|
var unsupported = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
|
||||||
|
unsupported["version"] = "2"
|
||||||
|
var downgrade = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
|
||||||
|
downgrade["version"] = "0"
|
||||||
|
var unknown = try JSONSerialization.jsonObject(with: valid) as! [String: Any]
|
||||||
|
unknown["unknown"] = true
|
||||||
|
for invalid in [
|
||||||
|
try JSONSerialization.data(withJSONObject: unsupported),
|
||||||
|
try JSONSerialization.data(withJSONObject: downgrade),
|
||||||
|
try JSONSerialization.data(withJSONObject: unknown),
|
||||||
|
Data("{".utf8),
|
||||||
|
valid + Data(" {}".utf8),
|
||||||
|
] {
|
||||||
|
do {
|
||||||
|
_ = try TunnelAdmissionRequest.decodeJSON(invalid)
|
||||||
|
fatalError("invalid tunnel admission request was accepted")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try AllocationPolicy(
|
||||||
|
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
||||||
|
audience: "audience", protocolValue: "verse", protocolVersion: 1,
|
||||||
|
grantTtlSeconds: 60, reservationLeaseSeconds: 300
|
||||||
|
)
|
||||||
|
fatalError("invalid allocation bounds were accepted")
|
||||||
|
} catch { }
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
run(["swiftc", str(ROOT / "gen/swift/Protocol.swift"), str(swift), "-o", str(workspace / "swift-contracts")], ROOT)
|
||||||
|
run([str(workspace / "swift-contracts")], ROOT)
|
||||||
|
|
||||||
|
rust = workspace / "protocol.rs"
|
||||||
|
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust)
|
||||||
|
with rust.open("a", encoding="utf-8") as output:
|
||||||
|
output.write(
|
||||||
|
"""
|
||||||
|
fn main() {
|
||||||
|
let capabilities = CapabilityProfile::new(
|
||||||
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
|
"encoded".into(), "server".into(), "h264-opus".into(),
|
||||||
|
).unwrap();
|
||||||
|
assert!(TunnelAdmissionRequest::new(
|
||||||
|
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
|
"g".repeat(43), 0, "n".repeat(16), capabilities.clone(),
|
||||||
|
).is_err());
|
||||||
|
assert!(TunnelAdmissionRequest::new(
|
||||||
|
"0".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
|
"g".repeat(43), 0, "n".repeat(16), capabilities.clone(),
|
||||||
|
).is_err());
|
||||||
|
assert!(TunnelAdmissionRequest::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
|
"g".repeat(43), 0, "short".into(), capabilities.clone(),
|
||||||
|
).is_err());
|
||||||
|
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
||||||
|
let incompatible = CapabilityProfile::new(
|
||||||
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
|
"encoded".into(), "server".into(), "hevc-opus".into(),
|
||||||
|
).unwrap();
|
||||||
|
assert!(intersect_capability_profiles(&[capabilities, incompatible]).is_err());
|
||||||
|
assert!(AllocationPolicy::new(
|
||||||
|
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
||||||
|
).is_err());
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
||||||
|
run([str(workspace / "rust-contracts")], ROOT)
|
||||||
|
rust_unknown = workspace / "unknown.rs"
|
||||||
|
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
||||||
|
with rust_unknown.open("a", encoding="utf-8") as output:
|
||||||
|
output.write("\nfn main() { let _ = CapabilityProfile { unknown: String::new() }; }\n")
|
||||||
|
run_failure(["rustc", str(rust_unknown), "-o", str(workspace / "rust-unknown")], ROOT, "no field named `unknown`")
|
||||||
|
print("Generated strict contract checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user