Merge remote-tracking branch 'origin/main' into t26
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 25s

This commit is contained in:
Libby 2026-11-07 12:27:39 +00:00
commit d18def46b3
26 changed files with 3510 additions and 7 deletions

View file

@ -0,0 +1,346 @@
{
"vectors": [
{
"name": "snapshot_request_v1_defaults",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"shardId": null,
"shardEpoch": null
}
},
{
"name": "snapshot_request_v1_full_selection_and_shard",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"shardId": "shard-1",
"shardEpoch": 7
}
},
{
"name": "database_subscribe_v1_defaults",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m",
"subscriptionId": "sub",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"includeSnapshot": true,
"consumerRuntimeId": null,
"bodyIds": null,
"supportedBodyTransports": null
}
},
{
"name": "database_subscribe_v1_full",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m-full",
"subscriptionId": "sub-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"includeSnapshot": false,
"consumerRuntimeId": "consumer",
"bodyIds": [
"body-a"
],
"supportedBodyTransports": [
"rudp",
"quic"
]
}
},
{
"name": "snapshot_response_raw_v1_headers",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 2,
"asOf": 5,
"next": "next-cursor",
"headers": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": null
},
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-lo",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": [
"alpha",
"beta"
]
}
],
"documents": null,
"edges": null,
"shardId": "shard-1",
"shardEpoch": 3,
"shardLogSequence": 41
}
},
{
"name": "snapshot_response_raw_v1_documents_and_edges",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 1,
"asOf": 5,
"next": null,
"headers": null,
"documents": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": null,
"schemaVersion": null,
"schemaContentHash": null,
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"payloadEncoding": "messagepack",
"payload": "gaFrAQ==",
"sourceRuntimeId": null,
"sourceAgentId": null,
"sourceRole": null,
"tags": null
}
],
"edges": [
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": null,
"payload": null
},
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": "messagepack",
"payload": "AQID"
}
],
"shardId": null,
"shardEpoch": null,
"shardLogSequence": null
}
},
{
"name": "error_plain",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "boom",
"routingHint": null,
"code": null,
"details": null
}
},
{
"name": "error_selection_invalid",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "fields[0].index",
"value": "kind",
"asOf": null,
"current": null
}
}
},
{
"name": "error_cursor_stale",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 3,
"current": 9
}
}
},
{
"name": "error_cursor_invalid_no_details",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: bad",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "error_write_not_authorized",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "k",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -228,6 +228,17 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted
unsubscribe()
```
## Authority route verification
`cultmesh_py.authority` ports the C# reference
(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate`
judges an Odin-signed ECDSA P-256 route (channel protection, trusted root,
validity window, IEEE P1363 signature over the canonical bytes) and
`verify_session_proof` checks the provider nonce proof. Refusals raise
`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`.
`tests/test_authority_route_vectors.py` judges
`contracts/cultmesh/authority-route-vectors*.json`.
## Verify
`cultmesh_py.verify` checks the public exports and `py.typed` markers of all

View file

@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"]
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"]
[project.scripts]
cultmesh-py-daemon = "cultmesh_py.daemon:main"
cultmesh-py-verify = "cultmesh_py.verify:main"

View file

@ -0,0 +1,338 @@
"""CultMesh authority route verification.
Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the
route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the
same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`:
unsigned, channel protection, root lookup, validity window, signature.
Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
"""
from __future__ import annotations
import base64
import binascii
import re
import struct
from dataclasses import dataclass
from typing import Iterable, Mapping, Sequence
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1"
SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1"
AUTHENTICATED_REMOTE = "authenticated-remote"
LOCAL_DEVELOPMENT = "local-development"
# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085.
# `str.strip()` is a different set (it also strips U+001C-U+001F).
_CSHARP_WHITESPACE = (
"\u0009\u000a\u000b\u000c\u000d \u0085  "
"           "
"

   "
)
def trim_csharp(value: str) -> str:
return value.strip(_CSHARP_WHITESPACE)
def is_blank_csharp(value: str | None) -> bool:
return value is None or trim_csharp(value) == ""
def _require(value: str | None, name: str) -> str:
if is_blank_csharp(value):
raise ValueError(f"Value must be non-empty. (Parameter '{name}')")
return trim_csharp(value) # type: ignore[arg-type]
class AuthorityRouteRefusal(Exception):
"""A route or proof the consumer's trust policy refuses.
Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with
reason `Authentication`: `reason` is always "authentication".
"""
reason = "authentication"
def __init__(self, message: str, endpoint: str = "") -> None:
super().__init__(message)
self.message = message
self.endpoint = endpoint
def _b64_strict(value: str) -> bytes:
# `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored.
stripped = re.sub(r"[ \t\r\n]", "", value)
return base64.b64decode(stripped, validate=True)
@dataclass(frozen=True)
class P256PublicKey:
key_id: str
x: str
y: str
def __post_init__(self) -> None:
object.__setattr__(self, "key_id", _require(self.key_id, "keyId"))
for name in ("x", "y"):
value = _require(getattr(self, name), name)
try:
_b64_strict(value)
except (binascii.Error, ValueError) as error:
raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error
object.__setattr__(self, name, value)
def verify(self, payload: bytes, signature_base64: str) -> bool:
"""IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation."""
try:
x, y = _b64_strict(self.x), _b64_strict(self.y)
signature = _b64_strict(signature_base64)
if len(x) != 32 or len(y) != 32 or len(signature) != 64:
return False
key = ec.EllipticCurvePublicNumbers(
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()
).public_key()
der = encode_dss_signature(
int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
)
key.verify(der, payload, ec.ECDSA(hashes.SHA256()))
return True
except (InvalidSignature, ValueError, binascii.Error):
return False
@dataclass(frozen=True)
class RouteCertificate:
provider_key: P256PublicKey
odin_key_id: str
issued_at_unix_milliseconds: int
expires_at_unix_milliseconds: int
signature: str = ""
def __post_init__(self) -> None:
if is_blank_csharp(self.odin_key_id):
raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')")
object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id))
if self.issued_at_unix_milliseconds < 0:
raise ValueError("issuedAtUnixMilliseconds must not be negative.")
if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds:
raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.")
object.__setattr__(self, "signature", trim_csharp(self.signature or ""))
@dataclass(frozen=True)
class AuthorityRoute:
"""`CultMeshAuthorityRoute`: construction cleans every field as the C# does."""
authority_runtime_id: str
endpoint: str
protocol_ids: Sequence[str] = ()
priority: int = 0
generation: str | None = None
certificate: RouteCertificate | None = None
def __post_init__(self) -> None:
runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId")
endpoint = _require(self.endpoint, "endpoint")
if self.priority < 0:
raise ValueError("priority must not be negative.")
object.__setattr__(self, "authority_runtime_id", runtime_id)
object.__setattr__(self, "endpoint", endpoint)
object.__setattr__(
self,
"protocol_ids",
tuple(
sorted(
{trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)},
key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order
)
),
)
object.__setattr__(
self,
"generation",
f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type]
)
@dataclass(frozen=True)
class SessionOpen:
"""The fields of `CultMeshSessionOpenMessage` the session proof covers."""
client_nonce: str | None = None
message_id: str | None = None
source_runtime_id: str | None = None
verse_id: str | None = None
authority_runtime_id: str | None = None
protocol_id: str | None = None
route_generation: str | None = None
@dataclass(frozen=True)
class SessionAccepted:
client_nonce: str | None
provider_key_id: str | None
provider_signature: str | None
@dataclass(frozen=True)
class VerifiedRoute:
route: AuthorityRoute
provider_key: P256PublicKey | None
is_local_development: bool
def canonical_fields(*values: str | None) -> bytes:
out = bytearray()
for value in values:
encoded = (value or "").encode("utf-8")
out += struct.pack(">I", len(encoded)) + encoded
return bytes(out)
def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes:
certificate = route.certificate
if certificate is None:
raise ValueError("Route certificate is required.")
return canonical_fields(
ROUTE_DOMAIN,
verse_id,
route.authority_runtime_id,
route.endpoint,
"\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point
str(route.priority),
route.generation,
certificate.provider_key.key_id,
certificate.provider_key.x,
certificate.provider_key.y,
certificate.odin_key_id,
str(certificate.issued_at_unix_milliseconds),
str(certificate.expires_at_unix_milliseconds),
)
def canonical_session(request: SessionOpen, endpoint: str) -> bytes:
return canonical_fields(
SESSION_DOMAIN,
request.client_nonce,
request.message_id,
request.source_runtime_id,
request.verse_id,
request.authority_runtime_id,
request.protocol_id,
endpoint,
request.route_generation,
)
# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call
# a refused endpoint loopback or protected. Same shape rule as the TypeScript port.
_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)")
def _split_endpoint(value: str) -> tuple[str, str] | None:
"""Return (scheme, lowercase host) or None where System.Uri would refuse."""
raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value)
if "\\" in raw:
return None
shape = _AUTHORITY.match(raw)
if not shape:
return None
authority = shape.group(1)
after_user = authority[authority.rfind("@") + 1 :]
if after_user.startswith("["):
host = after_user[: after_user.find("]") + 1]
else:
host = after_user.split(":")[0]
if not host or re.search(r"[^!-~]", host) or host.endswith("."):
return None
return raw.split("://", 1)[0].lower(), host.strip("[]").lower()
def is_loopback_endpoint(endpoint: str) -> bool:
parts = _split_endpoint(endpoint)
if parts is None:
return False
host = parts[1]
return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool(
re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host)
)
def is_protected_endpoint(endpoint: str) -> bool:
parts = _split_endpoint(endpoint)
return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0])
class AuthorityTrustPolicy:
"""Consumer-owned policy. Fails closed unless a route is valid under it."""
def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None:
if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT):
raise ValueError(f"Unknown trust mode '{mode}'.")
self.mode = mode
self._roots: dict[str, P256PublicKey] = {}
for root in odin_roots:
if root.key_id in self._roots:
raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.")
self._roots[root.key_id] = root
def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None:
self.verify(verse_id, route, now_unix_milliseconds)
def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute:
local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint)
def refuse(message: str) -> AuthorityRouteRefusal:
return AuthorityRouteRefusal(message, route.endpoint)
certificate = route.certificate
if certificate is None or is_blank_csharp(certificate.signature):
if local:
return VerifiedRoute(route, None, True)
raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.")
if not is_protected_endpoint(route.endpoint) and not local:
raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")
root = self._roots.get(certificate.odin_key_id)
if root is None:
raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.")
if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds):
raise refuse("The Odin route certificate is not currently valid.")
try:
signature = _b64_strict(certificate.signature)
except (binascii.Error, ValueError):
raise refuse("The Odin route signature is not base64.") from None
if len(signature) != 64:
raise refuse("The Odin route signature is not IEEE P1363 P-256.")
if not root.verify(canonical_route(verse_id, route), certificate.signature):
raise refuse("The Odin route certificate signature is invalid.")
return VerifiedRoute(route, certificate.provider_key, False)
def verify_session_proof(
request: SessionOpen,
response: SessionAccepted,
verse_id: str,
route: AuthorityRoute,
trust: AuthorityTrustPolicy,
now_unix_milliseconds: int,
) -> bool:
"""Verifies the Odin route certificate (raising) and the provider nonce proof."""
verified = trust.verify(verse_id, route, now_unix_milliseconds)
if verified.is_local_development:
return True
key = verified.provider_key
if (
key is None
or response.provider_key_id != key.key_id
or response.client_nonce != request.client_nonce
or is_blank_csharp(response.provider_signature)
):
return False
return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type]

View file

