Merge remote-tracking branch 'origin/main' into t20
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 6m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
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 6m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
# Conflicts: # packages/cultnet-ts/src/contracts.ts # packages/cultnet-ts/src/index.ts
This commit is contained in:
commit
fc184b0888
73 changed files with 6487 additions and 205 deletions
41
.github/workflows/cultmesh-portability.yml
vendored
41
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -9,44 +9,6 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
getting-started:
|
||||
name: ${{ matrix.os }} one-command multiplayer onboarding
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: CultLib
|
||||
|
||||
- name: Check out the matching Eve contract
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: GameCult/Eve
|
||||
# Eve main is the contract this sample is written against; the codex
|
||||
# branch this used to pin was never merged and stopped on 2026-08-25.
|
||||
ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }}
|
||||
path: Eve
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run the documented one-command checkpoint
|
||||
shell: pwsh
|
||||
run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve
|
||||
|
||||
reactive-and-resource-lifetime:
|
||||
name: ${{ matrix.os }} reactive semantics and leases
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -54,7 +16,8 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
# The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
|
||||
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
|
|
|
|||
7
.github/workflows/cultnet-interop.yml
vendored
7
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -1,10 +1,9 @@
|
|||
name: CultNet cross-runtime interop
|
||||
|
||||
# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge
|
||||
# runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run.
|
||||
# Run it from GitHub or any Windows runner.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
17
CultLib.sln
17
CultLib.sln
|
|
@ -35,10 +35,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSock
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
|
||||
|
|
@ -231,18 +227,6 @@ Global
|
|||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -322,7 +306,6 @@ Global
|
|||
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
|
||||
{B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
|
||||
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ start with the maintained
|
|||
series. Its first executable package-consumer checkpoint is
|
||||
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
|
||||
The real-process browser continuation is
|
||||
[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
|
||||
[`samples/eve-browser-network`](samples/eve-browser-network/README.md); it
|
||||
needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`.
|
||||
One documented command runs both layers:
|
||||
|
||||
```powershell
|
||||
|
|
|
|||
134
contracts/cultnet/interop/error-vectors.json
Normal file
134
contracts/cultnet/interop/error-vectors.json
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "selection_invalid_schemas_null",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: must not be empty",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "schemas",
|
||||
"value": null,
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "selection_invalid_field_value",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "selection_invalid: blank key entry",
|
||||
"routingHint": null,
|
||||
"code": "selection_invalid",
|
||||
"details": {
|
||||
"field": "keys",
|
||||
"value": " ",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_stale",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_stale: cursor is stale",
|
||||
"routingHint": null,
|
||||
"code": "cursor_stale",
|
||||
"details": {
|
||||
"field": null,
|
||||
"value": null,
|
||||
"asOf": 1,
|
||||
"current": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor_invalid",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "cursor_invalid: cannot parse cursor",
|
||||
"routingHint": null,
|
||||
"code": "cursor_invalid",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reference_outside_target",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "reference_outside_target: outside declared target",
|
||||
"routingHint": null,
|
||||
"code": "reference_outside_target",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_subscription_ended",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": {
|
||||
"field": "subscriptionId",
|
||||
"value": "sub-1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variant_unsupported_no_subscription",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "variant unsupported here",
|
||||
"routingHint": null,
|
||||
"code": "variant_unsupported",
|
||||
"details": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unowned_schema",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
|
||||
"routingHint": null,
|
||||
"code": "unowned_schema",
|
||||
"details": {
|
||||
"field": "schemaId",
|
||||
"value": "some.schema.v1",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_not_authorized",
|
||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
|
||||
"message": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "write_not_authorized: not granted",
|
||||
"routingHint": null,
|
||||
"code": "write_not_authorized",
|
||||
"details": {
|
||||
"field": "recordKey",
|
||||
"value": "some:key",
|
||||
"asOf": null,
|
||||
"current": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -16,10 +16,10 @@ this page.
|
|||
|
||||
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
|
||||
For the whole
|
||||
picture of what each piece is for, see the
|
||||
|
|
@ -222,6 +222,244 @@ def first_call() -> Item:
|
|||
return reopened.get_required(item_doc, "item:potion")
|
||||
```
|
||||
|
||||
## CultNet first calls
|
||||
|
||||
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
|
||||
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
|
||||
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
|
||||
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
|
||||
matching.
|
||||
|
||||
### C# CultNet first call
|
||||
|
||||
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
|
||||
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class Note
|
||||
{
|
||||
[Key(0)] [CultName] public string Title = string.Empty;
|
||||
[Key(1)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
public static class CultNetFirstCall
|
||||
{
|
||||
public static async Task<Note?> Run()
|
||||
{
|
||||
var inbox = new CultCache(); // the receiver's cache
|
||||
var documents = new CultNetDocumentRegistry();
|
||||
var received = new TaskCompletionSource<bool>();
|
||||
|
||||
// Receive: a loopback listener that applies each raw document put to the inbox.
|
||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
||||
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
|
||||
{
|
||||
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
|
||||
received.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Send: a typed document as a cultnet.document_put_raw.v0 message.
|
||||
using var client = new TcpFramedCultNetSchemaClient();
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
|
||||
client.SendCultNet(documents.CreateRawDocumentPutMessage(
|
||||
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
|
||||
|
||||
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
return inbox.Get<Note>(handle.Key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
|
||||
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
|
||||
from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`.
|
||||
|
||||
```ts
|
||||
import { createServer, connect } from "node:net";
|
||||
import { z } from "zod";
|
||||
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
createTcpFramedCultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
} from "cultnet-ts";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schemaId: "gamecult.first_call_note.v1",
|
||||
schemaName: "gamecult.first_call_note",
|
||||
schemaVersion: "gamecult.first_call_note.v1",
|
||||
schema: z.object({ title: z.string(), body: z.string() }),
|
||||
name: "title",
|
||||
});
|
||||
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
|
||||
|
||||
async function firstCall() {
|
||||
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
|
||||
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
|
||||
|
||||
// Receiver: a loopback listener whose peer applies each document put to the inbox.
|
||||
const received = new Promise<void>((resolve, reject) => {
|
||||
const server = createServer((socket) => {
|
||||
const peer = createTcpFramedCultNetPeer(socket, options);
|
||||
peer.on("invalidMessage", reject);
|
||||
peer.on("message", async (message) => {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
|
||||
await documents.applyRawDocumentPutMessage(inbox, message);
|
||||
peer.close();
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
}).listen(0, "127.0.0.1", () => {
|
||||
// Sender: connect, build a typed put message, send it.
|
||||
const { port } = server.address() as { port: number };
|
||||
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
|
||||
sender.send(
|
||||
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
|
||||
title: "Hello",
|
||||
body: "sent over CultNet",
|
||||
}),
|
||||
);
|
||||
received.finally(() => sender.close());
|
||||
});
|
||||
});
|
||||
await received;
|
||||
return inbox.get(noteDocument, "note:hello");
|
||||
}
|
||||
```
|
||||
|
||||
### Rust CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
|
||||
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
|
||||
|
||||
```rust
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
|
||||
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn first_call() -> anyhow::Result<Note> {
|
||||
let mut documents = CultNetDocumentRegistry::new();
|
||||
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
))?;
|
||||
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
|
||||
|
||||
// Receiver: a loopback listener that applies the document put it receives to its own cache.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let receiver = thread::spawn({
|
||||
let (documents, profile) = (documents.clone(), profile("receiver"));
|
||||
move || -> anyhow::Result<Note> {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
|
||||
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut inbox = CultCache::new();
|
||||
inbox.register_entry_type::<Note>()?;
|
||||
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
|
||||
}
|
||||
});
|
||||
|
||||
// Sender: build a typed put message, encode it for the wire, send one frame.
|
||||
let put = documents.create_raw_document_put_message(
|
||||
"put-1",
|
||||
"note:hello",
|
||||
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
|
||||
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
|
||||
.send("schema", &bytes)?;
|
||||
|
||||
receiver.join().expect("receiver thread")
|
||||
}
|
||||
```
|
||||
|
||||
### Python CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
|
||||
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
|
||||
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
|
||||
|
||||
```python
|
||||
import socket
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from cultcache_py import CultCache, define_document_type
|
||||
from cultnet_py import (
|
||||
apply_raw_document_record,
|
||||
document_put_raw,
|
||||
encode_frame,
|
||||
parse_message,
|
||||
read_frame,
|
||||
schema_document_map,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
note_doc = define_document_type(
|
||||
"note",
|
||||
encode=lambda note: asdict(note),
|
||||
decode=lambda raw: Note(**raw),
|
||||
name="title",
|
||||
)
|
||||
|
||||
|
||||
def first_call() -> Note:
|
||||
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
|
||||
|
||||
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
key="note:hello",
|
||||
schema_id=note_doc.catalog_entry().schema_id,
|
||||
stored_at="2026-01-01T00:00:00Z",
|
||||
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
|
||||
)
|
||||
sender.sendall(encode_frame(put.to_bytes()))
|
||||
|
||||
# Receive: read the frame, parse the message, apply its record to a second cache.
|
||||
message = parse_message(read_frame(receiver.makefile("rb")))
|
||||
assert message.schema_version == "cultnet.document_put_raw.v0"
|
||||
inbox = CultCache.builder().register_document_type(note_doc).build()
|
||||
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
|
||||
sender.close()
|
||||
receiver.close()
|
||||
return inbox.get_required(note_doc, "note:hello")
|
||||
```
|
||||
|
||||
## Where next
|
||||
|
||||
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
|
||||
|
|
|
|||
196
docs/start/io-game.md
Normal file
196
docs/start/io-game.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript)
|
||||
|
||||
A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache
|
||||
and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the
|
||||
document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and
|
||||
leave because both lease the same document.
|
||||
|
||||
## What ran, what did not
|
||||
|
||||
| Piece | Status |
|
||||
| --- | --- |
|
||||
| Two clients, real WebSockets, real `cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
|
||||
| Edge clamp, and the source below matching the files that ran | **tested** |
|
||||
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
|
||||
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
|
||||
| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below |
|
||||
| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory |
|
||||
| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only |
|
||||
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
|
||||
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
|
||||
|
||||
No `cultmesh-ts` or `cultnet-ts` TypeScript authority server exists. The server below is the CultNet
|
||||
message handling a provider owes, written in this recipe with the `ws` package.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
npm ci --ignore-scripts # repo root
|
||||
(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command
|
||||
cd docs/start/io-game && npm install
|
||||
npm test # node --test arena.test.ts, Node 24
|
||||
node serve.ts # http://127.0.0.1:8080, open it twice
|
||||
```
|
||||
|
||||
Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe).
|
||||
|
||||
## schema.ts
|
||||
|
||||
```ts
|
||||
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
||||
|
||||
export interface Player { x: number; y: number }
|
||||
export interface ArenaState { players: Record<string, Player> }
|
||||
|
||||
// The one shared document: every player's position. The server owns it; clients lease it.
|
||||
export const arenaDocument = defineDocumentType({
|
||||
type: "io.arena_state",
|
||||
schemaId: "io.arena_state.v1",
|
||||
schema: { parse: (value: unknown) => value as ArenaState },
|
||||
});
|
||||
|
||||
export const arenaKey = "arena:main";
|
||||
export const verseId = "io.arena";
|
||||
export const authorityRuntimeId = "io.arena-daemon";
|
||||
export const arenaSize = 100;
|
||||
```
|
||||
|
||||
## server.ts
|
||||
|
||||
```ts
|
||||
import { createServer, type RequestListener, type Server } from "node:http";
|
||||
import { CultCache } from "@gamecult/cultcache-ts";
|
||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
|
||||
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
|
||||
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
|
||||
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
|
||||
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
|
||||
await cache.put(arenaDocument, arenaKey, { players: {} });
|
||||
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
|
||||
await new Promise<void>(resolve => http.listen(port, host, resolve));
|
||||
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
|
||||
|
||||
const subscribers = new Map<WebSocket, string[]>();
|
||||
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
|
||||
const record = () => ({
|
||||
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
|
||||
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
|
||||
payloadEncoding: "messagepack" as const,
|
||||
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
|
||||
sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
const publish = async (next: ArenaState) => {
|
||||
await cache.put(arenaDocument, arenaKey, next);
|
||||
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
|
||||
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
|
||||
}
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
const operations: Record<string, (p: any) => Promise<object>> = {
|
||||
join: async () => {
|
||||
const id = `p${++nextId}`;
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
|
||||
return { id };
|
||||
},
|
||||
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
const me = state.players[id];
|
||||
if (!me) return { ok: false };
|
||||
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
|
||||
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
|
||||
return { ok: true };
|
||||
},
|
||||
leave: async ({ id }: { id: string }) => {
|
||||
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
|
||||
await publish({ players: rest });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (ws, req) => {
|
||||
const odin = req.url === "/odin";
|
||||
ws.on("close", () => subscribers.delete(ws));
|
||||
ws.on("message", async raw => {
|
||||
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
|
||||
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
|
||||
send(ws, {
|
||||
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
|
||||
verses: [{
|
||||
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
|
||||
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
|
||||
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
|
||||
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
|
||||
}],
|
||||
});
|
||||
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
|
||||
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
|
||||
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
|
||||
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
|
||||
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
|
||||
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
|
||||
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
|
||||
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
|
||||
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
|
||||
const handler = operations[m.operation];
|
||||
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
|
||||
send(ws, {
|
||||
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
|
||||
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
|
||||
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
odinUrl: `${endpoint}/odin`,
|
||||
state: () => cache.getRequired(arenaDocument, arenaKey),
|
||||
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## client.ts
|
||||
|
||||
```ts
|
||||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser";
|
||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
|
||||
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId, authorityRuntimeId, runtimeId, rendezvous,
|
||||
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
|
||||
});
|
||||
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
|
||||
const call = async (operation: string, payload: object) =>
|
||||
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
|
||||
|
||||
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
|
||||
const { id } = await call("join", {});
|
||||
lease.watch(record => {
|
||||
if (!record) return;
|
||||
latest = decodeCultNetPayload(record, arenaDocument);
|
||||
onState(latest, id!);
|
||||
});
|
||||
return {
|
||||
id: id!,
|
||||
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
|
||||
state: () => latest,
|
||||
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild.
|
||||
73
docs/start/io-game/arena.test.ts
Normal file
73
docs/start/io-game/arena.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { joinArena } from "./client.ts";
|
||||
import { startArena } from "./server.ts";
|
||||
import { arenaSize, type ArenaState } from "./schema.ts";
|
||||
|
||||
const until = async (cond: () => boolean) => {
|
||||
for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10));
|
||||
assert.ok(cond(), "condition not reached in 2s");
|
||||
};
|
||||
|
||||
test("two clients join one arena, see each other, and see each other move", async () => {
|
||||
const arena = await startArena();
|
||||
const seenByA: ArenaState[] = [];
|
||||
const seenByB: ArenaState[] = [];
|
||||
const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s)));
|
||||
const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s)));
|
||||
try {
|
||||
assert.notEqual(a.id, b.id);
|
||||
await until(() => Object.keys(a.state()?.players ?? {}).length === 2);
|
||||
await until(() => Object.keys(b.state()?.players ?? {}).length === 2);
|
||||
|
||||
const before = structuredClone(b.state()!.players[a.id]!);
|
||||
// Moves clamp at the arena edge, so pick a direction that always has room to move.
|
||||
const dx = before.x > 0 ? -1 : 1;
|
||||
await a.move(dx, 0);
|
||||
await until(() => b.state()!.players[a.id]!.x !== before.x);
|
||||
assert.equal(b.state()!.players[a.id]!.x, before.x + dx);
|
||||
assert.deepEqual(a.state(), arena.state());
|
||||
assert.deepEqual(b.state(), arena.state());
|
||||
|
||||
await b.leave();
|
||||
await until(() => !(a.id !== b.id && b.id in a.state()!.players));
|
||||
assert.deepEqual(Object.keys(a.state()!.players), [a.id]);
|
||||
} finally {
|
||||
await a.leave().catch(() => undefined);
|
||||
await arena.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a player cannot walk out of the arena", async () => {
|
||||
const arena = await startArena();
|
||||
const a = await joinArena(arena.odinUrl, "client-a", () => undefined);
|
||||
try {
|
||||
for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0);
|
||||
assert.equal(arena.state().players[a.id]!.x, 0);
|
||||
} finally {
|
||||
await a.leave();
|
||||
await arena.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("serve.ts bundles the browser client and serves the page", async () => {
|
||||
const { serveArena } = await import("./serve.ts");
|
||||
const site = await serveArena(0);
|
||||
try {
|
||||
const html = await (await fetch(site.url)).text();
|
||||
assert.match(html, /<canvas/);
|
||||
const js = await (await fetch(`${site.url}/browser.js`)).text();
|
||||
assert.match(js, /CultMeshBrowserClient/);
|
||||
} finally {
|
||||
await site.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the recipe page shows the source files verbatim", () => {
|
||||
const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
|
||||
for (const file of ["schema.ts", "server.ts", "client.ts"]) {
|
||||
const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n");
|
||||
assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`);
|
||||
}
|
||||
});
|
||||
17
docs/start/io-game/browser.ts
Normal file
17
docs/start/io-game/browser.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { joinArena } from "./client.ts";
|
||||
import { arenaSize, type ArenaState } from "./schema.ts";
|
||||
|
||||
const canvas = document.querySelector("canvas")!;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const scale = canvas.width / arenaSize;
|
||||
|
||||
const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const [id, p] of Object.entries(state.players)) {
|
||||
ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff";
|
||||
ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2);
|
||||
}
|
||||
});
|
||||
const keys: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); });
|
||||
addEventListener("pagehide", () => void game.leave());
|
||||
28
docs/start/io-game/client.ts
Normal file
28
docs/start/io-game/client.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser";
|
||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
|
||||
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId, authorityRuntimeId, runtimeId, rendezvous,
|
||||
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
|
||||
});
|
||||
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
|
||||
const call = async (operation: string, payload: object) =>
|
||||
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
|
||||
|
||||
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
|
||||
const { id } = await call("join", {});
|
||||
lease.watch(record => {
|
||||
if (!record) return;
|
||||
latest = decodeCultNetPayload(record, arenaDocument);
|
||||
onState(latest, id!);
|
||||
});
|
||||
return {
|
||||
id: id!,
|
||||
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
|
||||
state: () => latest,
|
||||
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
|
||||
};
|
||||
}
|
||||
6
docs/start/io-game/index.html
Normal file
6
docs/start/io-game/index.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>arena</title>
|
||||
<canvas width="500" height="500" style="border:1px solid #888"></canvas>
|
||||
<p>Arrow keys. Open this page in a second window.</p>
|
||||
<script type="module" src="/browser.js"></script>
|
||||
64
docs/start/io-game/package-lock.json
generated
Normal file
64
docs/start/io-game/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "cultlib-start-io-game",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cultlib-start-io-game",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
docs/start/io-game/package.json
Normal file
9
docs/start/io-game/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "cultlib-start-io-game",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).",
|
||||
"scripts": { "test": "node --test arena.test.ts" },
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"devDependencies": { "@types/ws": "^8.5.0" }
|
||||
}
|
||||
16
docs/start/io-game/schema.ts
Normal file
16
docs/start/io-game/schema.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
||||
|
||||
export interface Player { x: number; y: number }
|
||||
export interface ArenaState { players: Record<string, Player> }
|
||||
|
||||
// The one shared document: every player's position. The server owns it; clients lease it.
|
||||
export const arenaDocument = defineDocumentType({
|
||||
type: "io.arena_state",
|
||||
schemaId: "io.arena_state.v1",
|
||||
schema: { parse: (value: unknown) => value as ArenaState },
|
||||
});
|
||||
|
||||
export const arenaKey = "arena:main";
|
||||
export const verseId = "io.arena";
|
||||
export const authorityRuntimeId = "io.arena-daemon";
|
||||
export const arenaSize = 100;
|
||||
17
docs/start/io-game/serve.ts
Normal file
17
docs/start/io-game/serve.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { build } from "esbuild";
|
||||
import { startArena } from "./server.ts";
|
||||
|
||||
// Serves the page, the bundled browser client, and the arena's WebSocket on one port.
|
||||
export async function serveArena(port = 8080, host = "127.0.0.1") {
|
||||
const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false });
|
||||
const html = readFileSync(new URL("./index.html", import.meta.url));
|
||||
const arena = await startArena(port, host, (req, res) => {
|
||||
if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html);
|
||||
else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents);
|
||||
else res.writeHead(404).end();
|
||||
});
|
||||
return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close };
|
||||
}
|
||||
|
||||
if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`);
|
||||
100
docs/start/io-game/server.ts
Normal file
100
docs/start/io-game/server.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { createServer, type RequestListener, type Server } from "node:http";
|
||||
import { CultCache } from "@gamecult/cultcache-ts";
|
||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||
|
||||
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
|
||||
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
|
||||
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
|
||||
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
|
||||
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
|
||||
await cache.put(arenaDocument, arenaKey, { players: {} });
|
||||
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
|
||||
await new Promise<void>(resolve => http.listen(port, host, resolve));
|
||||
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
|
||||
|
||||
const subscribers = new Map<WebSocket, string[]>();
|
||||
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
|
||||
const record = () => ({
|
||||
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
|
||||
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
|
||||
payloadEncoding: "messagepack" as const,
|
||||
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
|
||||
sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
const publish = async (next: ArenaState) => {
|
||||
await cache.put(arenaDocument, arenaKey, next);
|
||||
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
|
||||
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
|
||||
}
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
const operations: Record<string, (p: any) => Promise<object>> = {
|
||||
join: async () => {
|
||||
const id = `p${++nextId}`;
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
|
||||
return { id };
|
||||
},
|
||||
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
|
||||
const state = cache.getRequired(arenaDocument, arenaKey);
|
||||
const me = state.players[id];
|
||||
if (!me) return { ok: false };
|
||||
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
|
||||
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
|
||||
return { ok: true };
|
||||
},
|
||||
leave: async ({ id }: { id: string }) => {
|
||||
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
|
||||
await publish({ players: rest });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (ws, req) => {
|
||||
const odin = req.url === "/odin";
|
||||
ws.on("close", () => subscribers.delete(ws));
|
||||
ws.on("message", async raw => {
|
||||
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
|
||||
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
|
||||
send(ws, {
|
||||
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
|
||||
verses: [{
|
||||
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
|
||||
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
|
||||
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
|
||||
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
|
||||
}],
|
||||
});
|
||||
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
|
||||
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
|
||||
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
|
||||
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
|
||||
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
|
||||
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
|
||||
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
|
||||
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
|
||||
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
|
||||
const handler = operations[m.operation];
|
||||
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
|
||||
send(ws, {
|
||||
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
|
||||
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
|
||||
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
odinUrl: `${endpoint}/odin`,
|
||||
state: () => cache.getRequired(arenaDocument, arenaKey),
|
||||
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
|
||||
- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used.
|
||||
|
||||
### Breaking
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ from collections.abc import Mapping, Sequence, Set
|
|||
from itertools import chain
|
||||
from typing import Any, Callable, Generic, Iterable, TypeVar
|
||||
|
||||
import msgpack
|
||||
from msgpack.exceptions import StackError as _MsgpackStackError
|
||||
|
||||
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
|
||||
from .leaves import Float32, SlotCodec
|
||||
from .leaves import any as _any_leaf
|
||||
|
|
@ -24,12 +27,7 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused,
|
|||
MAX_SLOT_KEY = 65535
|
||||
"""The largest slot key a record may define: the largest MessagePack array16 length."""
|
||||
|
||||
try:
|
||||
from msgpack.exceptions import StackError as _MsgpackStackError
|
||||
|
||||
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
|
||||
except ModuleNotFoundError:
|
||||
_STACK_FAULTS = ()
|
||||
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
|
||||
|
||||
|
||||
_CONTAINERS = (Mapping, Sequence, Set)
|
||||
|
|
@ -85,22 +83,10 @@ def _json_payload_decoder(payload: bytes) -> Any:
|
|||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def _msgpack() -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"MessagePack payload encoding requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack
|
||||
|
||||
|
||||
def _pack_tree(tree: Any) -> bytes:
|
||||
"""The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a
|
||||
TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a
|
||||
single-float packer."""
|
||||
msgpack = _msgpack()
|
||||
try:
|
||||
return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return]
|
||||
except TypeError:
|
||||
|
|
@ -125,13 +111,6 @@ def _pack_tree(tree: Any) -> bytes:
|
|||
|
||||
|
||||
def _messagepack_payload_decoder(payload: bytes) -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"MessagePack payload decoding requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack.unpackb(payload, raw=False)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ from collections.abc import Mapping
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, NoReturn
|
||||
|
||||
try:
|
||||
from msgpack import ExtType as _ExtType
|
||||
except ModuleNotFoundError:
|
||||
_ExtType = () # type: ignore[assignment,misc]
|
||||
from msgpack import ExtType as _ExtType
|
||||
|
||||
_bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str
|
||||
_BYTE_LIKE = (_bytes, bytearray, memoryview)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
from typing import Any, Callable, Iterator, TypeVar
|
||||
|
||||
import msgpack
|
||||
|
||||
from .backing_store import (
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -21,6 +27,8 @@ STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3"
|
|||
_READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS)
|
||||
_STORE_FORMAT_PREFIX = "cultcache.store."
|
||||
_PERSISTED_RECORD_SLOTS = 4
|
||||
_LOCK_POLL_SECONDS = 0.01
|
||||
LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_Contents = TypeVar("_Contents")
|
||||
|
||||
|
||||
|
|
@ -114,14 +122,14 @@ class JsonLinesBackingStore:
|
|||
|
||||
|
||||
class SingleFileMessagePackBackingStore:
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None:
|
||||
self.path = Path(path)
|
||||
self.lock_timeout = lock_timeout
|
||||
self._lock = threading.RLock()
|
||||
# The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker.
|
||||
self._format = STORE_FORMAT_VERSION
|
||||
|
||||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
msgpack = self._msgpack()
|
||||
with self._lock:
|
||||
# An empty file is not a store (no writer leaves one), so it is decoded and refused.
|
||||
# The disk decides the header: a file that is gone is not marked.
|
||||
|
|
@ -135,39 +143,34 @@ class SingleFileMessagePackBackingStore:
|
|||
if snapshot is not None:
|
||||
self._format, envelopes = snapshot
|
||||
return envelopes
|
||||
return _decode_legacy_envelopes(decoded, msgpack)
|
||||
return _decode_legacy_envelopes(decoded)
|
||||
except Exception as exc:
|
||||
raise StoreUnreadableError(self.path, exc) from exc
|
||||
|
||||
def push(self, envelope: CultCacheEnvelope) -> None:
|
||||
with self._lock:
|
||||
with self._write_transaction():
|
||||
self._replace_all([envelope], self.pull_all())
|
||||
|
||||
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
|
||||
with self._lock:
|
||||
with self._write_transaction():
|
||||
self._replace_all(list(envelopes), self.pull_all())
|
||||
|
||||
def delete(self, type: str, key: str) -> None:
|
||||
with self._lock:
|
||||
with self._write_transaction():
|
||||
self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)])
|
||||
|
||||
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None:
|
||||
msgpack = self._msgpack()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_bytes(msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True))
|
||||
temp.replace(self.path)
|
||||
@contextmanager
|
||||
def _write_transaction(self) -> Iterator[None]:
|
||||
"""The thread lock, then the cross-process lock on `<path>.lock`, held across the read and the replace so two
|
||||
processes never rewrite from the same stale read."""
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with store_write_lock(self.path, self.lock_timeout):
|
||||
yield
|
||||
|
||||
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None:
|
||||
commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True))
|
||||
|
||||
@staticmethod
|
||||
def _msgpack() -> Any:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. "
|
||||
"Install with: python -m pip install cultcache-py[msgpack]"
|
||||
) from exc
|
||||
return msgpack
|
||||
|
||||
|
||||
def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]:
|
||||
|
|
@ -261,6 +264,85 @@ def _encode_snapshot(
|
|||
]
|
||||
return [format_version, catalog, records]
|
||||
|
||||
def commit_store_file(path: Path, data: bytes) -> None:
|
||||
"""Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows,
|
||||
fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the
|
||||
store untouched. The caller holds the store lock."""
|
||||
staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}")
|
||||
try:
|
||||
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(fd, view):]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.replace(staged, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
if sys.platform != "win32":
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
|
||||
"""Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix,
|
||||
LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is
|
||||
created if absent and never written or deleted."""
|
||||
lock_path = path.with_name(path.name + ".lock")
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
release = _try_lock(lock_path)
|
||||
if release is not None:
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.")
|
||||
time.sleep(_LOCK_POLL_SECONDS)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
release()
|
||||
|
||||
|
||||
def _try_lock(lock_path: Path) -> Callable[[], None] | None:
|
||||
if sys.platform == "win32":
|
||||
return _try_lock_windows(lock_path)
|
||||
import fcntl
|
||||
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
return None
|
||||
return lambda: os.close(fd) # closing drops the flock
|
||||
|
||||
|
||||
def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None:
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p]
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666)
|
||||
handle = msvcrt.get_osfhandle(fd)
|
||||
overlapped = ctypes.create_string_buffer(32)
|
||||
# LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it.
|
||||
if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped):
|
||||
os.close(fd)
|
||||
return None
|
||||
return lambda: os.close(fd) # closing drops the lock
|
||||
|
||||
|
||||
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
|
||||
"""Reads a store file, or returns None when nothing is at its path.
|
||||
|
|
@ -351,7 +433,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
|
|||
return decoded[0], envelopes
|
||||
|
||||
|
||||
def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]:
|
||||
def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
|
||||
if not isinstance(decoded, list):
|
||||
raise ValueError("CultCache MessagePack store is not a recognized snapshot")
|
||||
envelopes: list[CultCacheEnvelope] = []
|
||||
|
|
|
|||
54
packages/cultcache-py/tests/test_slot_nil.py
Normal file
54
packages/cultcache-py/tests/test_slot_nil.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t
|
||||
|
||||
# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a
|
||||
# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites
|
||||
# the same bytes.
|
||||
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil"
|
||||
|
||||
|
||||
def _probe():
|
||||
inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)])
|
||||
return define_database_entry_type(
|
||||
"tests.slot-nil",
|
||||
[
|
||||
database_entry_field("s", 0, default="", codec=t.nullable(t.string)),
|
||||
database_entry_field("i", 1, default=0, codec=t.int32),
|
||||
database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)),
|
||||
database_entry_field("r", 3, default=None, codec=t.nullable(inner)),
|
||||
database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class SlotNilVectorTests(unittest.TestCase):
|
||||
def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None:
|
||||
rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)
|
||||
probe = _probe()
|
||||
self.assertEqual(17, len(rows))
|
||||
accepted = 0
|
||||
for name, payload, was_accepted, rewritten in rows:
|
||||
with self.subTest(name):
|
||||
if was_accepted:
|
||||
self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex())
|
||||
accepted += 1
|
||||
else:
|
||||
with self.assertRaises(Exception):
|
||||
probe.decode_payload(payload)
|
||||
self.assertEqual(15, accepted)
|
||||
|
||||
def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None:
|
||||
rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)}
|
||||
probe = _probe()
|
||||
self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"])
|
||||
self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
135
packages/cultcache-py/tests/test_store_lock.py
Normal file
135
packages/cultcache-py/tests/test_store_lock.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from cultcache_py import SingleFileMessagePackBackingStore, stores
|
||||
from cultcache_py.backing_store import CultCacheEnvelope
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
|
||||
HOLD_SECONDS = 1.5
|
||||
|
||||
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
|
||||
PY_HOLDER = """
|
||||
import fcntl, os, sys, time
|
||||
fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
print('HELD', flush=True)
|
||||
time.sleep(float(sys.argv[2]))
|
||||
"""
|
||||
# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts.
|
||||
TS_HOLDER = """
|
||||
const { withStoreWriteLock } = require(process.argv[1]);
|
||||
withStoreWriteLock(process.argv[2], async () => {
|
||||
console.log('HELD');
|
||||
await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000));
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
def envelope(key: str) -> CultCacheEnvelope:
|
||||
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
|
||||
|
||||
|
||||
def start_holder(argv: list[str]) -> subprocess.Popen[str]:
|
||||
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
|
||||
assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD"
|
||||
holder.held_at = time.monotonic() # type: ignore[attr-defined]
|
||||
return holder
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder")
|
||||
class StoreLockTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = self.dir / "state.cc"
|
||||
|
||||
def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None:
|
||||
self.addCleanup(holder.wait)
|
||||
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
|
||||
refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
|
||||
with self.assertRaises(TimeoutError):
|
||||
refused.push(envelope("blocked"))
|
||||
self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store")
|
||||
|
||||
SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after"))
|
||||
self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined]
|
||||
self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()])
|
||||
|
||||
def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None:
|
||||
self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]))
|
||||
|
||||
@unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)")
|
||||
def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None:
|
||||
holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)])
|
||||
self.assert_write_waits_for(holder)
|
||||
|
||||
def test_the_lock_is_released_after_each_write(self) -> None:
|
||||
store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
|
||||
for key in "abc":
|
||||
store.push(envelope(key))
|
||||
self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all()))
|
||||
|
||||
def test_concurrent_processes_lose_no_writes(self) -> None:
|
||||
script = (
|
||||
"import sys\n"
|
||||
"from cultcache_py import SingleFileMessagePackBackingStore as S\n"
|
||||
"from cultcache_py.backing_store import CultCacheEnvelope as E\n"
|
||||
"s = S(sys.argv[1])\n"
|
||||
"for i in range(25):\n"
|
||||
" s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n"
|
||||
)
|
||||
env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])}
|
||||
procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"]
|
||||
for proc in procs:
|
||||
self.assertEqual(0, proc.wait())
|
||||
self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all()))
|
||||
|
||||
|
||||
class StagedWriteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = self.dir / "state.cc"
|
||||
|
||||
def test_the_staged_file_is_fsynced_before_the_replace(self) -> None:
|
||||
order: list[str] = []
|
||||
real_fsync, real_replace = os.fsync, os.replace
|
||||
with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch(
|
||||
"os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1]
|
||||
):
|
||||
SingleFileMessagePackBackingStore(self.path).push(envelope("a"))
|
||||
self.assertEqual("fsync", order[0])
|
||||
self.assertLess(order.index("fsync"), order.index("replace"))
|
||||
|
||||
def test_the_staged_name_is_unique_per_write(self) -> None:
|
||||
names: list[str] = []
|
||||
real_replace = os.replace
|
||||
with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]):
|
||||
store = SingleFileMessagePackBackingStore(self.path)
|
||||
store.push(envelope("a"))
|
||||
store.push(envelope("b"))
|
||||
self.assertEqual(2, len(set(names)))
|
||||
self.assertNotIn(os.fspath(self.path) + ".tmp", names)
|
||||
|
||||
def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None:
|
||||
store = SingleFileMessagePackBackingStore(self.path)
|
||||
store.push(envelope("a"))
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError):
|
||||
store.push(envelope("b"))
|
||||
self.assertEqual(before, self.path.read_bytes())
|
||||
self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -13,6 +13,7 @@ only in git history.
|
|||
|
||||
### Added
|
||||
|
||||
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
|
||||
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
|
||||
|
||||
## [0.3.1]
|
||||
|
|
|
|||
|
|
@ -174,6 +174,9 @@ without pretending Rust has runtime reflection hiding under the floorboards.
|
|||
- `get_required_envelope::<T>`
|
||||
- `get_all::<T>`
|
||||
- `get_all_with_keys::<T>`
|
||||
- `get_by_name::<T>`
|
||||
- `get_by_index::<T>`
|
||||
- `get_global::<T>`
|
||||
- `soa::<T>`
|
||||
- `put::<T>`
|
||||
- `put_envelope::<T>`
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
let ident = input.ident.clone();
|
||||
let mut entry_type: Option<LitStr> = None;
|
||||
let mut schema_name: Option<LitStr> = None;
|
||||
let mut global = false;
|
||||
|
||||
for attribute in input
|
||||
.attrs
|
||||
|
|
@ -37,6 +38,12 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
once(&schema_name, &meta, "schema")?;
|
||||
schema_name = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("global") {
|
||||
if global {
|
||||
return Err(meta.error("repeated `global`: #[cultcache(global)] is given once"));
|
||||
}
|
||||
global = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported cultcache attribute"))
|
||||
}
|
||||
|
|
@ -54,11 +61,40 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
|
||||
let slots = named_slots(input.data, &ident, "DatabaseEntry")?;
|
||||
let codec = slot_codec(&ident, &slots);
|
||||
let name_member = slots.iter().find(|slot| slot.name).map(|slot| &slot.ident);
|
||||
let name_fn = name_member.map(|member| {
|
||||
quote! {
|
||||
fn cult_name(&self) -> ::std::option::Option<::std::string::String> {
|
||||
::cultcache_rs::CultLookupValue::lookup_value(&self.#member)
|
||||
}
|
||||
}
|
||||
});
|
||||
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident)));
|
||||
let index_pushes = index_members.map(|(alias, member)| {
|
||||
quote! {
|
||||
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) {
|
||||
held.push((#alias, value));
|
||||
}
|
||||
}
|
||||
});
|
||||
let index_pushes: Vec<_> = index_pushes.collect();
|
||||
let indexes_fn = (!index_pushes.is_empty()).then(|| {
|
||||
quote! {
|
||||
fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> {
|
||||
let mut held = ::std::vec::Vec::new();
|
||||
#(#index_pushes)*
|
||||
held
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
const GLOBAL: bool = #global;
|
||||
#name_fn
|
||||
#indexes_fn
|
||||
}
|
||||
|
||||
#codec
|
||||
|
|
@ -133,6 +169,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
let mut key: Option<usize> = None;
|
||||
let mut default = false;
|
||||
let mut bytes = false;
|
||||
let mut name = false;
|
||||
let mut index: Option<String> = None;
|
||||
for attribute in field
|
||||
.attrs
|
||||
.iter()
|
||||
|
|
@ -157,6 +195,22 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
} else if meta.path.is_ident("bytes") {
|
||||
bytes = true;
|
||||
Ok(())
|
||||
} else if owner == "DatabaseEntry" && meta.path.is_ident("name") {
|
||||
if name {
|
||||
return Err(meta.error("repeated `name`: #[cultcache(name)] is given once"));
|
||||
}
|
||||
name = true;
|
||||
Ok(())
|
||||
} else if owner == "DatabaseEntry" && meta.path.is_ident("index") {
|
||||
once(&index, &meta, "index")?;
|
||||
// A bare `index`, or an alias that is blank, names the field itself.
|
||||
let alias = if meta.input.peek(syn::Token![=]) {
|
||||
meta.value()?.parse::<LitStr>()?.value()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias });
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported field cultcache attribute"))
|
||||
}
|
||||
|
|
@ -180,8 +234,28 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
|
|||
ty: field.ty,
|
||||
default,
|
||||
bytes,
|
||||
name,
|
||||
index,
|
||||
});
|
||||
}
|
||||
if let Some(second) = slots.iter().filter(|slot| slot.name).nth(1) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&second.ident,
|
||||
format!("{owner} declares #[cultcache(name)] on more than one member; a record has one name"),
|
||||
));
|
||||
}
|
||||
for (position, slot) in slots.iter().enumerate() {
|
||||
let Some(alias) = &slot.index else { continue };
|
||||
if let Some(first) = slots[..position].iter().find(|earlier| earlier.index.as_ref() == Some(alias)) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&slot.ident,
|
||||
format!(
|
||||
"{owner} members {} and {} both declare index alias \"{alias}\"; an index alias must name one member",
|
||||
first.ident, slot.ident
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
slots.sort_by_key(|slot| slot.key);
|
||||
Ok(slots)
|
||||
}
|
||||
|
|
@ -367,6 +441,10 @@ struct CultCacheSlot {
|
|||
ty: Type,
|
||||
default: bool,
|
||||
bytes: bool,
|
||||
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
|
||||
name: bool,
|
||||
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by.
|
||||
index: Option<String>,
|
||||
}
|
||||
|
||||
fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
|
|
@ -842,4 +920,89 @@ mod tests {
|
|||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted");
|
||||
}
|
||||
fn entry_error(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
database_entry_codec(input).expect_err("the fixture is refused").to_string()
|
||||
}
|
||||
|
||||
fn entry_tokens(source: &str) -> String {
|
||||
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
|
||||
database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_index_and_global_generate_the_lookup_surface() {
|
||||
let tokens = entry_tokens(
|
||||
r#"#[cultcache(type = "t", global)] struct S {
|
||||
#[cultcache(key = 0, name)] label: String,
|
||||
#[cultcache(key = 1, index)] code: String,
|
||||
#[cultcache(key = 2, index = "sku")] stock: Option<String>,
|
||||
}"#,
|
||||
);
|
||||
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
|
||||
assert!(tokens.contains("fncult_name"), "{tokens}");
|
||||
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
|
||||
assert!(tokens.contains("(\"sku\",value)"), "{tokens}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_without_lookups_generates_none_of_them() {
|
||||
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
|
||||
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
|
||||
assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}");
|
||||
}
|
||||
|
||||
// ResolveIndexAlias: a blank alias is the member's name.
|
||||
#[test]
|
||||
fn a_blank_index_alias_is_the_member_name() {
|
||||
let tokens = entry_tokens(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
|
||||
);
|
||||
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_names_are_refused_for_that_reason() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("more than one member"), "{message}");
|
||||
}
|
||||
|
||||
// D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it.
|
||||
#[test]
|
||||
fn two_members_with_one_index_alias_are_refused_for_that_reason() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("both declare index alias \"k\""), "{message}");
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#,
|
||||
);
|
||||
assert!(message.contains("both declare index alias \"a\""), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_lookup_attribute_is_refused() {
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#,
|
||||
);
|
||||
assert!(message.contains("repeated `name`"), "{message}");
|
||||
let message = entry_error(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#,
|
||||
);
|
||||
assert!(message.contains("repeated `index`"), "{message}");
|
||||
let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#);
|
||||
assert!(message.contains("repeated `global`"), "{message}");
|
||||
}
|
||||
|
||||
// Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before.
|
||||
#[test]
|
||||
fn lookup_attributes_are_refused_on_a_slots_record() {
|
||||
let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#);
|
||||
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
|
||||
let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#);
|
||||
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,8 +162,68 @@ pub use cultcache_rs_derive::DatabaseEntry;
|
|||
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
||||
const TYPE: &'static str;
|
||||
const SCHEMA_NAME: &'static str = "DatabaseEntry";
|
||||
/// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`.
|
||||
const GLOBAL: bool = false;
|
||||
/// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name.
|
||||
fn cult_name(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by.
|
||||
fn cult_indexes(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member
|
||||
/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none.
|
||||
pub trait CultLookupValue {
|
||||
fn lookup_value(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl CultLookupValue for String {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
Some(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CultLookupValue> CultLookupValue for Option<T> {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
self.as_ref().and_then(CultLookupValue::lookup_value)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! lookup_value_by_display {
|
||||
($($ty:ty),* $(,)?) => {$(
|
||||
impl CultLookupValue for $ty {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
Some(self.to_string())
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
|
||||
|
||||
/// A global type already holds a record under another key; a second would leave `get_global` with two answers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CultCacheSecondGlobal {
|
||||
pub schema_name: String,
|
||||
pub existing_key: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CultCacheSecondGlobal {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"{} is a global and already has record {}; {} would be a second.",
|
||||
self.schema_name, self.existing_key, self.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CultCacheSecondGlobal {}
|
||||
|
||||
/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry`
|
||||
/// derives implement, and what a `CultUnion` arm must carry.
|
||||
#[diagnostic::on_unimplemented(
|
||||
|
|
@ -2655,6 +2715,8 @@ struct CultCacheStoreRegistration {
|
|||
|
||||
pub struct CultCache {
|
||||
definitions: BTreeMap<String, &'static str>,
|
||||
/// The registered types declared `#[cultcache(global)]`.
|
||||
globals: BTreeSet<String>,
|
||||
schema_name_definitions: BTreeMap<String, String>,
|
||||
entries: BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
stores: Vec<CultCacheStoreRegistration>,
|
||||
|
|
@ -2664,6 +2726,7 @@ impl CultCache {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
definitions: BTreeMap::new(),
|
||||
globals: BTreeSet::new(),
|
||||
schema_name_definitions: BTreeMap::new(),
|
||||
entries: BTreeMap::new(),
|
||||
stores: Vec::new(),
|
||||
|
|
@ -2694,6 +2757,11 @@ impl CultCache {
|
|||
));
|
||||
}
|
||||
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
|
||||
if T::GLOBAL {
|
||||
self.globals.insert(T::TYPE.to_string());
|
||||
} else {
|
||||
self.globals.remove(T::TYPE);
|
||||
}
|
||||
self.schema_name_definitions
|
||||
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
|
||||
Ok(())
|
||||
|
|
@ -2800,6 +2868,7 @@ impl CultCache {
|
|||
loaded.insert(entry_id(&entry), entry);
|
||||
}
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
self.entries = loaded;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2883,6 +2952,65 @@ impl CultCache {
|
|||
Ok(values)
|
||||
}
|
||||
|
||||
/// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is
|
||||
/// not a global).
|
||||
pub fn get_global<T: DatabaseEntry>(&self) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if !T::GLOBAL {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|_| true)
|
||||
}
|
||||
|
||||
/// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records
|
||||
/// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or
|
||||
/// loaded in; removing it finds the next.
|
||||
pub fn get_by_name<T: DatabaseEntry>(&self, name: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|document| document.cult_name().as_deref() == Some(name))
|
||||
}
|
||||
|
||||
/// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds
|
||||
/// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they
|
||||
/// were written or loaded in; removing it finds the next.
|
||||
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if value.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|document| {
|
||||
document
|
||||
.cult_indexes()
|
||||
.iter()
|
||||
.any(|(held_alias, held_value)| *held_alias == alias && held_value == value)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first record of `T`, in key order, that `matches`.
|
||||
fn find_record<T: DatabaseEntry>(&self, matches: impl Fn(&T) -> bool) -> Result<Option<T>> {
|
||||
let held = self
|
||||
.entries
|
||||
.range((T::TYPE.to_string(), String::new())..)
|
||||
.take_while(|((held_type, _), _)| held_type == T::TYPE);
|
||||
for (_, entry) in held {
|
||||
let document: T = decode_payload(&entry.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
entry.key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
if matches(&document) {
|
||||
return Ok(Some(document));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn soa<T: SoaDocument>(&self) -> Result<CultSoaTable<T>> {
|
||||
let keyed_rows = self.get_all_with_keys::<T>()?;
|
||||
let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip();
|
||||
|
|
@ -2900,6 +3028,7 @@ impl CultCache {
|
|||
|
||||
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
|
||||
let (entry, parsed) = self.prepare_entry(key, value)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3011,6 +3140,7 @@ impl CultCache {
|
|||
for entry in &staged {
|
||||
next.insert(entry_id(entry), entry.clone());
|
||||
}
|
||||
self.refuse_second_globals(&next)?;
|
||||
if let Some(store_index) = store_index {
|
||||
// The home store receives its own records only: those it already
|
||||
// holds in this cache plus the staged ones.
|
||||
|
|
@ -3029,6 +3159,7 @@ impl CultCache {
|
|||
|
||||
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
|
||||
let parsed = self.validate_envelope::<T>(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3061,6 +3192,7 @@ impl CultCache {
|
|||
));
|
||||
}
|
||||
require_payload_depth(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
if let Some(home) = self.home_index(&entry.r#type)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
|
|
@ -3146,10 +3278,43 @@ impl CultCache {
|
|||
self.home_index(&entry.r#type)?;
|
||||
loaded.insert(entry_id(&entry), entry);
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
self.entries = loaded;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse_second_global(
|
||||
&self,
|
||||
entries: &BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
type_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
if !self.globals.contains(type_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let held_other = entries
|
||||
.range((type_id.to_string(), String::new())..)
|
||||
.take_while(|((held_type, _), _)| held_type == type_id)
|
||||
.map(|((_, held_key), _)| held_key)
|
||||
.find(|held_key| held_key.as_str() != key);
|
||||
match held_other {
|
||||
Some(existing_key) => Err(CultCacheSecondGlobal {
|
||||
schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(),
|
||||
existing_key: existing_key.clone(),
|
||||
key: key.to_string(),
|
||||
}
|
||||
.into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> {
|
||||
for (type_id, key) in entries.keys() {
|
||||
self.refuse_second_global(entries, type_id, key)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> {
|
||||
match self.definitions.get(T::TYPE) {
|
||||
Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()),
|
||||
|
|
|
|||
323
packages/cultcache-rs/tests/lookups.rs
Normal file
323
packages/cultcache-rs/tests/lookups.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal`
|
||||
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
|
||||
|
||||
use cultcache_rs::{
|
||||
CacheBackingStore, CultCache, CultCacheSecondGlobal, DatabaseEntry, SingleFileMessagePackBackingStore,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "item")]
|
||||
struct Item {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
#[cultcache(key = 3, default)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn item(name: &str, category: &str, code: Option<&str>) -> Item {
|
||||
Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 }
|
||||
}
|
||||
|
||||
/// Same names and values as `Item`, a different type: lookups never cross types.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gadget")]
|
||||
struct Gadget {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "zed")]
|
||||
struct Zed {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
#[cultcache(key = 3, default)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings", global)]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn settings(value: &str) -> Settings {
|
||||
Settings { value: value.into() }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "plain")]
|
||||
struct Plain {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "numbered")]
|
||||
struct Numbered {
|
||||
#[cultcache(key = 0, name)]
|
||||
id: u32,
|
||||
#[cultcache(key = 1, index)]
|
||||
owner: u64,
|
||||
}
|
||||
|
||||
fn cache() -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Item>().unwrap();
|
||||
cache.register_entry_type::<Gadget>().unwrap();
|
||||
cache.register_entry_type::<Zed>().unwrap();
|
||||
cache.register_entry_type::<Settings>().unwrap();
|
||||
cache.register_entry_type::<Plain>().unwrap();
|
||||
cache.register_entry_type::<Numbered>().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn reopened(path: &std::path::Path) -> CultCache {
|
||||
let mut reopened = cache();
|
||||
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
reopened.pull_all_backing_stores().unwrap();
|
||||
reopened
|
||||
}
|
||||
|
||||
fn stored_cache(path: &std::path::Path) -> CultCache {
|
||||
let mut stored = cache();
|
||||
stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
stored.pull_all_backing_stores().unwrap();
|
||||
stored
|
||||
}
|
||||
|
||||
// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache.
|
||||
#[test]
|
||||
fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("lookups.cc");
|
||||
let mut written = stored_cache(&path);
|
||||
written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap();
|
||||
|
||||
let reopened = reopened(&path);
|
||||
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
|
||||
assert_eq!(by_name.value, 50);
|
||||
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
|
||||
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
|
||||
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
|
||||
|
||||
let bare = CultCache::new();
|
||||
assert!(bare.get_by_name::<Item>("A").is_err());
|
||||
assert!(bare.get_by_index::<Item>("category", "cat").is_err());
|
||||
assert!(bare.get_global::<Settings>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_without_an_alias_is_named_by_its_member() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("A", "cat", None)).unwrap();
|
||||
assert!(cache.get_by_index::<Item>("category", "cat").unwrap().is_some());
|
||||
}
|
||||
|
||||
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
|
||||
#[test]
|
||||
fn blank_and_nil_values_are_not_found() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:blank", &item("", " ", None)).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
|
||||
|
||||
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
|
||||
cache.put("item:space", &item(" ", "x", None)).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>(" ").unwrap().unwrap().category, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_never_cross_types() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("same", "same", None)).unwrap();
|
||||
cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap();
|
||||
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None);
|
||||
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:a", &item("old", "c1", None)).unwrap();
|
||||
cache.put("item:a", &item("new", "c2", None)).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found");
|
||||
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some());
|
||||
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_string_members_are_found_by_their_text() {
|
||||
let mut cache = cache();
|
||||
let owner = 161_u64;
|
||||
cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap();
|
||||
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Numbered>("owner", "161").unwrap().is_some());
|
||||
}
|
||||
|
||||
// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with
|
||||
// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other.
|
||||
#[test]
|
||||
fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("shared.cc");
|
||||
{
|
||||
let mut cache = stored_cache(&path);
|
||||
cache.put("item:p2", &item("p2", "x", None)).unwrap();
|
||||
cache.put("item:p1", &item("p1", "x", None)).unwrap();
|
||||
let found = |cache: &CultCache| cache.get_by_index::<Item>("category", "x").unwrap().map(|found| found.name);
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
|
||||
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
|
||||
|
||||
cache.put("item:p1", &item("p1", "x", None)).unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"));
|
||||
cache.delete::<Item>("item:p2").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache), None);
|
||||
}
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_sharing_a_name_find_the_smallest_key() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap();
|
||||
cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 1);
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 2);
|
||||
}
|
||||
|
||||
// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen.
|
||||
#[test]
|
||||
fn a_global_is_none_until_written_and_survives_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("global.cc");
|
||||
{
|
||||
let mut seed = stored_cache(&path);
|
||||
assert_eq!(seed.get_global::<Settings>().unwrap(), None, "opening a store invented a global");
|
||||
seed.put("global:settings", &settings("durable authority")).unwrap();
|
||||
assert_eq!(seed.get_global::<Settings>().unwrap(), Some(settings("durable authority")));
|
||||
}
|
||||
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_that_is_not_a_global_has_no_global() {
|
||||
let mut cache = cache();
|
||||
cache.put("plain:one", &Plain { value: "v".into() }).unwrap();
|
||||
assert_eq!(cache.get_global::<Plain>().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_global_under_another_key_is_refused_and_writes_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("second.cc");
|
||||
let mut cache = stored_cache(&path);
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
|
||||
let refusal = cache.put("g2", &settings("two")).unwrap_err();
|
||||
let second = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
|
||||
assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2"));
|
||||
assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second."));
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
|
||||
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("one")), "nothing reached the store");
|
||||
|
||||
// The same key is the same record, not a second.
|
||||
cache.put("g1", &settings("one again")).unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one again")));
|
||||
}
|
||||
|
||||
// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key.
|
||||
#[test]
|
||||
fn removing_a_global_frees_its_slot() {
|
||||
let mut cache = cache();
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
assert!(cache.put("g2", &settings("two")).is_err());
|
||||
cache.delete::<Settings>("g1").unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
|
||||
cache.put("g2", &settings("two")).unwrap();
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("two")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_write_path_refuses_a_second_global() {
|
||||
let mut cache = cache();
|
||||
cache.put("g1", &settings("one")).unwrap();
|
||||
let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
|
||||
|
||||
assert!(cache.put_envelope::<Settings>(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap();
|
||||
assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(cache.get::<Plain>("plain:1").unwrap(), None, "a refused batch lands none of it");
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_that_stages_two_globals_is_refused_whole() {
|
||||
let mut cache = cache();
|
||||
let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap();
|
||||
let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
|
||||
assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
|
||||
}
|
||||
|
||||
// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them.
|
||||
#[test]
|
||||
fn loading_a_store_that_holds_two_globals_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("two.cc");
|
||||
let source = cache();
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
for (key, value) in [("g1", "one"), ("g2", "two")] {
|
||||
let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap();
|
||||
store.push(&entry).unwrap();
|
||||
}
|
||||
|
||||
let mut loading = cache();
|
||||
loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
|
||||
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
|
||||
### Added
|
||||
|
||||
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
|
||||
|
||||
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
|
||||
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
|
||||
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed No
|
|||
const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
|
||||
```
|
||||
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins.
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`.
|
||||
|
||||
## `.cc` Inspection
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@
|
|||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"browser": "./dist/browser.js",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser.d.ts",
|
||||
"require": "./dist/browser.js",
|
||||
"default": "./dist/browser.js"
|
||||
},
|
||||
"./document": {
|
||||
"types": "./dist/document.d.ts",
|
||||
"require": "./dist/document.js",
|
||||
|
|
@ -25,6 +31,9 @@
|
|||
"*": {
|
||||
"document": [
|
||||
"dist/document.d.ts"
|
||||
],
|
||||
"browser": [
|
||||
"dist/browser.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
49
packages/cultcache-ts/src/browser.ts
Normal file
49
packages/cultcache-ts/src/browser.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Browser-safe surface: everything the package root exports except the node-only single-file store
|
||||
// (node:fs, koffi). Bring your own CacheBackingStore, or none.
|
||||
export * as codecs from "./codec";
|
||||
export type { Codec, SlotsCodec } from "./codec";
|
||||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
schemaIdentityOf,
|
||||
type CultCacheSchemaIdentity,
|
||||
} from "./document";
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
|
||||
export type { DocumentPayload } from "./document";
|
||||
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFieldName,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentIndexDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheSchema,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheIndexScalar,
|
||||
InferCultCacheSchemaValue,
|
||||
CultCacheStoreRegistration,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
export type {
|
||||
CultCacheInspection,
|
||||
InspectedCatalogEntry,
|
||||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
export {
|
||||
defineSwarmDocuments,
|
||||
type DefineDocumentType,
|
||||
type SwarmDocumentCatalog,
|
||||
} from "./swarm-documents";
|
||||
|
|
@ -1,48 +1,2 @@
|
|||
export * as codecs from "./codec";
|
||||
export type { Codec, SlotsCodec } from "./codec";
|
||||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
schemaIdentityOf,
|
||||
type CultCacheSchemaIdentity,
|
||||
} from "./document";
|
||||
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
|
||||
export type { DocumentPayload } from "./document";
|
||||
export * from "./browser";
|
||||
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
|
||||
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFieldName,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentIndexDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheSchema,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheIndexScalar,
|
||||
InferCultCacheSchemaValue,
|
||||
CultCacheStoreRegistration,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
export type {
|
||||
CultCacheInspection,
|
||||
InspectedCatalogEntry,
|
||||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
export {
|
||||
defineSwarmDocuments,
|
||||
type DefineDocumentType,
|
||||
type SwarmDocumentCatalog,
|
||||
} from "./swarm-documents";
|
||||
|
|
|
|||
45
packages/cultcache-ts/test/browser-entry.test.ts
Normal file
45
packages/cultcache-ts/test/browser-entry.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { build } from "esbuild";
|
||||
|
||||
// dist-test/test/ -> dist-test/src/
|
||||
const srcDir = join(__dirname, "..", "src");
|
||||
|
||||
async function bundleForBrowser(entry: string): Promise<string> {
|
||||
const result = await build({
|
||||
entryPoints: [join(srcDir, entry)],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
globalName: "cultcache",
|
||||
write: false,
|
||||
logLevel: "silent",
|
||||
});
|
||||
return result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
test("the browser entry bundles for platform=browser and runs without node", async () => {
|
||||
const code = await bundleForBrowser("browser.js");
|
||||
assert.ok(!/node:fs|koffi/.test(code), "no node-only store in the browser bundle");
|
||||
// A context with no require/process/Buffer: only what a browser page has.
|
||||
const sandbox: Record<string, unknown> = { TextEncoder, TextDecoder };
|
||||
runInNewContext(code + "\nglobalThis.__api = cultcache;", sandbox);
|
||||
const api = sandbox.__api as Record<string, unknown>;
|
||||
assert.equal(typeof api.CultCache, "function");
|
||||
assert.equal(typeof api.defineDocumentType, "function");
|
||||
assert.equal(api.SingleFileMessagePackBackingStore, undefined);
|
||||
});
|
||||
|
||||
test("the node entry still exports the single-file store and everything the browser entry has", async () => {
|
||||
const node = await import(join(srcDir, "index.js"));
|
||||
const browser = await import(join(srcDir, "browser.js"));
|
||||
assert.equal(typeof node.SingleFileMessagePackBackingStore, "function");
|
||||
assert.equal(typeof node.readSingleFileStore, "function");
|
||||
for (const name of Object.keys(browser)) assert.ok(name in node, `node entry lost ${name}`);
|
||||
});
|
||||
|
||||
test("the package root keeps the node-only store out of the browser build", async () => {
|
||||
await assert.rejects(bundleForBrowser("index.js"), /node:fs|node:path|koffi/);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
338
packages/cultmesh-py/src/cultmesh_py/authority.py
Normal file
338
packages/cultmesh-py/src/cultmesh_py/authority.py
Normal 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]
|
||||
217
packages/cultmesh-py/tests/test_authority_route_vectors.py
Normal file
217
packages/cultmesh-py/tests/test_authority_route_vectors.py
Normal 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()
|
||||
123
packages/cultmesh-rs/Cargo.lock
generated
123
packages/cultmesh-rs/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
424
packages/cultmesh-rs/src/authority.rs
Normal file
424
packages/cultmesh-rs/src/authority.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ use std::time::Duration;
|
|||
use std::time::Instant;
|
||||
|
||||
mod rudp_document_server;
|
||||
pub mod authority;
|
||||
|
||||
pub use rudp_document_server::*;
|
||||
|
||||
|
|
|
|||
261
packages/cultmesh-rs/tests/authority_route_vectors.rs
Normal file
261
packages/cultmesh-rs/tests/authority_route_vectors.rs
Normal 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(¬_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());
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from .cultmesh_contracts import (
|
|||
)
|
||||
from .framing import decode_frame, encode_frame, read_frame, write_frame
|
||||
from .messages import (
|
||||
CULTNET_ERROR_CODES,
|
||||
CultNetMessage,
|
||||
CultNetWitnessArtifactBundle,
|
||||
compute_simulation_claim_hash,
|
||||
|
|
@ -47,6 +48,7 @@ from .messages import (
|
|||
document_delete,
|
||||
document_put_raw,
|
||||
encode_witness_artifact_bundle_payload,
|
||||
error_message,
|
||||
hello,
|
||||
login,
|
||||
login_success,
|
||||
|
|
@ -103,6 +105,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,
|
||||
|
|
@ -165,6 +173,7 @@ __all__ = [
|
|||
"simulation_fact_document",
|
||||
"verse_catalog_request",
|
||||
"verse_from_wire",
|
||||
"CULTNET_ERROR_CODES",
|
||||
"CultNetMessage",
|
||||
"CultNetWitnessArtifactBundle",
|
||||
"CultNetDatabaseSubscription",
|
||||
|
|
@ -211,6 +220,10 @@ __all__ = [
|
|||
"CultNetRudpReceiveResult",
|
||||
"CultNetRudpSendOptions",
|
||||
"CultNetRudpSession",
|
||||
"CultNetRudpSocketServerFrame",
|
||||
"CultNetRudpSocketServerOptions",
|
||||
"CultNetRudpSocketServerPeer",
|
||||
"CultNetRudpSocketTransportServer",
|
||||
"is_permanent_send_error",
|
||||
"send_error_code",
|
||||
"CultNetRudpSessionOptions",
|
||||
|
|
@ -247,6 +260,7 @@ __all__ = [
|
|||
"encode_witness_artifact_bundle_payload",
|
||||
"encode_frame",
|
||||
"encode_rudp_packet",
|
||||
"error_message",
|
||||
"hello",
|
||||
"INTEROP_WIRE_CONTRACT",
|
||||
"login",
|
||||
|
|
|
|||
|
|
@ -28,9 +28,61 @@ def parse_message(payload: bytes) -> CultNetMessage:
|
|||
raise ValueError("CultNet schema-v0 messages must declare schemaVersion")
|
||||
body = dict(decoded)
|
||||
del body["schemaVersion"]
|
||||
if schema_version == "cultnet.error.v0":
|
||||
_validate_error_body(body)
|
||||
return CultNetMessage(schema_version=schema_version, body=body)
|
||||
|
||||
|
||||
# Closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). Adding a
|
||||
# code is one coordinated change across every runtime; a peer refuses a code it does not know.
|
||||
CULTNET_ERROR_CODES: tuple[str, ...] = (
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
)
|
||||
|
||||
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
||||
|
||||
|
||||
def _is_uint(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
|
||||
|
||||
def _validate_error_body(body: dict[str, Any]) -> None:
|
||||
"""Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
|
||||
expected = ("error", "routingHint", "code", "details")
|
||||
if set(body) != set(expected):
|
||||
raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}")
|
||||
if not isinstance(body["error"], str) or not body["error"]:
|
||||
raise ValueError("cultnet.error.v0 error must be a non-empty string")
|
||||
code = body["code"]
|
||||
if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
|
||||
raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code")
|
||||
hint = body["routingHint"]
|
||||
if hint is not None:
|
||||
if (
|
||||
not isinstance(hint, dict)
|
||||
or set(hint) != {"shard", "reason"}
|
||||
or not isinstance(hint["reason"], str)
|
||||
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
|
||||
):
|
||||
raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
|
||||
details = body["details"]
|
||||
if details is not None:
|
||||
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
|
||||
raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
|
||||
for key in ("field", "value"):
|
||||
if details[key] is not None and not isinstance(details[key], str):
|
||||
raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
|
||||
for key in ("asOf", "current"):
|
||||
if details[key] is not None and not _is_uint(details[key]):
|
||||
raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
|
||||
|
||||
|
||||
def hello(
|
||||
*,
|
||||
runtime_id: str,
|
||||
|
|
@ -99,6 +151,29 @@ def verify_session(
|
|||
)
|
||||
|
||||
|
||||
def error_message(
|
||||
error: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
routing_hint: dict[str, Any] | None = None,
|
||||
) -> CultNetMessage:
|
||||
"""Build cultnet.error.v0 with every key written (nil included), as the C# map formatter does.
|
||||
|
||||
``details`` may name any of field/value/asOf/current; the rest are written as null.
|
||||
"""
|
||||
body = {
|
||||
"error": error,
|
||||
"routingHint": routing_hint,
|
||||
"code": code,
|
||||
"details": None if details is None else {key: details.get(key) for key in _ERROR_DETAILS_KEYS},
|
||||
}
|
||||
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
|
||||
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
|
||||
_validate_error_body(body)
|
||||
return CultNetMessage("cultnet.error.v0", body)
|
||||
|
||||
|
||||
def login_success(
|
||||
*,
|
||||
nonce: str,
|
||||
|
|
|
|||
345
packages/cultnet-py/src/cultnet_py/rudp_server.py
Normal file
345
packages/cultnet-py/src/cultnet_py/rudp_server.py
Normal 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
|
||||
|
|
@ -12,6 +12,17 @@ PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0"
|
|||
TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0"
|
||||
TRANSPORT_PROFILE_SCHEMA_ID = f"{CULTNET_SCHEMA_BASE}/cultnet.transport-profile.schema.json"
|
||||
|
||||
# The closed code set of contracts/cultnet/cultnet.error.schema.json; a code outside it is one coordinated change across every runtime.
|
||||
CULTNET_ERROR_CODES = (
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
)
|
||||
|
||||
WIRE_MESSAGE_SCHEMA_VERSIONS = (
|
||||
("cultnet.hello.v0", "CultNet Hello Message", f"{CULTNET_SCHEMA_BASE}/cultnet.hello.schema.json"),
|
||||
("cultnet.error.v0", "CultNet Error Message", f"{CULTNET_SCHEMA_BASE}/cultnet.error.schema.json"),
|
||||
|
|
@ -269,7 +280,7 @@ def shared_contract_schema_json(schema_id: str, title: str, schema_version: str)
|
|||
def wire_message_required_fields(schema_version: str) -> list[str]:
|
||||
required = {
|
||||
"cultnet.hello.v0": ["schemaVersion", "runtimeId"],
|
||||
"cultnet.error.v0": ["schemaVersion", "error"],
|
||||
"cultnet.error.v0": ["schemaVersion", "error", "routingHint", "code", "details"],
|
||||
"cultnet.login.v0": ["schemaVersion", "nonce", "auth", "password"],
|
||||
"cultnet.register.v0": ["schemaVersion", "nonce", "email", "password", "name"],
|
||||
"cultnet.verify.v0": ["schemaVersion", "nonce", "session"],
|
||||
|
|
@ -355,10 +366,10 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]:
|
|||
"supportsSchemaCatalog": {"type": "boolean"},
|
||||
},
|
||||
"cultnet.error.v0": {
|
||||
"messageId": common["messageId"],
|
||||
"error": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"details": {"type": "object", "additionalProperties": True},
|
||||
"error": {"type": "string", "minLength": 1},
|
||||
"routingHint": {"type": ["object", "null"]},
|
||||
"code": {"type": ["string", "null"], "enum": [None, *CULTNET_ERROR_CODES]},
|
||||
"details": {"type": ["object", "null"]},
|
||||
},
|
||||
"cultnet.login.v0": {
|
||||
"nonce": {"type": "string", "minLength": 1},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2235,9 +2235,11 @@ class CultNetTests(unittest.TestCase):
|
|||
self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string")
|
||||
|
||||
error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"])
|
||||
self.assertEqual(error_schema["required"], ["schemaVersion", "error"])
|
||||
contract = json.loads((Path(__file__).resolve().parents[3] / "contracts" / "cultnet" / "cultnet.error.schema.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(error_schema["required"], contract["required"])
|
||||
self.assertEqual(sorted(error_schema["properties"]), sorted(contract["properties"]))
|
||||
self.assertEqual(error_schema["properties"]["error"]["type"], "string")
|
||||
self.assertIn("details", error_schema["properties"])
|
||||
self.assertEqual(error_schema["properties"]["code"]["enum"], contract["properties"]["code"]["enum"])
|
||||
|
||||
login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"])
|
||||
self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"])
|
||||
|
|
|
|||
90
packages/cultnet-py/tests/test_cultnet_error.py
Normal file
90
packages/cultnet-py/tests/test_cultnet_error.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message
|
||||
|
||||
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
||||
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
||||
_REPO = Path(__file__).resolve().parents[3]
|
||||
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
|
||||
_SCHEMA = json.loads((_REPO / "contracts/cultnet/cultnet.error.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class CultNetErrorMessageTests(unittest.TestCase):
|
||||
def test_closed_code_set_equals_the_schema_enum(self) -> None:
|
||||
schema_codes = [c for c in _SCHEMA["properties"]["code"]["enum"] if c is not None]
|
||||
self.assertEqual(sorted(CULTNET_ERROR_CODES), sorted(schema_codes))
|
||||
|
||||
def test_vectors_cover_every_code(self) -> None:
|
||||
self.assertEqual({v["message"]["code"] for v in _VECTORS}, set(CULTNET_ERROR_CODES))
|
||||
|
||||
def test_decodes_csharp_and_rust_written_bytes_with_every_key(self) -> None:
|
||||
for vector in _VECTORS:
|
||||
message = parse_message(bytes.fromhex(vector["messagePackHex"]))
|
||||
self.assertEqual(message.to_wire(), vector["message"], vector["name"])
|
||||
self.assertEqual(
|
||||
list(message.to_wire()), ["schemaVersion", "error", "routingHint", "code", "details"], vector["name"]
|
||||
)
|
||||
|
||||
def test_reencode_is_byte_identical(self) -> None:
|
||||
for vector in _VECTORS:
|
||||
expected = bytes.fromhex(vector["messagePackHex"])
|
||||
self.assertEqual(parse_message(expected).to_bytes(), expected, vector["name"])
|
||||
|
||||
def test_builder_produces_the_csharp_bytes(self) -> None:
|
||||
by_name = {v["name"]: bytes.fromhex(v["messagePackHex"]) for v in _VECTORS}
|
||||
stale = error_message(
|
||||
"cursor_stale: cursor is stale", code="cursor_stale", details={"asOf": 1, "current": 2}
|
||||
)
|
||||
self.assertEqual(stale.to_bytes(), by_name["cursor_stale"])
|
||||
invalid = error_message("cursor_invalid: cannot parse cursor", code="cursor_invalid")
|
||||
self.assertEqual(invalid.to_bytes(), by_name["cursor_invalid"])
|
||||
selection = error_message(
|
||||
"selection_invalid: blank key entry",
|
||||
code="selection_invalid",
|
||||
details={"field": "keys", "value": " "},
|
||||
)
|
||||
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
|
||||
|
||||
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
|
||||
wire = error_message("boom").to_wire()
|
||||
self.assertEqual(wire, {"schemaVersion": "cultnet.error.v0", "error": "boom", "routingHint": None, "code": None, "details": None})
|
||||
|
||||
def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
error_message("x", code="unsupported_schema_version")
|
||||
bad = msgpack.packb(
|
||||
{"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None},
|
||||
use_bin_type=True,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
parse_message(bad)
|
||||
|
||||
def test_dropping_code_or_details_is_refused(self) -> None:
|
||||
vector = next(v for v in _VECTORS if v["name"] == "cursor_stale")
|
||||
for dropped in ("code", "details", "routingHint"):
|
||||
wire = dict(vector["message"])
|
||||
del wire[dropped]
|
||||
with self.assertRaises(ValueError, msg=dropped):
|
||||
parse_message(msgpack.packb(wire, use_bin_type=True))
|
||||
|
||||
def test_malformed_details_are_refused(self) -> None:
|
||||
base = dict(next(v for v in _VECTORS if v["name"] == "cursor_stale")["message"])
|
||||
for details in (
|
||||
{"field": None, "value": None, "asOf": 1},
|
||||
{"field": None, "value": None, "asOf": -1, "current": 2},
|
||||
{"field": None, "value": None, "asOf": 1, "current": 2, "extra": 1},
|
||||
):
|
||||
with self.assertRaises(ValueError, msg=str(details)):
|
||||
parse_message(msgpack.packb({**base, "details": details}, use_bin_type=True))
|
||||
with self.assertRaises(ValueError):
|
||||
error_message("x", code="cursor_stale", details={"asOf": 1, "bogus": 2})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
packages/cultnet-py/tests/test_first_call.py
Normal file
72
packages/cultnet-py/tests/test_first_call.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""The Python CultNet first call from docs/start/README.md. The docs show the region between the
|
||||
`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# snippet:begin
|
||||
import socket
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from cultcache_py import CultCache, define_document_type
|
||||
from cultnet_py import (
|
||||
apply_raw_document_record,
|
||||
document_put_raw,
|
||||
encode_frame,
|
||||
parse_message,
|
||||
read_frame,
|
||||
schema_document_map,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
note_doc = define_document_type(
|
||||
"note",
|
||||
encode=lambda note: asdict(note),
|
||||
decode=lambda raw: Note(**raw),
|
||||
name="title",
|
||||
)
|
||||
|
||||
|
||||
def first_call() -> Note:
|
||||
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
|
||||
|
||||
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
key="note:hello",
|
||||
schema_id=note_doc.catalog_entry().schema_id,
|
||||
stored_at="2026-01-01T00:00:00Z",
|
||||
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
|
||||
)
|
||||
sender.sendall(encode_frame(put.to_bytes()))
|
||||
|
||||
# Receive: read the frame, parse the message, apply its record to a second cache.
|
||||
message = parse_message(read_frame(receiver.makefile("rb")))
|
||||
assert message.schema_version == "cultnet.document_put_raw.v0"
|
||||
inbox = CultCache.builder().register_document_type(note_doc).build()
|
||||
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
|
||||
sender.close()
|
||||
receiver.close()
|
||||
return inbox.get_required(note_doc, "note:hello")
|
||||
# snippet:end
|
||||
|
||||
|
||||
class FirstCallTests(unittest.TestCase):
|
||||
def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None:
|
||||
self.assertEqual(first_call(), Note("Hello", "sent over CultNet"))
|
||||
|
||||
def test_docs_show_the_tested_snippet(self) -> None:
|
||||
source = Path(__file__).read_text(encoding="utf-8")
|
||||
snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0]
|
||||
docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn("```python\n" + snippet + "```", docs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
350
packages/cultnet-py/tests/test_rudp_multipeer_server.py
Normal file
350
packages/cultnet-py/tests/test_rudp_multipeer_server.py
Normal 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()
|
||||
|
|
@ -211,3 +211,22 @@ fn round_trip_is_stable() {
|
|||
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes
|
||||
/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be
|
||||
/// pinned to bytes this crate no longer pins.
|
||||
#[test]
|
||||
fn error_vectors_file_matches_the_inline_csharp_captures() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../contracts/cultnet/interop/error-vectors.json");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json"))
|
||||
.expect("parse error-vectors.json");
|
||||
let vectors = file["vectors"].as_array().expect("vectors array");
|
||||
let all = cases();
|
||||
assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases");
|
||||
for (vector, case) in vectors.iter().zip(all.iter()) {
|
||||
assert_eq!(vector["name"].as_str(), Some(case.name));
|
||||
assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
packages/cultnet-rs/tests/first_call.rs
Normal file
77
packages/cultnet-rs/tests/first_call.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the
|
||||
//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ.
|
||||
|
||||
use cultcache_rs::{CultCache, DatabaseEntry};
|
||||
|
||||
// snippet:begin
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
|
||||
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn first_call() -> anyhow::Result<Note> {
|
||||
let mut documents = CultNetDocumentRegistry::new();
|
||||
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
))?;
|
||||
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
|
||||
|
||||
// Receiver: a loopback listener that applies the document put it receives to its own cache.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let receiver = thread::spawn({
|
||||
let (documents, profile) = (documents.clone(), profile("receiver"));
|
||||
move || -> anyhow::Result<Note> {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
|
||||
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut inbox = CultCache::new();
|
||||
inbox.register_entry_type::<Note>()?;
|
||||
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
|
||||
}
|
||||
});
|
||||
|
||||
// Sender: build a typed put message, encode it for the wire, send one frame.
|
||||
let put = documents.create_raw_document_put_message(
|
||||
"put-1",
|
||||
"note:hello",
|
||||
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
|
||||
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
|
||||
.send("schema", &bytes)?;
|
||||
|
||||
receiver.join().expect("receiver thread")
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
#[test]
|
||||
fn a_typed_document_crosses_loopback_into_a_second_cache() {
|
||||
let note = first_call().unwrap();
|
||||
assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_show_the_tested_snippet() {
|
||||
let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap();
|
||||
let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len();
|
||||
let end = source.find("// snippet:end\n").unwrap();
|
||||
let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap();
|
||||
let block = format!("```rust\n{}```", &source[begin..end]);
|
||||
assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim");
|
||||
}
|
||||
|
|
@ -185,17 +185,24 @@ export interface CultNetLoginSuccessMessage {
|
|||
session: string;
|
||||
}
|
||||
|
||||
/** The closed `code` enum of `cultnet.error.schema.json`; the schema refuses any other string. */
|
||||
export type CultNetErrorCode =
|
||||
| "selection_invalid"
|
||||
| "cursor_stale"
|
||||
| "cursor_invalid"
|
||||
| "reference_outside_target"
|
||||
| "variant_unsupported"
|
||||
| "unowned_schema"
|
||||
| "write_not_authorized";
|
||||
/** The closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). */
|
||||
export const CULTNET_ERROR_CODES = [
|
||||
"selection_invalid",
|
||||
"cursor_stale",
|
||||
"cursor_invalid",
|
||||
"reference_outside_target",
|
||||
"variant_unsupported",
|
||||
"unowned_schema",
|
||||
"write_not_authorized",
|
||||
] as const;
|
||||
|
||||
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
|
||||
|
||||
export interface CultNetErrorRoutingHint {
|
||||
shard: Record<string, unknown> | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Code-specific detail; each field is populated only for the code that defines it, null otherwise. */
|
||||
export interface CultNetErrorDetails {
|
||||
field: string | null;
|
||||
value: string | null;
|
||||
|
|
@ -203,17 +210,11 @@ export interface CultNetErrorDetails {
|
|||
current: number | null;
|
||||
}
|
||||
|
||||
/** Shard routing detail for an authority failure. */
|
||||
export interface CultNetShardRoutingHint {
|
||||
shard: Record<string, unknown> | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** The reference writes all five keys, nil included, so a received error always carries them. */
|
||||
/** Every key is written on the wire, null included (the C# map formatter never omits a key). */
|
||||
export interface CultNetErrorMessage {
|
||||
schemaVersion: "cultnet.error.v0";
|
||||
error: string;
|
||||
routingHint: CultNetShardRoutingHint | null;
|
||||
routingHint: CultNetErrorRoutingHint | null;
|
||||
code: CultNetErrorCode | null;
|
||||
details: CultNetErrorDetails | null;
|
||||
}
|
||||
|
|
@ -959,6 +960,7 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
|
|||
return {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
|
||||
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
|
||||
routingHint: null,
|
||||
code: null,
|
||||
details: null,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@ export {
|
|||
type CultNetDatabaseChangeRawMessage,
|
||||
type CultNetRawDocumentRecord,
|
||||
type CultNetRawPayloadEncoding,
|
||||
CULTNET_ERROR_CODES,
|
||||
type CultNetErrorCode,
|
||||
type CultNetErrorDetails,
|
||||
type CultNetErrorMessage,
|
||||
type CultNetShardRoutingHint,
|
||||
type CultNetErrorRoutingHint,
|
||||
type CultNetHelloMessage,
|
||||
type CultNetLoginMessage,
|
||||
type CultNetLoginSuccessMessage,
|
||||
|
|
|
|||
|
|
@ -127,10 +127,11 @@ export class CultNetDocumentRegistry {
|
|||
recordKey: documentKey,
|
||||
storedAt: options.storedAt ?? new Date().toISOString(),
|
||||
payload: parsed,
|
||||
sourceRuntimeId: options.sourceRuntimeId,
|
||||
sourceAgentId: options.sourceAgentId,
|
||||
sourceRole: options.sourceRole,
|
||||
tags: options.tags,
|
||||
// Absent optional fields must be omitted, not set to undefined: MessagePack encodes undefined as nil and the peer rejects it.
|
||||
...(options.sourceRuntimeId !== undefined && { sourceRuntimeId: options.sourceRuntimeId }),
|
||||
...(options.sourceAgentId !== undefined && { sourceAgentId: options.sourceAgentId }),
|
||||
...(options.sourceRole !== undefined && { sourceRole: options.sourceRole }),
|
||||
...(options.tags !== undefined && { tags: options.tags }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
83
packages/cultnet-ts/test/cultnet-error.test.ts
Normal file
83
packages/cultnet-ts/test/cultnet-error.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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 {
|
||||
CULTNET_ERROR_CODES,
|
||||
cultNetSchemas,
|
||||
encodeCultNetMessageForWire,
|
||||
parseCultNetMessage,
|
||||
type CultNetErrorMessage,
|
||||
} from "../src";
|
||||
|
||||
// contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
||||
// by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
||||
const VECTORS = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "error-vectors.json");
|
||||
const vectors = (JSON.parse(readFileSync(VECTORS, "utf8")) as {
|
||||
vectors: { name: string; messagePackHex: string; message: unknown }[];
|
||||
}).vectors;
|
||||
|
||||
const bytesOf = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex, "hex"));
|
||||
const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex").toUpperCase();
|
||||
const wireOf = (message: unknown): Uint8Array => encode(encodeCultNetMessageForWire(message as CultNetErrorMessage));
|
||||
|
||||
test("the error vector file carries a case for every closed code", () => {
|
||||
const codes = new Set(vectors.map((v) => (v.message as CultNetErrorMessage).code));
|
||||
for (const code of CULTNET_ERROR_CODES) {
|
||||
assert.ok(codes.has(code), `no vector for ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a null code with null routingHint and details is a valid error message (no captured bytes carry one)", () => {
|
||||
const message = parseCultNetMessage({
|
||||
schemaVersion: "cultnet.error.v0", error: "boom", routingHint: null, code: null, details: null,
|
||||
});
|
||||
assert.equal((message as CultNetErrorMessage).code, null);
|
||||
});
|
||||
|
||||
test("the closed code union equals the schema's enum", () => {
|
||||
const schemaCodes = ((cultNetSchemas.errorSchema.properties.code as { enum?: (string | null)[] }).enum ?? []).filter((c) => c !== null);
|
||||
assert.deepEqual([...CULTNET_ERROR_CODES].sort(), [...schemaCodes].sort());
|
||||
});
|
||||
|
||||
test("C#/Rust-written error bytes decode into the typed message with every key", () => {
|
||||
for (const vector of vectors) {
|
||||
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
|
||||
assert.deepEqual(parsed, vector.message, vector.name);
|
||||
assert.deepEqual(Object.keys(parsed), ["schemaVersion", "error", "routingHint", "code", "details"], vector.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a parsed error message re-encodes to the same bytes", () => {
|
||||
for (const vector of vectors) {
|
||||
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
|
||||
assert.equal(hexOf(wireOf(parsed)), vector.messagePackHex, vector.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping code or details breaks the round trip and the decode", () => {
|
||||
const vector = vectors.find((v) => v.name === "cursor_stale")!;
|
||||
for (const dropped of ["code", "details", "routingHint"]) {
|
||||
const copy = { ...(decode(bytesOf(vector.messagePackHex)) as Record<string, unknown>) };
|
||||
delete copy[dropped];
|
||||
assert.throws(() => parseCultNetMessage(copy), /cultnet\.error\.v0/, `missing ${dropped} must be refused`);
|
||||
assert.notEqual(hexOf(encode(copy)), vector.messagePackHex);
|
||||
}
|
||||
});
|
||||
|
||||
test("a code outside the closed enum is refused, as is a malformed details object", () => {
|
||||
const base = decode(bytesOf(vectors.find((v) => v.name === "cursor_stale")!.messagePackHex)) as Record<string, unknown>;
|
||||
assert.throws(() => parseCultNetMessage({ ...base, code: "unsupported_schema_version" }), /cultnet\.error\.v0/);
|
||||
assert.throws(() => parseCultNetMessage({ ...base, code: 7 }), /cultnet\.error\.v0/);
|
||||
assert.throws(
|
||||
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }),
|
||||
/cultnet\.error\.v0/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: -1, current: 2 } }),
|
||||
/cultnet\.error\.v0/,
|
||||
);
|
||||
});
|
||||
|
|
@ -3059,3 +3059,22 @@ test("RUDP operation service admits a restarted client on the same address", asy
|
|||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// MessagePack encodes undefined as nil, and a peer rejects a document whose optional source fields are nil.
|
||||
test("document put message omits unset optional keys so the encoded wire has no nil", () => {
|
||||
const definition = defineDocumentType({ type: "putnil.doc", schema: z.object({ body: z.string() }) });
|
||||
const binding = defineCultNetDocumentBinding({ definition });
|
||||
const registry = new CultNetDocumentRegistry([binding]);
|
||||
|
||||
const bare = registry.createDocumentPutMessage(binding, "m", "k", { body: "x" });
|
||||
assert.deepEqual(Object.keys(bare.document).sort(), ["payload", "recordKey", "schemaId", "storedAt"]);
|
||||
const decodedBare = decodeMsgpack(encode(bare)) as { document: Record<string, unknown> };
|
||||
assert.ok(Object.values(decodedBare.document).every(value => value !== null), "no nil values on the wire");
|
||||
assert.ok(!("sourceRuntimeId" in decodedBare.document));
|
||||
|
||||
const stamped = registry.createDocumentPutMessage(binding, "m", "k", { body: "x" },
|
||||
{ sourceRuntimeId: "r", sourceAgentId: "a", sourceRole: "role", tags: ["t"] });
|
||||
assert.deepEqual(decodeMsgpack(encode(stamped)), stamped);
|
||||
assert.equal(stamped.document.sourceRuntimeId, "r");
|
||||
assert.deepEqual(stamped.document.tags, ["t"]);
|
||||
});
|
||||
|
|
|
|||
78
packages/cultnet-ts/test/first-call.test.ts
Normal file
78
packages/cultnet-ts/test/first-call.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
|
||||
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
|
||||
// the region between the `snippet` markers verbatim, except that the package import reads
|
||||
// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
|
||||
// snippet:begin
|
||||
import { createServer, connect } from "node:net";
|
||||
import { z } from "zod";
|
||||
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
createTcpFramedCultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
} from "../src";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schemaId: "gamecult.first_call_note.v1",
|
||||
schemaName: "gamecult.first_call_note",
|
||||
schemaVersion: "gamecult.first_call_note.v1",
|
||||
schema: z.object({ title: z.string(), body: z.string() }),
|
||||
name: "title",
|
||||
});
|
||||
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
|
||||
|
||||
async function firstCall() {
|
||||
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
|
||||
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
|
||||
|
||||
// Receiver: a loopback listener whose peer applies each document put to the inbox.
|
||||
const received = new Promise<void>((resolve, reject) => {
|
||||
const server = createServer((socket) => {
|
||||
const peer = createTcpFramedCultNetPeer(socket, options);
|
||||
peer.on("invalidMessage", reject);
|
||||
peer.on("message", async (message) => {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
|
||||
await documents.applyRawDocumentPutMessage(inbox, message);
|
||||
peer.close();
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
}).listen(0, "127.0.0.1", () => {
|
||||
// Sender: connect, build a typed put message, send it.
|
||||
const { port } = server.address() as { port: number };
|
||||
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
|
||||
sender.send(
|
||||
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
|
||||
title: "Hello",
|
||||
body: "sent over CultNet",
|
||||
}),
|
||||
);
|
||||
received.finally(() => sender.close());
|
||||
});
|
||||
});
|
||||
await received;
|
||||
return inbox.get(noteDocument, "note:hello");
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
test("send a typed document over loopback and receive it into a second cache", async () => {
|
||||
const note = await firstCall();
|
||||
assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" });
|
||||
});
|
||||
|
||||
test("docs show the tested snippet", async () => {
|
||||
// Compiled to dist-test/test/first-call.test.js, so the package root is two levels up.
|
||||
const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8");
|
||||
const snippet = source
|
||||
.split("// snippet:begin\n")[1]
|
||||
.split("// snippet:end\n")[0]
|
||||
.replace('"../src"', '"cultnet-ts"');
|
||||
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
|
||||
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
|
||||
});
|
||||
716
packages/heimdall-consent/Cargo.lock
generated
Normal file
716
packages/heimdall-consent/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cultcache-rs-derive",
|
||||
"fs2",
|
||||
"redb",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"serde_stacker",
|
||||
"stacker",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heimdall-consent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"serde",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redb"
|
||||
version = "4.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_stacker"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
18
packages/heimdall-consent/Cargo.toml
Normal file
18
packages/heimdall-consent/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "heimdall-consent"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "heimdall_consent"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
36
packages/heimdall-consent/README.md
Normal file
36
packages/heimdall-consent/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# heimdall-consent
|
||||
|
||||
Typed CultCache records for Heimdall's answer collector: `NoticeAcceptance`, `Answer` and `MemberTerms`,
|
||||
plus the rules that need no store to state (`Answer::is_training_data`, `Answer::redacted`,
|
||||
`validate`). Cut from `docs/answer-collector-consent.md` in GameCult/Heimdall PR #6 at doc commit
|
||||
`aaeb175` (PR head); the doc's tables and Representation section are the contract, and its rulings are on the PR
|
||||
(comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here.
|
||||
|
||||
Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs`
|
||||
(`cargo test`). Planned, not live: the collector, the tally, the training export.
|
||||
|
||||
## Where each ruling lands (doc lines at aaeb175)
|
||||
|
||||
| Ruling | Doc | Crate |
|
||||
|---|---|---|
|
||||
| Timestamps are Unix ms UTC, signed 64-bit | L118-119; `received_at` is collector-assigned, L95, L208-209 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` |
|
||||
| Ids are non-empty opaque strings, `event_id` verbatim; every version field (`notice_version`, the `@N` of a sample id, `terms_version`) is a positive integer, no leading zeros, no sign, at most 4294967295 (Heimdall ruling 2026-11-07, one rule; doc line "Version rule" under Representation, Heimdall PR #6 commit d6a8d42, now in the doc at aaeb175) | L120-136 | `check_version` is the single rule; `check_sample_id` and `MemberTerms::validate` call it; `notice_version: u32` with `validate()` refusing 0; golden vector `newcomer` carries `terms_version` 4294967295, and case `one-version-rule` pins 4294967295 accepted, 4294967296 and `01` refused for `notice_version`, the sample id `@N` and `terms_version` |
|
||||
| Key parts joined with U+001F; empty part or part containing it refused | L137-138 | `key()` |
|
||||
| Tombstone is the exited `Answer` with `text_hash` (32 bytes); HMAC-SHA-256 keyed by a per-member salt held by the member-side service; the collector cannot compute it | L94, L165-185, L221-226 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L181-182); `validate()` requires 32 bytes and `exited` |
|
||||
| A reply with no established notice version or scope is stored with both absent, writes no acceptance, is excluded from every use | L107-114 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` |
|
||||
| A reply's version is the one pinned at its `origin_server_ts`; changed version needs a fresh first reply | L19-20, L49-51, L112-114 | acceptance key includes the version; `is_training_data` accepts only the acceptance for the answer's own version; vector case `version-change` |
|
||||
| `MemberTerms` is the typed shape of the member record's `terms_version`/`terms_accepted_at`, not a second store | L68-76 | `MemberTerms` doc comment; the collector does not hold it |
|
||||
| Training needs acceptance `scope = train` equal to the answer's `scope`, and `accepted_at <= received_at` | L149-156 | `is_training_data` |
|
||||
| One store per scope plus a store for scopeless replies | L158-163 | not modelled; the collector's layout owns it |
|
||||
|
||||
Rulings of comment 759 (doc L92, L98-105; no open questions remain):
|
||||
|
||||
| Ruling | Doc | Crate |
|
||||
|---|---|---|
|
||||
| A live answer must have non-empty `text` and is rejected otherwise; empty text is valid only on an exited answer (text cleared, hash-only tombstone) | L92, L103-105 | `Answer::validate()` refuses a non-exited answer whose `text` is absent or empty; an exited answer holds no text. Vector cases `empty-live-rejected`, `empty-revoked` |
|
||||
| `received_at` is assigned by the collector; a tie on it breaks by `event_id` compared bytewise, the greater wins as the later reply | L95, L98-101 | `Answer::supersedes`: `(received_at, event_id bytes)` order; the vector harness replaces a stored answer only when the incoming one supersedes it. Vector cases `tie-greater-first`, `tie-greater-last` |
|
||||
|
||||
Not modelled (doc at aaeb175):
|
||||
|
||||
- The `org.gamecult.notice` room state event (L24-37, L132-136): its content is `{version, scope}` with `version` a JSON number, and a string (including `"01"`) is malformed. The crate checks the version only as decimal text (`check_version`); reading, power-level checks and the malformed-means-`show` fallback belong to the collector.
|
||||
- The registration-checkbox gap (L78-79): the `m.login.terms` checkbox records no version. The doc rules nothing on it; the crate takes `terms_version` as given.
|
||||
225
packages/heimdall-consent/src/lib.rs
Normal file
225
packages/heimdall-consent/src/lib.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md`
|
||||
//! in GameCult/Heimdall PR #6 (doc commit aaeb175): the notice acceptance record, the answer record,
|
||||
//! and the typed shape of the terms pair on the member record. Field names and types follow the
|
||||
//! doc's tables and its Representation section; README.md cites the doc lines.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What a notice allows, each level including the ones below it. `Train` is declared in terms by the
|
||||
/// room's notice; nothing here widens a scope.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Scope {
|
||||
Show,
|
||||
Study,
|
||||
Train,
|
||||
}
|
||||
|
||||
/// Separates key parts (Representation: parts joined with U+001F; an empty part or one containing it
|
||||
/// is refused, not escaped).
|
||||
const SEP: char = '\u{1f}';
|
||||
|
||||
fn key(parts: &[&str]) -> Result<String> {
|
||||
if let Some(p) = parts.iter().find(|p| p.is_empty() || p.contains(SEP)) {
|
||||
bail!("key part {p:?} is empty or contains the key separator");
|
||||
}
|
||||
Ok(parts.join(&SEP.to_string()))
|
||||
}
|
||||
|
||||
fn nonempty(name: &str, value: &str) -> Result<()> {
|
||||
if value.is_empty() {
|
||||
bail!("{name} must be a non-empty string");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one version rule (Heimdall ruling 2026-11-07): a positive integer written without leading
|
||||
/// zeros or a sign, at most 4294967295 (`u32::MAX`). `01` and `1` would be two keys for one version.
|
||||
pub fn check_version(version: &str) -> Result<u32> {
|
||||
match version.parse::<u32>() {
|
||||
Ok(v) if v > 0 && !version.starts_with(['+', '0']) => Ok(v),
|
||||
_ => bail!("version {version:?} must be a positive integer, no leading zeros, at most {}", u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<sample>@<version>`, the version under `check_version`. The version is part of the id, so it is
|
||||
/// part of every answer key and no key can name a sample without one.
|
||||
pub fn check_sample_id(sample_id: &str) -> Result<(&str, u32)> {
|
||||
let Some((sample, version)) = sample_id.rsplit_once('@') else {
|
||||
bail!("sample id {sample_id:?} has no @version");
|
||||
};
|
||||
if sample.is_empty() {
|
||||
bail!("sample id {sample_id:?} needs a non-empty sample");
|
||||
}
|
||||
Ok((sample, check_version(version)?))
|
||||
}
|
||||
|
||||
/// The first reply a member posts under a notice version. Written once, never updated.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "heimdall.notice-acceptance", schema = "HeimdallNoticeAcceptance")]
|
||||
pub struct NoticeAcceptance {
|
||||
#[cultcache(key = 0)]
|
||||
pub member_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub experiment_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub notice_version: u32,
|
||||
#[cultcache(key = 3)]
|
||||
pub scope: Scope,
|
||||
/// `origin_server_ts` of the first reply, unix milliseconds UTC.
|
||||
#[cultcache(key = 4)]
|
||||
pub accepted_at: i64,
|
||||
#[cultcache(key = 5)]
|
||||
pub event_id: String,
|
||||
}
|
||||
|
||||
impl NoticeAcceptance {
|
||||
/// Representation: ids are non-empty strings, versions positive.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
nonempty("member_id", &self.member_id)?;
|
||||
nonempty("experiment_id", &self.experiment_id)?;
|
||||
nonempty("event_id", &self.event_id)?;
|
||||
if self.notice_version == 0 {
|
||||
bail!("notice_version must be positive");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `(experiment_id, member_id, notice_version)`.
|
||||
pub fn cache_key(experiment_id: &str, member_id: &str, notice_version: u32) -> Result<String> {
|
||||
key(&[experiment_id, member_id, ¬ice_version.to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
/// One answer. `notice_version` and `scope` are absent together on a reply with no established
|
||||
/// notice version or no matching acceptance: such a reply is stored, excluded from every use, and
|
||||
/// writes no acceptance (Answer record, "A reply whose `notice_version` or `scope` is missing").
|
||||
/// Once exited the record is the tombstone: `text` absent, `text_hash` set.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "heimdall.answer", schema = "HeimdallAnswer")]
|
||||
pub struct Answer {
|
||||
#[cultcache(key = 0)]
|
||||
pub sample_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub experiment_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub member_id: String,
|
||||
#[cultcache(key = 3, default)]
|
||||
pub notice_version: Option<u32>,
|
||||
#[cultcache(key = 4, default)]
|
||||
pub scope: Option<Scope>,
|
||||
/// Absent once exited.
|
||||
#[cultcache(key = 5, default)]
|
||||
pub text: Option<String>,
|
||||
/// One-way: set by redaction, never cleared.
|
||||
#[cultcache(key = 6, default)]
|
||||
pub exited: bool,
|
||||
/// Assigned by the collector on receipt, unix milliseconds UTC, not copied from the reply (doc L95);
|
||||
/// ties are broken by `supersedes`.
|
||||
#[cultcache(key = 7)]
|
||||
pub received_at: i64,
|
||||
#[cultcache(key = 8)]
|
||||
pub event_id: String,
|
||||
/// The tombstone hash of `text`: HMAC-SHA-256, 32 raw bytes, computed by the member-side service
|
||||
/// (it holds the salt). Present only once exited, and only if the collector held `text`.
|
||||
#[cultcache(key = 9, default)]
|
||||
pub text_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Answer {
|
||||
/// `(experiment_id, sample_id, member_id)`; refuses a sample id without a positive version.
|
||||
pub fn cache_key(experiment_id: &str, sample_id: &str, member_id: &str) -> Result<String> {
|
||||
check_sample_id(sample_id)?;
|
||||
key(&[experiment_id, sample_id, member_id])
|
||||
}
|
||||
|
||||
/// Representation and the invariants the table implies.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
nonempty("member_id", &self.member_id)?;
|
||||
nonempty("experiment_id", &self.experiment_id)?;
|
||||
nonempty("event_id", &self.event_id)?;
|
||||
check_sample_id(&self.sample_id)?;
|
||||
if self.notice_version == Some(0) {
|
||||
bail!("notice_version must be positive");
|
||||
}
|
||||
if self.notice_version.is_some() != self.scope.is_some() {
|
||||
bail!("notice_version and scope are present together or absent together");
|
||||
}
|
||||
if self.exited && self.text.is_some() {
|
||||
bail!("an exited answer holds no text");
|
||||
}
|
||||
if !self.exited && self.text.as_deref().is_none_or(str::is_empty) {
|
||||
bail!("a live answer needs non-empty text; empty text is valid only on a revoked answer");
|
||||
}
|
||||
if let Some(hash) = &self.text_hash {
|
||||
if !self.exited || hash.len() != 32 {
|
||||
bail!("text_hash is 32 bytes and only on an exited answer");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replacement rule for two answers at one key: the later reply is the greater `(received_at,
|
||||
/// event_id)`. `received_at` is assigned by the collector; a tie breaks by `event_id` compared
|
||||
/// bytewise, the greater winning (Heimdall PR #6 comment 759). Insertion order never decides.
|
||||
pub fn supersedes(&self, earlier: &Answer) -> bool {
|
||||
(self.received_at, self.event_id.as_bytes()) > (earlier.received_at, earlier.event_id.as_bytes())
|
||||
}
|
||||
|
||||
/// Redaction is exit: text erased, `exited` set, the tombstone hash kept. The hash comes from the
|
||||
/// member-side service, which holds the salt; this crate never computes it. A reply the collector
|
||||
/// never collected has no text and gets no tombstone, so an answer without `text` is refused.
|
||||
pub fn redacted(&self, text_hash: [u8; 32]) -> Result<Answer> {
|
||||
if self.text.is_none() {
|
||||
bail!("no text held for event {:?}: nothing to tombstone", self.event_id);
|
||||
}
|
||||
Ok(Answer { text: None, exited: true, text_hash: Some(text_hash.to_vec()), ..self.clone() })
|
||||
}
|
||||
|
||||
/// Training rule (doc "Training rule"): scope `train`; an acceptance for this answer's
|
||||
/// `(experiment_id, member_id, notice_version)` whose own scope is `train` and equals the answer's,
|
||||
/// with `accepted_at <= received_at`; not exited. A changed notice version needs its own acceptance,
|
||||
/// so an acceptance for another version never covers the answer. No recorded scope, version or
|
||||
/// acceptance means excluded.
|
||||
pub fn is_training_data(&self, acceptance: Option<&NoticeAcceptance>) -> bool {
|
||||
let (Some(Scope::Train), Some(version), Some(acc)) = (self.scope, self.notice_version, acceptance) else {
|
||||
return false;
|
||||
};
|
||||
!self.exited
|
||||
&& acc.notice_version == version
|
||||
&& acc.experiment_id == self.experiment_id
|
||||
&& acc.member_id == self.member_id
|
||||
&& acc.scope == Scope::Train
|
||||
&& Some(acc.scope) == self.scope
|
||||
&& acc.accepted_at <= self.received_at
|
||||
}
|
||||
}
|
||||
|
||||
/// The typed shape of the `terms_version` / `terms_accepted_at` pair on the Heimdall member record
|
||||
/// (newcomers only, recorded by the `m.login.terms` stage). It is not a second store and the
|
||||
/// collector does not hold it. Members who joined before the stage, and Minds, have no such pair.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "heimdall.member-terms", schema = "HeimdallMemberTerms")]
|
||||
pub struct MemberTerms {
|
||||
#[cultcache(key = 0)]
|
||||
pub member_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub terms_version: String,
|
||||
/// Unix milliseconds UTC.
|
||||
#[cultcache(key = 2)]
|
||||
pub terms_accepted_at: i64,
|
||||
}
|
||||
|
||||
impl MemberTerms {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
nonempty("member_id", &self.member_id)?;
|
||||
check_version(&self.terms_version).map(drop)
|
||||
}
|
||||
|
||||
/// `member_id`.
|
||||
pub fn cache_key(member_id: &str) -> Result<String> {
|
||||
key(&[member_id])
|
||||
}
|
||||
}
|
||||
470
packages/heimdall-consent/tests/golden.rs
Normal file
470
packages/heimdall-consent/tests/golden.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore};
|
||||
use heimdall_consent::*;
|
||||
|
||||
const VECTORS: &str = include_str!("vectors/consent_golden.csv");
|
||||
|
||||
fn new_cache(dir: &tempfile::TempDir) -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<NoticeAcceptance>().unwrap();
|
||||
cache.register_entry_type::<Answer>().unwrap();
|
||||
cache.register_entry_type::<MemberTerms>().unwrap();
|
||||
let _ = cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("consent.cc")));
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn scope(s: &str) -> Option<Scope> {
|
||||
match s {
|
||||
"" => None,
|
||||
"show" => Some(Scope::Show),
|
||||
"study" => Some(Scope::Study),
|
||||
"train" => Some(Scope::Train),
|
||||
other => panic!("unknown scope {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn put_answer(cache: &mut CultCache, a: &Answer) {
|
||||
cache.put(Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap(), a).unwrap();
|
||||
}
|
||||
|
||||
/// The collector's replace rule: an incoming answer replaces the stored one only if it is the later reply.
|
||||
fn put_if_later(cache: &mut CultCache, a: &Answer) {
|
||||
let k = Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap();
|
||||
if cache.get::<Answer>(&k).unwrap().is_some_and(|old| !a.supersedes(&old)) {
|
||||
return;
|
||||
}
|
||||
cache.put(k, a).unwrap();
|
||||
}
|
||||
|
||||
fn training_events(cache: &CultCache) -> Vec<String> {
|
||||
let mut ids: Vec<String> = cache
|
||||
.get_all::<Answer>()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
let acc = a.notice_version.and_then(|v| {
|
||||
cache.get::<NoticeAcceptance>(&NoticeAcceptance::cache_key(&a.experiment_id, &a.member_id, v).unwrap()).unwrap()
|
||||
});
|
||||
a.is_training_data(acc.as_ref())
|
||||
})
|
||||
.map(|a| a.event_id.clone())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_vectors() {
|
||||
let mut cases: Vec<(&str, Vec<Vec<&str>>)> = Vec::new();
|
||||
for line in VECTORS.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) {
|
||||
let f: Vec<&str> = line.split(',').collect();
|
||||
assert_eq!(f.len(), 10, "row `{line}`");
|
||||
match cases.last_mut() {
|
||||
Some((name, rows)) if *name == f[0] => rows.push(f),
|
||||
_ => cases.push((f[0], vec![f])),
|
||||
}
|
||||
}
|
||||
assert_eq!(cases.len(), 10, "fixture shrank");
|
||||
for (name, rows) in cases {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cache = new_cache(&dir);
|
||||
for f in rows {
|
||||
let (op, member, room, nv, sc, sample, payload, ts, event) = (f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]);
|
||||
let ctx = format!("case {name} op {op} event {event}");
|
||||
let ts: i64 = ts.parse().unwrap_or(0);
|
||||
let nv: Option<u32> = nv.parse().ok();
|
||||
match op {
|
||||
"reply" | "answer_raw" => {
|
||||
if op == "reply" {
|
||||
if let (Some(v), Some(s)) = (nv, scope(sc)) {
|
||||
let k = NoticeAcceptance::cache_key(room, member, v).unwrap();
|
||||
if cache.get::<NoticeAcceptance>(&k).unwrap().is_none() {
|
||||
cache
|
||||
.put(k, &NoticeAcceptance {
|
||||
member_id: member.into(),
|
||||
experiment_id: room.into(),
|
||||
notice_version: v,
|
||||
scope: s,
|
||||
accepted_at: ts,
|
||||
event_id: event.into(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let answer = Answer {
|
||||
sample_id: sample.into(),
|
||||
experiment_id: room.into(),
|
||||
member_id: member.into(),
|
||||
notice_version: nv,
|
||||
scope: scope(sc),
|
||||
text: Some(payload.into()),
|
||||
exited: false,
|
||||
received_at: ts,
|
||||
event_id: event.into(),
|
||||
text_hash: None,
|
||||
};
|
||||
answer.validate().unwrap();
|
||||
put_if_later(&mut cache, &answer);
|
||||
}
|
||||
"reply_rejected" => {
|
||||
let answer = Answer {
|
||||
sample_id: sample.into(),
|
||||
experiment_id: room.into(),
|
||||
member_id: member.into(),
|
||||
notice_version: nv,
|
||||
scope: scope(sc),
|
||||
text: Some(payload.into()),
|
||||
exited: false,
|
||||
received_at: ts,
|
||||
event_id: event.into(),
|
||||
text_hash: None,
|
||||
};
|
||||
assert!(answer.validate().is_err(), "{ctx}: must be refused");
|
||||
}
|
||||
"answer_revoked" => {
|
||||
let answer = Answer {
|
||||
sample_id: sample.into(),
|
||||
experiment_id: room.into(),
|
||||
member_id: member.into(),
|
||||
notice_version: nv,
|
||||
scope: scope(sc),
|
||||
text: None,
|
||||
exited: true,
|
||||
received_at: ts,
|
||||
event_id: event.into(),
|
||||
text_hash: if payload.is_empty() { None } else { Some(hex(payload)) },
|
||||
};
|
||||
answer.validate().unwrap();
|
||||
put_if_later(&mut cache, &answer);
|
||||
}
|
||||
"expect_winner" => {
|
||||
let a = cache.get_required::<Answer>(&Answer::cache_key(room, sample, member).unwrap()).unwrap();
|
||||
assert_eq!((a.event_id.as_str(), a.text.as_deref()), (event, Some(payload)), "{ctx}");
|
||||
}
|
||||
"redact" => {
|
||||
let k = Answer::cache_key(room, sample, member).unwrap();
|
||||
let a = cache.get_required::<Answer>(&k).unwrap();
|
||||
assert_eq!(a.event_id, event, "{ctx}");
|
||||
let hash: [u8; 32] = hex(payload).try_into().expect("tombstone hash is 32 bytes");
|
||||
put_answer(&mut cache, &a.redacted(hash).unwrap());
|
||||
}
|
||||
"terms" => {
|
||||
let terms = MemberTerms { member_id: member.into(), terms_version: payload.into(), terms_accepted_at: ts };
|
||||
terms.validate().unwrap();
|
||||
cache.put(MemberTerms::cache_key(member).unwrap(), &terms).unwrap();
|
||||
}
|
||||
"expect_terms" => {
|
||||
let t = cache.get_required::<MemberTerms>(&MemberTerms::cache_key(member).unwrap()).unwrap();
|
||||
assert_eq!((t.member_id.as_str(), t.terms_version.as_str(), t.terms_accepted_at), (member, payload, ts), "{ctx}");
|
||||
}
|
||||
"expect_no_terms" => assert!(cache.get::<MemberTerms>(&MemberTerms::cache_key(member).unwrap()).unwrap().is_none(), "{ctx}"),
|
||||
"expect_counts" => {
|
||||
let want = format!(
|
||||
"answers={};acceptances={}",
|
||||
cache.get_all::<Answer>().unwrap().len(),
|
||||
cache.get_all::<NoticeAcceptance>().unwrap().len()
|
||||
);
|
||||
assert_eq!(want, payload, "{ctx}");
|
||||
}
|
||||
"expect_train" => assert_eq!(training_events(&cache).join(";"), payload, "{ctx}"),
|
||||
"expect_exited" => {
|
||||
let a = cache.get_required::<Answer>(&Answer::cache_key(room, sample, member).unwrap()).unwrap();
|
||||
assert!(a.exited && a.text.is_none(), "{ctx}");
|
||||
assert_eq!(a.text_hash, Some(hex(payload)), "{ctx}");
|
||||
assert_eq!((a.event_id.as_str(), a.received_at, a.sample_id.as_str()), (event, ts, sample), "{ctx}");
|
||||
}
|
||||
"expect_acceptance" => {
|
||||
let a = cache
|
||||
.get_required::<NoticeAcceptance>(&NoticeAcceptance::cache_key(room, member, nv.unwrap()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((a.notice_version, Some(a.scope), a.accepted_at), (nv.unwrap(), scope(sc), ts), "{ctx}");
|
||||
assert_eq!(a.event_id, payload, "{ctx}");
|
||||
}
|
||||
"version_ok" | "version_refused" => {
|
||||
// member column names the field; payload is the version text as it arrives
|
||||
let ok = match member {
|
||||
"notice_version" => check_version(payload).is_ok_and(|v| {
|
||||
NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: room.into(),
|
||||
notice_version: v,
|
||||
scope: Scope::Train,
|
||||
accepted_at: 1,
|
||||
event_id: "e".into(),
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
}),
|
||||
"sample_id" => check_sample_id(&format!("s@{payload}")).is_ok(),
|
||||
"terms_version" => {
|
||||
MemberTerms { member_id: "m".into(), terms_version: payload.into(), terms_accepted_at: 1 }.validate().is_ok()
|
||||
}
|
||||
other => panic!("unknown version field {other}"),
|
||||
};
|
||||
assert_eq!(ok, op == "version_ok", "{ctx} field {member} version {payload}");
|
||||
}
|
||||
other => panic!("unknown op {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_survive_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cache = new_cache(&dir);
|
||||
let a = Answer {
|
||||
sample_id: "s@1".into(),
|
||||
experiment_id: "r".into(),
|
||||
member_id: "m".into(),
|
||||
notice_version: Some(2),
|
||||
scope: Some(Scope::Train),
|
||||
text: None,
|
||||
exited: true,
|
||||
received_at: 5,
|
||||
event_id: "e".into(),
|
||||
text_hash: Some(vec![7; 32]),
|
||||
};
|
||||
a.validate().unwrap();
|
||||
put_answer(&mut cache, &a);
|
||||
let reopened = new_cache(&dir);
|
||||
assert_eq!(reopened.get_required::<Answer>(&Answer::cache_key("r", "s@1", "m").unwrap()).unwrap(), a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_refuses_unversioned_sample_ids() {
|
||||
for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1", "s@4294967296", "s@01", "s@00", "s@001"] {
|
||||
assert!(Answer::cache_key("r", bad, "m").is_err(), "{bad}");
|
||||
}
|
||||
assert!(Answer::cache_key("r", "s@4294967295", "m").is_ok());
|
||||
assert!(Answer::cache_key("r", "s@1", "m").unwrap() != Answer::cache_key("r", "s@2", "m").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_rule_everywhere() {
|
||||
for ok in ["1", "2", "4294967295"] {
|
||||
let n: u32 = ok.parse().unwrap();
|
||||
assert_eq!(check_version(ok).unwrap(), n, "{ok}");
|
||||
assert_eq!(check_sample_id(&format!("s@{ok}")).unwrap(), ("s", n), "s@{ok}");
|
||||
assert!(MemberTerms { member_id: "m".into(), terms_version: ok.into(), terms_accepted_at: 1 }.validate().is_ok(), "terms {ok}");
|
||||
}
|
||||
for bad in ["0", "4294967296", "4294967297", "8589934593", "99999999999999999999", "01", "+1", "-1", "", "2026-11"] {
|
||||
assert!(check_version(bad).is_err(), "{bad}");
|
||||
assert!(check_sample_id(&format!("s@{bad}")).is_err(), "s@{bad}");
|
||||
assert!(MemberTerms { member_id: "m".into(), terms_version: bad.into(), terms_accepted_at: 1 }.validate().is_err(), "terms {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_version_zero_is_refused_in_both_records() {
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 0,
|
||||
scope: Scope::Train,
|
||||
accepted_at: 1,
|
||||
event_id: "e".into(),
|
||||
};
|
||||
assert!(acc.validate().is_err());
|
||||
assert!(NoticeAcceptance { notice_version: u32::MAX, ..acc }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_after_the_answer_does_not_cover_it() {
|
||||
let a = Answer {
|
||||
sample_id: "s@1".into(),
|
||||
experiment_id: "r".into(),
|
||||
member_id: "m".into(),
|
||||
notice_version: Some(1),
|
||||
scope: Some(Scope::Train),
|
||||
text: Some("t".into()),
|
||||
exited: false,
|
||||
received_at: 100,
|
||||
event_id: "e".into(),
|
||||
text_hash: None,
|
||||
};
|
||||
let acc = |at| NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 1,
|
||||
scope: Scope::Train,
|
||||
accepted_at: at,
|
||||
event_id: "e".into(),
|
||||
};
|
||||
assert!(a.is_training_data(Some(&acc(100))));
|
||||
assert!(!a.is_training_data(Some(&acc(101))));
|
||||
assert!(!a.is_training_data(None));
|
||||
}
|
||||
|
||||
fn answer() -> Answer {
|
||||
Answer {
|
||||
sample_id: "s@1".into(),
|
||||
experiment_id: "r".into(),
|
||||
member_id: "m".into(),
|
||||
notice_version: Some(1),
|
||||
scope: Some(Scope::Train),
|
||||
text: Some("t".into()),
|
||||
exited: false,
|
||||
received_at: 100,
|
||||
event_id: "e".into(),
|
||||
text_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_for_another_notice_version_never_covers_the_answer() {
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 1,
|
||||
scope: Scope::Train,
|
||||
accepted_at: 1,
|
||||
event_id: "e0".into(),
|
||||
};
|
||||
let mut a = answer();
|
||||
assert!(a.is_training_data(Some(&acc)));
|
||||
a.notice_version = Some(2);
|
||||
assert!(!a.is_training_data(Some(&acc)), "a changed notice version needs a fresh first reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_needs_acceptance_scope_equal_to_the_answers_scope() {
|
||||
let mut acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 1,
|
||||
scope: Scope::Study,
|
||||
accepted_at: 1,
|
||||
event_id: "e0".into(),
|
||||
};
|
||||
assert!(!answer().is_training_data(Some(&acc)));
|
||||
acc.scope = Scope::Train;
|
||||
assert!(answer().is_training_data(Some(&acc)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_never_collected_gets_no_tombstone() {
|
||||
let mut a = answer();
|
||||
a.text = None;
|
||||
assert!(a.redacted([0; 32]).is_err());
|
||||
let t = answer().redacted([9; 32]).unwrap();
|
||||
assert!(t.exited && t.text.is_none() && t.text_hash == Some(vec![9; 32]));
|
||||
assert!(t.redacted([9; 32]).is_err(), "exit is one-way and a tombstone has no text to hash again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_enforces_the_representation_rules() {
|
||||
assert!(answer().validate().is_ok());
|
||||
let cases: Vec<(&str, Box<dyn Fn(&mut Answer)>)> = vec![
|
||||
("empty member", Box::new(|a| a.member_id.clear())),
|
||||
("empty event", Box::new(|a| a.event_id.clear())),
|
||||
("version zero", Box::new(|a| a.notice_version = Some(0))),
|
||||
("version without scope", Box::new(|a| a.scope = None)),
|
||||
("scope without version", Box::new(|a| a.notice_version = None)),
|
||||
("live without text", Box::new(|a| a.text = None)),
|
||||
("live with empty text", Box::new(|a| a.text = Some(String::new()))),
|
||||
("exited with text", Box::new(|a| a.exited = true)),
|
||||
("hash on a live answer", Box::new(|a| a.text_hash = Some(vec![0; 32]))),
|
||||
("short hash", Box::new(|a| {
|
||||
a.exited = true;
|
||||
a.text = None;
|
||||
a.text_hash = Some(vec![0; 31]);
|
||||
})),
|
||||
];
|
||||
for (name, break_it) in cases {
|
||||
let mut a = answer();
|
||||
break_it(&mut a);
|
||||
assert!(a.validate().is_err(), "{name}");
|
||||
}
|
||||
let mut revoked = answer();
|
||||
revoked.text = None;
|
||||
revoked.exited = true;
|
||||
assert!(revoked.validate().is_ok(), "empty text is valid on a revoked answer");
|
||||
let mut unscoped = answer();
|
||||
unscoped.notice_version = None;
|
||||
unscoped.scope = None;
|
||||
assert!(unscoped.validate().is_ok(), "a scopeless reply is stored");
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 0,
|
||||
scope: Scope::Show,
|
||||
accepted_at: 1,
|
||||
event_id: "e".into(),
|
||||
};
|
||||
assert!(acc.validate().is_err());
|
||||
assert!(MemberTerms { member_id: "m".into(), terms_version: String::new(), terms_accepted_at: 1 }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_reply_is_greater_received_at_then_bytewise_event_id() {
|
||||
let at = |t, e: &str| Answer { received_at: t, event_id: e.into(), ..answer() };
|
||||
assert!(at(2, "a").supersedes(&at(1, "z")));
|
||||
assert!(!at(1, "z").supersedes(&at(2, "a")));
|
||||
assert!(at(1, "a").supersedes(&at(1, "B")), "bytewise: 0x61 > 0x42");
|
||||
assert!(at(1, "z").supersedes(&at(1, "aa")), "bytewise, not by length");
|
||||
assert!(!at(1, "e").supersedes(&at(1, "e")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_join_the_parts_with_u001f_and_refuse_empty_or_separator_parts() {
|
||||
assert_eq!(Answer::cache_key("r", "s@1", "m").unwrap(), "r\u{1f}s@1\u{1f}m");
|
||||
assert_eq!(NoticeAcceptance::cache_key("r", "m", 3).unwrap(), "r\u{1f}m\u{1f}3");
|
||||
assert_eq!(MemberTerms::cache_key("m").unwrap(), "m");
|
||||
for (experiment, sample, member) in [("", "s@1", "m"), ("r", "s@1", ""), ("r\u{1f}x", "s@1", "m"), ("r", "s\u{1f}x@1", "m"), ("r", "s@1", "m\u{1f}x")] {
|
||||
assert!(Answer::cache_key(experiment, sample, member).is_err(), "{experiment:?} {sample:?} {member:?}");
|
||||
}
|
||||
assert!(NoticeAcceptance::cache_key("", "m", 1).is_err());
|
||||
assert!(NoticeAcceptance::cache_key("r", "", 1).is_err());
|
||||
assert!(NoticeAcceptance::cache_key("r\u{1f}", "m", 1).is_err());
|
||||
assert!(NoticeAcceptance::cache_key("r", "m\u{1f}", 1).is_err());
|
||||
assert!(MemberTerms::cache_key("").is_err());
|
||||
assert!(MemberTerms::cache_key("m\u{1f}").is_err());
|
||||
// Parts that would collide if the separator were not refused or were `,` or empty.
|
||||
assert_ne!(Answer::cache_key("a", "s@1", "bc").unwrap(), Answer::cache_key("ab", "s@1", "c").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_acceptance_for_another_member_or_experiment_never_covers_the_answer() {
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 1,
|
||||
scope: Scope::Train,
|
||||
accepted_at: 1,
|
||||
event_id: "e0".into(),
|
||||
};
|
||||
assert!(answer().is_training_data(Some(&acc)));
|
||||
let other_member = NoticeAcceptance { member_id: "m2".into(), ..acc.clone() };
|
||||
let other_experiment = NoticeAcceptance { experiment_id: "r2".into(), ..acc };
|
||||
assert!(!answer().is_training_data(Some(&other_member)));
|
||||
assert!(!answer().is_training_data(Some(&other_experiment)));
|
||||
}
|
||||
|
||||
/// The doc states the rule once: the answer's scope is `train`, the acceptance's own scope is `train`,
|
||||
/// and the two are equal. The code keeps all three conditions (any two imply the third, so a single
|
||||
/// removal is an equivalent mutant); this pins the rule itself over every pair of scopes.
|
||||
#[test]
|
||||
fn only_a_train_answer_under_a_train_acceptance_is_training_data() {
|
||||
let scopes = [Scope::Show, Scope::Study, Scope::Train];
|
||||
for answer_scope in scopes {
|
||||
for acceptance_scope in scopes {
|
||||
let a = Answer { scope: Some(answer_scope), ..answer() };
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 1,
|
||||
scope: acceptance_scope,
|
||||
accepted_at: 1,
|
||||
event_id: "e0".into(),
|
||||
};
|
||||
let want = answer_scope == Scope::Train && acceptance_scope == Scope::Train;
|
||||
assert_eq!(a.is_training_data(Some(&acc)), want, "answer {answer_scope:?} under acceptance {acceptance_scope:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
78
packages/heimdall-consent/tests/vectors/consent_golden.csv
Normal file
78
packages/heimdall-consent/tests/vectors/consent_golden.csv
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# case,op,member,experiment,notice_version,scope,sample_id,payload,ts,event_id
|
||||
# Ops run in order against a real CultCache; each case starts empty. Timestamps are unix ms.
|
||||
# reply: first reply under a (room, member, notice_version) writes an acceptance, then the answer replaces any earlier one at (room, sample_id, member); payload is the text.
|
||||
# answer_raw: stores the answer with no acceptance written. redact: replaces the answer whose event_id matches with its tombstone; payload is the 32-byte tombstone hash as 64 hex chars (the member-side service's output; the crate never computes it).
|
||||
# A reply with no established notice version or scope is stored with both absent (doc L107-114, Answer record), writes no acceptance, and is excluded. answer_raw with a version but no acceptance models a lost or never-written acceptance: still excluded (doc L152-158).
|
||||
# reply_rejected: validate must refuse the answer (nothing stored). answer_revoked: stores an exited answer with no text; payload is the tombstone hash (empty means none). expect_winner: the stored answer's event_id and text must equal event and payload.
|
||||
# Replacement at one key: the later reply is the greater (received_at, event_id), event_id compared bytewise; input order never decides (doc L98-101 and Heimdall PR #6 comment 759).
|
||||
# terms: member field terms_version (payload) and terms_accepted_at (ts). expect_terms: the stored pair for that member reads back as payload and ts; expect_no_terms: the member has none. expect_*: payload is what must hold.
|
||||
# version_ok / version_refused: the member column names the field (notice_version, sample_id, terms_version), payload is the version text; a sample id is checked as s@<payload>. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 d6a8d42, doc at aaeb175).
|
||||
# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit aaeb175), not computed by the crate.
|
||||
versioned-key,reply,m1,room1,1,train,s1@1,first,100,e1
|
||||
versioned-key,reply,m1,room1,1,train,s1@2,second,200,e2
|
||||
versioned-key,reply,m1,room1,1,train,s1@1,replaced,300,e3
|
||||
versioned-key,expect_counts,,,,,,answers=2;acceptances=1,,
|
||||
versioned-key,expect_train,,,,,,e2;e3,,
|
||||
no-scope,reply,m2,room1,,,s1@1,unscoped,100,e4
|
||||
no-scope,reply,m2,room1,,,s1@2,unversioned,110,e5
|
||||
no-scope,answer_raw,m3,room1,1,train,s1@1,no-acceptance,120,e6
|
||||
no-scope,reply,m4,room1,1,study,s1@1,study-only,130,e7
|
||||
no-scope,reply,m5,room1,1,train,s1@1,trainable,140,e8
|
||||
no-scope,expect_counts,,,,,,answers=5;acceptances=2,,
|
||||
no-scope,expect_train,,,,,,e8,,
|
||||
redacted-reply,reply,m6,room1,1,train,s1@1,secret words,100,e9
|
||||
redacted-reply,reply,m7,room1,1,train,s1@1,kept,110,e10
|
||||
redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,,e9
|
||||
redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9
|
||||
redacted-reply,expect_train,,,,,,e10,,
|
||||
redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,,
|
||||
newcomer,terms,m8,,,,,4294967295,50,
|
||||
newcomer,expect_terms,m8,,,,,4294967295,50,
|
||||
newcomer,expect_no_terms,m9,,,,,,,
|
||||
newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11
|
||||
newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12
|
||||
newcomer,reply,m8,room1,4,study,s1@1,again,200,e13
|
||||
newcomer,expect_acceptance,m8,room1,3,train,,e11,100,
|
||||
newcomer,expect_acceptance,m8,room1,4,study,,e13,200,
|
||||
newcomer,expect_acceptance,m9,room1,3,train,,e12,110,
|
||||
newcomer,expect_counts,,,,,,answers=2;acceptances=3,,
|
||||
newcomer,expect_train,,,,,,e12,,
|
||||
version-change,reply,m10,room1,1,train,s1@1,under v1,100,e14
|
||||
version-change,answer_raw,m10,room1,2,train,s1@2,v2 without a fresh first reply,200,e15
|
||||
version-change,expect_counts,,,,,,answers=2;acceptances=1,,
|
||||
version-change,expect_train,,,,,,e14,,
|
||||
version-change,reply,m10,room1,2,train,s1@3,first reply under v2,300,e16
|
||||
version-change,expect_counts,,,,,,answers=3;acceptances=2,,
|
||||
version-change,expect_train,,,,,,e14;e16,,
|
||||
empty-live-rejected,reply_rejected,m11,room1,,,s1@1,,100,e17
|
||||
empty-live-rejected,expect_counts,,,,,,answers=0;acceptances=0,,
|
||||
empty-live-rejected,reply,m11,room1,,,s1@1,now filled,110,e18
|
||||
empty-live-rejected,expect_winner,m11,room1,,,s1@1,now filled,,e18
|
||||
empty-revoked,answer_revoked,m12,room1,1,train,s1@1,,100,e19
|
||||
empty-revoked,answer_revoked,m13,room1,1,train,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e20
|
||||
empty-revoked,expect_counts,,,,,,answers=2;acceptances=0,,
|
||||
empty-revoked,expect_train,,,,,,,,
|
||||
tie-greater-first,reply,m14,room1,,,s1@1,upper-B,500,B
|
||||
tie-greater-first,reply,m14,room1,,,s1@1,lower-a,500,a
|
||||
tie-greater-first,reply,m15,room1,,,s1@1,short-z,500,z
|
||||
tie-greater-first,reply,m15,room1,,,s1@1,long-aa,500,aa
|
||||
tie-greater-first,reply,m16,room1,,,s1@1,newer-ts-first,600,a
|
||||
tie-greater-first,reply,m16,room1,,,s1@1,older-ts-last,500,z
|
||||
tie-greater-first,expect_winner,m14,room1,,,s1@1,lower-a,,a
|
||||
tie-greater-first,expect_winner,m15,room1,,,s1@1,short-z,,z
|
||||
tie-greater-first,expect_winner,m16,room1,,,s1@1,newer-ts-first,,a
|
||||
tie-greater-last,reply,m14,room1,,,s1@1,lower-a,500,a
|
||||
tie-greater-last,reply,m14,room1,,,s1@1,upper-B,500,B
|
||||
tie-greater-last,reply,m15,room1,,,s1@1,long-aa,500,aa
|
||||
tie-greater-last,reply,m15,room1,,,s1@1,short-z,500,z
|
||||
tie-greater-last,expect_winner,m14,room1,,,s1@1,lower-a,,a
|
||||
tie-greater-last,expect_winner,m15,room1,,,s1@1,short-z,,z
|
||||
one-version-rule,version_ok,notice_version,room1,,,,4294967295,,version_ok-notice_version-4294967295
|
||||
one-version-rule,version_refused,notice_version,room1,,,,4294967296,,version_refused-notice_version-4294967296
|
||||
one-version-rule,version_refused,notice_version,room1,,,,01,,version_refused-notice_version-01
|
||||
one-version-rule,version_ok,sample_id,room1,,,,4294967295,,version_ok-sample_id-4294967295
|
||||
one-version-rule,version_refused,sample_id,room1,,,,4294967296,,version_refused-sample_id-4294967296
|
||||
one-version-rule,version_refused,sample_id,room1,,,,01,,version_refused-sample_id-01
|
||||
one-version-rule,version_ok,terms_version,room1,,,,4294967295,,version_ok-terms_version-4294967295
|
||||
one-version-rule,version_refused,terms_version,room1,,,,4294967296,,version_refused-terms_version-4294967296
|
||||
one-version-rule,version_refused,terms_version,room1,,,,01,,version_refused-terms_version-01
|
||||
|
|
|
@ -12,7 +12,9 @@ over binary loopback CultNet WebSocket routes under an explicit
|
|||
`local-development` trust policy. It proves one canonical
|
||||
command effect, idempotent retry, shared observation, provider restart on a new
|
||||
physical route, browser rediscovery, retained lease resubscription, and a second
|
||||
canonical command after reconnection.
|
||||
canonical command after reconnection. Running the sample needs a sibling Eve
|
||||
checkout (`--eve-root`); it is not part of `CultLib.sln` and does not build
|
||||
standalone.
|
||||
|
||||
The current substrate has a browser client boundary and local live proof, but
|
||||
not a complete public Verse proof:
|
||||
|
|
|
|||
|
|
@ -135,7 +135,9 @@ does **not** prove the network layer; the second program owns that evidence.
|
|||
|
||||
The network program is
|
||||
[`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md).
|
||||
Run it separately only while diagnosing that layer. A smoke against the
|
||||
Running it needs a sibling Eve checkout (`--eve-root`); it is not part of
|
||||
`CultLib.sln` and does not build standalone. Run it separately only while
|
||||
diagnosing that layer. A smoke against the
|
||||
deployed Odin daemon remains a separate infrastructure gate. Its owner map
|
||||
and full acceptance contract are recorded in
|
||||
[`browser-verse-transport.md`](../browser-verse-transport.md).
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ source-tree fallbacks into application code.
|
|||
|
||||
The process harness lives in
|
||||
[`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md).
|
||||
Application code should copy the typed state, operation, and Eve composition
|
||||
Running it needs a sibling Eve checkout (`--eve-root`); it does not build
|
||||
standalone. Application code should copy the typed state, operation, and Eve composition
|
||||
shape—not its benchmark flags or fixture orchestration.
|
||||
|
||||
For a TypeScript provider daemon, continue with the
|
||||
|
|
|
|||
80
tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs
Normal file
80
tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
|
||||
// snippet:begin
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class Note
|
||||
{
|
||||
[Key(0)] [CultName] public string Title = string.Empty;
|
||||
[Key(1)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
public static class CultNetFirstCall
|
||||
{
|
||||
public static async Task<Note?> Run()
|
||||
{
|
||||
var inbox = new CultCache(); // the receiver's cache
|
||||
var documents = new CultNetDocumentRegistry();
|
||||
var received = new TaskCompletionSource<bool>();
|
||||
|
||||
// Receive: a loopback listener that applies each raw document put to the inbox.
|
||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
||||
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
|
||||
{
|
||||
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
|
||||
received.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Send: a typed document as a cultnet.document_put_raw.v0 message.
|
||||
using var client = new TcpFramedCultNetSchemaClient();
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
|
||||
client.SendCultNet(documents.CreateRawDocumentPutMessage(
|
||||
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
|
||||
|
||||
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
return inbox.Get<Note>(handle.Key);
|
||||
}
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it
|
||||
// over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ.
|
||||
public class CultNetFirstCallTests
|
||||
{
|
||||
[Test]
|
||||
public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache()
|
||||
{
|
||||
var note = await CultNetFirstCall.Run();
|
||||
Assert.That(note, Is.Not.Null);
|
||||
Assert.That(note!.Title, Is.EqualTo("Hello"));
|
||||
Assert.That(note.Body, Is.EqualTo("sent over CultNet"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocsShowTheTestedSnippet()
|
||||
{
|
||||
var source = File.ReadAllText(SourcePath());
|
||||
const string begin = "// snippet:begin\n";
|
||||
var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length;
|
||||
var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)];
|
||||
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md"));
|
||||
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"),
|
||||
"docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim");
|
||||
}
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
tests/vectors/rudp-multipeer-server/README.md
Normal file
13
tests/vectors/rudp-multipeer-server/README.md
Normal 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.
|
||||
30
tests/vectors/rudp-multipeer-server/transcript.txt
Normal file
30
tests/vectors/rudp-multipeer-server/transcript.txt
Normal 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
|
||||
Loading…
Reference in a new issue