@ -0,0 +1,217 @@
"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python.
`authority-route-vectors.json` is written by the C# reference (signatures are
captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward
inputs. Each negative case fails when the matching check is removed from
`cultmesh_py.authority`.
"""
from __future__ import annotations
import base64
import json
import unittest
from dataclasses import replace
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric import ec
from cultmesh_py.authority import (
AUTHENTICATED_REMOTE,
LOCAL_DEVELOPMENT,
AuthorityRoute,
AuthorityRouteRefusal,
AuthorityTrustPolicy,
P256PublicKey,
RouteCertificate,
SessionAccepted,
SessionOpen,
canonical_route,
verify_session_proof,
)
CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh"
def _key(element: dict) -> P256PublicKey:
return P256PublicKey(element["keyId"], element["x"], element["y"])
class Vector:
def __init__(self, name: str) -> None:
doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8"))
self.now = doc["nowUnixMilliseconds"]
self.root = _key(doc["odinRoot"])
r = doc["route"]
self.verse_id = r["verseId"]
c = r["certificate"]
self.certificate = RouteCertificate(
_key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"],
c["expiresAtUnixMilliseconds"], c["signature"],
)
self.route = AuthorityRoute(
r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"],
r["generation"], self.certificate,
)
p = doc["sessionProof"]
q = p["request"]
self.request = SessionOpen(
q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"],
q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"],
)
self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"])
self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root])
def with_certificate(self, **changes) -> AuthorityRoute:
return replace(self.route, certificate=replace(self.certificate, **changes))
def refusal(test: unittest.TestCase, fn) -> str:
with test.assertRaises(AuthorityRouteRefusal) as raised:
fn()
test.assertEqual("authentication", raised.exception.reason)
return raised.exception.message
class AuthorityRouteVectorTests(unittest.TestCase):
def test_cs_written_vector_verifies_route_and_session_proof(self) -> None:
v = Vector("authority-route-vectors.json")
v.trust.validate(v.verse_id, v.route, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None:
v = Vector("authority-route-vectors.ts-signed.json")
v.trust.validate(v.verse_id, v.route, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
tampered = replace(v.route, priority=v.route.priority + 1)
self.assertEqual(
"The Odin route certificate signature is invalid.",
refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)),
)
def test_canonical_route_is_length_prefixed_and_sorted(self) -> None:
v = Vector("authority-route-vectors.json")
data = canonical_route(v.verse_id, v.route)
self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"))
self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data)
def test_tampered_payload_fields_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
invalid = "The Odin route certificate signature is invalid."
for tampered, verse in (
(replace(v.route, priority=8), v.verse_id),
(replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id),
(replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id),
(replace(v.route, generation="other"), v.verse_id),
(v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id),
(v.route, "other-verse"),
):
self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now)))
def test_wrong_root_key_is_refused(self) -> None:
v = Vector("authority-route-vectors.json")
numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers()
impostor = P256PublicKey(
v.root.key_id,
base64.b64encode(numbers.x.to_bytes(32, "big")).decode(),
base64.b64encode(numbers.y.to_bytes(32, "big")).decode(),
)
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor])
self.assertEqual(
"The Odin route certificate signature is invalid.",
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
)
def test_untrusted_odin_key_id_is_refused(self) -> None:
v = Vector("authority-route-vectors.json")
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")])
self.assertEqual(
"Odin key 'odin-vector' is not trusted by this consumer.",
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
)
def test_expired_and_not_yet_valid_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
window = "The Odin route certificate is not currently valid."
expires = v.certificate.expires_at_unix_milliseconds
issued = v.certificate.issued_at_unix_milliseconds
v.trust.validate(v.verse_id, v.route, expires - 1)
v.trust.validate(v.verse_id, v.route, issued)
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires)))
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1)))
def test_unsigned_and_unprotected_routes_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
unsigned = replace(v.route, certificate=None)
self.assertEqual(
"Remote CultMesh routes require an Odin-signed authority certificate.",
refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)),
)
blank = v.with_certificate(signature=" ")
self.assertEqual(
"Remote CultMesh routes require an Odin-signed authority certificate.",
refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)),
)
plain = replace(v.route, endpoint="ws://provider.example/mesh")
self.assertEqual(
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)),
)
def test_local_development_admits_only_unsigned_loopback(self) -> None:
v = Vector("authority-route-vectors.json")
dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, [])
loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh")
dev.validate(v.verse_id, loop, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now))
for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"):
refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now))
authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [])
refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now))
def test_malformed_signatures_get_their_own_refusals(self) -> None:
v = Vector("authority-route-vectors.json")
not_base64 = v.with_certificate(signature="!!not base64!!")
self.assertEqual(
"The Odin route signature is not base64.",
refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)),
)
short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode())
self.assertEqual(
"The Odin route signature is not IEEE P1363 P-256.",
refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)),
)
def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None:
v = Vector("authority-route-vectors.json")
order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
raw = base64.b64decode(v.certificate.signature)
s = int.from_bytes(raw[32:], "big")
flipped = raw[:32] + (order - s).to_bytes(32, "big")
high = v.with_certificate(signature=base64.b64encode(flipped).decode())
v.trust.validate(v.verse_id, high, v.now)
def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None:
v = Vector("authority-route-vectors.json")
replayed = replace(v.request, client_nonce="replayed")
accepted = replace(v.accepted, client_nonce="replayed")
self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now))
raw = bytearray(base64.b64decode(v.accepted.provider_signature))
raw[0] ^= 1
forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode())
self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now))
moved = replace(v.request, protocol_id="cultmesh.content.v1")
self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now))
# An expired route fails closed even with a good proof.
refusal(self, lambda: verify_session_proof(
v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds))
if __name__ == "__main__":
unittest.main()

View file

@ -67,6 +67,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
@ -161,6 +167,18 @@ dependencies = [
"libc",
]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -214,11 +232,14 @@ name = "cultmesh-rs"
version = "0.2.0"
dependencies = [
"anyhow",
"base64",
"cultcache-rs",
"cultnet-rs",
"p256",
"pretty_assertions",
"rmp-serde",
"serde",
"serde_json",
"tempfile",
]
@ -298,10 +319,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@ -326,6 +362,25 @@ dependencies = [
"zeroize",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "errno"
version = "0.3.14"
@ -342,6 +397,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@ -396,6 +461,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@ -442,6 +508,17 @@ dependencies = [
"polyval",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -555,6 +632,18 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -602,6 +691,15 @@ dependencies = [
"yansi",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@ -689,6 +787,16 @@ dependencies = [
"libc",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "rmp"
version = "0.8.15"
@ -747,6 +855,20 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "semver"
version = "1.0.28"
@ -840,6 +962,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]

View file

@ -13,9 +13,12 @@ path = "src/lib.rs"
anyhow = "1"
cultcache-rs = { path = "../cultcache-rs" }
cultnet-rs = { path = "../cultnet-rs" }
base64 = "0.22"
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
pretty_assertions = "1.4.1"
rmp-serde = "1"
serde_json = "1"
tempfile = "3"

View file

@ -0,0 +1,424 @@
//! CultMesh authority route verification.
//!
//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of
//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same
//! order, the same input yields the same refusal text as
//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root
//! lookup, validity window, signature.
//!
//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
use base64::alphabet::STANDARD as STANDARD_ALPHABET;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::Engine;
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
use std::collections::BTreeMap;
use std::fmt;
const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1";
const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1";
// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits.
const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new(
&STANDARD_ALPHABET,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical)
.with_decode_allow_trailing_bits(true),
);
fn base64_decode(value: &str) -> Option<Vec<u8>> {
let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect();
DOTNET_BASE64.decode(stripped).ok()
}
/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`.
fn trim(value: &str) -> &str {
value.trim_matches(char::is_whitespace)
}
fn is_blank(value: &str) -> bool {
trim(value).is_empty()
}
fn require(value: &str, name: &str) -> Result<String, String> {
if is_blank(value) {
Err(format!("Value must be non-empty. (Parameter '{name}')"))
} else {
Ok(trim(value).to_owned())
}
}
/// A route or proof the consumer's trust policy refuses. Mirrors
/// `CultMeshSessionFailure` with reason `Authentication`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityRouteRefusal {
pub message: String,
pub endpoint: String,
}
impl AuthorityRouteRefusal {
pub const REASON: &'static str = "authentication";
}
impl fmt::Display for AuthorityRouteRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for AuthorityRouteRefusal {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct P256PublicKey {
pub key_id: String,
pub x: String,
pub y: String,
}
impl P256PublicKey {
pub fn new(key_id: &str, x: &str, y: &str) -> Result<Self, String> {
let key = Self {
key_id: require(key_id, "keyId")?,
x: require(x, "x")?,
y: require(y, "y")?,
};
for (name, value) in [("x", &key.x), ("y", &key.y)] {
if base64_decode(value).is_none() {
return Err(format!("Coordinate must be base64. (Parameter '{name}')"));
}
}
Ok(key)
}
/// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation.
pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool {
let (Some(x), Some(y), Some(signature)) = (
base64_decode(&self.x),
base64_decode(&self.y),
base64_decode(signature_base64),
) else {
return false;
};
if x.len() != 32 || y.len() != 32 || signature.len() != 64 {
return false;
}
let mut sec1 = Vec::with_capacity(65);
sec1.push(4);
sec1.extend_from_slice(&x);
sec1.extend_from_slice(&y);
let (Ok(key), Ok(signature)) = (
VerifyingKey::from_sec1_bytes(&sec1),
Signature::from_slice(&signature),
) else {
return false;
};
key.verify(payload, &signature).is_ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteCertificate {
pub provider_key: P256PublicKey,
pub odin_key_id: String,
pub issued_at_unix_milliseconds: i64,
pub expires_at_unix_milliseconds: i64,
pub signature: String,
}
impl RouteCertificate {
pub fn new(
provider_key: P256PublicKey,
odin_key_id: &str,
issued_at_unix_milliseconds: i64,
expires_at_unix_milliseconds: i64,
signature: &str,
) -> Result<Self, String> {
if is_blank(odin_key_id) {
return Err("Odin key identity is required. (Parameter 'odinKeyId')".into());
}
if issued_at_unix_milliseconds < 0 {
return Err("issuedAtUnixMilliseconds must not be negative.".into());
}
if expires_at_unix_milliseconds <= issued_at_unix_milliseconds {
return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into());
}
Ok(Self {
provider_key,
odin_key_id: trim(odin_key_id).to_owned(),
issued_at_unix_milliseconds,
expires_at_unix_milliseconds,
signature: trim(signature).to_owned(),
})
}
}
/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityRoute {
pub authority_runtime_id: String,
pub endpoint: String,
pub protocol_ids: Vec<String>,
pub priority: i32,
pub generation: String,
pub certificate: Option<RouteCertificate>,
}
impl AuthorityRoute {
pub fn new(
authority_runtime_id: &str,
endpoint: &str,
protocol_ids: &[&str],
priority: i32,
generation: Option<&str>,
certificate: Option<RouteCertificate>,
) -> Result<Self, String> {
let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?;
let endpoint = require(endpoint, "endpoint")?;
if priority < 0 {
return Err("priority must not be negative.".into());
}
let mut protocols: Vec<String> = protocol_ids
.iter()
.filter(|value| !is_blank(value))
.map(|value| trim(value).to_owned())
.collect();
// StringComparer.Ordinal is UTF-16 code-unit order.
protocols.sort_by_key(|value| value.encode_utf16().collect::<Vec<u16>>());
protocols.dedup();
let generation = match generation {
Some(value) if !is_blank(value) => trim(value).to_owned(),
_ => format!("{authority_runtime_id}@{endpoint}"),
};
Ok(Self {
authority_runtime_id,
endpoint,
protocol_ids: protocols,
priority,
generation,
certificate,
})
}
}
/// The fields of `CultMeshSessionOpenMessage` the session proof covers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionOpen {
pub client_nonce: String,
pub message_id: String,
pub source_runtime_id: String,
pub verse_id: String,
pub authority_runtime_id: String,
pub protocol_id: String,
pub route_generation: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionAccepted {
pub client_nonce: String,
pub provider_key_id: String,
pub provider_signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustMode {
AuthenticatedRemote,
LocalDevelopment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoute {
pub route: AuthorityRoute,
pub provider_key: Option<P256PublicKey>,
pub is_local_development: bool,
}
pub fn canonical_fields(values: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
for value in values {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value.as_bytes());
}
out
}
pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result<Vec<u8>, String> {
let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?;
let protocols = route.protocol_ids.join("\u{1f}");
let priority = route.priority.to_string();
let issued = certificate.issued_at_unix_milliseconds.to_string();
let expires = certificate.expires_at_unix_milliseconds.to_string();
Ok(canonical_fields(&[
ROUTE_DOMAIN,
verse_id,
&route.authority_runtime_id,
&route.endpoint,
&protocols,
&priority,
&route.generation,
&certificate.provider_key.key_id,
&certificate.provider_key.x,
&certificate.provider_key.y,
&certificate.odin_key_id,
&issued,
&expires,
]))
}
pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec<u8> {
canonical_fields(&[
SESSION_DOMAIN,
&request.client_nonce,
&request.message_id,
&request.source_runtime_id,
&request.verse_id,
&request.authority_runtime_id,
&request.protocol_id,
endpoint,
&request.route_generation,
])
}
/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling.
/// A lenient parse could call a refused endpoint loopback or protected, so the
/// shape rule is the same strict one the TypeScript port applies.
fn split_endpoint(value: &str) -> Option<(String, String)> {
let raw = value.trim_matches(|c: char| c <= ' ');
if raw.contains('\\') {
return None;
}
let (scheme, rest) = raw.split_once("://")?;
let mut scheme_chars = scheme.chars();
if !scheme_chars.next()?.is_ascii_alphabetic()
|| !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return None;
}
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
if authority.is_empty() {
return None;
}
let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..];
let host = if after_user.starts_with('[') {
&after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)]
} else {
after_user.split(':').next().unwrap_or("")
};
if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') {
return None;
}
Some((
scheme.to_ascii_lowercase(),
host.trim_matches(['[', ']']).to_ascii_lowercase(),
))
}
pub fn is_loopback_endpoint(endpoint: &str) -> bool {
let Some((_, host)) = split_endpoint(endpoint) else {
return false;
};
let octets: Vec<&str> = host.split('.').collect();
matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1")
|| (octets.len() == 4
&& octets[0] == "127"
&& octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit())))
}
pub fn is_protected_endpoint(endpoint: &str) -> bool {
split_endpoint(endpoint)
.is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic"))
}
/// Consumer-owned policy. Fails closed unless a route is valid under it.
pub struct AuthorityTrustPolicy {
mode: TrustMode,
roots: BTreeMap<String, P256PublicKey>,
}
impl AuthorityTrustPolicy {
pub fn new(mode: TrustMode, odin_roots: Vec<P256PublicKey>) -> Result<Self, String> {
let mut roots = BTreeMap::new();
for root in odin_roots {
let key_id = root.key_id.clone();
if roots.insert(key_id.clone(), root).is_some() {
return Err(format!(
"Odin root key id '{key_id}' is listed more than once in the trust policy."
));
}
}
Ok(Self { mode, roots })
}
pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> {
self.verify(verse_id, route, now_unix_milliseconds).map(|_| ())
}
pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<VerifiedRoute, AuthorityRouteRefusal> {
let refuse = |message: &str| AuthorityRouteRefusal {
message: message.to_owned(),
endpoint: route.endpoint.clone(),
};
let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint);
let certificate = match &route.certificate {
Some(certificate) if !is_blank(&certificate.signature) => certificate,
_ if local => {
return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true });
}
_ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")),
};
if !is_protected_endpoint(&route.endpoint) && !local {
return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection."));
}
let Some(root) = self.roots.get(&certificate.odin_key_id) else {
return Err(refuse(&format!(
"Odin key '{}' is not trusted by this consumer.",
certificate.odin_key_id
)));
};
if now_unix_milliseconds < certificate.issued_at_unix_milliseconds
|| now_unix_milliseconds >= certificate.expires_at_unix_milliseconds
{
return Err(refuse("The Odin route certificate is not currently valid."));
}
let Some(signature) = base64_decode(&certificate.signature) else {
return Err(refuse("The Odin route signature is not base64."));
};
if signature.len() != 64 {
return Err(refuse("The Odin route signature is not IEEE P1363 P-256."));
}
let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?;
if !root.verify(&canonical, &certificate.signature) {
return Err(refuse("The Odin route certificate signature is invalid."));
}
Ok(VerifiedRoute {
route: route.clone(),
provider_key: Some(certificate.provider_key.clone()),
is_local_development: false,
})
}
}
/// Verifies the Odin route certificate (refusing) and the provider nonce proof.
pub fn verify_session_proof(
request: &SessionOpen,
response: &SessionAccepted,
verse_id: &str,
route: &AuthorityRoute,
trust: &AuthorityTrustPolicy,
now_unix_milliseconds: i64,
) -> Result<bool, AuthorityRouteRefusal> {
let verified = trust.verify(verse_id, route, now_unix_milliseconds)?;
if verified.is_local_development {
return Ok(true);
}
let Some(key) = verified.provider_key else {
return Ok(false);
};
if response.provider_key_id != key.key_id
|| response.client_nonce != request.client_nonce
|| is_blank(&response.provider_signature)
{
return Ok(false);
}
Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature))
}

View file

@ -25,6 +25,7 @@ use std::time::Duration;
use std::time::Instant;
mod rudp_document_server;
pub mod authority;
pub use rudp_document_server::*;

View file

@ -0,0 +1,261 @@
//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust.
//!
//! `authority-route-vectors.json` is written by the C# reference (its signatures
//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript
//! over awkward inputs. Each negative case fails when the matching check is
//! removed from `cultmesh_rs::authority`.
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use cultmesh_rs::authority::*;
use p256::ecdsa::Signature;
use serde_json::Value;
use std::path::PathBuf;
struct Vector {
now: i64,
root: P256PublicKey,
verse_id: String,
route: AuthorityRoute,
request: SessionOpen,
accepted: SessionAccepted,
trust: AuthorityTrustPolicy,
}
fn s<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_else(|| panic!("missing string {key}"))
}
fn key(value: &Value) -> P256PublicKey {
P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap()
}
fn load(name: &str) -> Vector {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../contracts/cultmesh")
.join(name);
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let r = &doc["route"];
let c = &r["certificate"];
let certificate = RouteCertificate::new(
key(&c["providerKey"]),
s(c, "odinKeyId"),
c["issuedAtUnixMilliseconds"].as_i64().unwrap(),
c["expiresAtUnixMilliseconds"].as_i64().unwrap(),
s(c, "signature"),
)
.unwrap();
let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
let route = AuthorityRoute::new(
s(r, "authorityRuntimeId"),
s(r, "endpoint"),
&protocols,
r["priority"].as_i64().unwrap() as i32,
Some(s(r, "generation")),
Some(certificate),
)
.unwrap();
let p = &doc["sessionProof"];
let q = &p["request"];
let root = key(&doc["odinRoot"]);
Vector {
now: doc["nowUnixMilliseconds"].as_i64().unwrap(),
verse_id: s(r, "verseId").to_owned(),
route,
request: SessionOpen {
client_nonce: s(q, "clientNonce").into(),
message_id: s(q, "messageId").into(),
source_runtime_id: s(q, "sourceRuntimeId").into(),
verse_id: s(q, "verseId").into(),
authority_runtime_id: s(q, "authorityRuntimeId").into(),
protocol_id: s(q, "protocolId").into(),
route_generation: s(q, "routeGeneration").into(),
},
accepted: SessionAccepted {
client_nonce: s(q, "clientNonce").into(),
provider_key_id: s(p, "providerKeyId").into(),
provider_signature: s(p, "providerSignature").into(),
},
trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(),
root,
}
}
impl Vector {
fn certificate(&self) -> &RouteCertificate {
self.route.certificate.as_ref().unwrap()
}
fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute {
let mut route = self.route.clone();
edit(route.certificate.as_mut().unwrap());
route
}
fn refusal(&self, route: &AuthorityRoute, now: i64) -> String {
let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused");
assert_eq!(AuthorityRouteRefusal::REASON, "authentication");
assert_eq!(route.endpoint, error.endpoint);
error.message
}
fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool {
verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap()
}
}
const INVALID: &str = "The Odin route certificate signature is invalid.";
#[test]
fn cs_written_vector_verifies_route_and_session_proof() {
let v = load("authority-route-vectors.json");
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
assert!(v.proof(&v.request, &v.accepted));
}
#[test]
fn ts_signed_vector_verifies_and_tamper_is_refused() {
let v = load("authority-route-vectors.ts-signed.json");
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
assert!(v.proof(&v.request, &v.accepted));
let mut tampered = v.route.clone();
tampered.priority += 1;
assert_eq!(INVALID, v.refusal(&tampered, v.now));
}
#[test]
fn canonical_route_is_length_prefixed_and_sorted() {
let v = load("authority-route-vectors.json");
let bytes = canonical_route(&v.verse_id, &v.route).unwrap();
assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"));
let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1";
assert!(bytes.windows(needle.len()).any(|w| w == needle));
}
#[test]
fn tampered_payload_fields_are_refused() {
let v = load("authority-route-vectors.json");
let edits: Vec<Box<dyn Fn(&mut AuthorityRoute)>> = vec![
Box::new(|r| r.priority = 8),
Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()),
Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]),
Box::new(|r| r.generation = "other".into()),
Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1),
];
for edit in edits {
let mut route = v.route.clone();
edit(&mut route);
assert_eq!(INVALID, v.refusal(&route, v.now));
}
let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err();
assert_eq!(INVALID, error.message);
}
#[test]
fn wrong_root_key_is_refused() {
let v = load("authority-route-vectors.json");
// The provider's key is a real P-256 key that did not sign this route.
let provider = &v.certificate().provider_key;
let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap();
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap();
assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message);
}
#[test]
fn untrusted_odin_key_id_is_refused() {
let v = load("authority-route-vectors.json");
let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap();
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap();
assert_eq!(
"Odin key 'odin-vector' is not trusted by this consumer.",
trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message
);
}
#[test]
fn expired_and_not_yet_valid_are_refused() {
let v = load("authority-route-vectors.json");
let window = "The Odin route certificate is not currently valid.";
let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds);
v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap();
v.trust.validate(&v.verse_id, &v.route, issued).unwrap();
assert_eq!(window, v.refusal(&v.route, expires));
assert_eq!(window, v.refusal(&v.route, issued - 1));
}
#[test]
fn unsigned_and_unprotected_routes_are_refused() {
let v = load("authority-route-vectors.json");
let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate.";
let mut unsigned = v.route.clone();
unsigned.certificate = None;
assert_eq!(unsigned_message, v.refusal(&unsigned, v.now));
let blank = v.with_certificate(|c| c.signature = " ".into());
assert_eq!(unsigned_message, v.refusal(&blank, v.now));
let mut plain = v.route.clone();
plain.endpoint = "ws://provider.example/mesh".into();
assert_eq!(
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
v.refusal(&plain, v.now)
);
}
#[test]
fn local_development_admits_only_unsigned_loopback() {
let v = load("authority-route-vectors.json");
let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap();
let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap();
let loopback = route("ws://127.0.0.1:9/mesh");
dev.validate(&v.verse_id, &loopback, v.now).unwrap();
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap());
for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] {
assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}");
}
let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap();
assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err());
}
#[test]
fn malformed_signatures_get_their_own_refusals() {
let v = load("authority-route-vectors.json");
let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into());
assert_eq!("The Odin route signature is not base64.", v.refusal(&not_base64, v.now));
let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63]));
assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now));
}
#[test]
fn high_s_signature_verifies_as_it_does_in_dotnet() {
let v = load("authority-route-vectors.json");
let raw = STANDARD.decode(&v.certificate().signature).unwrap();
let signature = Signature::from_slice(&raw).unwrap();
let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap();
let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes()));
v.trust.validate(&v.verse_id, &high, v.now).unwrap();
}
#[test]
fn session_proof_rejects_replay_wrong_key_and_forgery() {
let v = load("authority-route-vectors.json");
let mut replayed = v.request.clone();
replayed.client_nonce = "replayed".into();
let mut accepted = v.accepted.clone();
accepted.client_nonce = "replayed".into();
assert!(!v.proof(&replayed, &accepted));
let mut wrong_id = v.accepted.clone();
wrong_id.provider_key_id = "other".into();
assert!(!v.proof(&v.request, &wrong_id));
let mut wrong_nonce = v.accepted.clone();
wrong_nonce.client_nonce = "other".into();
assert!(!v.proof(&v.request, &wrong_nonce));
let mut empty = v.accepted.clone();
empty.provider_signature = String::new();
assert!(!v.proof(&v.request, &empty));
let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap();
raw[0] ^= 1;
let mut forged = v.accepted.clone();
forged.provider_signature = STANDARD.encode(raw);
assert!(!v.proof(&v.request, &forged));
let mut moved = v.request.clone();
moved.protocol_id = "cultmesh.content.v1".into();
assert!(!v.proof(&moved, &v.accepted));
let expires = v.certificate().expires_at_unix_milliseconds;
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err());
}

View file

@ -2,6 +2,12 @@
## Unreleased (breaking; version 0.x, so the next minor)
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
`tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C#
listener it never reaps idle peers. `CultNetRudpSession` gained `next_sequence`, `withdraw_send` and `end_with`, and
`CultNetTransportStats` gained `send_failures`.
- Removed `resolve_document_and_schema_id_for_raw_record` from the package exports. A raw record resolves to one local schema id, the one
its document registers, so there is no wire id to return. Use `resolve_document_for_raw_record(documents_by_schema_id, schema_id, record)`,
which returns the document; take the schema id from `document.catalog_entry().schema_id`.

View file

@ -106,6 +106,12 @@ from .shard_replication import (
)
from .snapshot import CultNetRawDocumentRecord, CultNetRawSnapshotResponse
from .subscription import CultNetDatabaseChange
from .rudp_server import (
CultNetRudpSocketServerFrame,
CultNetRudpSocketServerOptions,
CultNetRudpSocketServerPeer,
CultNetRudpSocketTransportServer,
)
from .transport import (
CultNetRudpDeliveredFrame,
CultNetRudpPacket,
@ -215,6 +221,10 @@ __all__ = [
"CultNetRudpReceiveResult",
"CultNetRudpSendOptions",
"CultNetRudpSession",
"CultNetRudpSocketServerFrame",
"CultNetRudpSocketServerOptions",
"CultNetRudpSocketServerPeer",
"CultNetRudpSocketTransportServer",
"is_permanent_send_error",
"send_error_code",
"CultNetRudpSessionOptions",

View file

@ -0,0 +1,345 @@
"""Multi-peer RUDP listener: one UDP socket, one reliable session per remote endpoint.
Matches the C# `CultNetRudpSocketTransportServer` (the reference): a Connect opens a peer's
session, a Connect from the same endpoint that is not a repeat replaces only that peer, and
a peer that ends, is refused or cannot be answered never disturbs the others. The caller
drives it: `try_receive_once` consumes one datagram, `poll_resends` sends what is due.
Everything runs on the caller's thread, so no session needs a lock.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
import socket
from .transport import (
REFUSED_PACKET_REASON,
CultNetRudpPacket,
CultNetRudpPacketType,
CultNetRudpSession,
CultNetRudpSessionOptions,
CultNetReconnectPolicy,
CultNetTransportFrame,
CultNetTransportStats,
_channel_send_options,
_now_ms,
create_rudp_transport_profile,
decode_rudp_packet,
encode_rudp_packet,
is_permanent_send_error,
)
REPLACED_PEER_REASON = b"replaced by a new Connect generation"
@dataclass
class CultNetRudpSocketServerOptions:
runtime_id: str
socket: socket.socket
connection_id: int
initial_sequence: int | None = None
resend_delay_ms: int = 250
transport_id: str = "rudp"
max_payload_bytes: int | None = None
max_fragment_bytes: int | None = None
max_pending_reliable_packets: int | None = None
reconnect_policy: CultNetReconnectPolicy | None = None
accept_payload: bytes = b""
class CultNetRudpSocketServerPeer:
"""One remote endpoint's session. A peer that reconnects from the same endpoint is a new object."""
def __init__(self, remote_addr: tuple, connect_payload: bytes, session: CultNetRudpSession) -> None:
self.remote_addr = remote_addr
self.connect_payload = connect_payload
self.session = session
# The last transport-level reason the session ended with, once it has.
self.disconnect_reason: bytes | None = None
@property
def connected(self) -> bool:
return self.session.connected
@property
def pending_reliable_packet_count(self) -> int:
return self.session.outstanding_reliable_packet_count
@dataclass(frozen=True)
class CultNetRudpSocketServerFrame:
peer: CultNetRudpSocketServerPeer
frame: CultNetTransportFrame
def _remote_key(addr: tuple) -> tuple:
return tuple(addr[:2])
class CultNetRudpSocketTransportServer:
def __init__(self, options: CultNetRudpSocketServerOptions) -> None:
if not options.runtime_id.strip():
raise ValueError("Runtime id is required.")
self.socket = options.socket
self._options = options
host, port = self.socket.getsockname()[:2]
self.profile = create_rudp_transport_profile(
options.runtime_id,
transport_id=options.transport_id,
host=host,
port=port,
max_payload_bytes=options.max_payload_bytes,
max_fragment_bytes=options.max_fragment_bytes,
max_pending_reliable_packets=options.max_pending_reliable_packets,
reconnect_policy=options.reconnect_policy,
)
self._peers: dict[tuple, CultNetRudpSocketServerPeer] = {}
self._delivered: deque[CultNetRudpSocketServerFrame] = deque()
self._bytes_received = 0
self._bytes_sent = 0
self._frames_received = 0
self._frames_sent = 0
self._packets_dropped = 0
self._send_failures = 0
self._closed = False
self.peer_disconnected: list = [] # callbacks taking the peer
@property
def peers(self) -> tuple[CultNetRudpSocketServerPeer, ...]:
return tuple(self._peers.values())
@property
def stats(self) -> CultNetTransportStats:
return CultNetTransportStats(
bytes_received=self._bytes_received,
bytes_sent=self._bytes_sent,
frames_received=self._frames_received,
frames_sent=self._frames_sent,
packets_dropped=self._packets_dropped,
send_failures=self._send_failures,
)
def send(self, peer: CultNetRudpSocketServerPeer, channel_id: str, payload: bytes) -> None:
"""Sends to one peer. Raises when that peer's session has ended or was replaced, or when a
datagram can never be sent as built: the first fragment failing queues nothing, a later one
ends the session, as the peer has seen part of the frame."""
if self._peers.get(_remote_key(peer.remote_addr)) is not peer:
raise ConnectionError("RUDP peer is not connected.")
first_sequence = peer.session.next_sequence
packets = peer.session.send_many(
channel_id,
payload,
_channel_send_options(channel_id, _now_ms()),
max_fragment_bytes=self._options.max_fragment_bytes,
)
for index, packet in enumerate(packets):
error = self._send_packet(peer.remote_addr, packet)
if error is None:
continue
if index == 0:
peer.session.withdraw_send(first_sequence)
else:
self._end_unsendable_peer(peer, error)
raise error
self._frames_sent += 1
def send_schema(self, peer: CultNetRudpSocketServerPeer, payload: bytes) -> None:
self.send(peer, "schema", payload)
def broadcast(self, channel_id: str, payload: bytes) -> list[CultNetRudpSocketServerPeer]:
"""Sends to every connected peer and returns the peers that could not be sent to; one
peer's failure never stops the others."""
failed = []
for peer in self.peers:
try:
self.send(peer, channel_id, payload)
except (OSError, ConnectionError, ValueError):
failed.append(peer)
return failed
def disconnect(self, peer: CultNetRudpSocketServerPeer, reason: bytes = b"") -> bool:
"""Ends one peer's session, telling it why. False when the session had already ended."""
return self.end_peer_session(peer, reason)
def end_peer_session(self, peer: CultNetRudpSocketServerPeer, reason: bytes) -> bool:
"""Ends the session of a peer whose delivered frame the application could not decode or
handle, with a goodbye carrying the application's reason. The frame is already
acknowledged, so the session cannot be kept; the peer's frames still queued are not
delivered. A session that already ended owes no goodbye, since the endpoint may belong
to a new session; the call then returns False."""
self._packets_dropped += 1
if peer.session.ended:
return False
self._send_packet(peer.remote_addr, peer.session.end_with(reason))
self._delivered = deque(item for item in self._delivered if item.peer is not peer)
self._retire(peer, reason)
return True
def receive_once(self) -> CultNetRudpSocketServerFrame | None:
return self.try_receive_once()[1]
def try_receive_once(self) -> tuple[bool, CultNetRudpSocketServerFrame | None]:
"""Consumes one transport work item. Returns whether progress was made, independently of
whether it completed an application frame."""
if self._delivered:
return True, self._delivered.popleft()
try:
wire, remote = self.socket.recvfrom(65535)
except (TimeoutError, BlockingIOError, ConnectionResetError):
return False, None
self._bytes_received += len(wire)
try:
packet = decode_rudp_packet(wire)
except ValueError:
self._packets_dropped += 1
return True, None
if packet.connection_id != self._options.connection_id:
self._packets_dropped += 1
return True, None
key = _remote_key(remote)
if packet.packet_type == CultNetRudpPacketType.CONNECT:
self._receive_connect(packet, remote, key)
return True, None
peer = self._peers.get(key)
if peer is None:
self._packets_dropped += 1
return True, None
self._receive_from_peer(peer, key, packet)
return True, (self._delivered.popleft() if self._delivered else None)
def poll_resends(self) -> None:
"""Sends the reliable packets whose resend timers are due, for every peer."""
for peer in self.peers:
if self._peers.get(_remote_key(peer.remote_addr)) is not peer:
continue
for packet in peer.session.due_resends(_now_ms()):
error = self._send_packet(peer.remote_addr, packet)
if error is not None:
self._end_unsendable_peer(peer, error)
break
def close(self) -> None:
if self._closed:
return
self._closed = True
self.socket.close()
def __enter__(self) -> "CultNetRudpSocketTransportServer":
return self
def __exit__(self, *_: object) -> None:
self.close()
def _receive_connect(self, packet: CultNetRudpPacket, remote: tuple, key: tuple) -> None:
admitted = self._peers.get(key)
accept_payload = self._options.accept_payload
# The admitted session decides whether this Connect repeats the one it accepted: a repeat
# gets the Accept already owed and queues nothing. Any other Connect is a new generation
# and ends the admitted session here.
if admitted is not None:
if admitted.session.connect_repeats(packet):
try:
reply = admitted.session.accept_connect(packet, _now_ms(), accept_payload)
except ValueError:
self._packets_dropped += 1
return
error = self._send_packet(admitted.remote_addr, reply)
if error is not None:
self._end_unsendable_peer(admitted, error)
return
admitted.session.reset_peer_state()
peer = CultNetRudpSocketServerPeer(
tuple(remote),
packet.payload,
CultNetRudpSession(
CultNetRudpSessionOptions(
connection_id=self._options.connection_id,
initial_sequence=self._options.initial_sequence,
resend_delay_ms=self._options.resend_delay_ms,
max_pending_reliable_packets=self._options.max_pending_reliable_packets,
)
),
)
try:
accept = peer.session.accept_connect(packet, _now_ms(), accept_payload)
except ValueError:
self._packets_dropped += 1
accept = None
# A peer that cannot be answered gets no session, and the one it replaced has already ended.
if accept is None or self._send_packet(peer.remote_addr, accept) is not None:
self._peers.pop(key, None)
if accept is not None:
self._packets_dropped += 1
else:
self._peers[key] = peer
if admitted is not None:
admitted.disconnect_reason = REPLACED_PEER_REASON
self._notify_disconnected(admitted)
def _receive_from_peer(self, peer: CultNetRudpSocketServerPeer, key: tuple, packet: CultNetRudpPacket) -> None:
session = peer.session
try:
result = session.receive(packet, _now_ms())
except ValueError:
# receive() has already recorded the packet's reliable sequence, so the session cannot
# be kept: a retransmit would be acknowledged and the frame silently lost.
self._packets_dropped += 1
self._send_packet(peer.remote_addr, session.end_with(REFUSED_PACKET_REASON))
self._retire(peer, REFUSED_PACKET_REASON)
return
error = None
if result.reply is not None:
error = self._send_packet(peer.remote_addr, result.reply)
for ready in result.ready_to_send:
if error is None:
error = self._send_packet(peer.remote_addr, ready)
if result.disconnected:
self._retire(peer, result.disconnect_reason)
return
for frame in result.delivered:
self._delivered.append(
CultNetRudpSocketServerFrame(peer, CultNetTransportFrame(frame.channel_id, frame.payload))
)
self._frames_received += 1
if error is None and packet.packet_type == CultNetRudpPacketType.DATA:
error = self._send_packet(peer.remote_addr, session.create_ack_for_received(packet.sequence))
if error is not None:
self._end_unsendable_peer(peer, error)
def _end_unsendable_peer(self, peer: CultNetRudpSocketServerPeer, error: OSError) -> None:
"""An admitted peer owes a packet that can never be sent as built: its session ends, never
the poll, the peer is told, and the disconnect reason names the error. A session that has
already ended owes no goodbye, since the endpoint may belong to a new session."""
if peer.session.ended:
return
goodbye = peer.session.end_unsendable_session(error)
self._send_packet(peer.remote_addr, goodbye)
self._retire(peer, goodbye.payload)
def _retire(self, peer: CultNetRudpSocketServerPeer, reason: bytes | None) -> None:
# A peer a new Connect already replaced was reported then; its key now names the successor.
key = _remote_key(peer.remote_addr)
if self._peers.get(key) is not peer:
return
del self._peers[key]
peer.disconnect_reason = reason
self._notify_disconnected(peer)
def _notify_disconnected(self, peer: CultNetRudpSocketServerPeer) -> None:
for callback in list(self.peer_disconnected):
callback(peer)
def _send_packet(self, remote_addr: tuple, packet: CultNetRudpPacket) -> OSError | None:
"""A transient failure is that peer's lost datagram: counted, never raised. A permanent
one (`is_permanent_send_error`) is returned for the caller to refuse or end the session."""
wire = encode_rudp_packet(packet)
try:
self._bytes_sent += self.socket.sendto(wire, remote_addr)
except OSError as error:
if is_permanent_send_error(error):
return error
self._send_failures += 1
return None

View file

@ -29,6 +29,9 @@ class CultNetTransportStats:
# transport: malformed frames, another session's connection id, a sender
# that is not the peer, or a packet the session refuses.
packets_dropped: int = 0
# Datagrams a multi-peer server could not send to one peer and lost: each is
# a transient failure, never a reason to end a session.
send_failures: int = 0
@dataclass(frozen=True)
@ -437,6 +440,7 @@ def create_rudp_transport_profile(
UNSENDABLE_PACKET_REASON = "packet could not be sent"
REFUSED_PACKET_REASON = b"session refused a packet"
# The fixed name of each permanent send failure, keyed by errno, Winsock equivalents included.
_PERMANENT_SEND_ERROR_CODES = {
@ -600,6 +604,28 @@ class CultNetRudpSession:
self._fragment_buffers.clear()
self._fragment_sets_evicted = 0
@property
def next_sequence(self) -> int:
return self._next_sequence
def withdraw_send(self, first_sequence: int) -> None:
"""Undoes a send that never reached the wire: every packet it created at or after
`first_sequence`, pending or queued, leaves no trace and its sequences are reissued,
so the peer never sees a gap. Only valid while nothing else has been sent since."""
for sequence in [s for s in self._pending_reliable if s >= first_sequence]:
del self._pending_reliable[sequence]
kept = [p for p in self._queued_reliable if p.sequence < first_sequence]
self._queued_reliable.clear()
self._queued_reliable.extend(kept)
self._next_sequence = first_sequence
def end_with(self, reason: bytes) -> CultNetRudpPacket:
"""Ends a session whose peer's frame was refused or failed after the session acknowledged
it. The peer's sequences are forgotten before the goodbye is built, or its ack field would
acknowledge the very frame that was refused."""
self.reset_peer_state()
return self.create_disconnect(reason)
def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket:
# A session that has had a peer starts a new generation: nothing it
# learned from that peer describes the one this Connect reaches, and
@ -1397,11 +1423,8 @@ class CultNetRudpSocketTransportConnection:
self.socket.close()
def _end_refused_session(self) -> None:
reason = b"session refused a packet"
# The goodbye is built after the reset, or its ack field would acknowledge
# the very frame the session refused.
self.session.reset_peer_state()
goodbye = self.session.create_disconnect(reason)
reason = REFUSED_PACKET_REASON
goodbye = self.session.end_with(reason)
self.disconnect_reason = reason
try:
self._send_packet(goodbye)

View file

@ -0,0 +1,350 @@
"""One UDP socket serves many peers, each in its own session, as the C# reference does.
Sockets bind port 0, so no test races another for a port.
"""
from __future__ import annotations
import errno
import socket
import time
import unittest
from pathlib import Path
from cultnet_py.rudp_server import (
CultNetRudpSocketServerOptions,
CultNetRudpSocketTransportServer,
)
from cultnet_py.transport import (
CultNetRudpPacketType,
CultNetRudpSendOptions,
CultNetRudpSession,
CultNetRudpSessionOptions,
decode_rudp_packet,
encode_rudp_packet,
)
CONNECTION_ID = 0x43554C54
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "rudp-multipeer-server" / "transcript.txt"
def bind() -> socket.socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
sock.settimeout(0.03)
return sock
def make_server(**kwargs) -> CultNetRudpSocketTransportServer:
options = dict(runtime_id="py-test", socket=bind(), connection_id=CONNECTION_ID, resend_delay_ms=5)
options.update(kwargs)
return CultNetRudpSocketTransportServer(CultNetRudpSocketServerOptions(**options))
def pump(server: CultNetRudpSocketTransportServer) -> list:
"""Consumes datagrams until the socket is idle, returning the frames delivered."""
frames = []
while True:
progressed, frame = server.try_receive_once()
if frame is not None:
frames.append(frame)
if not progressed:
return frames
def drain(sock: socket.socket) -> list[bytes]:
wires = []
while True:
try:
wires.append(sock.recvfrom(65535)[0])
except TimeoutError:
return wires
class Client:
def __init__(self, server: CultNetRudpSocketTransportServer, initial_sequence: int) -> None:
self.server = server
self.socket = bind()
self.session = CultNetRudpSession(
CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence)
)
@property
def addr(self) -> tuple:
return self.socket.getsockname()
def transmit(self, packet) -> None:
self.socket.sendto(encode_rudp_packet(packet), self.server.socket.getsockname())
def connect(self, payload: bytes = b"") -> None:
self.transmit(self.session.create_connect(0, payload))
pump(self.server)
self.receive()
def receive(self) -> list:
packets = [decode_rudp_packet(wire) for wire in drain(self.socket)]
for packet in packets:
if self.session.connected or packet.packet_type == CultNetRudpPacketType.ACCEPT:
self.session.receive(packet, 0)
return packets
def write(self, text: str) -> None:
self.transmit(
self.session.send("schema", text.encode(), CultNetRudpSendOptions(reliable=True, ordered=True))
)
def peer(self):
return next(p for p in self.server.peers if p.remote_addr == self.addr)
def close(self) -> None:
self.socket.close()
class MultiPeerServerTests(unittest.TestCase):
def setUp(self) -> None:
self.server = make_server(initial_sequence=500, accept_payload=b"acc")
self.addCleanup(self.server.close)
self.clients: list[Client] = []
def client(self, initial_sequence: int) -> Client:
client = Client(self.server, initial_sequence)
self.clients.append(client)
self.addCleanup(client.close)
return client
def test_interleaved_peers_each_get_their_own_session_and_frames(self) -> None:
x, y = self.client(10), self.client(20)
x.connect(b"hx")
y.connect(b"hy")
self.assertEqual(len(self.server.peers), 2)
self.assertEqual(x.peer().connect_payload, b"hx")
self.assertEqual(y.peer().connect_payload, b"hy")
x.write("x1")
y.write("y1")
x.write("x2")
y.write("y2")
frames = pump(self.server)
by_peer = {}
for item in frames:
by_peer.setdefault(item.peer.remote_addr, []).append(item.frame.payload)
self.assertEqual(by_peer, {x.addr: [b"x1", b"x2"], y.addr: [b"y1", b"y2"]})
self.server.send(x.peer(), "schema", b"to-x")
self.server.send(y.peer(), "schema", b"to-y")
for client, expected in ((x, b"to-x"), (y, b"to-y")):
data = [p for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA]
self.assertEqual([p.payload for p in data], [expected])
def test_one_peer_ending_does_not_disturb_the_other(self) -> None:
x, y = self.client(10), self.client(20)
x.connect()
y.connect()
y_peer = y.peer()
ended = []
self.server.peer_disconnected.append(ended.append)
x.transmit(x.session.create_disconnect(b"bye"))
pump(self.server)
self.assertEqual(len(self.server.peers), 1)
self.assertEqual([p.disconnect_reason for p in ended], [b"bye"])
self.assertIs(y.peer(), y_peer)
# Datagrams from the ended endpoint belong to no session.
before = self.server.stats.packets_dropped
x_late = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=99))
x_late.accept_connect(x.session.create_connect(0), 0)
x.transmit(x_late.send("schema", b"late", CultNetRudpSendOptions(reliable=True, ordered=True)))
pump(self.server)
self.assertEqual(self.server.stats.packets_dropped, before + 1)
y.write("still here")
self.assertEqual([f.frame.payload for f in pump(self.server)], [b"still here"])
self.server.send(y_peer, "schema", b"reply")
self.assertEqual(
[p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"reply"]
)
def test_a_new_connect_from_the_same_endpoint_replaces_only_that_peer(self) -> None:
x, y = self.client(10), self.client(20)
x.connect()
y.connect()
old_y = y.peer()
ended = []
self.server.peer_disconnected.append(ended.append)
y.session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=30))
y.connect(b"again")
self.assertEqual(ended, [old_y])
self.assertEqual(old_y.disconnect_reason, b"replaced by a new Connect generation")
self.assertIsNot(y.peer(), old_y)
self.assertEqual(y.peer().connect_payload, b"again")
with self.assertRaises(ConnectionError):
self.server.send(old_y, "schema", b"to the old session")
self.assertEqual(len(self.server.peers), 2)
def test_a_repeated_connect_is_answered_with_the_owed_accept_and_starts_nothing(self) -> None:
x = self.client(10)
connect = x.session.create_connect(0, b"h")
x.transmit(connect)
pump(self.server)
first = x.peer()
x.transmit(connect)
pump(self.server)
self.assertIs(x.peer(), first)
accepts = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.ACCEPT]
self.assertEqual(len(accepts), 2)
self.assertEqual(accepts[0].sequence, accepts[1].sequence)
def test_strays_are_dropped_and_counted(self) -> None:
x = self.client(10)
x.socket.sendto(b"not rudp", self.server.socket.getsockname())
other = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID + 1, initial_sequence=1))
x.transmit(other.create_connect(0))
x.transmit(
CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=1)).create_ping()
)
pump(self.server)
self.assertEqual(self.server.stats.packets_dropped, 3)
self.assertEqual(self.server.peers, ())
def test_resends_go_only_to_peers_with_unacknowledged_writes(self) -> None:
x, y = self.client(10), self.client(20)
x.connect()
y.connect()
self.server.send(x.peer(), "schema", b"owed")
x.receive() # the first transmission is lost to the test: x never acks
y.receive()
time.sleep(0.02)
self.server.poll_resends()
x_data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA]
self.assertEqual([p.payload for p in x_data], [b"owed"])
self.assertEqual([p for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [])
def test_broadcast_reaches_every_peer(self) -> None:
x, y = self.client(10), self.client(20)
x.connect()
y.connect()
self.assertEqual(self.server.broadcast("schema", b"all"), [])
for client in (x, y):
self.assertEqual(
[p.payload for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"all"]
)
def test_an_application_ending_a_session_tells_the_peer_and_drops_its_queued_frames(self) -> None:
x, y = self.client(10), self.client(20)
x.connect()
y.connect()
x.write("x1")
y.write("y1")
# Both frames are decoded into the server's queue; ending x discards x's.
progressed, first = self.server.try_receive_once()
self.assertTrue(progressed)
pump_rest = pump(self.server)
frames = [first] + pump_rest if first else pump_rest
peer_x = x.peer()
self.assertTrue(self.server.end_peer_session(peer_x, b"server could not handle a message"))
self.assertFalse(self.server.end_peer_session(peer_x, b"again"), "an ended session owes no second goodbye")
goodbye = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DISCONNECT]
self.assertEqual([p.payload for p in goodbye], [b"server could not handle a message"])
self.assertEqual(len(self.server.peers), 1)
self.assertEqual({f.peer.remote_addr for f in frames}, {x.addr, y.addr})
def test_a_datagram_too_large_to_send_is_that_callers_error_and_queues_nothing(self) -> None:
x = self.client(10)
x.connect()
peer = x.peer()
owed = peer.pending_reliable_packet_count # the Accept, not yet acknowledged
with self.assertRaises(OSError) as raised:
self.server.send(peer, "schema", bytes(70_000))
self.assertEqual(raised.exception.errno, errno.EMSGSIZE)
self.assertEqual(peer.pending_reliable_packet_count, owed)
self.assertEqual(len(self.server.peers), 1)
# The refused write left no gap in the sequence space the peer is waiting on.
self.server.send(peer, "schema", b"fine")
data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA]
self.assertEqual([(p.sequence, p.payload) for p in data], [(501, b"fine")])
class UnsendableAfterFirstFragmentSocket:
"""Delegates to a real socket; fails permanently once its budget of sends to one address is spent."""
def __init__(self, real: socket.socket, addr: tuple, budget: int) -> None:
self._real, self._addr, self._budget = real, addr, budget
def sendto(self, wire: bytes, addr: tuple) -> int:
if tuple(addr) == self._addr:
if self._budget == 0:
raise OSError(errno.EMSGSIZE, "Message too long")
self._budget -= 1
return self._real.sendto(wire, addr)
def __getattr__(self, name: str):
return getattr(self._real, name)
class UnsendableSessionTests(unittest.TestCase):
def test_a_later_fragment_that_cannot_be_sent_ends_that_session_only(self) -> None:
server = make_server(initial_sequence=500, max_fragment_bytes=8)
self.addCleanup(server.close)
x, y = Client(server, 10), Client(server, 20)
self.addCleanup(x.close)
self.addCleanup(y.close)
x.connect()
y.connect()
x_peer = x.peer()
server.socket = UnsendableAfterFirstFragmentSocket(server.socket, x.addr, budget=1)
ended = []
server.peer_disconnected.append(ended.append)
with self.assertRaises(OSError):
server.send(x_peer, "schema", bytes(24))
self.assertEqual(ended, [x_peer])
self.assertEqual(x_peer.disconnect_reason, b"packet could not be sent: EMSGSIZE")
self.assertEqual(len(server.peers), 1)
# The goodbye could not be sent either (same address), so the peer is only retired; y is unharmed.
server.send(y.peer(), "schema", b"ok")
self.assertEqual(
[p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"ok"]
)
class ReferenceTranscriptTests(unittest.TestCase):
"""Replays the datagrams the C# reference listener was captured answering, byte for byte."""
def test_the_listener_answers_with_the_bytes_the_reference_does(self) -> None:
server = make_server(
connection_id=0x43554C54, initial_sequence=500, max_fragment_bytes=8, accept_payload=b"acc", resend_delay_ms=250
)
self.addCleanup(server.close)
sockets = {label: bind() for label in ("X", "Y")}
for sock in sockets.values():
self.addCleanup(sock.close)
target = server.socket.getsockname()
steps: list[tuple[list[str], list[tuple[str, str]]]] = []
for line in VECTORS.read_text().splitlines():
if line.startswith("#") or not line.strip():
continue
fields = line.split(" ")
if fields[0] == "out":
steps[-1][1].append((fields[1], fields[2]))
else:
steps.append((fields, []))
self.assertGreater(len(steps), 10)
for action, expected in steps:
if action[0] == "in":
sockets[action[1]].sendto(bytes.fromhex(action[2]), target)
while not server.try_receive_once()[0]:
time.sleep(0.001)
else:
peer = next(p for p in server.peers if p.remote_addr == sockets[action[1]].getsockname())
server.send(peer, action[2], bytes.fromhex(action[3]))
actual = [(label, wire.hex()) for label in ("X", "Y") for wire in drain(sockets[label])]
self.assertEqual(actual, expected, f"after {' '.join(action[:2])}")
self.assertEqual(len(server.peers), 1)
self.assertEqual(server.stats.packets_dropped, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -9,8 +9,12 @@ All notable changes to `cultnet-ts` are recorded here, following
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported.
### Breaking
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference.
- Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): the Idunn runtime authority reader calls `readSingleFileStore`, which 0.14.x does not export. An absent authority file is now a plain `Error` without a code.

View file

@ -65,6 +65,7 @@ Current message families:
- `cultnet.snapshot_request.v0`
- `cultnet.snapshot_response.v0`
- `cultnet.snapshot_response_raw.v0`
- `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet)
- `cultnet.sample.change_name.v0`
- `cultnet.sample.chat.v0`

View file

@ -33,8 +33,25 @@ import verseCatalogRequestSchema from "../contracts/cultnet/cultmesh.verse-catal
import verseCatalogResponseSchema from "../contracts/cultnet/cultmesh.verse-catalog-response.schema.json";
import sessionOpenSchema from "../contracts/cultnet/cultmesh.session-open.schema.json";
import sessionAcceptedSchema from "../contracts/cultnet/cultmesh.session-accepted.schema.json";
import selectionSchema from "../contracts/cultnet/cultnet.selection.schema.json";
import rawDocumentHeaderSchema from "../contracts/cultnet/cultnet.raw-document-header.schema.json";
import snapshotRequestV1Schema from "../contracts/cultnet/cultnet.snapshot-request.v1.schema.json";
import snapshotResponseRawV1Schema from "../contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json";
import databaseSubscribeV1Schema from "../contracts/cultnet/cultnet.database-subscribe.v1.schema.json";
import ghostlightAgentStateSchema from "../contracts/cultnet/ghostlight.agent-state.schema.json";
import {
encodeSelectionCarrierForWire,
normalizeSelectionCarrierNulls,
type CultNetSelectionCarrier,
} from "./selection-carriers";
import {
validateSelectionShape,
type CultNetEdge,
type CultNetRawDocumentHeader,
type CultNetSelection,
} from "./selection";
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
export type CultNetSchemaVersion =
@ -53,6 +70,9 @@ export type CultNetSchemaVersion =
| "cultnet.snapshot_response.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.database_subscribe.v0"
| "cultnet.snapshot_request.v1"
| "cultnet.snapshot_response_raw.v1"
| "cultnet.database_subscribe.v1"
| "cultnet.database_unsubscribe.v0"
| "cultnet.database_change_raw.v0"
| "cultnet.schema_catalog_request.v0"
@ -277,6 +297,46 @@ export interface CultNetDatabaseSubscribeMessage {
supportedBodyTransports?: string[];
}
/** A document snapshot through a typed selection (docs/cultnet-selection-cut.md). */
export interface CultNetSnapshotRequestV1Message {
schemaVersion: "cultnet.snapshot_request.v1";
messageId: string;
selection: CultNetSelection;
shardId?: string;
shardEpoch?: number;
}
/** A live subscription through a typed selection. */
export interface CultNetDatabaseSubscribeV1Message {
schemaVersion: "cultnet.database_subscribe.v1";
messageId: string;
subscriptionId: string;
selection: CultNetSelection;
/** Default true. */
includeSnapshot?: boolean;
consumerRuntimeId?: string;
bodyIds?: string[];
supportedBodyTransports?: string[];
}
/** One selection page: exactly one of `headers` / `documents` is present, by projection. */
export interface CultNetSnapshotResponseRawV1Message {
schemaVersion: "cultnet.snapshot_response_raw.v1";
messageId: string;
/** Rows on this page. */
matched: number;
/** The snapshot this page is exact as of. */
asOf: number;
/** Cursor for the next page, absent on the last page. */
next?: string;
headers?: CultNetRawDocumentHeader[];
documents?: CultNetRawDocumentRecord[];
edges?: CultNetEdge[];
shardId?: string;
shardEpoch?: number;
shardLogSequence?: number;
}
export interface CultNetDatabaseUnsubscribeMessage {
schemaVersion: "cultnet.database_unsubscribe.v0";
messageId: string;
@ -689,6 +749,9 @@ export type CultNetMessage =
| CultNetSnapshotResponseMessage
| CultNetSnapshotResponseRawMessage
| CultNetDatabaseSubscribeMessage
| CultNetSnapshotRequestV1Message
| CultNetSnapshotResponseRawV1Message
| CultNetDatabaseSubscribeV1Message
| CultNetDatabaseUnsubscribeMessage
| CultNetDatabaseChangeRawMessage
| CultNetSchemaCatalogRequestMessage
@ -723,6 +786,9 @@ const CULTNET_MESSAGE_SCHEMAS = [
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
snapshotRequestV1Schema,
snapshotResponseRawV1Schema,
databaseSubscribeV1Schema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaCatalogRequestSchema,
@ -741,6 +807,8 @@ const cultNetValidators = new Map<CultNetSchemaVersion, ValidateFunction>();
for (const schema of [
documentRecordSchema,
rawDocumentRecordSchema,
rawDocumentHeaderSchema,
selectionSchema,
documentMutationContractSchema,
transportProfileSchema,
schemaDescriptorSchema,
@ -787,6 +855,20 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
return input as CultNetSnapshotResponseRawMessage;
}
if (schemaVersion === "cultnet.snapshot_response_raw.v1") {
validateSnapshotResponseRawV1Message(input);
return input as CultNetSnapshotResponseRawV1Message;
}
if (schemaVersion === "cultnet.snapshot_request.v1" || schemaVersion === "cultnet.database_subscribe.v1") {
// The door first, so an empty or blank schemas/keys entry is a typed refusal naming its field
// rather than a generic schema-validation failure.
const selection = (input as { selection?: unknown }).selection;
if (selection && typeof selection === "object") {
validateSelectionShape(selection as CultNetSelection);
}
}
if (schemaVersion === "cultnet.database_change_raw.v0") {
validateDatabaseChangeRawMessage(input);
return input as CultNetDatabaseChangeRawMessage;
@ -808,7 +890,14 @@ export function encodeCultNetMessageForWire(
return encodeGameCultNetworkingMessage(message);
}
return message;
switch (message.schemaVersion) {
case "cultnet.snapshot_request.v1":
case "cultnet.database_subscribe.v1":
case "cultnet.snapshot_response_raw.v1":
return encodeSelectionCarrierForWire(message as CultNetSelectionCarrier);
default:
return message;
}
}
export function validateGhostlightAgentState(input: unknown): GhostlightAgentStateDocument {
@ -932,6 +1021,7 @@ function encodeGameCultNetworkingMessage(message: CultNetMessage): [number, unkn
decodeBase64Url(message.session, "LoginSuccessMessage.Session"),
]];
case "cultnet.error.v0":
// The gamecult.networking.v0 union's ErrorMessage carries only the text.
return [4, [message.error]];
case "cultnet.sample.change_name.v0":
return [5, [message.name]];
@ -1136,6 +1226,89 @@ function validateSnapshotResponseRawMessage(
});
}
function validateSnapshotResponseRawV1Message(
input: unknown,
): asserts input is CultNetSnapshotResponseRawV1Message {
const candidate = validateBinaryMessageRoot(input, "cultnet.snapshot_response_raw.v1");
const fail = (path: string, problem: string): never => {
throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`);
};
for (const counter of ["matched", "asOf"] as const) {
const value = candidate[counter];
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
fail(`/${counter}`, "must be a non-negative integer");
}
}
if (typeof candidate.next !== "undefined" && typeof candidate.next !== "string") {
fail("/next", "must be a string");
}
const hasHeaders = typeof candidate.headers !== "undefined";
const hasDocuments = typeof candidate.documents !== "undefined";
if (hasHeaders === hasDocuments) {
fail("/", "exactly one of headers and documents must be present");
}
if (hasHeaders) {
if (!Array.isArray(candidate.headers)) fail("/headers", "must be an array");
(candidate.headers as unknown[]).forEach((header, index) => validateRawDocumentHeader(header, `/headers/${index}`));
} else {
if (!Array.isArray(candidate.documents)) fail("/documents", "must be an array");
(candidate.documents as unknown[]).forEach((document, index) => validateRawDocumentRecord(document, `/documents/${index}`));
}
if (typeof candidate.edges !== "undefined") {
if (!Array.isArray(candidate.edges)) fail("/edges", "must be an array");
(candidate.edges as unknown[]).forEach((edge, index) => validateEdge(edge, `/edges/${index}`));
}
requireOptionalNonEmptyString(candidate.shardId, "/shardId");
for (const counter of ["shardEpoch", "shardLogSequence"] as const) {
const value = candidate[counter];
if (typeof value !== "undefined" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
fail(`/${counter}`, "must be an integer");
}
}
}
function validateRawDocumentHeader(input: unknown, instancePath: string): void {
if (!input || typeof input !== "object") {
throw new Error(`Validation failed for raw CultNet document header: ${instancePath}: must be an object`);
}
const candidate = input as Record<string, unknown>;
requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`);
requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`);
requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`);
requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`);
requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`);
requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`);
requireOptionalNonEmptyString(candidate.sourceRuntimeId, `${instancePath}/sourceRuntimeId`);
requireOptionalNonEmptyString(candidate.sourceAgentId, `${instancePath}/sourceAgentId`);
requireOptionalNonEmptyString(candidate.sourceRole, `${instancePath}/sourceRole`);
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
}
function validateEdge(input: unknown, instancePath: string): void {
const fail = (path: string, problem: string): never => {
throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`);
};
if (!input || typeof input !== "object") {
fail(instancePath, "must be an object");
}
const candidate = input as Record<string, unknown>;
for (const end of ["from", "to"] as const) {
const ref = candidate[end] as Record<string, unknown> | undefined;
if (!ref || typeof ref !== "object") {
fail(`${instancePath}/${end}`, "must be an object");
}
requireNonEmptyString(ref!.schemaId, `${instancePath}/${end}/schemaId`);
requireNonEmptyString(ref!.recordKey, `${instancePath}/${end}/recordKey`);
}
requireNonEmptyString(candidate.role, `${instancePath}/role`);
if (typeof candidate.payloadEncoding !== "undefined" && candidate.payloadEncoding !== "messagepack") {
fail(`${instancePath}/payloadEncoding`, "must equal messagepack");
}
if (typeof candidate.payload !== "undefined" && !(candidate.payload instanceof Uint8Array)) {
fail(`${instancePath}/payload`, "must be a Uint8Array");
}
}
function validateDatabaseChangeRawMessage(
input: unknown,
): asserts input is CultNetDatabaseChangeRawMessage {
@ -1168,6 +1341,7 @@ function validateBinaryMessageRoot(
schemaVersion:
| "cultnet.document_put_raw.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.snapshot_response_raw.v1"
| "cultnet.database_change_raw.v0",
): Record<string, unknown> {
if (!input || typeof input !== "object") {
@ -1270,6 +1444,11 @@ function normalizeCultNetOptionalNulls(
"supportedBodyTransports",
]);
return;
case "cultnet.snapshot_request.v1":
case "cultnet.database_subscribe.v1":
case "cultnet.snapshot_response_raw.v1":
normalizeSelectionCarrierNulls(schemaVersion, candidate);
return;
case "cultnet.operation_request.v0":
stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]);
return;
@ -1339,6 +1518,11 @@ export const cultNetSchemas = {
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
selectionSchema,
rawDocumentHeaderSchema,
snapshotRequestV1Schema,
snapshotResponseRawV1Schema,
databaseSubscribeV1Schema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaDescriptorSchema,

View file

@ -3,6 +3,9 @@ export {
encodeCultNetMessageForWire,
parseCultNetMessage,
validateGhostlightAgentState,
type CultNetDatabaseSubscribeV1Message,
type CultNetSnapshotRequestV1Message,
type CultNetSnapshotResponseRawV1Message,
type CultNetDocumentDeleteMessage,
type CultNetDocumentMutationContract,
type CultNetDocumentPutMessage,
@ -66,6 +69,19 @@ export {
type GhostlightVariableMap,
type GhostlightWorldState,
} from "./contracts";
export {
CultNetSelectionInvalidError,
validateSelectionShape,
type CultNetCitation,
type CultNetEdge,
type CultNetFieldPredicate,
type CultNetIncoming,
type CultNetRawDocumentHeader,
type CultNetRecordRef,
type CultNetSelection,
type CultNetSelectionOperator,
type CultNetSelectionProjection,
} from "./selection";
export {
base64ToBytes,
bytesToBase64,

View file

@ -0,0 +1,178 @@
// The two shape conversions the selection/v1 carriers need between the reference's wire and TypeScript:
// the reference writes every declared key, nil included, in declaration order (MessagePack-CSharp map
// mode; schema notes R-S/R-X), so a received message arrives with explicit nulls that are normalised
// to absent keys, and a sent message is rebuilt with every key in the reference's order.
import type {
CultNetDatabaseSubscribeV1Message,
CultNetRawDocumentRecord,
CultNetSnapshotRequestV1Message,
CultNetSnapshotResponseRawV1Message,
} from "./contracts";
import type { CultNetEdge, CultNetRawDocumentHeader, CultNetRecordRef, CultNetSelection } from "./selection";
export type CultNetSelectionCarrier =
| CultNetSnapshotRequestV1Message
| CultNetDatabaseSubscribeV1Message
| CultNetSnapshotResponseRawV1Message;
type Bag = Record<string, unknown>;
const isBag = (value: unknown): value is Bag => !!value && typeof value === "object" && !Array.isArray(value);
function stripNulls(candidate: unknown, keys: readonly string[]): void {
if (!isBag(candidate)) return;
for (const key of keys) {
if (candidate[key] === null) delete candidate[key];
}
}
function eachBag(list: unknown, visit: (item: Bag) => void): void {
if (!Array.isArray(list)) return;
for (const item of list) {
if (isBag(item)) visit(item);
}
}
/** Drops the nil an absent selection member travels as, at every depth the selection carries one. */
export function normalizeSelectionNulls(selection: unknown): void {
stripNulls(selection, ["schemas", "keys", "fields", "cites", "cited", "limit", "cursor"]);
if (!isBag(selection)) return;
eachBag(selection.fields, field => stripNulls(field, ["values", "number"]));
stripNulls(selection.cites, ["role"]);
}
/** Drops the nil an absent member travels as, for one selection/v1 carrier message. */
export function normalizeSelectionCarrierNulls(
schemaVersion: CultNetSelectionCarrier["schemaVersion"],
candidate: Bag,
): void {
switch (schemaVersion) {
case "cultnet.snapshot_request.v1":
stripNulls(candidate, ["shardId", "shardEpoch"]);
normalizeSelectionNulls(candidate.selection);
return;
case "cultnet.database_subscribe.v1":
stripNulls(candidate, ["consumerRuntimeId", "bodyIds", "supportedBodyTransports"]);
normalizeSelectionNulls(candidate.selection);
return;
case "cultnet.snapshot_response_raw.v1":
stripNulls(candidate, ["next", "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"]);
eachBag(candidate.headers, header => stripNulls(header, HEADER_OPTIONAL));
eachBag(candidate.documents, document => stripNulls(document, HEADER_OPTIONAL));
eachBag(candidate.edges, edge => stripNulls(edge, ["payloadEncoding", "payload"]));
return;
}
}
const HEADER_OPTIONAL = ["schemaName", "schemaVersion", "schemaContentHash", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags"] as const;
const orNull = <T>(value: T | undefined | null): T | null => value ?? null;
function recordRefWire(ref: CultNetRecordRef) {
return { schemaId: ref.schemaId, recordKey: ref.recordKey };
}
function selectionWire(selection: CultNetSelection) {
return {
schemas: orNull(selection.schemas),
keys: orNull(selection.keys),
fields: selection.fields
? selection.fields.map(field => ({
index: field.index,
op: field.op,
values: orNull(field.values),
number: orNull(field.number),
}))
: null,
cites: selection.cites
? { target: recordRefWire(selection.cites.target), role: orNull(selection.cites.role) }
: null,
cited: selection.cited ? { role: selection.cited.role, exists: selection.cited.exists } : null,
projection: selection.projection ?? "header",
descending: selection.descending ?? false,
limit: orNull(selection.limit),
cursor: orNull(selection.cursor),
};
}
function headerWire(header: CultNetRawDocumentHeader) {
return {
schemaId: header.schemaId,
schemaName: orNull(header.schemaName),
schemaVersion: orNull(header.schemaVersion),
schemaContentHash: orNull(header.schemaContentHash),
recordKey: header.recordKey,
storedAt: header.storedAt,
sourceRuntimeId: orNull(header.sourceRuntimeId),
sourceAgentId: orNull(header.sourceAgentId),
sourceRole: orNull(header.sourceRole),
tags: orNull(header.tags),
};
}
function documentWire(document: CultNetRawDocumentRecord) {
return {
schemaId: document.schemaId,
schemaName: orNull(document.schemaName),
schemaVersion: orNull(document.schemaVersion),
schemaContentHash: orNull(document.schemaContentHash),
recordKey: document.recordKey,
storedAt: document.storedAt,
payloadEncoding: document.payloadEncoding,
payload: document.payload,
sourceRuntimeId: orNull(document.sourceRuntimeId),
sourceAgentId: orNull(document.sourceAgentId),
sourceRole: orNull(document.sourceRole),
tags: orNull(document.tags),
};
}
function edgeWire(edge: CultNetEdge) {
return {
from: recordRefWire(edge.from),
role: edge.role,
to: recordRefWire(edge.to),
payloadEncoding: orNull(edge.payloadEncoding),
payload: orNull(edge.payload),
};
}
/** The reference's nil-keyed, declaration-ordered shape for one selection/v1 carrier message. */
export function encodeSelectionCarrierForWire(message: CultNetSelectionCarrier): unknown {
switch (message.schemaVersion) {
case "cultnet.snapshot_request.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
selection: selectionWire(message.selection),
shardId: orNull(message.shardId),
shardEpoch: orNull(message.shardEpoch),
};
case "cultnet.database_subscribe.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
subscriptionId: message.subscriptionId,
selection: selectionWire(message.selection),
includeSnapshot: message.includeSnapshot ?? true,
consumerRuntimeId: orNull(message.consumerRuntimeId),
bodyIds: orNull(message.bodyIds),
supportedBodyTransports: orNull(message.supportedBodyTransports),
};
case "cultnet.snapshot_response_raw.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
matched: message.matched,
asOf: message.asOf,
next: orNull(message.next),
headers: message.headers ? message.headers.map(headerWire) : null,
documents: message.documents ? message.documents.map(documentWire) : null,
edges: message.edges ? message.edges.map(edgeWire) : null,
shardId: orNull(message.shardId),
shardEpoch: orNull(message.shardEpoch),
shardLogSequence: orNull(message.shardLogSequence),
};
}
}

View file

@ -0,0 +1,115 @@
// cultnet.selection.v1 payload shapes (docs/cultnet-selection-cut.md, section 2), the carriers' common
// vocabulary: one typed selection, the header/edge rows a page answers with, and the door refusal.
// The messages that carry them live in contracts.ts; the nil-keyed wire encoding, null normalization and
// response validation live in selection-carriers.ts.
export type CultNetSelectionOperator = "any_of" | "lt" | "le" | "ge" | "gt";
export type CultNetSelectionProjection = "header" | "document";
/** One predicate over a single declared index alias. `values` iff any_of; `number` (canonical decimal string) iff a comparison. */
export interface CultNetFieldPredicate {
index: string;
op: CultNetSelectionOperator;
values?: string[];
number?: string;
}
/** Identifies one row on the wire by schema and record key. */
export interface CultNetRecordRef {
schemaId: string;
recordKey: string;
}
/** Selects rows whose declared reference names `target`; an absent `role` matches any declared reference. */
export interface CultNetCitation {
target: CultNetRecordRef;
role?: string;
}
/** Selects rows some other row does (`exists`) or does not name in `role`. */
export interface CultNetIncoming {
role: string;
exists: boolean;
}
/** Absent `schemas`/`keys` mean every schema/key; a present empty list is refused at the door. */
export interface CultNetSelection {
schemas?: string[];
keys?: string[];
fields?: CultNetFieldPredicate[];
cites?: CultNetCitation;
cited?: CultNetIncoming;
/** Default "header". */
projection?: CultNetSelectionProjection;
descending?: boolean;
/** Clamped to 1..=200 by the answering server. */
limit?: number;
/** Opaque; minted by the answering server. */
cursor?: string;
}
/** A raw document record minus its payload and payloadEncoding. */
export interface CultNetRawDocumentHeader {
schemaId: string;
schemaName?: string;
schemaVersion?: string;
schemaContentHash?: string;
recordKey: string;
storedAt: string;
sourceRuntimeId?: string;
sourceAgentId?: string;
sourceRole?: string;
tags?: string[];
}
/** One edge a hop traversed. `payload` is present under document projection only. */
export interface CultNetEdge {
from: CultNetRecordRef;
role: string;
to: CultNetRecordRef;
payloadEncoding?: "messagepack";
payload?: Uint8Array;
}
/**
* Refused typed at the door: a selection the declared shape cannot support. Carried back as
* `cultnet.error.v0 { code: "selection_invalid", details: { field, value } }`.
*/
export class CultNetSelectionInvalidError extends Error {
constructor(
readonly field: string,
readonly value: string | null,
message: string,
) {
super(message);
this.name = "CultNetSelectionInvalidError";
}
}
/**
* The declaration-independent half of the door, matching the reference's `ValidateShape`: an empty or
* blank `schemas`/`keys` entry or list, and an unrecognised `projection`. Checks that need the
* declared schemas (index aliases, reference roles, cites target) belong to the selection engine.
*/
export function validateSelectionShape(selection: CultNetSelection): void {
const list = (field: "schemas" | "keys") => {
const entries = selection[field];
if (entries === undefined || entries === null) return;
if (entries.length === 0) {
throw new CultNetSelectionInvalidError(
field, null, `selection.${field} is present and empty; omit it to reach every ${field === "schemas" ? "schema" : "key"}.`);
}
for (const entry of entries) {
if (typeof entry !== "string" || entry.trim().length === 0) {
throw new CultNetSelectionInvalidError(field, entry, `selection.${field} carries an empty or whitespace entry.`);
}
}
};
list("schemas");
list("keys");
const projection = selection.projection ?? "header";
if (projection !== "header" && projection !== "document") {
throw new CultNetSelectionInvalidError(
"projection", String(projection), `selection.projection "${String(projection)}" is neither "header" nor "document".`);
}
}

View file

@ -0,0 +1,171 @@
// The reference's own bytes for the cultnet.error.v0 code/details shape and the selection/v1
// carriers (`contracts/cultnet/interop/carrier-vectors.cs-written.json`, written by the C#
// CarrierVectorTests). Each vector is decoded, parsed, and re-encoded: the re-encoding must be the
// reference's exact bytes, and the decoded message must equal the reference's own JSON of the object.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { encodeCultNetMessageForWire, parseCultNetMessage } from "../src/contracts";
const FILE = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
type Vector = { name: string; schemaVersion: string; messagePackBase64: string; expected: unknown };
const vectors: Vector[] = JSON.parse(readFileSync(FILE, "utf8")).vectors;
const vectorsFor = (prefix: string) => vectors.filter(v => v.name.startsWith(prefix));
const bytesOf = (v: Vector) => new Uint8Array(Buffer.from(v.messagePackBase64, "base64"));
/** Uint8Array to base64, recursively, so a decoded message compares with the reference's JSON. */
function jsonShape(value: unknown): unknown {
if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
if (Array.isArray(value)) return value.map(jsonShape);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonShape(v)]));
}
return value;
}
const reencode = (message: Parameters<typeof encodeCultNetMessageForWire>[0]) =>
new Uint8Array(encode(encodeCultNetMessageForWire(message), { ignoreUndefined: true }));
test("every error vector keeps the reference's code and details through parse and re-encode", () => {
const errors = vectorsFor("error_");
assert.ok(errors.length >= 5);
for (const v of errors) {
const raw = decode(bytesOf(v));
assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`);
const message = parseCultNetMessage(raw);
assert.equal(message.schemaVersion, "cultnet.error.v0");
assert.deepEqual(jsonShape(message), v.expected, `${v.name}: parsed message`);
assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`);
}
});
test("an error whose code is outside the schema's closed enum is refused", () => {
const raw = decode(bytesOf(vectors.find(v => v.name === "error_cursor_stale")!)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/);
});
// ---- selection/v1 carriers ----
for (const prefix of ["snapshot_request_v1", "database_subscribe_v1", "snapshot_response_raw_v1"]) {
test(`${prefix} vectors: decode, parse, and re-encode to the reference's exact bytes`, () => {
const group = vectorsFor(prefix);
assert.ok(group.length >= 2, `${prefix}: vectors present`);
for (const v of group) {
const raw = decode(bytesOf(v));
assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`);
const message = parseCultNetMessage(raw);
assert.equal(message.schemaVersion, v.schemaVersion);
assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`);
}
});
}
test("a parsed v1 message drops the nil an absent member travels as", () => {
const full = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_defaults")!))) as unknown as
{ selection: Record<string, unknown>; shardId?: unknown };
assert.deepEqual(Object.keys(full.selection).sort(), ["descending", "projection"]);
assert.equal("shardId" in full, false);
const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!))) as unknown as
{ headers: Record<string, unknown>[]; documents?: unknown };
assert.equal("documents" in page, false);
assert.equal("tags" in page.headers[0], false);
assert.deepEqual(page.headers[1].tags, ["alpha", "beta"]);
});
test("edge payloads and document payloads arrive as bytes, not text", () => {
const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_documents_and_edges")!))) as unknown as
{ edges: { payload?: Uint8Array }[]; documents: { payload: Uint8Array }[] };
assert.deepEqual(Array.from(page.edges[1].payload!), [1, 2, 3]);
assert.equal("payload" in page.edges[0], false);
assert.deepEqual(Array.from(page.documents[0].payload), [0x81, 0xa1, 0x6b, 0x01]);
});
const request = () => decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_full_selection_and_shard")!)) as
{ selection: Record<string, unknown> } & Record<string, unknown>;
const refusedField = (message: unknown): string | undefined => {
try {
parseCultNetMessage(message);
} catch (error) {
return (error as { field?: string }).field;
}
return "accepted";
};
test("the door refuses an empty or blank schemas/keys entry with a typed field, never as every-schema", () => {
const withSelection = (patch: Record<string, unknown>) => ({ ...request(), selection: { ...request().selection, ...patch } });
assert.equal(refusedField(withSelection({ schemas: [] })), "schemas");
assert.equal(refusedField(withSelection({ keys: ["a-eq", " "] })), "keys");
assert.equal(refusedField(withSelection({ projection: "everything" })), "projection");
});
test("the schema refuses a comparison whose number is not a canonical decimal, and a predicate that mixes values and number", () => {
const withField = (field: Record<string, unknown>) => {
const message = request();
return { ...message, selection: { ...message.selection, fields: [field] } };
};
for (const number of ["05", "1.50", "+1", "1e3", "-0", " 1"]) {
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: null, number })), /snapshot_request\.v1/, number);
}
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: ["1"], number: "1" })), /snapshot_request\.v1/);
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "near", values: null, number: "1" })), /snapshot_request\.v1/);
});
test("a selection page with both or neither of headers and documents is refused", () => {
const page = decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...page, documents: [] }), /exactly one of headers and documents/);
assert.throws(() => parseCultNetMessage({ ...page, headers: null }), /exactly one of headers and documents/);
});
// ---- selection vectors: the reference's and Rust's selection bytes ----
const INTEROP = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop");
type SelectionVector = { name: string; selectionMessagePackBase64: string; expectedRefusalField?: string | null };
const selectionVectors = (file: string): SelectionVector[] =>
JSON.parse(readFileSync(join(INTEROP, file), "utf8")).vectors;
import { CultNetSelectionInvalidError, validateSelectionShape, type CultNetSelection } from "../src/selection";
for (const file of ["selection-vectors.cs-written.json", "selection-vectors.rs-written.json"]) {
test(`${file}: every selection decodes inside a v1 request and the schema door agrees with the vector`, () => {
const all = selectionVectors(file);
assert.ok(all.length >= 20);
for (const v of all) {
const selection = decode(new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")));
const message = { schemaVersion: "cultnet.snapshot_request.v1", messageId: "m", selection, shardId: null, shardEpoch: null };
if (v.expectedRefusalField === "schemas" || v.expectedRefusalField === "keys") {
assert.equal(refusedField(message), v.expectedRefusalField, `${v.name}: shape refusal`);
continue;
}
if (v.expectedRefusalField) {
// Declaration-dependent refusals (cites target, index aliases) need the selection engine's schemas.
const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection };
validateSelectionShape(parsed.selection);
continue;
}
const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection };
validateSelectionShape(parsed.selection);
const rewritten = new Uint8Array(encode(
(encodeCultNetMessageForWire(parsed as never) as { selection: unknown }).selection));
if (file.includes("cs-written")) {
// The reference's bytes are the nil-keyed shape: our encoder must reproduce them exactly.
assert.deepEqual(rewritten, new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")),
`${v.name}: selection re-encodes to the reference's bytes`);
} else {
// Rust omits absent members; what it means must survive our nil-keyed rewrite unchanged.
const again = parseCultNetMessage({ ...message, selection: decode(rewritten) }) as unknown as { selection: CultNetSelection };
assert.deepEqual(again.selection, parsed.selection, `${v.name}: selection meaning survives re-encode`);
}
}
});
}
test("CultNetSelectionInvalidError carries the field and offending value", () => {
assert.throws(
() => validateSelectionShape({ keys: ["ok", ""] }),
(error: unknown) => error instanceof CultNetSelectionInvalidError && error.field === "keys" && error.value === "",
);
});

View file

@ -0,0 +1,178 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// The C# reference's own bytes for the selection/v1 message carriers and the cultnet.error.v0
// code/details shape, written to contracts/cultnet/interop/carrier-vectors.cs-written.json for
// the runtimes that decode and re-encode them (packages/cultnet-ts reads it; a Rust or Python
// reader may follow). Each vector carries the whole message's MessagePack bytes plus `expected`,
// the reference's own camelCase JSON of the same object (byte[] as base64), so a reader judges
// against what the reference serialized rather than a second hand-written spec.
//
// Gated like SelectionParityVectorTests: CULTNET_WRITE_VECTORS=1 rewrites the committed file;
// otherwise the committed bytes are compared with a fresh serialization so reference drift fails
// here instead of in a reader.
public sealed class CarrierVectorTests
{
private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff";
private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static CultNetSelection FullSelection() => new()
{
Schemas = new[] { "leaf_a.v9", LeafA },
Keys = new[] { "a-eq", "\U0001F600-row" },
Fields = new[]
{
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon", "tool" } },
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "-2233759.25" }
},
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, Role = "Design" },
Cited = new CultNetIncoming { Role = "Design", Exists = false },
Projection = CultNetSelectionProjections.Document,
Descending = true,
Limit = 200,
Cursor = "opaque-cursor"
};
private static CultNetRawDocumentHeader Header(string key, params string[] tags) => new()
{
SchemaId = LeafA,
SchemaName = "leaf_a",
SchemaVersion = "1",
SchemaContentHash = "abc",
RecordKey = key,
StoredAt = "1970-01-01T00:00:00.000Z",
SourceRuntimeId = "rt",
SourceAgentId = "agent",
SourceRole = "role",
Tags = tags.Length == 0 ? null : tags
};
private static IEnumerable<(string Name, ICultNetSchemaMessage Message)> Cases()
{
yield return ("snapshot_request_v1_defaults", new CultNetSnapshotRequestV1Message { MessageId = "m" });
yield return ("snapshot_request_v1_full_selection_and_shard", new CultNetSnapshotRequestV1Message
{
MessageId = "m-full", Selection = FullSelection(), ShardId = "shard-1", ShardEpoch = 7
});
yield return ("database_subscribe_v1_defaults", new CultNetDatabaseSubscribeV1Message { MessageId = "m", SubscriptionId = "sub" });
yield return ("database_subscribe_v1_full", new CultNetDatabaseSubscribeV1Message
{
MessageId = "m-full", SubscriptionId = "sub-full", Selection = FullSelection(), IncludeSnapshot = false,
ConsumerRuntimeId = "consumer", BodyIds = new[] { "body-a" }, SupportedBodyTransports = new[] { "rudp", "quic" }
});
yield return ("snapshot_response_raw_v1_headers", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 2, AsOf = 5, Next = "next-cursor",
Headers = new[] { Header("a-eq"), Header("a-lo", "alpha", "beta") },
ShardId = "shard-1", ShardEpoch = 3, ShardLogSequence = 41
});
yield return ("snapshot_response_raw_v1_documents_and_edges", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 1, AsOf = 5,
Documents = new[]
{
new CultNetRawDocumentRecord { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z", Payload = new byte[] { 0x81, 0xA1, 0x6B, 0x01 } }
},
Edges = new[]
{
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }
},
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" },
PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
}
}
});
yield return ("error_plain", new CultNetErrorMessage { Error = "boom" });
yield return ("error_selection_invalid", CultNetErrorMessage.ForSelectionInvalid(
new CultNetSelectionInvalidException("fields[0].index", "kind", "fields[0].index \"kind\" is not declared.")));
yield return ("error_cursor_stale", new CultNetErrorMessage
{
Error = "cursor_stale: stale", Code = "cursor_stale", Details = new CultNetErrorDetails { AsOf = 3, Current = 9 }
});
yield return ("error_cursor_invalid_no_details", new CultNetErrorMessage { Error = "cursor_invalid: bad", Code = "cursor_invalid" });
yield return ("error_write_not_authorized", new CultNetErrorMessage
{
Error = "write_not_authorized", Code = "write_not_authorized", Details = new CultNetErrorDetails { Field = "recordKey", Value = "k" }
});
}
private static object Vector(string name, ICultNetSchemaMessage message)
{
var bytes = MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options);
return new
{
name,
schemaVersion = message.SchemaVersion,
messagePackBase64 = Convert.ToBase64String(bytes),
expected = WithoutDerivedMembers(JsonSerializer.SerializeToNode(message, message.GetType(), JsonOptions))
};
}
// CultNetSelection.HasHop is an [IgnoreMember] convenience, not wire; System.Text.Json would emit it.
private static JsonNode? WithoutDerivedMembers(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
obj.Remove("hasHop");
foreach (var child in obj.Select(pair => pair.Value).ToArray()) WithoutDerivedMembers(child);
break;
case JsonArray array:
foreach (var child in array) WithoutDerivedMembers(child);
break;
}
return node;
}
private static string VectorPath()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
var root = directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
return Path.Combine(root, "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
}
[Test]
public void WriteCarrierVectors()
{
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
var vectors = Cases().Select(c => Vector(c.Name, c.Message)).ToArray();
File.WriteAllText(VectorPath(), JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true }));
}
[Test]
public void CommittedCarrierVectorsMatchAFreshSerialization()
{
using var committed = JsonDocument.Parse(File.ReadAllText(VectorPath()));
var byName = committed.RootElement.GetProperty("vectors").EnumerateArray()
.ToDictionary(v => v.GetProperty("name").GetString()!, v => v.GetProperty("messagePackBase64").GetString()!);
Assert.That(byName.Count, Is.EqualTo(Cases().Count()), "the committed file must carry exactly the cases this writer produces");
foreach (var (name, message) in Cases())
{
var fresh = Convert.ToBase64String(MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options));
Assert.That(byName[name], Is.EqualTo(fresh), $"{name}: the committed bytes drifted from the reference serializer");
}
}
}
}

View file

@ -0,0 +1,155 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
/// <summary>
/// The multi-peer listener's golden in tests/vectors/rudp-multipeer-server: two peers on one socket, the
/// datagrams each sends and the datagrams the C# reference answers with. The C# reference is the only writer
/// (CULTNET_WRITE_VECTORS=1); every other run replays the scenario and compares it byte for byte. Other runtimes'
/// listeners replay the same transcript and are held to the same answers.
/// </summary>
public class CultNetRudpMultiPeerVectorTests
{
private const string WriteVariable = "CULTNET_WRITE_VECTORS";
private const uint ConnectionId = 0x43554c54;
private sealed class Client
{
public string Label = "";
public Socket Socket = null!;
public CultNetRudpSession Session = null!;
}
private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
private static Socket Bind()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return socket;
}
[Test]
public void TheReferenceListenerAnswersTwoPeersWithThePinnedBytes()
{
var transcript = Run();
var path = Path.Combine(VectorDirectory(), "transcript.txt");
if (Environment.GetEnvironmentVariable(WriteVariable) == "1")
{
File.WriteAllText(path, transcript);
Assert.Pass("wrote " + path);
}
Assert.That(transcript, Is.EqualTo(File.ReadAllText(path)));
}
private static string VectorDirectory()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !Directory.Exists(Path.Combine(directory.FullName, "tests", "vectors")))
directory = directory.Parent;
if (directory == null) throw new InvalidOperationException("tests/vectors not found above the test directory.");
return Path.Combine(directory.FullName, "tests", "vectors", "rudp-multipeer-server");
}
private static string Run()
{
var log = new StringBuilder();
log.Append("# config connection_id=0x43554c54 initial_sequence=500 max_fragment_bytes=8 accept_payload=616363\n");
log.Append("# in <peer> <datagram> | send <peer> <channel> <payload> | out <peer> <datagram>; peer labels name remote endpoints\n");
var serverSocket = Bind();
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
{
RuntimeId = "csharp-vector",
Socket = serverSocket,
ConnectionId = ConnectionId,
InitialSequence = 500,
MaxFragmentBytes = 8,
AcceptPayload = Encoding.ASCII.GetBytes("acc")
});
var target = serverSocket.LocalEndPoint!;
Client Make(string label, uint sequence) => new Client
{
Label = label,
Socket = Bind(),
Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = sequence })
};
var x = Make("X", 10);
var y = Make("Y", 20);
var clients = new[] { x, y };
void Deliver(Client client, CultNetRudpPacket packet)
{
var wire = CultNetRudpPacketCodec.Encode(packet);
log.Append($"in {client.Label} {Hex(wire)}\n");
client.Socket.SendTo(wire, target);
while (!server.TryReceiveOnce(out _))
System.Threading.Thread.Sleep(1);
Flush();
}
void Flush()
{
foreach (var client in clients)
{
var buffer = new byte[65535];
EndPoint from = new IPEndPoint(IPAddress.Any, 0);
while (client.Socket.Poll(30_000, SelectMode.SelectRead))
{
var received = client.Socket.ReceiveFrom(buffer, ref from);
var wire = buffer.AsSpan(0, received).ToArray();
log.Append($"out {client.Label} {Hex(wire)}\n");
// Clients that stay connected take the server's packets so their acks stay true.
var packet = CultNetRudpPacketCodec.Decode(wire);
if (client.Session.Connected || packet.PacketType == CultNetRudpPacketType.Accept)
client.Session.Receive(packet, 0);
}
}
}
CultNetRudpSocketServerPeer PeerOf(Client client) =>
server.Peers.Single(p => p.RemoteEndPoint.Equals(client.Socket.LocalEndPoint));
void Send(Client client, string channel, byte[] payload)
{
log.Append($"send {client.Label} {channel} {Hex(payload)}\n");
server.Send(PeerOf(client), channel, payload);
Flush();
}
CultNetRudpPacket Data(Client client, string text) =>
client.Session.Send("schema", Encoding.ASCII.GetBytes(text), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
var xConnect = x.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hx"));
Deliver(x, xConnect);
Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy")));
// A repeat of X's accepted Connect is answered with the Accept owed, not a new session.
Deliver(x, xConnect);
Deliver(x, Data(x, "ping-x"));
Deliver(y, Data(y, "ping-y"));
Send(x, "schema", Encoding.ASCII.GetBytes("pong"));
// Larger than the fragment size: three fragments, one per datagram.
Send(y, "schema", Encoding.ASCII.GetBytes("0123456789abcdefghijklmn"));
Send(x, "latest", Encoding.ASCII.GetBytes("sample"));
// X leaves; Y is undisturbed, and what X sends afterwards belongs to no session.
var late = Data(x, "after-bye");
Deliver(x, x.Session.CreateDisconnect(Encoding.ASCII.GetBytes("bye")));
Deliver(x, late);
Send(y, "schema", Encoding.ASCII.GetBytes("still-here"));
// Y reconnects from the same endpoint: a new generation replaces only Y.
y.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = 30 });
Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy2")));
Send(y, "schema", Encoding.ASCII.GetBytes("new-gen"));
log.Append($"# peers {server.Peers.Count} dropped {server.Stats.PacketsDropped}\n");
foreach (var client in clients) client.Socket.Dispose();
return log.ToString();
}
}
}

View file

@ -0,0 +1,13 @@
# rudp-multipeer-server vectors
Written only by the C# reference, `tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs`, with
`CULTNET_WRITE_VECTORS=1`. Every other run replays the scenario and compares it byte for byte; a changed vector is a reviewed
commit. Other runtimes' multi-peer listeners replay the same transcript and are held to the same answers
(`packages/cultnet-py/tests/test_rudp_multipeer_server.py`).
`transcript.txt` is one listener (`connection_id`, `initial_sequence`, `max_fragment_bytes` and `accept_payload` in its first line)
serving two loopback peers, X and Y. Each line is an action or what it caused: `in <peer> <hex>` is a datagram the peer sends the
listener, `send <peer> <channel> <hex>` is a payload the application sends that peer, and the `out <peer> <hex>` lines after it are
every datagram the listener put on the wire in answer, X's before Y's. Peers are named by label because ports are ephemeral. The
scenario covers a Connect, a repeat of an accepted Connect, data and acks, a fragmented send, an unreliable `latest` send, one peer's
Disconnect and the datagram from it that follows, and the other peer's reconnect from the same endpoint.

View file

@ -0,0 +1,30 @@
# config connection_id=0x43554c54 initial_sequence=500 max_fragment_bytes=8 accept_payload=616363
# in <peer> <datagram> | send <peer> <channel> <payload> | out <peer> <datagram>; peer labels name remote endpoints
in X 434e52300001032b43554c540000000a0000000000000000000000000000000000020700636f6e74726f6c6878
out X 434e52300002032b43554c54000001f40000000a00000000000000000000000000030700636f6e74726f6c616363
in Y 434e52300001032b43554c54000000140000000000000000000000000000000000020700636f6e74726f6c6879
out Y 434e52300002032b43554c54000001f40000001400000000000000000000000000030700636f6e74726f6c616363
in X 434e52300001032b43554c540000000a0000000000000000000000000000000000020700636f6e74726f6c6878
out X 434e52300002032b43554c54000001f40000000a00000000000000000000000000030700636f6e74726f6c616363
in X 434e52300003032a43554c540000000b000001f400000000000000000000000000060600736368656d6170696e672d78
out X 434e52300004002b43554c54000000000000000b00000001000000000000000000000700636f6e74726f6c
in Y 434e52300003032a43554c5400000015000001f400000000000000000000000000060600736368656d6170696e672d79
out Y 434e52300004002b43554c54000000000000001500000001000000000000000000000700636f6e74726f6c
send X schema 706f6e67
out X 434e52300003032a43554c54000001f50000000b00000001000000000000000000040600736368656d61706f6e67
send Y schema 303132333435363738396162636465666768696a6b6c6d6e
out Y 434e523000030b2a43554c54000001f50000001500000001000100000003000000080600736368656d613031323334353637
out Y 434e523000030b2a43554c54000001f60000001500000001000100010003000000080600736368656d613839616263646566
out Y 434e523000030b2a43554c54000001f70000001500000001000100020003000000080600736368656d616768696a6b6c6d6e
send X latest 73616d706c65
out X 434e52300003042a43554c54000000010000000b000000010000000000000000000606006c617465737473616d706c65
in X 434e52300007002b43554c5400000000000001f500000001000000000000000000030700636f6e74726f6c627965
in X 434e52300003032a43554c540000000c000001f500000001000000000000000000090600736368656d6161667465722d627965
send Y schema 7374696c6c2d68657265
out Y 434e523000030b2a43554c54000001f80000001500000001000200000002000000080600736368656d617374696c6c2d6865
out Y 434e523000030b2a43554c54000001f90000001500000001000200010002000000020600736368656d617265
in Y 434e52300001032b43554c540000001e0000000000000000000000000000000000030700636f6e74726f6c687932
out Y 434e52300002032b43554c54000001f40000001e00000000000000000000000000030700636f6e74726f6c616363
send Y schema 6e65772d67656e
out Y 434e52300003032a43554c54000001f50000001e00000000000000000000000000070600736368656d616e65772d67656e
# peers 1 dropped 1