Consume the core stack from CultLib instead of forking it
vendor/README.md declared cultcache-rs, cultnet-rs and cultmesh-rs "a locally owned compatible protocol generation". That charter is the defect. A consumer holding its own generation of the wire stack sits outside the only harness that checks cross-runtime agreement, and the fork behaved exactly as that predicts: runtime_authority_contracts.rs drifted to e542225 while this repo's own authority map named f4ab237, and the Rust CultMesh implementation existed nowhere else in GameCult. All three now come from CultLib pinned at 85f7024, and 19,604 lines of forked substrate are deleted. Nothing was lost on the way out. The vendored cultnet tree was compared file by file against CultLib: ten of twelve matched, lib.rs differed only in module declarations, and transport.rs carried one guard that existed nowhere else -- a framed length checked against the advertised maximum before allocating. That guard is now CultLib 85f7024 with a test. The migration this was expected to need does not exist. RuntimePresenceCorrelation changing from enum to struct is Odin's side of the contract and no file here touches it; with line endings normalised the two contract copies differ by 132 lines, and the only production change that reaches a provider is IdunnExpectedRoute requiring rudp:// schemes for rudp transport, which this service does not use. The code compiles unchanged. 89 tests pass across the workspace against the pinned rev. vendor/ still holds the cultcache-py and eve submodules. cultcache-py is the same pattern -- a runtime-specific repo CultLib also carries as packages/cultcache-py -- but it backs the state tooling rather than the daemon, so it is left for its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d852b76b0
commit
d0510ac85b
60 changed files with 17 additions and 19604 deletions
519
Cargo.lock
generated
519
Cargo.lock
generated
|
|
@ -37,20 +37,6 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
|
|
@ -60,12 +46,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
|
|
@ -208,21 +188,6 @@ version = "1.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
|
|
@ -238,24 +203,12 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
|
|
@ -306,7 +259,7 @@ dependencies = [
|
|||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -388,18 +341,17 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cultcache-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cultcache-rs-derive",
|
||||
"fs2",
|
||||
"pretty_assertions",
|
||||
"redb",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
|
@ -407,6 +359,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -416,19 +369,18 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cultmesh-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"pretty_assertions",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultnet-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
|
@ -437,7 +389,6 @@ dependencies = [
|
|||
"cultcache-rs",
|
||||
"ed25519-dalek",
|
||||
"hmac",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.5",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
|
|
@ -446,9 +397,9 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"tempfile",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -478,12 +429,6 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -500,12 +445,6 @@ version = "0.5.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -528,12 +467,6 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
|
|
@ -558,21 +491,6 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -583,17 +501,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
|
|
@ -612,23 +519,6 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -638,16 +528,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
|
|
@ -748,11 +628,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -796,13 +674,11 @@ dependencies = [
|
|||
"cultnet-rs",
|
||||
"ghostlight-persona-projection",
|
||||
"hmac",
|
||||
"jsonschema",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"rand 0.9.5",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
|
|
@ -815,7 +691,6 @@ dependencies = [
|
|||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -827,23 +702,6 @@ dependencies = [
|
|||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
|
|
@ -976,7 +834,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1124,58 +982,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.49.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"data-encoding",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"idna",
|
||||
"itoa",
|
||||
"jsonschema-regex",
|
||||
"jsonschema-value",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"unicode-general-category",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.49.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.49.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
|
|
@ -1257,12 +1063,6 @@ version = "2.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "micromap"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
|
@ -1299,20 +1099,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
|
|
@ -1323,21 +1109,6 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
|
|
@ -1353,27 +1124,6 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1395,12 +1145,6 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
|
|
@ -1421,7 +1165,7 @@ dependencies = [
|
|||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1492,16 +1236,6 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||
dependencies = [
|
||||
"diff",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -1670,55 +1404,6 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.49.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown",
|
||||
"itoa",
|
||||
"micromap",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
|
|
@ -1896,33 +1581,6 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1975,17 +1633,6 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
|
|
@ -2127,27 +1774,6 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -2480,12 +2106,6 @@ version = "2.9.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -2538,16 +2158,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
|
@ -2560,12 +2170,6 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2709,41 +2313,6 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
|
@ -2752,20 +2321,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2790,53 +2348,19 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2845,7 +2369,7 @@ version = "0.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2872,7 +2396,7 @@ version = "0.61.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2891,15 +2415,6 @@ dependencies = [
|
|||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -2960,12 +2475,6 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -2,10 +2,6 @@
|
|||
members = [
|
||||
"crates/ghostlight-dungeon",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"vendor/cultcache-rs",
|
||||
"vendor/cultcache-rs/cultcache-rs-derive",
|
||||
"vendor/cultnet-rs",
|
||||
"vendor/cultmesh-rs",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
@ -23,9 +19,9 @@ axum = { version = "0.8", features = ["macros"] }
|
|||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
|
||||
cultcache-rs = { path = "vendor/cultcache-rs" }
|
||||
cultnet-rs = { path = "vendor/cultnet-rs" }
|
||||
cultmesh-rs = { path = "vendor/cultmesh-rs" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" }
|
||||
jsonwebtoken = "9"
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
|
|
|
|||
11
vendor/README.md
vendored
11
vendor/README.md
vendored
|
|
@ -1,11 +0,0 @@
|
|||
# Vendored GameCult Rust substrate
|
||||
|
||||
`cultcache-rs`, `cultnet-rs`, and `cultmesh-rs` are a locally owned compatible
|
||||
protocol generation. Ghostlight vendors the set so an immutable release can be
|
||||
rebuilt from its exact Git commit.
|
||||
|
||||
The vendored CultCache crate owns both the snapshot store and the pinned redb
|
||||
store used by application sessions, the world journal, and controller-work
|
||||
custody. Its redb table, key, and envelope encoding remain compatible with the
|
||||
existing Ghostlight stores. There is no second CultCache package or persistence
|
||||
authority in the workspace.
|
||||
2
vendor/cultcache-rs/.gitignore
vendored
2
vendor/cultcache-rs/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
28
vendor/cultcache-rs/Cargo.toml
vendored
28
vendor/cultcache-rs/Cargo.toml
vendored
|
|
@ -1,28 +0,0 @@
|
|||
[package]
|
||||
name = "cultcache-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cultcache_rs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cultcache-rs-derive = { path = "cultcache-rs-derive" }
|
||||
fs2 = "0.4"
|
||||
redb = "4.1.0"
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
332
vendor/cultcache-rs/README.md
vendored
332
vendor/cultcache-rs/README.md
vendored
|
|
@ -1,332 +0,0 @@
|
|||
# CultCache Rust
|
||||
|
||||
`cultcache-rs` is the Rust port of the useful part of GameCult's CultCache:
|
||||
a polymorphism-aware in-memory cache that remembers through pluggable backing
|
||||
stores.
|
||||
|
||||
Consumer code should think in domain types:
|
||||
|
||||
```rust
|
||||
let player = cache.get_required::<PlayerData>("player:meta")?;
|
||||
cache.put("player:meta", &player)?;
|
||||
```
|
||||
|
||||
The persistence layer should deal with envelopes, routing, MessagePack payload
|
||||
bytes, and schema identity. Application code should not paw at loose JSON files
|
||||
like a sad little bureaucrat with a clipboard.
|
||||
|
||||
## What It Is
|
||||
|
||||
CultCache is a domain cache with persistence adapters.
|
||||
|
||||
- `CultCache` is the query and mutation surface.
|
||||
- Domain structs implement `DatabaseEntry`.
|
||||
- Entries are stored behind a `type::key` identity, so multiple entry types
|
||||
can share a logical key without colliding.
|
||||
- Each payload is encoded directly from the known `DatabaseEntry` type into
|
||||
MessagePack bytes.
|
||||
- Backing stores are adapters, not the public data model.
|
||||
- Writes persist to the resolved backing store before the in-memory cache is
|
||||
updated.
|
||||
- Type-specific backing stores beat generic backing stores.
|
||||
- `SingleFileMessagePackBackingStore` is the first concrete store and guards
|
||||
file access with a sidecar lock file.
|
||||
|
||||
This is not an ORM, not a database, and not distributed consensus in a novelty
|
||||
hat. If multiple processes write the same backing file, use an external lock or
|
||||
a coordinator.
|
||||
|
||||
## Current API
|
||||
|
||||
```rust
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings")]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
theme: String,
|
||||
|
||||
#[cultcache(key = 1, default)]
|
||||
retries: u32,
|
||||
}
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
|
||||
cache.pull_all_backing_stores()?;
|
||||
|
||||
cache.put("app", &Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
})?;
|
||||
|
||||
let settings = cache.get_required::<Settings>("app")?;
|
||||
# Ok::<(), anyhow::Error>(())
|
||||
```
|
||||
|
||||
For a closed set of entries, generate a registry instead of repeating yourself:
|
||||
|
||||
```rust
|
||||
use cultcache_rs::cultcache_registry;
|
||||
|
||||
cultcache_registry!(GameCultEntries {
|
||||
Settings,
|
||||
PlayerData,
|
||||
});
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(GameCultEntries)?;
|
||||
```
|
||||
|
||||
The important part is that callers retrieve domain values directly:
|
||||
|
||||
```rust
|
||||
let settings: Settings = cache.get_required("app")?;
|
||||
let all_settings: Vec<Settings> = cache.get_all()?;
|
||||
```
|
||||
|
||||
The cache is the ergonomic surface. The backing store is the memory prosthetic.
|
||||
|
||||
## How This Maps From Original CultCache
|
||||
|
||||
The original C# CultCache relies on runtime reflection:
|
||||
|
||||
- cacheable models inherit from `DatabaseEntry`
|
||||
- the cache scans child classes at startup
|
||||
- `Get<T>(id)` returns a `T`
|
||||
- optional `INamedEntry` enables name lookup
|
||||
- optional field/property indexes are registered by member name
|
||||
- backing stores pull/push/delete `DatabaseEntry` values
|
||||
|
||||
Rust does not have C#-style assembly scanning or runtime subclass discovery.
|
||||
The closest honest Rust translation is:
|
||||
|
||||
- domain structs implement a marker trait, `DatabaseEntry`
|
||||
- the derive macro generates a positional array formatter from explicit integer
|
||||
field slots
|
||||
- the envelope carries the polymorphic type discriminator
|
||||
- unknown persisted type ids fail closed instead of constructing arbitrary
|
||||
runtime types
|
||||
- a generated registry should eventually replace repetitive manual
|
||||
registration
|
||||
|
||||
Manual `register_entry_type::<T>()` is still accepted, but entry identity no
|
||||
longer needs a hand-written impl: `#[derive(DatabaseEntry)]` declares it beside
|
||||
the domain type. `cultcache_registry!` provides the current generated resolver
|
||||
surface for a closed entry set.
|
||||
|
||||
## Intended Rust Ergonomics
|
||||
|
||||
The target user-facing shape should be:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, DatabaseEntry)]
|
||||
#[cultcache(type = "player")]
|
||||
pub struct PlayerData {
|
||||
#[cultcache(key = 0)]
|
||||
pub id: String,
|
||||
|
||||
#[cultcache(key = 1)]
|
||||
pub name: String,
|
||||
|
||||
#[cultcache(key = 2)]
|
||||
pub faction: String,
|
||||
}
|
||||
|
||||
let mut cache = CultCache::builder()
|
||||
.with_generated_entries(game_entries())
|
||||
.with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc"))
|
||||
.build()?;
|
||||
|
||||
cache.pull_all_backing_stores()?;
|
||||
let player = cache.get_required::<PlayerData>("player:ari")?;
|
||||
```
|
||||
|
||||
The current `cultcache_registry!` macro is deliberately simple. The remaining
|
||||
ergonomic endpoint is automatic inventory or build-script generation so users
|
||||
do not have to maintain the registry list by hand.
|
||||
|
||||
The registry is the Rust equivalent of C# reflection and MessagePack resolver
|
||||
setup. It should know:
|
||||
|
||||
- entry type id
|
||||
- generated MessagePack/serde formatter path
|
||||
- Rust type name / schema name
|
||||
- optional store domain route
|
||||
- optional name key extractor
|
||||
- optional secondary indexes
|
||||
- optional global singleton marker
|
||||
|
||||
In other words: code generation should restore the original CultCache feeling
|
||||
without pretending Rust has runtime reflection hiding under the floorboards.
|
||||
|
||||
## Current Surface
|
||||
|
||||
- `CultCache::new`
|
||||
- `register_entry_type::<T>`
|
||||
- `add_generic_backing_store`
|
||||
- `add_backing_store`
|
||||
- `pull_all_backing_stores`
|
||||
- `get::<T>`
|
||||
- `get_required::<T>`
|
||||
- `get_envelope::<T>`
|
||||
- `get_required_envelope::<T>`
|
||||
- `get_all::<T>`
|
||||
- `put::<T>`
|
||||
- `put_envelope::<T>`
|
||||
- `update::<T>`
|
||||
- `delete::<T>`
|
||||
- `snapshot`
|
||||
- `SingleFileMessagePackBackingStore`
|
||||
|
||||
## Backing Store Routing
|
||||
|
||||
Generic store:
|
||||
|
||||
```rust
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
|
||||
```
|
||||
|
||||
Type-specific store:
|
||||
|
||||
```rust
|
||||
cache.add_backing_store(
|
||||
SingleFileMessagePackBackingStore::new("players.cc"),
|
||||
["player"],
|
||||
);
|
||||
```
|
||||
|
||||
When writing a `PlayerData`, the cache checks type-specific stores first. If none
|
||||
match, it writes to the first generic store. Later matching stores are mirrors.
|
||||
|
||||
This mirrors the C# behavior:
|
||||
|
||||
- specific domain stores own their domain
|
||||
- the first generic store is the primary generic write target
|
||||
- later generic stores mirror writes
|
||||
- this is not multi-master
|
||||
|
||||
## Persistence Semantics
|
||||
|
||||
`put` persists before mutating the in-memory cache. If persistence fails, the
|
||||
cache does not pretend the write succeeded.
|
||||
|
||||
The envelope is MessagePack, and the payload inside each envelope is also raw
|
||||
MessagePack bytes encoded from the registered concrete `DatabaseEntry` type.
|
||||
That avoids the old bootstrap path where payloads were normalized through
|
||||
`serde_json::Value`.
|
||||
|
||||
For bit-compatible neighbors, that also enables a real fast lane:
|
||||
|
||||
- `get_envelope::<T>()` exports the canonical persisted bytes for a typed entry
|
||||
- `put_envelope::<T>()` ingests the same envelope into another cache instance
|
||||
without re-encoding the payload first
|
||||
|
||||
It still decodes once for validation and typed reads. We are not pretending one
|
||||
`Vec<u8>` became metaphysically zero-copy because we believed in it harder. But
|
||||
the stupid decode/re-encode loop is gone.
|
||||
|
||||
The `DatabaseEntry` derive does not trust Rust source field order as the durable
|
||||
schema. Every persisted member must declare a stable integer slot:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, DatabaseEntry)]
|
||||
#[cultcache(type = "player")]
|
||||
pub struct PlayerData {
|
||||
#[cultcache(key = 0)]
|
||||
pub id: String,
|
||||
|
||||
#[cultcache(key = 1)]
|
||||
pub name: String,
|
||||
|
||||
#[cultcache(key = 2, default)]
|
||||
pub level: u32,
|
||||
}
|
||||
```
|
||||
|
||||
The derive emits a tuple/array formatter. Gaps serialize as nil. Missing fields
|
||||
marked `default` use `Default::default()` when older payloads are read. Deleted
|
||||
field slots should stay reserved until an explicit store migration rewrites the
|
||||
data.
|
||||
|
||||
The single-file MessagePack store rewrites an atomic snapshot. That is a sane
|
||||
starting point for small typed state surfaces, settings, Epiphany agent memory,
|
||||
heartbeat state, and other compact control-plane data. Large corpora should use
|
||||
a sharded store or a real database instead of asking one file to become a
|
||||
warehouse and then acting wounded when physics invoices us.
|
||||
|
||||
The single-file store uses a sidecar lock file for shared reads and exclusive
|
||||
writes. That protects ordinary multi-process access to the same file. It is
|
||||
still not a multi-master replication protocol, and it is not a substitute for a
|
||||
coordinator when higher-level write ordering matters.
|
||||
|
||||
## Near-Term Ergonomic Improvements
|
||||
|
||||
1. **Derive macro**
|
||||
- `#[derive(DatabaseEntry)]`
|
||||
- `#[cultcache(type = "...")]`
|
||||
- `#[cultcache(key = N)]` on every persisted field
|
||||
- tuple/array formatter generation landed
|
||||
- optional `#[cultcache(name)]`, `#[cultcache(index)]`, and
|
||||
`#[cultcache(global)]` are still future work
|
||||
|
||||
2. **Generated registry**
|
||||
- a `CultCacheRegistry` trait
|
||||
- `cache.register_registry(GameCultEntries)`
|
||||
- basic macro registry landed
|
||||
- automatic inventory/build-script generation and store routes are future work
|
||||
|
||||
3. **Name and index lookups**
|
||||
- `cache.get_by_name::<T>("Potion")`
|
||||
- `cache.get_by_index::<T>("faction", "Lucent")`
|
||||
- generated extractors instead of reflection strings
|
||||
|
||||
4. **Schema/version metadata**
|
||||
- schema version in the envelope or payload metadata
|
||||
- explicit migration hooks
|
||||
- refusal on unknown persisted entry types unless a migration/resolver is
|
||||
installed
|
||||
|
||||
5. **Projection helpers**
|
||||
- JSON projection for review and git diffs
|
||||
- vector stripping / large-field elision for agent memory surfaces
|
||||
|
||||
## Why Not Skip Registration Immediately?
|
||||
|
||||
Rust can only deserialize polymorphic data into concrete types if something
|
||||
maps the persisted `type` discriminator to the Rust type. In C#, reflection and
|
||||
MessagePack resolvers can discover a closed `DatabaseEntry` inheritance tree at
|
||||
runtime and route it through known formatters. In Rust, that map has to come from
|
||||
somewhere:
|
||||
|
||||
- explicit registration
|
||||
- a generated registry
|
||||
- a macro inventory crate
|
||||
- a hand-written resolver
|
||||
|
||||
Explicit registration is the simplest honest implementation. Generated registry
|
||||
is the ergonomic destination. Hand-written resolver tables are the punishment
|
||||
we give ourselves if we get lazy.
|
||||
|
||||
## Security Model
|
||||
|
||||
CultCache should never parse arbitrary structured data into arbitrary runtime
|
||||
types. It only accepts persisted envelopes whose `type` discriminator resolves
|
||||
to a known `DatabaseEntry` type registered in the cache or generated registry.
|
||||
|
||||
That is the Rust version of the original trick:
|
||||
|
||||
- C# refuses to store objects outside the `DatabaseEntry` root type.
|
||||
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
|
||||
- Unknown or unregistered types are rejected.
|
||||
- MessagePack payload bytes deserialize through known concrete formatters, not
|
||||
an open-world type loader or generic structured-value bridge.
|
||||
|
||||
The derive/registry work should preserve that shape. Its job is to remove
|
||||
manual registration ceremony, not to loosen the closed-world boundary.
|
||||
|
||||
## License
|
||||
|
||||
Private GameCult infrastructure for now. Public packaging can wait until the
|
||||
API grows enough teeth to deserve strangers.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["derive", "parsing"] }
|
||||
243
vendor/cultcache-rs/cultcache-rs-derive/src/lib.rs
vendored
243
vendor/cultcache-rs/cultcache-rs-derive/src/lib.rs
vendored
|
|
@ -1,243 +0,0 @@
|
|||
use proc_macro::TokenStream;
|
||||
use quote::format_ident;
|
||||
use quote::quote;
|
||||
use syn::Data;
|
||||
use syn::DeriveInput;
|
||||
use syn::Fields;
|
||||
use syn::LitInt;
|
||||
use syn::LitStr;
|
||||
use syn::Type;
|
||||
use syn::parse_macro_input;
|
||||
|
||||
#[proc_macro_derive(DatabaseEntry, attributes(cultcache))]
|
||||
pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let ident = input.ident.clone();
|
||||
let mut entry_type: Option<LitStr> = None;
|
||||
let mut schema_name: Option<LitStr> = None;
|
||||
|
||||
for attribute in input
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("type") {
|
||||
entry_type = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("schema") {
|
||||
schema_name = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported cultcache attribute"))
|
||||
}
|
||||
});
|
||||
if let Err(error) = parse_result {
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(entry_type) = entry_type else {
|
||||
return syn::Error::new_spanned(
|
||||
ident,
|
||||
"DatabaseEntry derive requires #[cultcache(type = \"...\")]",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
let schema_name = schema_name.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
|
||||
|
||||
let fields = match input.data {
|
||||
Data::Struct(data) => match data.fields {
|
||||
Fields::Named(fields) => fields.named,
|
||||
_ => {
|
||||
return syn::Error::new_spanned(
|
||||
ident,
|
||||
"DatabaseEntry derive requires a named struct",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return syn::Error::new_spanned(ident, "DatabaseEntry derive requires a struct")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
};
|
||||
|
||||
let mut slots: Vec<CultCacheSlot> = Vec::new();
|
||||
for field in fields {
|
||||
let Some(field_ident) = field.ident else {
|
||||
return syn::Error::new_spanned(field.ty, "DatabaseEntry derive requires named fields")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
let mut key: Option<usize> = None;
|
||||
let mut default = false;
|
||||
for attribute in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
let value: LitInt = meta.value()?.parse()?;
|
||||
key = Some(value.base10_parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("default") {
|
||||
default = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported field cultcache attribute"))
|
||||
}
|
||||
});
|
||||
if let Err(error) = parse_result {
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
}
|
||||
let Some(key) = key else {
|
||||
return syn::Error::new_spanned(
|
||||
field_ident,
|
||||
"DatabaseEntry fields require #[cultcache(key = N)]",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
if slots.iter().any(|slot| slot.key == key) {
|
||||
return syn::Error::new_spanned(
|
||||
field_ident,
|
||||
format!("duplicate DatabaseEntry slot {key}"),
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
slots.push(CultCacheSlot {
|
||||
key,
|
||||
ident: field_ident,
|
||||
ty: field.ty,
|
||||
default,
|
||||
});
|
||||
}
|
||||
slots.sort_by_key(|slot| slot.key);
|
||||
let max_slot = slots.iter().map(|slot| slot.key).max().unwrap_or(0);
|
||||
|
||||
let serialize_elements = (0..=max_slot).map(|slot| {
|
||||
if let Some(field) = slots.iter().find(|field| field.key == slot) {
|
||||
let field_ident = &field.ident;
|
||||
quote! {
|
||||
tuple.serialize_element(&self.#field_ident)?;
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
tuple.serialize_element(&())?;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let option_declarations = slots.iter().map(|field| {
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
let ty = &field.ty;
|
||||
quote! {
|
||||
let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
|
||||
}
|
||||
});
|
||||
|
||||
let deserialize_match_arms = (0..=max_slot).map(|slot| {
|
||||
if let Some(field) = slots.iter().find(|field| field.key == slot) {
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
let ty = &field.ty;
|
||||
quote! {
|
||||
#slot => {
|
||||
#variable = seq.next_element::<#ty>()?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#slot => {
|
||||
let _ = seq.next_element::<::serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let build_fields = slots.iter().map(|field| {
|
||||
let field_ident = &field.ident;
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
if field.default {
|
||||
quote! {
|
||||
#field_ident: #variable.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
let field_name = field_ident.to_string();
|
||||
quote! {
|
||||
#field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
}
|
||||
|
||||
impl ::serde::Serialize for #ident {
|
||||
fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: ::serde::Serializer,
|
||||
{
|
||||
use ::serde::ser::SerializeTuple;
|
||||
let mut tuple = serializer.serialize_tuple(#max_slot + 1)?;
|
||||
#(#serialize_elements)*
|
||||
tuple.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> ::serde::Deserialize<'de> for #ident {
|
||||
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: ::serde::Deserializer<'de>,
|
||||
{
|
||||
struct CultCacheVisitor;
|
||||
|
||||
impl<'de> ::serde::de::Visitor<'de> for CultCacheVisitor {
|
||||
type Value = #ident;
|
||||
|
||||
fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
|
||||
formatter.write_str(concat!("a CultCache array for ", stringify!(#ident)))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> ::std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: ::serde::de::SeqAccess<'de>,
|
||||
{
|
||||
#(#option_declarations)*
|
||||
let mut index = 0usize;
|
||||
while index <= #max_slot {
|
||||
match index {
|
||||
#(#deserialize_match_arms)*
|
||||
_ => unreachable!(),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
while let ::std::option::Option::Some(_) = seq.next_element::<::serde::de::IgnoredAny>()? {}
|
||||
Ok(#ident {
|
||||
#(#build_fields),*
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_tuple(#max_slot + 1, CultCacheVisitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
struct CultCacheSlot {
|
||||
key: usize,
|
||||
ident: syn::Ident,
|
||||
ty: Type,
|
||||
default: bool,
|
||||
}
|
||||
121
vendor/cultcache-rs/examples/cultcache_interop.rs
vendored
121
vendor/cultcache-rs/examples/cultcache_interop.rs
vendored
|
|
@ -1,121 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const INTEROP_TYPE: &str = "cultcache.interop-note";
|
||||
const INTEROP_SCHEMA_VERSION: &str = "cultcache.interop_note.v1";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "cultcache.interop-note", schema = "CultCacheInteropNote")]
|
||||
struct CultCacheInteropNote {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
document_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
author_runtime_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
title: String,
|
||||
#[cultcache(key = 4)]
|
||||
body: String,
|
||||
#[cultcache(key = 5, default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mode = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("expected mode: write | read"))?;
|
||||
let options = parse_args(args.collect());
|
||||
let file = require_arg(&options, "file")?;
|
||||
|
||||
match mode.as_str() {
|
||||
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
|
||||
"read" => read_note(file)?,
|
||||
_ => return Err(anyhow!("unknown mode {mode}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_note(file: &str, runtime_id: &str) -> Result<()> {
|
||||
let mut cache = build_cache(file)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let note = CultCacheInteropNote {
|
||||
schema_version: INTEROP_SCHEMA_VERSION.to_string(),
|
||||
document_id: format!("note:{runtime_id}"),
|
||||
author_runtime_id: runtime_id.to_string(),
|
||||
title: format!("{runtime_id} wrote a CultCache note"),
|
||||
body: "The v1 store format is the contract.".to_string(),
|
||||
tags: vec![
|
||||
runtime_id.to_string(),
|
||||
"rust".to_string(),
|
||||
"interop".to_string(),
|
||||
],
|
||||
};
|
||||
cache.put(¬e.document_id, ¬e)?;
|
||||
print_note(¬e)
|
||||
}
|
||||
|
||||
fn read_note(file: &str) -> Result<()> {
|
||||
let mut cache = build_cache(file)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let notes = cache.get_all::<CultCacheInteropNote>()?;
|
||||
let note = notes
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("no {INTEROP_TYPE} records found"))?;
|
||||
print_note(note)
|
||||
}
|
||||
|
||||
fn build_cache(file: &str) -> Result<CultCache> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultCacheInteropNote>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn print_note(note: &CultCacheInteropNote) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"schemaVersion": note.schema_version,
|
||||
"documentId": note.document_id,
|
||||
"authorRuntimeId": note.author_runtime_id,
|
||||
"title": note.title,
|
||||
"body": note.body,
|
||||
"tags": note.tags,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> BTreeMap<String, String> {
|
||||
let mut parsed = BTreeMap::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let token = &args[index];
|
||||
if !token.starts_with("--") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let name = token.trim_start_matches("--").to_string();
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("missing value for --{name}"));
|
||||
parsed.insert(name, value);
|
||||
index += 2;
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn require_arg<'a>(options: &'a BTreeMap<String, String>, name: &str) -> Result<&'a str> {
|
||||
options
|
||||
.get(name)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| anyhow!("missing required argument --{name}"))
|
||||
}
|
||||
1080
vendor/cultcache-rs/src/lib.rs
vendored
1080
vendor/cultcache-rs/src/lib.rs
vendored
File diff suppressed because it is too large
Load diff
564
vendor/cultcache-rs/src/owned_redb.rs
vendored
564
vendor/cultcache-rs/src/owned_redb.rs
vendored
|
|
@ -1,564 +0,0 @@
|
|||
use crate::{CacheBackingStore, CultCacheEnvelope, PushAllOptions};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const REDB_ENVELOPES: TableDefinition<&[u8], &[u8]> = TableDefinition::new("cultcache_envelopes");
|
||||
|
||||
struct OwnedRedbInner {
|
||||
database: Database,
|
||||
_owner_lock: File,
|
||||
file_identity: String,
|
||||
}
|
||||
|
||||
/// A pinned redb store for one long-lived service owner. This handle holds the
|
||||
/// CultCache external lock, an open file handle, and the redb database for its
|
||||
/// entire lifetime. Clones share that exact ownership authority.
|
||||
#[derive(Clone)]
|
||||
pub struct OwnedRedbMessagePackBackingStore {
|
||||
path: PathBuf,
|
||||
inner: Arc<OwnedRedbInner>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OwnedRedbMessagePackBackingStore {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OwnedRedbMessagePackBackingStore")
|
||||
.field("path", &self.path)
|
||||
.field("file_identity", &self.inner.file_identity)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnedRedbMessagePackBackingStore {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
|
||||
let path = path.into();
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let lock_path = redb_lock_path(&path);
|
||||
let owner_lock = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("failed to open {}", lock_path.display()))?;
|
||||
fs2::FileExt::try_lock_exclusive(&owner_lock).with_context(|| {
|
||||
format!(
|
||||
"redb CultCache {} already has an active owner",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Give redb ownership of the already pinned file, structurally closing
|
||||
// path substitution during database creation. The post-open identity
|
||||
// comparison separately verifies that the pathname still names it.
|
||||
let database_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("failed to pin redb CultCache {}", path.display()))?;
|
||||
let held_identity = file_identity_from_file(&database_file)?;
|
||||
let database = Database::builder()
|
||||
.create_file(database_file)
|
||||
.with_context(|| format!("failed to open redb CultCache {}", path.display()))?;
|
||||
let path_identity_file = File::open(&path)?;
|
||||
let path_identity = file_identity_from_file(&path_identity_file)?;
|
||||
if held_identity != path_identity {
|
||||
bail!(
|
||||
"redb CultCache path identity changed while opening: held {held_identity}, path {path_identity}"
|
||||
);
|
||||
}
|
||||
let write = database.begin_write()?;
|
||||
{
|
||||
write.open_table(REDB_ENVELOPES)?;
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(Self {
|
||||
path,
|
||||
inner: Arc::new(OwnedRedbInner {
|
||||
database,
|
||||
_owner_lock: owner_lock,
|
||||
file_identity: held_identity,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn file_identity(&self) -> &str {
|
||||
&self.inner.file_identity
|
||||
}
|
||||
|
||||
pub fn require_file_identity(&self, expected: &str) -> Result<()> {
|
||||
if expected != self.file_identity() {
|
||||
bail!(
|
||||
"redb CultCache file identity mismatch: expected {expected}, owned {}",
|
||||
self.file_identity()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_path_identity(&self) -> Result<()> {
|
||||
let current_file = File::open(&self.path)
|
||||
.with_context(|| format!("owned redb path {} is missing", self.path.display()))?;
|
||||
let current = file_identity_from_file(¤t_file)?;
|
||||
self.require_file_identity(¤t).with_context(|| {
|
||||
format!(
|
||||
"owned redb path {} no longer names the pinned file",
|
||||
self.path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compare_and_swap_batch(
|
||||
&self,
|
||||
expected: &[CultCacheEnvelope],
|
||||
replacements: Vec<CultCacheEnvelope>,
|
||||
) -> Result<bool> {
|
||||
if replacements.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"conditional batch requires a non-empty replacement set"
|
||||
));
|
||||
}
|
||||
let expected_ids = unique_batch_ids(expected, "expected")?;
|
||||
let replacement_ids = unique_batch_ids(&replacements, "replacement")?;
|
||||
if !expected_ids.is_subset(&replacement_ids) {
|
||||
return Err(anyhow!(
|
||||
"conditional batch must replace every expected identity"
|
||||
));
|
||||
}
|
||||
let write = self.inner.database.begin_write()?;
|
||||
let matched = {
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
let mut valid = true;
|
||||
for row in expected {
|
||||
if read_redb_entry(&table, row)? != Some(row.clone()) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
for row in &replacements {
|
||||
if !expected_ids.contains(&entry_id(row))
|
||||
&& read_redb_entry(&table, row)?.is_some()
|
||||
{
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
for row in &replacements {
|
||||
insert_redb_entry(&mut table, row)?;
|
||||
}
|
||||
}
|
||||
valid
|
||||
};
|
||||
if matched {
|
||||
write.commit()?;
|
||||
} else {
|
||||
write.abort()?;
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
pub fn append_if_snapshot_unchanged(
|
||||
&self,
|
||||
expected_snapshot: &[CultCacheEnvelope],
|
||||
additions: Vec<CultCacheEnvelope>,
|
||||
) -> Result<bool> {
|
||||
if additions.is_empty() {
|
||||
return Err(anyhow!("conditional snapshot append requires additions"));
|
||||
}
|
||||
unique_batch_ids(expected_snapshot, "expected snapshot")?;
|
||||
let addition_ids = unique_batch_ids(&additions, "snapshot additions")?;
|
||||
let write = self.inner.database.begin_write()?;
|
||||
let matched = {
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
let mut current = read_all_redb(&table)?;
|
||||
let mut expected = expected_snapshot.to_vec();
|
||||
current.sort_by_key(entry_id);
|
||||
expected.sort_by_key(entry_id);
|
||||
let valid = current == expected
|
||||
&& !current
|
||||
.iter()
|
||||
.any(|row| addition_ids.contains(&entry_id(row)));
|
||||
if valid {
|
||||
for row in &additions {
|
||||
insert_redb_entry(&mut table, row)?;
|
||||
}
|
||||
}
|
||||
valid
|
||||
};
|
||||
if matched {
|
||||
write.commit()?;
|
||||
} else {
|
||||
write.abort()?;
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
pub fn replace_and_append_if_snapshot_unchanged(
|
||||
&self,
|
||||
expected_snapshot: &[CultCacheEnvelope],
|
||||
replacements: Vec<CultCacheEnvelope>,
|
||||
) -> Result<bool> {
|
||||
if replacements.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"conditional snapshot replacement requires replacements"
|
||||
));
|
||||
}
|
||||
unique_batch_ids(expected_snapshot, "expected snapshot")?;
|
||||
unique_batch_ids(&replacements, "snapshot replacements")?;
|
||||
let write = self.inner.database.begin_write()?;
|
||||
let matched = {
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
let mut current = read_all_redb(&table)?;
|
||||
let mut expected = expected_snapshot.to_vec();
|
||||
current.sort_by_key(entry_id);
|
||||
expected.sort_by_key(entry_id);
|
||||
let valid = current == expected;
|
||||
if valid {
|
||||
for row in &replacements {
|
||||
insert_redb_entry(&mut table, row)?;
|
||||
}
|
||||
}
|
||||
valid
|
||||
};
|
||||
if matched {
|
||||
write.commit()?;
|
||||
} else {
|
||||
write.abort()?;
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheBackingStore for OwnedRedbMessagePackBackingStore {
|
||||
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
|
||||
let read = self.inner.database.begin_read()?;
|
||||
let table = read.open_table(REDB_ENVELOPES)?;
|
||||
read_all_redb(&table)
|
||||
}
|
||||
|
||||
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
|
||||
let write = self.inner.database.begin_write()?;
|
||||
{
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
insert_redb_entry(&mut table, entry)?;
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
|
||||
let write = self.inner.database.begin_write()?;
|
||||
{
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
let key = redb_identity(entry)?;
|
||||
table.remove(key.as_slice())?;
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> {
|
||||
unique_batch_ids(entries, "push all")?;
|
||||
let write = self.inner.database.begin_write()?;
|
||||
{
|
||||
let mut table = write.open_table(REDB_ENVELOPES)?;
|
||||
let keys = table
|
||||
.iter()?
|
||||
.map(|row| Ok(row?.0.value().to_vec()))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
for key in keys {
|
||||
table.remove(key.as_slice())?;
|
||||
}
|
||||
for entry in entries {
|
||||
insert_redb_entry(&mut table, entry)?;
|
||||
}
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_batch_ids(
|
||||
entries: &[CultCacheEnvelope],
|
||||
label: &str,
|
||||
) -> Result<BTreeSet<(String, String)>> {
|
||||
let ids = entries.iter().map(entry_id).collect::<BTreeSet<_>>();
|
||||
if ids.len() != entries.len() {
|
||||
return Err(anyhow!(
|
||||
"conditional batch {label} set contains duplicate identities"
|
||||
));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn entry_id(entry: &CultCacheEnvelope) -> (String, String) {
|
||||
(entry.r#type.clone(), entry.key.clone())
|
||||
}
|
||||
|
||||
fn redb_lock_path(path: &Path) -> PathBuf {
|
||||
let mut lock_name = path
|
||||
.file_name()
|
||||
.map(|value| value.to_os_string())
|
||||
.unwrap_or_else(|| "cultcache.cc".into());
|
||||
lock_name.push(".lock");
|
||||
path.with_file_name(lock_name)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_identity_from_file(file: &File) -> Result<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let metadata = file.metadata()?;
|
||||
Ok(format!(
|
||||
"unix:{:016x}:{:016x}",
|
||||
metadata.dev(),
|
||||
metadata.ino()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn file_identity_from_file(file: &File) -> Result<String> {
|
||||
use std::mem::zeroed;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
|
||||
};
|
||||
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() };
|
||||
let success =
|
||||
unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut information) };
|
||||
if success == 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("failed to read Windows file identity");
|
||||
}
|
||||
let index = ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64;
|
||||
Ok(format!(
|
||||
"windows:{:08x}:{index:016x}",
|
||||
information.dwVolumeSerialNumber
|
||||
))
|
||||
}
|
||||
|
||||
fn redb_identity(entry: &CultCacheEnvelope) -> Result<Vec<u8>> {
|
||||
rmp_serde::to_vec(&entry_id(entry)).context("failed to encode CultCache identity")
|
||||
}
|
||||
|
||||
fn read_redb_entry(
|
||||
table: &impl ReadableTable<&'static [u8], &'static [u8]>,
|
||||
identity: &CultCacheEnvelope,
|
||||
) -> Result<Option<CultCacheEnvelope>> {
|
||||
let key = redb_identity(identity)?;
|
||||
table
|
||||
.get(key.as_slice())?
|
||||
.map(|value| {
|
||||
rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope")
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn insert_redb_entry(
|
||||
table: &mut redb::Table<&[u8], &[u8]>,
|
||||
entry: &CultCacheEnvelope,
|
||||
) -> Result<()> {
|
||||
let key = redb_identity(entry)?;
|
||||
let value = rmp_serde::to_vec(entry).context("failed to encode redb CultCache envelope")?;
|
||||
table.insert(key.as_slice(), value.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_all_redb(
|
||||
table: &impl ReadableTable<&'static [u8], &'static [u8]>,
|
||||
) -> Result<Vec<CultCacheEnvelope>> {
|
||||
let mut entries = table
|
||||
.iter()?
|
||||
.map(|row| {
|
||||
let (_, value) = row?;
|
||||
rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope")
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
entries.sort_by_key(entry_id);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope {
|
||||
CultCacheEnvelope {
|
||||
key: key.to_string(),
|
||||
r#type: r#type.to_string(),
|
||||
payload: payload.to_vec(),
|
||||
stored_at: "2026-07-13T00:00:00Z".to_string(),
|
||||
schema_id: Some(r#type.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_redb_identity_remains_tuple_encoded() -> Result<()> {
|
||||
let entry = test_envelope("model", "current", b"one");
|
||||
assert_eq!(
|
||||
redb_identity(&entry)?,
|
||||
vec![
|
||||
0x92, 0xa5, b'm', b'o', b'd', b'e', b'l', 0xa7, b'c', b'u', b'r', b'r', b'e', b'n',
|
||||
b't',
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_clones_share_authority_and_fresh_owner_is_refused() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("owned.cc");
|
||||
let owner = OwnedRedbMessagePackBackingStore::new(&path)?;
|
||||
let clone = owner.clone();
|
||||
assert_eq!(owner.file_identity(), clone.file_identity());
|
||||
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err());
|
||||
drop(owner);
|
||||
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err());
|
||||
drop(clone);
|
||||
assert!(OwnedRedbMessagePackBackingStore::new(&path).is_ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_file_identity_is_stable_across_writes_and_reopen() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("identity.cc");
|
||||
let identity = {
|
||||
let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?;
|
||||
let identity = owner.file_identity().to_string();
|
||||
owner.push(&test_envelope("model", "current", b"one"))?;
|
||||
owner.validate_path_identity()?;
|
||||
owner.require_file_identity(&identity)?;
|
||||
identity
|
||||
};
|
||||
let reopened = OwnedRedbMessagePackBackingStore::new(&path)?;
|
||||
assert_eq!(reopened.file_identity(), identity);
|
||||
assert_eq!(reopened.pull_all()?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_identity_helper_refuses_mismatch() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("mismatch.cc"))?;
|
||||
assert!(owner.require_file_identity("not-the-owned-file").is_err());
|
||||
owner.require_file_identity(owner.file_identity())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_batch_refuses_stale_member_and_commits_success_atomically() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let mut owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("batch-owned.cc"))?;
|
||||
let first = test_envelope("model", "first", b"one");
|
||||
let second = test_envelope("model", "second", b"one");
|
||||
owner.push(&first)?;
|
||||
owner.push(&second)?;
|
||||
let first_two = test_envelope("model", "first", b"two");
|
||||
let second_two = test_envelope("model", "second", b"two");
|
||||
let companion = test_envelope("receipt", "batch", b"committed");
|
||||
let stale_second = test_envelope("model", "second", b"stale");
|
||||
assert!(!owner.compare_and_swap_batch(
|
||||
&[first.clone(), stale_second],
|
||||
vec![first_two.clone(), second_two.clone(), companion.clone()],
|
||||
)?);
|
||||
assert_eq!(owner.pull_all()?, vec![first.clone(), second.clone()]);
|
||||
assert!(owner.compare_and_swap_batch(
|
||||
&[first, second],
|
||||
vec![first_two.clone(), second_two.clone(), companion.clone()],
|
||||
)?);
|
||||
assert_eq!(owner.pull_all()?, vec![first_two, second_two, companion]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_snapshot_append_refuses_stale_or_colliding_state() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let mut owner =
|
||||
OwnedRedbMessagePackBackingStore::new(temp.path().join("snapshot-append.cc"))?;
|
||||
let model = test_envelope("model", "current", b"revision-1");
|
||||
owner.push(&model)?;
|
||||
let stale = owner.pull_all()?;
|
||||
owner.push(&test_envelope("event", "concurrent", b"live"))?;
|
||||
assert!(!owner.append_if_snapshot_unchanged(
|
||||
&stale,
|
||||
vec![test_envelope("readiness", "proof", b"ready")],
|
||||
)?);
|
||||
let exact = owner.pull_all()?;
|
||||
assert!(owner.append_if_snapshot_unchanged(
|
||||
&exact,
|
||||
vec![test_envelope("readiness", "proof", b"ready")],
|
||||
)?);
|
||||
let current = owner.pull_all()?;
|
||||
assert!(!owner.append_if_snapshot_unchanged(
|
||||
¤t,
|
||||
vec![test_envelope("model", "current", b"collision")],
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_snapshot_replacement_and_append_refuses_concurrent_rows() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let mut owner =
|
||||
OwnedRedbMessagePackBackingStore::new(temp.path().join("snapshot-replace.cc"))?;
|
||||
let current = test_envelope("session", "current", b"active");
|
||||
owner.push(¤t)?;
|
||||
let stale = owner.pull_all()?;
|
||||
let concurrent = test_envelope("event", "concurrent", b"live");
|
||||
owner.push(&concurrent)?;
|
||||
let completed = test_envelope("session", "current", b"completed");
|
||||
let receipt = test_envelope("receipt", "terminal", b"done");
|
||||
assert!(!owner.replace_and_append_if_snapshot_unchanged(
|
||||
&stale,
|
||||
vec![completed.clone(), receipt.clone()],
|
||||
)?);
|
||||
let exact = owner.pull_all()?;
|
||||
assert!(owner.replace_and_append_if_snapshot_unchanged(
|
||||
&exact,
|
||||
vec![completed.clone(), receipt.clone()],
|
||||
)?);
|
||||
assert_eq!(owner.pull_all()?, vec![concurrent, receipt, completed]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_redb_path_replacement_cannot_redirect_pinned_authority() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("pinned.cc");
|
||||
let displaced = temp.path().join("displaced.cc");
|
||||
let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?;
|
||||
let identity = owner.file_identity().to_string();
|
||||
owner.push(&test_envelope("model", "before", b"one"))?;
|
||||
fs::rename(&path, &displaced)?;
|
||||
File::create(&path)?;
|
||||
assert!(owner.validate_path_identity().is_err());
|
||||
assert_eq!(owner.file_identity(), identity);
|
||||
owner.push(&test_envelope("model", "after", b"two"))?;
|
||||
assert_eq!(owner.pull_all()?.len(), 2);
|
||||
assert_ne!(
|
||||
file_identity_from_file(&File::open(&path)?)?,
|
||||
owner.file_identity()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1233
vendor/cultmesh-rs/Cargo.lock
generated
vendored
1233
vendor/cultmesh-rs/Cargo.lock
generated
vendored
File diff suppressed because it is too large
Load diff
20
vendor/cultmesh-rs/Cargo.toml
vendored
20
vendor/cultmesh-rs/Cargo.toml
vendored
|
|
@ -1,20 +0,0 @@
|
|||
[package]
|
||||
name = "cultmesh-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cultmesh_rs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
cultnet-rs = { path = "../cultnet-rs" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
rmp-serde = "1"
|
||||
tempfile = "3"
|
||||
1363
vendor/cultmesh-rs/src/lib.rs
vendored
1363
vendor/cultmesh-rs/src/lib.rs
vendored
File diff suppressed because it is too large
Load diff
2
vendor/cultnet-rs/.gitignore
vendored
2
vendor/cultnet-rs/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
36
vendor/cultnet-rs/Cargo.toml
vendored
36
vendor/cultnet-rs/Cargo.toml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
[package]
|
||||
name = "cultnet-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
license = "MIT"
|
||||
description = "CultNet-style typed MessagePack messaging, direct-pipe framing, security helpers, and CultCache replication for Rust runtimes."
|
||||
|
||||
[lib]
|
||||
name = "cultnet_rs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
ed25519-dalek = "2.2"
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
rmp-serde = "1.3"
|
||||
rmpv = { version = "1.3", features = ["with-serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.5"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
279
vendor/cultnet-rs/README.md
vendored
279
vendor/cultnet-rs/README.md
vendored
|
|
@ -1,279 +0,0 @@
|
|||
# CultNet RS
|
||||
|
||||
`cultnet-rs` is the Rust sibling of `cultnet-ts`: typed MessagePack messages,
|
||||
4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and
|
||||
CultCache replication without making callers paw through raw envelopes like we
|
||||
lost a bet.
|
||||
|
||||
The contract is intentionally boring:
|
||||
|
||||
- `cultnet.schema.v0` sends schema-versioned MessagePack objects.
|
||||
- `gamecult.networking.v0` maps the legacy C# union shape explicitly.
|
||||
- schema discovery uses explicit catalog request/response messages; no inbound
|
||||
auto-detect sludge.
|
||||
- document put/delete/snapshot messages move typed CultCache entries.
|
||||
- raw document put/snapshot messages preserve canonical MessagePack payload
|
||||
bytes for bit-compatible neighbors.
|
||||
- hello messages advertise `supportedMutationContracts`, so callers discover
|
||||
which document types are read-only, which accept typed intents, which
|
||||
authority owns the mutation, and which receipt documents prove the outcome.
|
||||
- payloads are decoded through registered Rust types before entering the cache.
|
||||
|
||||
This crate is not an HTTP wrapper. It is the wire vocabulary Epiphany,
|
||||
Ghostlight, VoidBot, and the rest of the swarm can share.
|
||||
|
||||
The public API surface is the schema plus its mutation contracts. A runtime does
|
||||
not expose a pile of bespoke verbs and hope everyone remembers the ritual; it
|
||||
advertises typed documents, typed intents, and typed receipts. Polite machines
|
||||
knock on the contract before touching the furniture.
|
||||
|
||||
## Receipts
|
||||
|
||||
```powershell
|
||||
cargo test
|
||||
```
|
||||
|
||||
The initial tests prove:
|
||||
|
||||
- CultLib-compatible AES-GCM string encryption and HMAC session tokens
|
||||
- 4-byte big-endian MessagePack framing
|
||||
- schema-versioned message round trips
|
||||
- legacy `gamecult.networking.v0` login mapping
|
||||
- schema discovery catalog responses with canonical JSON schema hashes
|
||||
- shard catalog request/response filtering and response application
|
||||
- CultCache snapshot replication through registered typed documents
|
||||
- raw snapshot replication that preserves the original payload bytes
|
||||
- raw snapshot replication into a local same-schema Rust alias type
|
||||
- document mutation contract advertisement through hello frames and registries
|
||||
|
||||
## Schema Discovery
|
||||
|
||||
`cultnet-rs` now ships a built-in schema registry for the shared swarm contract
|
||||
surface:
|
||||
|
||||
- core wire messages
|
||||
- legacy `gamecult.networking.v0` auth/sample payloads
|
||||
- schema catalog request/response messages
|
||||
- shard catalog request/response messages
|
||||
- the canonical `ghostlight.agent-state` document payload schema
|
||||
|
||||
Use `builtin_schema_registry()` or `CultMesh::create_builtin_schema_registry()`
|
||||
when you want the standard catalog, or register your own closed-world schema set
|
||||
with `CultNetSchemaRegistry` / `CultMesh::create_schema_registry()`. Discovery
|
||||
stays explicit on purpose: peers advertise only the contracts they were compiled
|
||||
to understand, the same way CultCache only consumes the document types you
|
||||
registered instead of pretending polymorphism is a public park.
|
||||
|
||||
## Shard Catalogs
|
||||
|
||||
Rust now exposes the same shard topology vocabulary as the other runtimes
|
||||
without claiming to be the full C# service body:
|
||||
|
||||
```rust
|
||||
use cultnet_rs::{CultMesh, CultNetMessage, CultNetShardDescriptor};
|
||||
|
||||
let mut catalog = CultMesh::create_shard_catalog();
|
||||
catalog.upsert(CultNetShardDescriptor {
|
||||
shard_id: "notes-a".to_string(),
|
||||
owner_runtime_id: "rust-primary".to_string(),
|
||||
epoch: 3,
|
||||
is_primary: Some(true),
|
||||
schema_ids: vec!["note.v0".to_string()],
|
||||
key_prefix: Some("note:".to_string()),
|
||||
primary_endpoints: vec!["rudp://127.0.0.1:4100".to_string()],
|
||||
replica_endpoints: vec![],
|
||||
read_replica_endpoints: vec![],
|
||||
region: None,
|
||||
authority_lease_id: None,
|
||||
})?;
|
||||
|
||||
let response = catalog.create_catalog_response(&CultNetMessage::ShardCatalogRequest {
|
||||
message_id: "discover-shards".to_string(),
|
||||
schema_ids: Some(vec!["note.v0".to_string()]),
|
||||
record_keys: Some(vec!["note:1".to_string()]),
|
||||
})?;
|
||||
```
|
||||
|
||||
`CultNetShardCatalog::apply_response(...)` imports remote
|
||||
`cultnet.shard_catalog_response.v0` descriptors into the local catalog. The
|
||||
catalog owns topology inspection only; shard-log serving, daemon policy, and
|
||||
game-session authority remain separate responsibilities.
|
||||
|
||||
## Local Fast Lane
|
||||
|
||||
`cultnet-rs` now mirrors the raw replication seam from `cultnet-ts`:
|
||||
|
||||
- `cultnet.document_put_raw.v0`
|
||||
- `cultnet.snapshot_response_raw.v0`
|
||||
|
||||
Those messages carry the exact persisted MessagePack payload bytes from
|
||||
CultCache along with the typed envelope metadata. Combined with
|
||||
`CultCache::put_envelope::<T>()`, that lets a bit-compatible neighbor ingest the
|
||||
document without re-encoding the payload first.
|
||||
|
||||
The receiving Rust type does not have to be the sender's concrete Rust type. If
|
||||
the local registry binds a different `DatabaseEntry` to the same schema id,
|
||||
`sync_raw_document_from_snapshot_response::<LocalAlias>(...)` hydrates one local
|
||||
alias type directly:
|
||||
|
||||
```rust
|
||||
let mut target_registry = CultNetDocumentRegistry::new();
|
||||
target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<UiNote>(
|
||||
"aetheria.note.v1".to_string(),
|
||||
"aetheria.note.v1".to_string(),
|
||||
));
|
||||
|
||||
let note = target_registry.sync_raw_document_from_snapshot_response::<UiNote>(
|
||||
&mut target_cache,
|
||||
&snapshot_response,
|
||||
"note:bridge",
|
||||
)?;
|
||||
```
|
||||
|
||||
That still is not zero-copy in the religious sense. Frames allocate, bytes move,
|
||||
and the receiving cache still decodes once to keep typed reads and validation
|
||||
honest. The win is narrower and realer: identical payload bytes stop getting
|
||||
decoded into generic sludge and then encoded right back into the same bytes for
|
||||
no reason.
|
||||
|
||||
## Reactive Documents
|
||||
|
||||
For local state that should feel owned by the caller, Rust can wrap a registered
|
||||
CultCache record as a managed reactive document:
|
||||
|
||||
```rust
|
||||
use std::sync::{Arc, Mutex};
|
||||
use cultnet_rs::{CultNetReactiveDocumentOptions, CultNetDocumentRegistry};
|
||||
|
||||
let cache = Arc::new(Mutex::new(target_cache));
|
||||
let note = registry.reactive_document::<UiNote>(
|
||||
Arc::clone(&cache),
|
||||
"note:bridge",
|
||||
CultNetReactiveDocumentOptions::default(),
|
||||
)?;
|
||||
|
||||
{
|
||||
let current = note.current();
|
||||
current.lock().unwrap().body = "local prediction".to_string();
|
||||
}
|
||||
```
|
||||
|
||||
The handle polls the serialized `current` value on the configured debounce
|
||||
cadence and flushes changed state back through the typed cache. `update(...)`,
|
||||
`set_current(...)`, `mark_dirty()`, and `flush()` are available when the caller
|
||||
wants a more explicit Rust shape.
|
||||
|
||||
When a canonical document put arrives while local state is dirty,
|
||||
`apply_document_put_message(...)` or `apply_raw_document_put_message(...)`
|
||||
records a `CultNetReactiveDocumentReconciliation`: the canonical value, the
|
||||
predicted local value, and a delta that can be smoothed by the caller. CultCache
|
||||
payloads serialize by numeric field slot, so Rust tuple-backed deltas use slot
|
||||
keys such as `"0"` and `"1"` when field names are not present on the wire.
|
||||
|
||||
## CultMesh Rust Facade
|
||||
|
||||
Rust is still the low-level substrate runtime, not a clone of the C# game
|
||||
server. It does now expose the small CultMesh entrypoint needed for practical
|
||||
contact and authority ergonomics:
|
||||
|
||||
```rust
|
||||
use cultnet_rs::{
|
||||
CultMesh, CultMeshAuthorityLease, CultMeshPeerCard, CultMeshRudpClientOptions,
|
||||
CultMeshRudpSocketOptions, CultNetMessage, CultNetSchemaKind,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
let schemas = CultMesh::create_builtin_schema_registry()?;
|
||||
let shared_contracts = schemas.list(&cultnet_rs::CultNetSchemaCatalogOptions {
|
||||
include_schema_json: false,
|
||||
schema_ids: None,
|
||||
kinds: Some(vec![CultNetSchemaKind::SharedContract]),
|
||||
});
|
||||
|
||||
let mut server = CultMesh::create_rudp_server(
|
||||
"rust-server",
|
||||
0x1020_3040,
|
||||
CultMeshRudpSocketOptions::default(),
|
||||
)?;
|
||||
let port = server.profile.transports[0].port.unwrap();
|
||||
|
||||
let peer = CultMeshPeerCard::new(
|
||||
"rust-server",
|
||||
"local",
|
||||
[format!("rudp://127.0.0.1:{port}")],
|
||||
)
|
||||
.with_roles(["schema"])
|
||||
.with_authority_lease_id("lease:rust-server");
|
||||
|
||||
let now = Utc::now();
|
||||
let mut peers = CultMesh::create_peer_catalog();
|
||||
let mut leases = CultMesh::create_authority_lease_catalog();
|
||||
peers.upsert(peer.clone())?;
|
||||
leases.upsert(CultMeshAuthorityLease {
|
||||
lease_id: "lease:rust-server".to_string(),
|
||||
verse_id: "local".to_string(),
|
||||
peer_id: "rust-server".to_string(),
|
||||
roles: vec!["schema".to_string()],
|
||||
shard_ids: Vec::new(),
|
||||
issuer_runtime_id: None,
|
||||
valid_from: now - Duration::minutes(1),
|
||||
expires_at: now + Duration::minutes(1),
|
||||
})?;
|
||||
|
||||
let mut client = CultMesh::connect_rudp_client_for_authorized_peer(
|
||||
"rust-client",
|
||||
0x1020_3040,
|
||||
&peers,
|
||||
&leases,
|
||||
"local",
|
||||
"schema",
|
||||
None,
|
||||
now,
|
||||
CultMeshRudpClientOptions {
|
||||
socket_options: CultMeshRudpSocketOptions::default(),
|
||||
connect_payload: b"join".to_vec(),
|
||||
..CultMeshRudpClientOptions::default()
|
||||
},
|
||||
)?;
|
||||
client.send_schema_message(&CultNetMessage::SchemaCatalogRequest {
|
||||
message_id: "rust-schema-catalog".to_string(),
|
||||
include_schema_json: Some(false),
|
||||
schema_ids: None,
|
||||
kinds: Some(vec![CultNetSchemaKind::DocumentPayload]),
|
||||
})?;
|
||||
```
|
||||
|
||||
`create_rudp_client...` remains available when the caller intentionally owns the
|
||||
handshake and polling loop. `connect_rudp_client...` performs the client
|
||||
handshake before returning the same schema-message-capable RUDP transport.
|
||||
|
||||
For caller-owned reconnect loops, `CultNetRudpReconnectLoop` keeps transport
|
||||
construction outside the library while sharing the portable retry controller:
|
||||
|
||||
```rust
|
||||
use cultnet_rs::{
|
||||
CultNetReconnectPolicyOptions, CultNetRudpReconnectLoop, create_reconnect_policy,
|
||||
};
|
||||
|
||||
let policy = create_reconnect_policy(CultNetReconnectPolicyOptions::default());
|
||||
let mut reconnect = CultNetRudpReconnectLoop::new(policy, b"join".to_vec(), || {
|
||||
CultMesh::create_rudp_client_for_peer(
|
||||
"rust-client",
|
||||
0x1020_3040,
|
||||
&peer,
|
||||
CultMeshRudpSocketOptions::default(),
|
||||
)
|
||||
});
|
||||
|
||||
reconnect.start()?;
|
||||
let decision = reconnect.handle_closed(now_ms, 0);
|
||||
if decision.is_some() {
|
||||
reconnect.reconnect_if_due(now_ms)?;
|
||||
}
|
||||
```
|
||||
|
||||
`CultMesh::parse_rudp_endpoint(...)` handles advertised `rudp://host:port`
|
||||
contact hints, and `CultMeshAuthorityLeaseCatalog` keeps trust separate from
|
||||
discovery. A peer card can say where to dial; `peers.first_authorized(...)` and
|
||||
`CultMesh::create_rudp_client_for_authorized_peer(...)` only use that contact
|
||||
hint after a lease says the peer may own the requested role or shard.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
|
||||
"title": "CultNet Document Delete Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemaId",
|
||||
"recordKey"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_delete.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
|
||||
"title": "CultNet Document Mutation Contract",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"documentType",
|
||||
"operations",
|
||||
"authority"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"payloadSchemaVersion": { "type": "string", "minLength": 1 },
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"]
|
||||
}
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"]
|
||||
},
|
||||
"intentDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"receiptDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
|
||||
"title": "CultNet Raw Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
|
||||
"title": "CultNet Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
|
||||
"title": "CultNet Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payload": {},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
|
||||
"title": "CultNet Hello Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"runtimeId",
|
||||
"runtimeKind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.hello.v0" },
|
||||
"runtimeId": { "type": "string", "minLength": 1 },
|
||||
"runtimeKind": { "type": "string", "minLength": 1 },
|
||||
"agentId": { "type": "string", "minLength": 1 },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string", "minLength": 1 },
|
||||
"supportedDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportedMutationContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-mutation-contract.schema.json"
|
||||
}
|
||||
},
|
||||
"supportedMessageVersions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"transportProfiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.transport-profile.schema.json"
|
||||
}
|
||||
},
|
||||
"supportsSchemaCatalog": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json",
|
||||
"title": "CultNet Login Success Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login_success.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
|
||||
"title": "CultNet Login Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"auth",
|
||||
"password"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"auth": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
|
||||
"title": "CultNet Register Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"email",
|
||||
"password",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.register.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"email": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
|
||||
"title": "CultNet Sample Change Name Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.change_name.v0" },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
|
||||
"title": "CultNet Sample Chat Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.chat.v0" },
|
||||
"text": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json",
|
||||
"title": "CultNet Schema Catalog Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"includeSchemaJson": { "type": "boolean" },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
|
||||
"title": "CultNet Schema Catalog Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemas"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.schema-descriptor.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
|
||||
"title": "CultNet Schema Descriptor",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"kind",
|
||||
"wireContracts",
|
||||
"contentHash"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
},
|
||||
"schemaVersion": { "type": "string", "minLength": 1 },
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"wireContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cultnet.schema.v0",
|
||||
"gamecult.networking.v0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contentHash": { "type": "string", "minLength": 1 },
|
||||
"schemaJson": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cultnet.shard_catalog_request.v0",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.shard_catalog_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"recordKeys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cultnet.shard_catalog_response.v0",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "shards"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.shard_catalog_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"shards": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shardDescriptor" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"shardDescriptor": {
|
||||
"type": "object",
|
||||
"required": ["shardId", "ownerRuntimeId", "epoch"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shardId": { "type": "string", "minLength": 1 },
|
||||
"ownerRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"epoch": { "type": "integer", "minimum": 0 },
|
||||
"isPrimary": { "type": "boolean" },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"keyPrefix": { "type": "string", "minLength": 1 },
|
||||
"primaryEndpoints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"replicaEndpoints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"readReplicaEndpoints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"region": { "type": "string", "minLength": 1 },
|
||||
"authorityLeaseId": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
|
||||
"title": "CultNet Snapshot Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"recordKeys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json",
|
||||
"title": "CultNet Raw Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
|
||||
"title": "CultNet Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
|
||||
"title": "CultNet Transport Profile",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"runtimeId",
|
||||
"transports"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.transport_profile.v0" },
|
||||
"runtimeId": { "type": "string", "minLength": 1 },
|
||||
"transports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"transportId",
|
||||
"protocol",
|
||||
"channels"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"transportId": { "type": "string", "minLength": 1 },
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tcp_framed",
|
||||
"litenetlib",
|
||||
"websocket",
|
||||
"rudp"
|
||||
]
|
||||
},
|
||||
"host": { "type": "string", "minLength": 1 },
|
||||
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
|
||||
"path": { "type": "string", "minLength": 1 },
|
||||
"discoveryGroup": { "type": "string", "minLength": 1 },
|
||||
"wireContracts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"reconnectPolicy": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"policyId",
|
||||
"baseDelayMs",
|
||||
"maxDelayMs",
|
||||
"maxJitterMs"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.reconnect_policy.v0" },
|
||||
"policyId": { "type": "string", "minLength": 1 },
|
||||
"baseDelayMs": { "type": "integer", "minimum": 0 },
|
||||
"maxDelayMs": { "type": "integer", "minimum": 0 },
|
||||
"maxJitterMs": { "type": "integer", "minimum": 0 },
|
||||
"maxAttempts": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"channelId",
|
||||
"delivery",
|
||||
"ordering"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"channelId": { "type": "string", "minLength": 1 },
|
||||
"delivery": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reliable",
|
||||
"unreliable"
|
||||
]
|
||||
},
|
||||
"ordering": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ordered",
|
||||
"unordered",
|
||||
"sequenced"
|
||||
]
|
||||
},
|
||||
"maxPayloadBytes": { "type": "integer", "minimum": 1 },
|
||||
"maxFragmentBytes": { "type": "integer", "minimum": 1 },
|
||||
"maxPendingReliablePackets": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
|
||||
"title": "CultNet Verify Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.verify.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,845 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json",
|
||||
"title": "Ghostlight Agent State Schema",
|
||||
"description": "First canonical contract for Ghostlight agent, perception, relationship, event, scene, and dialogue-context state.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"world",
|
||||
"agents",
|
||||
"relationships",
|
||||
"events",
|
||||
"scenes"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "ghostlight.agent_state.v0"
|
||||
},
|
||||
"world": {
|
||||
"$ref": "#/$defs/world_state"
|
||||
},
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/agent"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/relationship"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event"
|
||||
}
|
||||
},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/scene"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"world_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"world_id",
|
||||
"setting",
|
||||
"time",
|
||||
"canon_context"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"world_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label"
|
||||
],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"canon_context": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ambient_pressures": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/pressure"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"identity",
|
||||
"canonical_state",
|
||||
"goals",
|
||||
"memories",
|
||||
"perceived_state_overlays"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/$defs/identity"
|
||||
},
|
||||
"canonical_state": {
|
||||
"$ref": "#/$defs/canonical_state"
|
||||
},
|
||||
"goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/goal"
|
||||
}
|
||||
},
|
||||
"memories": {
|
||||
"$ref": "#/$defs/memory_bundle"
|
||||
},
|
||||
"perceived_state_overlays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/perceived_state_overlay"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"roles",
|
||||
"origin",
|
||||
"public_description"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_notes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"canonical_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"underlying_organization",
|
||||
"stable_dispositions",
|
||||
"behavioral_dimensions",
|
||||
"presentation_strategy",
|
||||
"voice_style",
|
||||
"situational_state",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"underlying_organization": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"stable_dispositions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"behavioral_dimensions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"presentation_strategy": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"situational_state": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/value_commitment"
|
||||
}
|
||||
},
|
||||
"voice_style": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/state_variable"
|
||||
}
|
||||
},
|
||||
"state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mean",
|
||||
"plasticity",
|
||||
"current_activation"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mean": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"plasticity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"current_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"value_commitment": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"value_id",
|
||||
"label",
|
||||
"priority",
|
||||
"unforgivable_if_betrayed"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"unforgivable_if_betrayed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"goal_id",
|
||||
"description",
|
||||
"scope",
|
||||
"priority",
|
||||
"emotional_stake",
|
||||
"status"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"goal_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"enum": [
|
||||
"immediate",
|
||||
"scene",
|
||||
"case",
|
||||
"arc",
|
||||
"life"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"emotional_stake": {
|
||||
"type": "string"
|
||||
},
|
||||
"blockers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"active",
|
||||
"blocked",
|
||||
"dormant",
|
||||
"resolved",
|
||||
"abandoned"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory_bundle": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"episodic",
|
||||
"semantic",
|
||||
"relationship_summaries"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"episodic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"semantic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"relationship_summaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"memory_id",
|
||||
"summary",
|
||||
"salience",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"salience": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"linked_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"linked_relationship_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"perceived_state_overlay": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observer_agent_id",
|
||||
"target_agent_id",
|
||||
"perceived_dimensions",
|
||||
"beliefs",
|
||||
"distortions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observer_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"perceived_dimensions": {
|
||||
"$ref": "#/$defs/inferred_variable_map"
|
||||
},
|
||||
"beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/belief"
|
||||
}
|
||||
},
|
||||
"distortions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"hostile_attribution",
|
||||
"false_reassurance",
|
||||
"overread_warmth",
|
||||
"underread_threat",
|
||||
"false_mask_detection",
|
||||
"missed_mask_detection",
|
||||
"status_injury",
|
||||
"threat_amplification",
|
||||
"institutional_suspicion_transfer",
|
||||
"care_underread",
|
||||
"mask_overfocus"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"belief": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"belief_id",
|
||||
"claim",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"belief_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim": {
|
||||
"type": "string"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim_type": {
|
||||
"enum": [
|
||||
"fact",
|
||||
"identity_inference",
|
||||
"motive_inference",
|
||||
"prediction",
|
||||
"norm",
|
||||
"value_read",
|
||||
"relationship_read"
|
||||
]
|
||||
},
|
||||
"evidence_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"enum": [
|
||||
"private",
|
||||
"shared",
|
||||
"public",
|
||||
"rumor"
|
||||
]
|
||||
},
|
||||
"emotional_charge": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"relationship": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"relationship_id",
|
||||
"source_id",
|
||||
"target_id",
|
||||
"stance",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"relationship_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"stance": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"unresolved_incident_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"event_id",
|
||||
"kind",
|
||||
"summary",
|
||||
"participants",
|
||||
"pressure_tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"dialogue",
|
||||
"action",
|
||||
"memory",
|
||||
"relationship_shift",
|
||||
"scene_outcome",
|
||||
"author_note"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"pressure_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"observed_exchange": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_exchange_turn"
|
||||
}
|
||||
},
|
||||
"private_interpretations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_private_interpretation"
|
||||
}
|
||||
},
|
||||
"event_effects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_effect"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"scene_id",
|
||||
"location",
|
||||
"participants",
|
||||
"public_stakes",
|
||||
"hidden_stakes",
|
||||
"dialogue_context_packs"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"public_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"hidden_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"dialogue_context_packs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/dialogue_context_pack"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogue_context_pack": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_agent_id",
|
||||
"listener_ids",
|
||||
"speaker_local_truth",
|
||||
"speaker_beliefs",
|
||||
"active_memories",
|
||||
"active_goals",
|
||||
"presentation_constraints"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"listener_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_local_truth": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_memories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"presentation_constraints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pressure_id",
|
||||
"label",
|
||||
"intensity"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pressure_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"intensity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"inferred_variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/inferred_state_variable"
|
||||
}
|
||||
},
|
||||
"inferred_state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observed_activation",
|
||||
"attributed_disposition",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observed_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"attributed_disposition": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_exchange_turn": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_id",
|
||||
"utterance_summary",
|
||||
"dialogue_function"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"utterance_summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"dialogue_function": {
|
||||
"type": "string"
|
||||
},
|
||||
"observable_cues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_private_interpretation": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"interpretation",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"interpretation": {
|
||||
"type": "string"
|
||||
},
|
||||
"attributed_motive": {
|
||||
"type": "string"
|
||||
},
|
||||
"appraisal_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_effect": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"state_deltas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/state_delta"
|
||||
}
|
||||
},
|
||||
"new_belief_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"memory_update_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_delta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"delta"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"delta": {
|
||||
"type": "number",
|
||||
"minimum": -1,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1628
vendor/cultnet-rs/examples/cultnet_interop_peer.rs
vendored
1628
vendor/cultnet-rs/examples/cultnet_interop_peer.rs
vendored
File diff suppressed because it is too large
Load diff
1406
vendor/cultnet-rs/src/contracts.rs
vendored
1406
vendor/cultnet-rs/src/contracts.rs
vendored
File diff suppressed because it is too large
Load diff
525
vendor/cultnet-rs/src/cultmesh.rs
vendored
525
vendor/cultnet-rs/src/cultmesh.rs
vendored
|
|
@ -1,525 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::net::UdpSocket;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::CultNetRudpSocketMode;
|
||||
use crate::CultNetRudpSocketTransportConnection;
|
||||
use crate::CultNetRudpSocketTransportOptions;
|
||||
use crate::CultNetSchemaRegistry;
|
||||
use crate::CultNetShardCatalog;
|
||||
use crate::builtin_schema_registry;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultMeshPeerCard {
|
||||
pub peer_id: String,
|
||||
pub verse_id: String,
|
||||
pub endpoints: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub shard_ids: Vec<String>,
|
||||
pub authority_lease_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CultMeshPeerCard {
|
||||
pub fn new(
|
||||
peer_id: impl Into<String>,
|
||||
verse_id: impl Into<String>,
|
||||
endpoints: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
peer_id: peer_id.into(),
|
||||
verse_id: verse_id.into(),
|
||||
endpoints: endpoints.into_iter().map(Into::into).collect(),
|
||||
roles: Vec::new(),
|
||||
shard_ids: Vec::new(),
|
||||
authority_lease_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_roles(mut self, roles: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
self.roles = roles.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_shard_ids(
|
||||
mut self,
|
||||
shard_ids: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Self {
|
||||
self.shard_ids = shard_ids.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_authority_lease_id(mut self, authority_lease_id: impl Into<String>) -> Self {
|
||||
self.authority_lease_id = Some(authority_lease_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn rudp_endpoint(&self) -> Result<CultNetRudpEndpoint> {
|
||||
let endpoint = self
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|endpoint| endpoint.to_ascii_lowercase().starts_with("rudp://"))
|
||||
.ok_or_else(|| anyhow!("Peer {} does not advertise a RUDP endpoint", self.peer_id))?;
|
||||
CultMesh::parse_rudp_endpoint(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultMeshPeerCatalog {
|
||||
peers: BTreeMap<String, CultMeshPeerCard>,
|
||||
}
|
||||
|
||||
impl CultMeshPeerCatalog {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, peer: CultMeshPeerCard) -> Result<()> {
|
||||
require_non_empty(&peer.peer_id, "peer.peer_id")?;
|
||||
require_non_empty(&peer.verse_id, "peer.verse_id")?;
|
||||
self.peers.insert(peer.peer_id.clone(), peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, peer_id: &str) -> Option<&CultMeshPeerCard> {
|
||||
self.peers.get(peer_id)
|
||||
}
|
||||
|
||||
pub fn peers(&self) -> Vec<CultMeshPeerCard> {
|
||||
self.peers.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn find(&self, verse_id: &str, role: Option<&str>) -> Vec<CultMeshPeerCard> {
|
||||
self.peers
|
||||
.values()
|
||||
.filter(|peer| {
|
||||
peer.verse_id == verse_id
|
||||
&& role.is_none_or(|role| peer.roles.iter().any(|value| value == role))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn find_authorized(
|
||||
&self,
|
||||
verse_id: &str,
|
||||
role: &str,
|
||||
leases: &CultMeshAuthorityLeaseCatalog,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
) -> Vec<CultMeshPeerCard> {
|
||||
self.find(verse_id, Some(role))
|
||||
.into_iter()
|
||||
.filter(|peer| leases.is_authorized(peer, role, shard_id, at))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn first_authorized(
|
||||
&self,
|
||||
verse_id: &str,
|
||||
role: &str,
|
||||
leases: &CultMeshAuthorityLeaseCatalog,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
) -> Option<CultMeshPeerCard> {
|
||||
self.find_authorized(verse_id, role, leases, shard_id, at)
|
||||
.into_iter()
|
||||
.next()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultMeshAuthorityLease {
|
||||
pub lease_id: String,
|
||||
pub verse_id: String,
|
||||
pub peer_id: String,
|
||||
pub roles: Vec<String>,
|
||||
pub shard_ids: Vec<String>,
|
||||
pub issuer_runtime_id: Option<String>,
|
||||
pub valid_from: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CultMeshAuthorityLease {
|
||||
pub fn covers(
|
||||
&self,
|
||||
peer: &CultMeshPeerCard,
|
||||
role: &str,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
) -> bool {
|
||||
at >= self.valid_from
|
||||
&& at < self.expires_at
|
||||
&& self.verse_id == peer.verse_id
|
||||
&& self.peer_id == peer.peer_id
|
||||
&& self.roles.iter().any(|value| value == role)
|
||||
&& peer.roles.iter().any(|value| value == role)
|
||||
&& shard_id.is_none_or(|shard_id| {
|
||||
self.shard_ids.is_empty() || self.shard_ids.iter().any(|value| value == shard_id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultMeshAuthorityLeaseCatalog {
|
||||
leases: BTreeMap<String, CultMeshAuthorityLease>,
|
||||
}
|
||||
|
||||
impl CultMeshAuthorityLeaseCatalog {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, lease: CultMeshAuthorityLease) -> Result<()> {
|
||||
require_non_empty(&lease.lease_id, "lease.lease_id")?;
|
||||
require_non_empty(&lease.verse_id, "lease.verse_id")?;
|
||||
require_non_empty(&lease.peer_id, "lease.peer_id")?;
|
||||
if lease.expires_at <= lease.valid_from {
|
||||
return Err(anyhow!(
|
||||
"CultMesh authority lease expiry must be after valid_from"
|
||||
));
|
||||
}
|
||||
self.leases.insert(lease.lease_id.clone(), lease);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, lease_id: &str) -> Option<&CultMeshAuthorityLease> {
|
||||
self.leases.get(lease_id)
|
||||
}
|
||||
|
||||
pub fn leases(&self) -> Vec<CultMeshAuthorityLease> {
|
||||
self.leases.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_authorized(
|
||||
&self,
|
||||
peer: &CultMeshPeerCard,
|
||||
role: &str,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
) -> bool {
|
||||
let Some(lease_id) = peer.authority_lease_id.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
self.leases
|
||||
.get(lease_id)
|
||||
.is_some_and(|lease| lease.covers(peer, role, shard_id, at))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetRudpEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl CultNetRudpEndpoint {
|
||||
pub fn uri(&self) -> String {
|
||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
||||
format!("rudp://[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("rudp://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn socket_addr(&self) -> Result<SocketAddr> {
|
||||
format!("{}:{}", socket_host(&self.host), self.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| anyhow!("Invalid RUDP socket endpoint {}: {error}", self.uri()))?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("RUDP socket endpoint {} did not resolve", self.uri()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CultMeshRudpSocketOptions {
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub read_timeout: Option<Duration>,
|
||||
pub initial_sequence: u32,
|
||||
pub resend_delay_ms: u64,
|
||||
pub max_payload_bytes: Option<u32>,
|
||||
pub max_fragment_bytes: Option<u32>,
|
||||
pub max_pending_reliable_packets: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for CultMeshRudpSocketOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 0,
|
||||
read_timeout: Some(Duration::from_millis(20)),
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_payload_bytes: None,
|
||||
max_fragment_bytes: None,
|
||||
max_pending_reliable_packets: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CultMeshRudpClientOptions {
|
||||
pub socket_options: CultMeshRudpSocketOptions,
|
||||
pub connect_payload: Vec<u8>,
|
||||
pub connect_timeout: Duration,
|
||||
pub poll_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for CultMeshRudpClientOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket_options: CultMeshRudpSocketOptions::default(),
|
||||
connect_payload: Vec::new(),
|
||||
connect_timeout: Duration::from_secs(1),
|
||||
poll_interval: Duration::from_millis(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CultMesh;
|
||||
|
||||
impl CultMesh {
|
||||
pub fn create_schema_registry() -> CultNetSchemaRegistry {
|
||||
CultNetSchemaRegistry::new()
|
||||
}
|
||||
|
||||
pub fn create_builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
|
||||
builtin_schema_registry()
|
||||
}
|
||||
|
||||
pub fn create_shard_catalog() -> CultNetShardCatalog {
|
||||
CultNetShardCatalog::new()
|
||||
}
|
||||
|
||||
pub fn create_peer_catalog() -> CultMeshPeerCatalog {
|
||||
CultMeshPeerCatalog::new()
|
||||
}
|
||||
|
||||
pub fn create_authority_lease_catalog() -> CultMeshAuthorityLeaseCatalog {
|
||||
CultMeshAuthorityLeaseCatalog::new()
|
||||
}
|
||||
|
||||
pub fn parse_rudp_endpoint(endpoint: &str) -> Result<CultNetRudpEndpoint> {
|
||||
cultnet_rudp_endpoint(endpoint)
|
||||
}
|
||||
|
||||
pub fn create_rudp_server(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
options: CultMeshRudpSocketOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let socket = bind_rudp_socket(&options)?;
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
socket,
|
||||
mode: CultNetRudpSocketMode::Server,
|
||||
remote_addr: None,
|
||||
connection_id,
|
||||
initial_sequence: options.initial_sequence,
|
||||
resend_delay_ms: options.resend_delay_ms,
|
||||
transport_id: None,
|
||||
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: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_rudp_client(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
endpoint: &CultNetRudpEndpoint,
|
||||
options: CultMeshRudpSocketOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let socket = bind_rudp_socket(&options)?;
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
socket,
|
||||
mode: CultNetRudpSocketMode::Client,
|
||||
remote_addr: Some(endpoint.socket_addr()?),
|
||||
connection_id,
|
||||
initial_sequence: options.initial_sequence,
|
||||
resend_delay_ms: options.resend_delay_ms,
|
||||
transport_id: None,
|
||||
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: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_rudp_client_for_endpoint(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
endpoint: &str,
|
||||
options: CultMeshRudpSocketOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let endpoint = Self::parse_rudp_endpoint(endpoint)?;
|
||||
Self::create_rudp_client(runtime_id, connection_id, &endpoint, options)
|
||||
}
|
||||
|
||||
pub fn create_rudp_client_for_peer(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
peer: &CultMeshPeerCard,
|
||||
options: CultMeshRudpSocketOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let endpoint = peer.rudp_endpoint()?;
|
||||
Self::create_rudp_client(runtime_id, connection_id, &endpoint, options)
|
||||
}
|
||||
|
||||
pub fn create_rudp_client_for_authorized_peer(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
peers: &CultMeshPeerCatalog,
|
||||
leases: &CultMeshAuthorityLeaseCatalog,
|
||||
verse_id: &str,
|
||||
role: &str,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
options: CultMeshRudpSocketOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let peer = peers
|
||||
.first_authorized(verse_id, role, leases, shard_id, at)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("No authorized RUDP peer for role {role} in Verse {verse_id}")
|
||||
})?;
|
||||
Self::create_rudp_client_for_peer(runtime_id, connection_id, &peer, options)
|
||||
}
|
||||
|
||||
pub fn connect_rudp_client(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
endpoint: &CultNetRudpEndpoint,
|
||||
options: CultMeshRudpClientOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let runtime_id = runtime_id.into();
|
||||
let mut client = Self::create_rudp_client(
|
||||
runtime_id.clone(),
|
||||
connection_id,
|
||||
endpoint,
|
||||
options.socket_options,
|
||||
)?;
|
||||
client.connect(options.connect_payload)?;
|
||||
let deadline = Instant::now() + options.connect_timeout;
|
||||
while Instant::now() < deadline {
|
||||
let _ = client.receive_once()?;
|
||||
client.poll_resends()?;
|
||||
if client.connected() {
|
||||
return Ok(client);
|
||||
}
|
||||
thread::sleep(options.poll_interval);
|
||||
}
|
||||
if client.connected() {
|
||||
return Ok(client);
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Timed out waiting for RUDP client {runtime_id} to connect"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn connect_rudp_client_for_endpoint(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
endpoint: &str,
|
||||
options: CultMeshRudpClientOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let endpoint = Self::parse_rudp_endpoint(endpoint)?;
|
||||
Self::connect_rudp_client(runtime_id, connection_id, &endpoint, options)
|
||||
}
|
||||
|
||||
pub fn connect_rudp_client_for_peer(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
peer: &CultMeshPeerCard,
|
||||
options: CultMeshRudpClientOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let endpoint = peer.rudp_endpoint()?;
|
||||
Self::connect_rudp_client(runtime_id, connection_id, &endpoint, options)
|
||||
}
|
||||
|
||||
pub fn connect_rudp_client_for_authorized_peer(
|
||||
runtime_id: impl Into<String>,
|
||||
connection_id: u32,
|
||||
peers: &CultMeshPeerCatalog,
|
||||
leases: &CultMeshAuthorityLeaseCatalog,
|
||||
verse_id: &str,
|
||||
role: &str,
|
||||
shard_id: Option<&str>,
|
||||
at: DateTime<Utc>,
|
||||
options: CultMeshRudpClientOptions,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let peer = peers
|
||||
.first_authorized(verse_id, role, leases, shard_id, at)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("No authorized RUDP peer for role {role} in Verse {verse_id}")
|
||||
})?;
|
||||
Self::connect_rudp_client_for_peer(runtime_id, connection_id, &peer, options)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cultnet_rudp_endpoint(endpoint: &str) -> Result<CultNetRudpEndpoint> {
|
||||
let scheme = "rudp://";
|
||||
if endpoint.len() < scheme.len() || !endpoint[..scheme.len()].eq_ignore_ascii_case(scheme) {
|
||||
return Err(anyhow!("RUDP endpoint must use the rudp scheme"));
|
||||
}
|
||||
let rest = &endpoint[scheme.len()..];
|
||||
let (host, port_text) = if let Some(rest) = rest.strip_prefix('[') {
|
||||
let (host, port_rest) = rest
|
||||
.split_once(']')
|
||||
.ok_or_else(|| anyhow!("RUDP IPv6 endpoint must close the host bracket"))?;
|
||||
let port_text = port_rest
|
||||
.strip_prefix(':')
|
||||
.ok_or_else(|| anyhow!("RUDP endpoint must include a valid port"))?;
|
||||
(host.to_string(), port_text)
|
||||
} else {
|
||||
let (host, port_text) = rest
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow!("RUDP endpoint must include a host and port"))?;
|
||||
if host.contains(':') {
|
||||
return Err(anyhow!("RUDP IPv6 endpoint host must be bracketed"));
|
||||
}
|
||||
(host.to_string(), port_text)
|
||||
};
|
||||
require_non_empty(&host, "rudp host")?;
|
||||
let port = port_text
|
||||
.parse::<u16>()
|
||||
.map_err(|error| anyhow!("RUDP endpoint must include a valid port: {error}"))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("RUDP endpoint port must be greater than zero"));
|
||||
}
|
||||
Ok(CultNetRudpEndpoint { host, port })
|
||||
}
|
||||
|
||||
fn bind_rudp_socket(options: &CultMeshRudpSocketOptions) -> Result<UdpSocket> {
|
||||
let socket = UdpSocket::bind(format!(
|
||||
"{}:{}",
|
||||
socket_host(&options.bind_host),
|
||||
options.bind_port
|
||||
))?;
|
||||
socket.set_read_timeout(options.read_timeout)?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn socket_host(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn require_non_empty(value: &str, field: &str) -> Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(anyhow!("CultMesh {field} must not be empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
49
vendor/cultnet-rs/src/framing.rs
vendored
49
vendor/cultnet-rs/src/framing.rs
vendored
|
|
@ -1,49 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
|
||||
pub const FRAME_HEADER_BYTES: usize = 4;
|
||||
|
||||
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let len: u32 = payload
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("CultNet frame payload is too large"))?;
|
||||
let mut frame = Vec::with_capacity(FRAME_HEADER_BYTES + payload.len());
|
||||
frame.extend_from_slice(&len.to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LengthPrefixedMessageFramer {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LengthPrefixedMessageFramer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chunk: impl AsRef<[u8]>) -> Vec<Vec<u8>> {
|
||||
self.buffer.extend_from_slice(chunk.as_ref());
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
if self.buffer.len() < FRAME_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
let payload_len = u32::from_be_bytes([
|
||||
self.buffer[0],
|
||||
self.buffer[1],
|
||||
self.buffer[2],
|
||||
self.buffer[3],
|
||||
]) as usize;
|
||||
let total_len = FRAME_HEADER_BYTES + payload_len;
|
||||
if self.buffer.len() < total_len {
|
||||
break;
|
||||
}
|
||||
frames.push(self.buffer[FRAME_HEADER_BYTES..total_len].to_vec());
|
||||
self.buffer.drain(0..total_len);
|
||||
}
|
||||
frames
|
||||
}
|
||||
}
|
||||
25
vendor/cultnet-rs/src/lib.rs
vendored
25
vendor/cultnet-rs/src/lib.rs
vendored
|
|
@ -1,25 +0,0 @@
|
|||
mod contracts;
|
||||
mod cultmesh;
|
||||
mod framing;
|
||||
mod operation_service;
|
||||
mod provider_session;
|
||||
mod replication;
|
||||
mod rudp;
|
||||
mod schema_discovery;
|
||||
mod security;
|
||||
mod service_identity;
|
||||
mod shard_catalog;
|
||||
mod transport;
|
||||
|
||||
pub use contracts::*;
|
||||
pub use cultmesh::*;
|
||||
pub use framing::*;
|
||||
pub use operation_service::*;
|
||||
pub use provider_session::*;
|
||||
pub use replication::*;
|
||||
pub use rudp::*;
|
||||
pub use schema_discovery::*;
|
||||
pub use security::*;
|
||||
pub use service_identity::*;
|
||||
pub use shard_catalog::*;
|
||||
pub use transport::*;
|
||||
60
vendor/cultnet-rs/src/operation_service.rs
vendored
60
vendor/cultnet-rs/src/operation_service.rs
vendored
|
|
@ -1,60 +0,0 @@
|
|||
use std::net::UdpSocket;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::{
|
||||
CultNetMessage, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions,
|
||||
};
|
||||
|
||||
pub const CULTNET_OPERATION_CONNECTION_ID: u32 = 0x4355_4c54;
|
||||
|
||||
pub struct CultNetOperationServer {
|
||||
transport: CultNetRudpSocketTransportConnection,
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
impl CultNetOperationServer {
|
||||
pub fn bind(runtime_id: impl Into<String>, host: &str, port: u16) -> Result<Self> {
|
||||
let socket = UdpSocket::bind((host, port))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(25)))?;
|
||||
let address = socket.local_addr()?;
|
||||
let mut options = CultNetRudpSocketTransportOptions::server(
|
||||
runtime_id,
|
||||
socket,
|
||||
CULTNET_OPERATION_CONNECTION_ID,
|
||||
);
|
||||
options.max_fragment_bytes = Some(2048);
|
||||
Ok(Self {
|
||||
transport: CultNetRudpSocketTransportConnection::new(options)?,
|
||||
endpoint: format!("rudp://{}:{}", address.ip(), address.port()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn serve_once(
|
||||
&mut self,
|
||||
mut handler: impl FnMut(CultNetMessage) -> Result<CultNetMessage>,
|
||||
) -> Result<bool> {
|
||||
let Some(request) = self.transport.receive_schema_message_once()? else {
|
||||
self.transport.poll_resends()?;
|
||||
return Ok(false);
|
||||
};
|
||||
if !matches!(request, CultNetMessage::OperationRequest { .. }) {
|
||||
return Err(anyhow!(
|
||||
"CultNet operation service received a non-operation message"
|
||||
));
|
||||
}
|
||||
let response = handler(request)?;
|
||||
if !matches!(response, CultNetMessage::OperationResponse { .. }) {
|
||||
return Err(anyhow!(
|
||||
"CultNet operation handler returned a non-operation response"
|
||||
));
|
||||
}
|
||||
self.transport.send_schema_message(&response)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
625
vendor/cultnet-rs/src/provider_session.rs
vendored
625
vendor/cultnet-rs/src/provider_session.rs
vendored
|
|
@ -1,625 +0,0 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use chrono::DateTime;
|
||||
use rmpv::Value;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{CultNetMessage, CultNetRawDocumentRecord};
|
||||
|
||||
pub const CULTMESH_PROVIDER_SESSION_SERVICE_ID: &str = "gamecult.mesh.provider_session";
|
||||
pub const CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING: &str = "messagepack-base64";
|
||||
|
||||
pub const CULTMESH_PROVIDER_REGISTER_OPERATION: &str = "provider.register";
|
||||
pub const CULTMESH_PROVIDER_RENEW_OPERATION: &str = "provider.renew";
|
||||
pub const CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION: &str = "provider.publication.put";
|
||||
pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION: &str = "provider.publication.delete";
|
||||
pub const CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION: &str = "provider.receipt.put";
|
||||
pub const CULTMESH_PROVIDER_WITHDRAW_OPERATION: &str = "provider.withdraw";
|
||||
|
||||
pub const CULTMESH_PROVIDER_REGISTRATION_SCHEMA: &str = "gamecult.mesh.provider_registration.v1";
|
||||
pub const CULTMESH_PROVIDER_LEASE_SCHEMA: &str = "gamecult.mesh.provider_lease.v1";
|
||||
pub const CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA: &str = "gamecult.mesh.provider_lease_renewal.v1";
|
||||
pub const CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA: &str =
|
||||
"gamecult.mesh.provider_publication_put.v1";
|
||||
pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA: &str =
|
||||
"gamecult.mesh.provider_publication_delete.v1";
|
||||
pub const CULTMESH_PROVIDER_COMMAND_SCHEMA: &str = "gamecult.mesh.provider_command.v1";
|
||||
pub const CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA: &str = "gamecult.mesh.provider_receipt_put.v1";
|
||||
pub const CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA: &str = "gamecult.mesh.provider_withdrawal.v1";
|
||||
pub const CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA: &str =
|
||||
"gamecult.mesh.provider_mutation_acceptance.v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CultMeshProviderOperationStatus {
|
||||
Ok,
|
||||
Conflict,
|
||||
Expired,
|
||||
Denied,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl CultMeshProviderOperationStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ok => "ok",
|
||||
Self::Conflict => "conflict",
|
||||
Self::Expired => "expired",
|
||||
Self::Denied => "denied",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"ok" => Ok(Self::Ok),
|
||||
"conflict" => Ok(Self::Conflict),
|
||||
"expired" => Ok(Self::Expired),
|
||||
"denied" => Ok(Self::Denied),
|
||||
"invalid" => Ok(Self::Invalid),
|
||||
_ => Err(anyhow!("Unsupported provider-session status {value:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CultMeshProviderSessionPayload {
|
||||
fn validate(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Provider-owned evidence carried verbatim in the RUDP Connect payload.
|
||||
/// `client_session_id` distinguishes process/session generations; the optional
|
||||
/// token remains opaque authorization evidence for the receiving service.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderConnectEvidenceWire {
|
||||
pub client_session_id: String,
|
||||
#[serde(default)]
|
||||
pub session_token: Option<String>,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderConnectEvidenceWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.client_session_id, "clientSessionId")?;
|
||||
require_optional_text(self.session_token.as_deref(), "sessionToken")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderRegistrationWire {
|
||||
pub provider_id: String,
|
||||
pub service_instance_id: String,
|
||||
pub endpoint_id: String,
|
||||
pub verse_id: String,
|
||||
pub requested_lease_duration_ms: i32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub authority_lease_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderRegistrationWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_identity(
|
||||
&self.provider_id,
|
||||
&self.service_instance_id,
|
||||
&self.endpoint_id,
|
||||
&self.verse_id,
|
||||
)?;
|
||||
require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs")?;
|
||||
require_optional_text(self.authority_lease_id.as_deref(), "authorityLeaseId")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderLeaseWire {
|
||||
pub provider_id: String,
|
||||
pub service_instance_id: String,
|
||||
pub endpoint_id: String,
|
||||
pub verse_id: String,
|
||||
pub lease_id: String,
|
||||
pub valid_from_utc: String,
|
||||
pub expires_at_utc: String,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderLeaseWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_identity(
|
||||
&self.provider_id,
|
||||
&self.service_instance_id,
|
||||
&self.endpoint_id,
|
||||
&self.verse_id,
|
||||
)?;
|
||||
require_text(&self.lease_id, "leaseId")?;
|
||||
let valid_from = require_timestamp(&self.valid_from_utc, "validFromUtc")?;
|
||||
let expires_at = require_timestamp(&self.expires_at_utc, "expiresAtUtc")?;
|
||||
if expires_at <= valid_from {
|
||||
return Err(anyhow!("Provider lease must expire after validFromUtc"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderLeaseRenewalWire {
|
||||
pub lease_id: String,
|
||||
pub requested_lease_duration_ms: i32,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderLeaseRenewalWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.lease_id, "leaseId")?;
|
||||
require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderPublicationPutWire {
|
||||
pub lease_id: String,
|
||||
pub publication_id: String,
|
||||
pub document: CultNetRawDocumentRecord,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderPublicationPutWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.lease_id, "leaseId")?;
|
||||
require_text(&self.publication_id, "publicationId")?;
|
||||
validate_raw_document(&self.document)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderPublicationDeleteWire {
|
||||
pub lease_id: String,
|
||||
pub publication_id: String,
|
||||
pub schema_id: String,
|
||||
pub record_key: String,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderPublicationDeleteWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.lease_id, "leaseId")?;
|
||||
require_text(&self.publication_id, "publicationId")?;
|
||||
require_text(&self.schema_id, "schemaId")?;
|
||||
require_text(&self.record_key, "recordKey")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderCommandWire {
|
||||
pub command_id: String,
|
||||
pub command_kind: String,
|
||||
pub provider_id: String,
|
||||
pub service_instance_id: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderCommandWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.command_id, "commandId")?;
|
||||
require_text(&self.command_kind, "commandKind")?;
|
||||
require_text(&self.provider_id, "providerId")?;
|
||||
require_text(&self.service_instance_id, "serviceInstanceId")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CultMeshProviderReceiptStateWire {
|
||||
Applied,
|
||||
Rejected,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderCommandReceiptWire {
|
||||
pub receipt_id: String,
|
||||
pub command_id: String,
|
||||
pub command_kind: String,
|
||||
pub provider_id: String,
|
||||
pub service_instance_id: String,
|
||||
pub state: CultMeshProviderReceiptStateWire,
|
||||
pub completed_at_utc: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderCommandReceiptWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.receipt_id, "receiptId")?;
|
||||
require_text(&self.command_id, "commandId")?;
|
||||
require_text(&self.command_kind, "commandKind")?;
|
||||
require_text(&self.provider_id, "providerId")?;
|
||||
require_text(&self.service_instance_id, "serviceInstanceId")?;
|
||||
require_timestamp(&self.completed_at_utc, "completedAtUtc")?;
|
||||
require_optional_text(self.error.as_deref(), "error")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderReceiptPutWire {
|
||||
pub lease_id: String,
|
||||
pub receipt: CultMeshProviderCommandReceiptWire,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderReceiptPutWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.lease_id, "leaseId")?;
|
||||
self.receipt.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderWithdrawalWire {
|
||||
pub lease_id: String,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderWithdrawalWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_text(&self.lease_id, "leaseId")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultMeshProviderMutationAcceptanceWire {
|
||||
pub accepted_at_utc: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lease_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publication_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub command_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub receipt_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderMutationAcceptanceWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
require_timestamp(&self.accepted_at_utc, "acceptedAtUtc")?;
|
||||
require_optional_text(self.lease_id.as_deref(), "leaseId")?;
|
||||
require_optional_text(self.publication_id.as_deref(), "publicationId")?;
|
||||
require_optional_text(self.command_id.as_deref(), "commandId")?;
|
||||
require_optional_text(self.receipt_id.as_deref(), "receiptId")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CultMeshProviderErrorWire {}
|
||||
|
||||
impl CultMeshProviderSessionPayload for CultMeshProviderErrorWire {
|
||||
fn validate(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_provider_session_payload<T>(value: &T) -> Result<String>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + Serialize,
|
||||
{
|
||||
value.validate()?;
|
||||
Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?))
|
||||
}
|
||||
|
||||
pub fn encode_provider_connect_evidence(
|
||||
evidence: &CultMeshProviderConnectEvidenceWire,
|
||||
) -> Result<Vec<u8>> {
|
||||
evidence.validate()?;
|
||||
Ok(rmp_serde::to_vec_named(evidence)?)
|
||||
}
|
||||
|
||||
pub fn decode_provider_connect_evidence(
|
||||
payload: &[u8],
|
||||
) -> Result<CultMeshProviderConnectEvidenceWire> {
|
||||
if payload.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Provider Connect evidence must be a non-empty MessagePack map"
|
||||
));
|
||||
}
|
||||
let evidence: CultMeshProviderConnectEvidenceWire = rmp_serde::from_slice(payload)?;
|
||||
evidence.validate()?;
|
||||
Ok(evidence)
|
||||
}
|
||||
|
||||
pub fn decode_provider_session_payload<T>(payload: &str) -> Result<T>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + DeserializeOwned,
|
||||
{
|
||||
require_text(payload, "payload")?;
|
||||
let bytes = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|error| anyhow!("Provider-session payload must be MessagePack base64: {error}"))?;
|
||||
let value: T = rmp_serde::from_slice(&bytes)?;
|
||||
value.validate()?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn decode_provider_command_document(
|
||||
document: &CultNetRawDocumentRecord,
|
||||
) -> Result<CultMeshProviderCommandWire> {
|
||||
if document.schema_id != CULTMESH_PROVIDER_COMMAND_SCHEMA {
|
||||
return Err(anyhow!(
|
||||
"Expected provider command schema {:?}, received {:?}",
|
||||
CULTMESH_PROVIDER_COMMAND_SCHEMA,
|
||||
document.schema_id
|
||||
));
|
||||
}
|
||||
let command: CultMeshProviderCommandWire = rmp_serde::from_slice(&document.payload)?;
|
||||
command.validate()?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub fn create_provider_session_request<T>(
|
||||
message_id: impl Into<String>,
|
||||
operation: impl Into<String>,
|
||||
payload_schema: impl Into<String>,
|
||||
payload: &T,
|
||||
source_runtime_id: Option<String>,
|
||||
target_runtime_id: Option<String>,
|
||||
) -> Result<CultNetMessage>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + Serialize,
|
||||
{
|
||||
let message_id = message_id.into();
|
||||
let operation = operation.into();
|
||||
let payload_schema = payload_schema.into();
|
||||
require_text(&message_id, "messageId")?;
|
||||
validate_operation_schema(&operation, &payload_schema)?;
|
||||
Ok(CultNetMessage::OperationRequest {
|
||||
message_id,
|
||||
service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(),
|
||||
operation,
|
||||
payload_schema,
|
||||
payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(),
|
||||
payload: encode_provider_session_payload(payload)?,
|
||||
source_runtime_id,
|
||||
target_runtime_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_provider_session_request<T>(
|
||||
request: &CultNetMessage,
|
||||
expected_operation: &str,
|
||||
expected_payload_schema: &str,
|
||||
) -> Result<T>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::OperationRequest {
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
payload_encoding,
|
||||
payload,
|
||||
..
|
||||
} = request
|
||||
else {
|
||||
return Err(anyhow!("Expected a CultNet operation request"));
|
||||
};
|
||||
require_envelope(
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
payload_encoding,
|
||||
expected_operation,
|
||||
expected_payload_schema,
|
||||
)?;
|
||||
decode_provider_session_payload(payload)
|
||||
}
|
||||
|
||||
pub fn create_provider_session_response<T>(
|
||||
request: &CultNetMessage,
|
||||
status: CultMeshProviderOperationStatus,
|
||||
payload_schema: impl Into<String>,
|
||||
payload: &T,
|
||||
source_runtime_id: Option<String>,
|
||||
) -> Result<CultNetMessage>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + Serialize,
|
||||
{
|
||||
let CultNetMessage::OperationRequest {
|
||||
message_id,
|
||||
operation,
|
||||
..
|
||||
} = request
|
||||
else {
|
||||
return Err(anyhow!("Expected a CultNet operation request"));
|
||||
};
|
||||
require_text(message_id, "messageId")?;
|
||||
require_text(operation, "operation")?;
|
||||
let payload_schema = payload_schema.into();
|
||||
require_text(&payload_schema, "payloadSchema")?;
|
||||
Ok(CultNetMessage::OperationResponse {
|
||||
message_id: message_id.clone(),
|
||||
service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(),
|
||||
operation: operation.clone(),
|
||||
status: status.as_str().to_string(),
|
||||
payload_schema,
|
||||
payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(),
|
||||
payload: encode_provider_session_payload(payload)?,
|
||||
diagnostics: Vec::new(),
|
||||
source_runtime_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_provider_session_error_response(
|
||||
request: &CultNetMessage,
|
||||
status: CultMeshProviderOperationStatus,
|
||||
diagnostics: Vec<String>,
|
||||
source_runtime_id: Option<String>,
|
||||
) -> Result<CultNetMessage> {
|
||||
if status == CultMeshProviderOperationStatus::Ok {
|
||||
return Err(anyhow!(
|
||||
"Provider-session error responses require a non-ok status"
|
||||
));
|
||||
}
|
||||
if diagnostics.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Provider-session error responses require diagnostics"
|
||||
));
|
||||
}
|
||||
for diagnostic in &diagnostics {
|
||||
require_text(diagnostic, "diagnostics[]")?;
|
||||
}
|
||||
let mut response = create_provider_session_response(
|
||||
request,
|
||||
status,
|
||||
CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA,
|
||||
&CultMeshProviderErrorWire {},
|
||||
source_runtime_id,
|
||||
)?;
|
||||
let CultNetMessage::OperationResponse {
|
||||
diagnostics: response_diagnostics,
|
||||
..
|
||||
} = &mut response
|
||||
else {
|
||||
unreachable!("provider-session response helper returned a request")
|
||||
};
|
||||
*response_diagnostics = diagnostics;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn decode_provider_session_response<T>(
|
||||
response: &CultNetMessage,
|
||||
expected_operation: &str,
|
||||
expected_payload_schema: &str,
|
||||
) -> Result<(CultMeshProviderOperationStatus, T)>
|
||||
where
|
||||
T: CultMeshProviderSessionPayload + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::OperationResponse {
|
||||
service_id,
|
||||
operation,
|
||||
status,
|
||||
payload_schema,
|
||||
payload_encoding,
|
||||
payload,
|
||||
..
|
||||
} = response
|
||||
else {
|
||||
return Err(anyhow!("Expected a CultNet operation response"));
|
||||
};
|
||||
require_envelope(
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
payload_encoding,
|
||||
expected_operation,
|
||||
expected_payload_schema,
|
||||
)?;
|
||||
Ok((
|
||||
CultMeshProviderOperationStatus::parse(status)?,
|
||||
decode_provider_session_payload(payload)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_operation_schema(operation: &str, payload_schema: &str) -> Result<()> {
|
||||
let expected = match operation {
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION => CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
CULTMESH_PROVIDER_RENEW_OPERATION => CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA,
|
||||
CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION => CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA,
|
||||
CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION => {
|
||||
CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA
|
||||
}
|
||||
CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION => CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA,
|
||||
CULTMESH_PROVIDER_WITHDRAW_OPERATION => CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"Unsupported provider-session operation {operation:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if payload_schema != expected {
|
||||
return Err(anyhow!(
|
||||
"Provider-session operation {operation:?} requires payload schema {expected:?}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_envelope(
|
||||
service_id: &str,
|
||||
operation: &str,
|
||||
payload_schema: &str,
|
||||
payload_encoding: &str,
|
||||
expected_operation: &str,
|
||||
expected_payload_schema: &str,
|
||||
) -> Result<()> {
|
||||
if service_id != CULTMESH_PROVIDER_SESSION_SERVICE_ID {
|
||||
return Err(anyhow!(
|
||||
"Unexpected provider-session service id {service_id:?}"
|
||||
));
|
||||
}
|
||||
if operation != expected_operation {
|
||||
return Err(anyhow!(
|
||||
"Unexpected provider-session operation {operation:?}"
|
||||
));
|
||||
}
|
||||
if payload_schema != expected_payload_schema {
|
||||
return Err(anyhow!(
|
||||
"Unexpected provider-session payload schema {payload_schema:?}"
|
||||
));
|
||||
}
|
||||
if payload_encoding != CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING {
|
||||
return Err(anyhow!(
|
||||
"Unexpected provider-session payload encoding {payload_encoding:?}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_raw_document(document: &CultNetRawDocumentRecord) -> Result<()> {
|
||||
require_text(&document.schema_id, "document.schemaId")?;
|
||||
require_text(&document.record_key, "document.recordKey")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_identity(
|
||||
provider_id: &str,
|
||||
service_instance_id: &str,
|
||||
endpoint_id: &str,
|
||||
verse_id: &str,
|
||||
) -> Result<()> {
|
||||
require_text(provider_id, "providerId")?;
|
||||
require_text(service_instance_id, "serviceInstanceId")?;
|
||||
require_text(endpoint_id, "endpointId")?;
|
||||
require_text(verse_id, "verseId")
|
||||
}
|
||||
|
||||
fn require_text(value: &str, field: &str) -> Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(anyhow!("Provider-session {field} must not be empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_optional_text(value: Option<&str>, field: &str) -> Result<()> {
|
||||
if let Some(value) = value {
|
||||
require_text(value, field)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_positive(value: i32, field: &str) -> Result<()> {
|
||||
if value <= 0 {
|
||||
return Err(anyhow!(
|
||||
"Provider-session {field} must be greater than zero"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_timestamp(value: &str, field: &str) -> Result<DateTime<chrono::FixedOffset>> {
|
||||
require_text(value, field)?;
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|error| anyhow!("Provider-session {field} must be RFC3339: {error}"))
|
||||
}
|
||||
1014
vendor/cultnet-rs/src/replication.rs
vendored
1014
vendor/cultnet-rs/src/replication.rs
vendored
File diff suppressed because it is too large
Load diff
2038
vendor/cultnet-rs/src/rudp.rs
vendored
2038
vendor/cultnet-rs/src/rudp.rs
vendored
File diff suppressed because it is too large
Load diff
410
vendor/cultnet-rs/src/schema_discovery.rs
vendored
410
vendor/cultnet-rs/src/schema_discovery.rs
vendored
|
|
@ -1,410 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CultNetMessage;
|
||||
use crate::CultNetSchemaDescriptor;
|
||||
use crate::CultNetSchemaKind;
|
||||
use crate::CultNetWireContract;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetSchemaRegistration {
|
||||
pub schema_id: String,
|
||||
pub kind: CultNetSchemaKind,
|
||||
pub wire_contracts: Vec<CultNetWireContract>,
|
||||
pub schema_version: Option<String>,
|
||||
pub document_type: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub schema_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetSchemaCatalogOptions {
|
||||
pub include_schema_json: bool,
|
||||
pub schema_ids: Option<Vec<String>>,
|
||||
pub kinds: Option<Vec<CultNetSchemaKind>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultNetSchemaRegistry {
|
||||
entries: BTreeMap<String, CultNetSchemaDescriptor>,
|
||||
}
|
||||
|
||||
impl CultNetSchemaRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, registration: CultNetSchemaRegistration) -> Result<()> {
|
||||
if registration.schema_id.trim().is_empty() {
|
||||
return Err(anyhow!("schema_id must be non-empty"));
|
||||
}
|
||||
if registration.wire_contracts.is_empty() {
|
||||
return Err(anyhow!("wire_contracts must contain at least one entry"));
|
||||
}
|
||||
|
||||
let content_hash = if let Some(schema_json) = registration.schema_json.as_deref() {
|
||||
canonical_schema_hash(schema_json)?
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"schema_json is required so discovery can advertise canonical hashes"
|
||||
));
|
||||
};
|
||||
|
||||
self.entries.insert(
|
||||
registration.schema_id.clone(),
|
||||
CultNetSchemaDescriptor {
|
||||
schema_id: registration.schema_id,
|
||||
kind: registration.kind,
|
||||
schema_version: registration.schema_version,
|
||||
document_type: registration.document_type,
|
||||
title: registration.title,
|
||||
wire_contracts: registration.wire_contracts,
|
||||
content_hash,
|
||||
schema_json: registration.schema_json,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
schema_id: &str,
|
||||
include_schema_json: bool,
|
||||
) -> Option<CultNetSchemaDescriptor> {
|
||||
self.entries
|
||||
.get(schema_id)
|
||||
.map(|entry| sanitize_descriptor(entry, include_schema_json))
|
||||
}
|
||||
|
||||
pub fn list(&self, options: &CultNetSchemaCatalogOptions) -> Vec<CultNetSchemaDescriptor> {
|
||||
let requested_ids = options
|
||||
.schema_ids
|
||||
.as_ref()
|
||||
.map(|values| values.iter().cloned().collect::<BTreeSet<_>>());
|
||||
let requested_kinds = options
|
||||
.kinds
|
||||
.as_ref()
|
||||
.map(|values| values.iter().copied().collect::<BTreeSet<_>>());
|
||||
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
if let Some(requested_ids) = &requested_ids
|
||||
&& !requested_ids.contains(&entry.schema_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(requested_kinds) = &requested_kinds
|
||||
&& !requested_kinds.contains(&entry.kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.map(|entry| sanitize_descriptor(entry, options.include_schema_json))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_catalog_response(&self, request: &CultNetMessage) -> Result<CultNetMessage> {
|
||||
let CultNetMessage::SchemaCatalogRequest {
|
||||
message_id,
|
||||
include_schema_json,
|
||||
schema_ids,
|
||||
kinds,
|
||||
} = request
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"expected cultnet.schema_catalog_request.v0 for schema discovery"
|
||||
));
|
||||
};
|
||||
|
||||
Ok(CultNetMessage::SchemaCatalogResponse {
|
||||
message_id: message_id.clone(),
|
||||
schemas: self.list(&CultNetSchemaCatalogOptions {
|
||||
include_schema_json: include_schema_json.unwrap_or(false),
|
||||
schema_ids: schema_ids.clone(),
|
||||
kinds: kinds.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_descriptor(
|
||||
descriptor: &CultNetSchemaDescriptor,
|
||||
include_schema_json: bool,
|
||||
) -> CultNetSchemaDescriptor {
|
||||
let mut clone = descriptor.clone();
|
||||
if !include_schema_json {
|
||||
clone.schema_json = None;
|
||||
}
|
||||
clone
|
||||
}
|
||||
|
||||
fn canonical_schema_hash(schema_json: &str) -> Result<String> {
|
||||
let value: Value = serde_json::from_str(schema_json)?;
|
||||
let canonical = stable_stringify(&value);
|
||||
Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
fn stable_stringify(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => serde_json::to_string(value).expect("string serialization"),
|
||||
Value::Array(values) => format!(
|
||||
"[{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(stable_stringify)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
Value::Object(values) => {
|
||||
let mut entries = values.iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
format!(
|
||||
"{{{}}}",
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
serde_json::to_string(key).expect("key serialization"),
|
||||
stable_stringify(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
|
||||
let mut registry = CultNetSchemaRegistry::new();
|
||||
|
||||
for registration in [
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.hello.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.hello.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-mutation-contract.schema.json"),
|
||||
CultNetSchemaKind::SharedContract,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
None,
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.transport-profile.schema.json"),
|
||||
CultNetSchemaKind::SharedContract,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.transport_profile.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.login.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.login.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.register.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.register.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.verify.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.verify.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.login-success.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.login_success.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.error.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.error.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.sample-change-name.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.sample.change_name.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.sample-chat.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.sample.chat.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-put.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_put.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-delete.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_delete.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.raw-document-record.schema.json"),
|
||||
CultNetSchemaKind::SharedContract,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
None,
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-put-raw.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_put_raw.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-request.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_request.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-response.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_response.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-response-raw.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_response_raw.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.schema-catalog-request.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.schema_catalog_request.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.schema-catalog-response.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.schema_catalog_response.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.shard-catalog-request.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.shard_catalog_request.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.shard-catalog-response.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.shard_catalog_response.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/ghostlight.agent-state.schema.json"),
|
||||
CultNetSchemaKind::DocumentPayload,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("ghostlight.agent_state.v0"),
|
||||
Some("ghostlight.agent-state"),
|
||||
)?,
|
||||
] {
|
||||
registry.register(registration)?;
|
||||
}
|
||||
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn schema_registration(
|
||||
schema_json: &str,
|
||||
kind: CultNetSchemaKind,
|
||||
wire_contracts: Vec<CultNetWireContract>,
|
||||
schema_version: Option<&str>,
|
||||
document_type: Option<&str>,
|
||||
) -> Result<CultNetSchemaRegistration> {
|
||||
let value: Value = serde_json::from_str(schema_json)?;
|
||||
let schema_id = value
|
||||
.get("$id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("schema is missing $id"))?;
|
||||
let title = value
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
Ok(CultNetSchemaRegistration {
|
||||
schema_id: schema_id.to_string(),
|
||||
kind,
|
||||
wire_contracts,
|
||||
schema_version: schema_version.map(str::to_string),
|
||||
document_type: document_type.map(str::to_string),
|
||||
title,
|
||||
schema_json: Some(schema_json.to_string()),
|
||||
})
|
||||
}
|
||||
270
vendor/cultnet-rs/src/security.rs
vendored
270
vendor/cultnet-rs/src/security.rs
vendored
|
|
@ -1,270 +0,0 @@
|
|||
use aes_gcm::Aes256Gcm;
|
||||
use aes_gcm::KeyInit;
|
||||
use aes_gcm::Nonce;
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::aead::Payload;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::DateTime;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use hmac::Hmac;
|
||||
use hmac::Mac;
|
||||
use rand::RngCore;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
|
||||
const NONCE_LENGTH: usize = 12;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetClientSecurityOptions {
|
||||
pub connection_key: String,
|
||||
encryption_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl CultNetClientSecurityOptions {
|
||||
pub fn new(connection_key: impl Into<String>) -> Result<Self> {
|
||||
let connection_key = connection_key.into();
|
||||
if connection_key.trim().is_empty() {
|
||||
return Err(anyhow!("Connection key must be provided"));
|
||||
}
|
||||
let encryption_key = sha256(connection_key.as_bytes());
|
||||
Ok(Self {
|
||||
connection_key,
|
||||
encryption_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn development() -> Self {
|
||||
Self::new("gamecult-dev-connection-key").expect("development key is valid")
|
||||
}
|
||||
|
||||
pub fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetServerSecurityOptions {
|
||||
client: CultNetClientSecurityOptions,
|
||||
pub session_signing_secret: String,
|
||||
session_signing_key: [u8; 32],
|
||||
pub is_development: bool,
|
||||
}
|
||||
|
||||
impl CultNetServerSecurityOptions {
|
||||
pub fn new(
|
||||
connection_key: impl Into<String>,
|
||||
session_signing_secret: impl Into<String>,
|
||||
is_development: bool,
|
||||
) -> Result<Self> {
|
||||
let session_signing_secret = session_signing_secret.into();
|
||||
if session_signing_secret.trim().is_empty() {
|
||||
return Err(anyhow!("Session signing secret must be provided"));
|
||||
}
|
||||
Ok(Self {
|
||||
client: CultNetClientSecurityOptions::new(connection_key)?,
|
||||
session_signing_key: sha256(session_signing_secret.as_bytes()),
|
||||
session_signing_secret,
|
||||
is_development,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn development() -> Self {
|
||||
Self::new(
|
||||
"gamecult-dev-connection-key",
|
||||
"gamecult-dev-session-signing-secret",
|
||||
true,
|
||||
)
|
||||
.expect("development security config is valid")
|
||||
}
|
||||
|
||||
pub fn to_client_options(&self) -> CultNetClientSecurityOptions {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
pub fn encryption_key(&self) -> [u8; 32] {
|
||||
self.client.encryption_key()
|
||||
}
|
||||
|
||||
pub fn session_signing_key(&self) -> [u8; 32] {
|
||||
self.session_signing_key
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CultNetEncryptionOptions {
|
||||
fn encryption_key(&self) -> [u8; 32];
|
||||
}
|
||||
|
||||
impl CultNetEncryptionOptions for CultNetClientSecurityOptions {
|
||||
fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key()
|
||||
}
|
||||
}
|
||||
|
||||
impl CultNetEncryptionOptions for CultNetServerSecurityOptions {
|
||||
fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ValidatedCultNetSessionToken {
|
||||
pub user_id: String,
|
||||
pub expires_at_utc: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct CultNetSecret;
|
||||
|
||||
impl CultNetSecret {
|
||||
pub fn new_nonce() -> [u8; NONCE_LENGTH] {
|
||||
let mut nonce = [0_u8; NONCE_LENGTH];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
nonce
|
||||
}
|
||||
|
||||
pub fn encrypt_string(
|
||||
input: Option<&str>,
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let Some(input) = input.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Self::encrypt_bytes(input.as_bytes(), nonce, options)?))
|
||||
}
|
||||
|
||||
pub fn decrypt_string(
|
||||
encrypted: Option<&[u8]>,
|
||||
nonce: Option<&[u8]>,
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Option<String>> {
|
||||
let (Some(encrypted), Some(nonce)) = (encrypted, nonce) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = Self::decrypt_bytes(encrypted, nonce, options)?;
|
||||
Ok(Some(String::from_utf8(bytes)?))
|
||||
}
|
||||
|
||||
pub fn encrypt_bytes(
|
||||
input: &[u8],
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Vec<u8>> {
|
||||
let nonce = validate_nonce(nonce)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&options.encryption_key())?;
|
||||
let encrypted = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: input,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
pub fn decrypt_bytes(
|
||||
encrypted: &[u8],
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Vec<u8>> {
|
||||
let nonce = validate_nonce(nonce)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&options.encryption_key())?;
|
||||
cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: encrypted,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("AES-GCM decryption failed"))
|
||||
}
|
||||
|
||||
pub fn create_session_token(
|
||||
user_id: &str,
|
||||
expires_at_utc: DateTime<Utc>,
|
||||
options: &CultNetServerSecurityOptions,
|
||||
) -> Result<String> {
|
||||
let payload = format!("{}|{}", user_id, expires_at_utc.timestamp());
|
||||
let signature = hmac_sha256(&options.session_signing_key(), payload.as_bytes())?;
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(payload.as_bytes()),
|
||||
URL_SAFE_NO_PAD.encode(signature)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn try_validate_session_token(
|
||||
token: Option<&str>,
|
||||
options: &CultNetServerSecurityOptions,
|
||||
) -> Result<Option<ValidatedCultNetSessionToken>> {
|
||||
let Some(token) = token.filter(|value| !value.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some((payload_part, signature_part)) = token.split_once('.') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = URL_SAFE_NO_PAD.decode(payload_part)?;
|
||||
let signature = URL_SAFE_NO_PAD.decode(signature_part)?;
|
||||
let expected = hmac_sha256(&options.session_signing_key(), &payload)?;
|
||||
if !constant_time_eq(&signature, &expected) {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = String::from_utf8(payload)?;
|
||||
let Some((user_id, expires_at)) = payload.split_once('|') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let expires_at_seconds: i64 = expires_at.parse()?;
|
||||
let Some(expires_at_utc) = Utc.timestamp_opt(expires_at_seconds, 0).single() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if expires_at_utc <= Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(ValidatedCultNetSessionToken {
|
||||
user_id: user_id.to_string(),
|
||||
expires_at_utc,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn to_base64_url(input: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(input)
|
||||
}
|
||||
|
||||
pub fn from_base64_url(input: &str) -> Result<Vec<u8>> {
|
||||
Ok(URL_SAFE_NO_PAD.decode(input)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_nonce(nonce: &[u8]) -> Result<&[u8]> {
|
||||
if nonce.len() != NONCE_LENGTH {
|
||||
return Err(anyhow!("Invalid nonce"));
|
||||
}
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
fn sha256(input: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(input).into()
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], input: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(key)?;
|
||||
mac.update(input);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |acc, (left, right)| acc | (left ^ right))
|
||||
== 0
|
||||
}
|
||||
705
vendor/cultnet-rs/src/service_identity.rs
vendored
705
vendor/cultnet-rs/src/service_identity.rs
vendored
|
|
@ -1,705 +0,0 @@
|
|||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, SingleFileMessagePackBackingStore};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
use std::marker::PhantomData;
|
||||
use std::path::Path;
|
||||
|
||||
pub const WINDOWS_SERVICE_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort";
|
||||
pub const LINUX_SERVICE_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline";
|
||||
|
||||
/// Fixes every authority-bearing name at the call site. A service cannot open
|
||||
/// another service's identity by supplying a convenient runtime string.
|
||||
pub trait ServiceIdentityProfile: Send + Sync + 'static {
|
||||
const PRIVATE_TYPE: &'static str;
|
||||
const PRIVATE_SCHEMA: &'static str;
|
||||
const PRIVATE_KEY: &'static str;
|
||||
const TRUST_ANCHOR_TYPE: &'static str;
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str;
|
||||
const TRUST_ANCHOR_KEY: &'static str;
|
||||
const ID_DOMAIN: &'static [u8];
|
||||
const SIGNATURE_DOMAIN: &'static [u8];
|
||||
const PROTECTOR_CONTEXT: &'static str;
|
||||
}
|
||||
|
||||
/// A purpose is a type, not caller-provided prose. Protocol implementations
|
||||
/// define one zero-sized type per signed statement kind.
|
||||
pub trait ServiceSignaturePurpose<P: ServiceIdentityProfile>: Send + Sync + 'static {
|
||||
const PURPOSE: &'static [u8];
|
||||
}
|
||||
|
||||
pub enum IdunnServiceIdentity {}
|
||||
|
||||
/// Root-operated identity dedicated to deployment-brake release grants. The
|
||||
/// Idunn daemon receives only this profile's public trust anchor; opening the
|
||||
/// private store is a provisioning operation, never daemon startup work.
|
||||
pub enum IdunnDeploymentBrakeOperatorIdentity {}
|
||||
|
||||
pub struct IdunnDeploymentBrakeReleasePurpose;
|
||||
|
||||
impl ServiceSignaturePurpose<IdunnDeploymentBrakeOperatorIdentity>
|
||||
for IdunnDeploymentBrakeReleasePurpose
|
||||
{
|
||||
const PURPOSE: &'static [u8] = b"idunn.deployment-release.v1";
|
||||
}
|
||||
|
||||
impl ServiceIdentityProfile for IdunnDeploymentBrakeOperatorIdentity {
|
||||
const PRIVATE_TYPE: &'static str = "idunn.deployment_brake_operator.private.v1";
|
||||
const PRIVATE_SCHEMA: &'static str = "idunn.deployment_brake_operator.private.v1";
|
||||
const PRIVATE_KEY: &'static str = "idunn-deployment-brake-operator";
|
||||
const TRUST_ANCHOR_TYPE: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_KEY: &'static str = "idunn-deployment-brake-operator-public";
|
||||
const ID_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.id.v1\0";
|
||||
const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.signature.v1\0";
|
||||
const PROTECTOR_CONTEXT: &'static str = "idunn-deployment-brake-operator-v1";
|
||||
}
|
||||
|
||||
/// Dedicated provider-owned identity for generic daemon-health statements.
|
||||
/// It is intentionally independent of any one provider runtime or repository.
|
||||
pub enum GameCultProviderHealthIdentity {}
|
||||
|
||||
/// The only generic health statement purpose accepted from provider-health
|
||||
/// identities by Idunn admission.
|
||||
pub struct IdunnSignedDaemonHealthPurpose;
|
||||
|
||||
impl ServiceSignaturePurpose<GameCultProviderHealthIdentity> for IdunnSignedDaemonHealthPurpose {
|
||||
const PURPOSE: &'static [u8] = b"idunn.signed_daemon_health.v1";
|
||||
}
|
||||
|
||||
impl ServiceIdentityProfile for GameCultProviderHealthIdentity {
|
||||
const PRIVATE_TYPE: &'static str = "gamecult.provider_health_identity.private.v1";
|
||||
const PRIVATE_SCHEMA: &'static str = "gamecult.provider_health_identity.private.v1";
|
||||
const PRIVATE_KEY: &'static str = "gamecult-provider-health-identity";
|
||||
const TRUST_ANCHOR_TYPE: &'static str = "gamecult.provider_health_identity.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str = "gamecult.provider_health_identity.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_KEY: &'static str = "gamecult-provider-health-identity-public";
|
||||
const ID_DOMAIN: &'static [u8] = b"gamecult.provider-health.identity.v1\0";
|
||||
const SIGNATURE_DOMAIN: &'static [u8] = b"gamecult.provider-health.signature.v1\0";
|
||||
const PROTECTOR_CONTEXT: &'static str = "gamecult-provider-health-identity-v1";
|
||||
}
|
||||
|
||||
/// The only signing purpose accepted for Idunn's public projection of a
|
||||
/// provider-authenticated health admission. Keeping this profile here makes
|
||||
/// purpose selection a compile-time protocol choice rather than caller text.
|
||||
pub struct IdunnAuthenticatedProviderHealthProjectionPurpose;
|
||||
|
||||
impl ServiceSignaturePurpose<IdunnServiceIdentity>
|
||||
for IdunnAuthenticatedProviderHealthProjectionPurpose
|
||||
{
|
||||
const PURPOSE: &'static [u8] = b"idunn.authenticated-provider-health-projection.v1";
|
||||
}
|
||||
|
||||
impl ServiceIdentityProfile for IdunnServiceIdentity {
|
||||
const PRIVATE_TYPE: &'static str = "idunn.service_identity.private.v1";
|
||||
const PRIVATE_SCHEMA: &'static str = "idunn.service_identity.private.v1";
|
||||
const PRIVATE_KEY: &'static str = "idunn-service-identity";
|
||||
const TRUST_ANCHOR_TYPE: &'static str = "idunn.service_identity.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.service_identity.trust_anchor.v1";
|
||||
const TRUST_ANCHOR_KEY: &'static str = "idunn-service-identity-public";
|
||||
const ID_DOMAIN: &'static [u8] = b"idunn.service-identity.id.v1\0";
|
||||
const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.service-identity.signature.v1\0";
|
||||
const PROTECTOR_CONTEXT: &'static str = "idunn-service-identity-v1";
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ServiceIdentityPrivateEntry {
|
||||
pub schema_version: String,
|
||||
pub identity_id: String,
|
||||
pub public_key: Vec<u8>,
|
||||
pub protected_private_seed: Vec<u8>,
|
||||
pub protector_kind: String,
|
||||
pub protector_binding: String,
|
||||
pub protector_version: String,
|
||||
pub assurance: String,
|
||||
pub created_at: String,
|
||||
pub enrollment_nonce: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServiceIdentityTrustAnchor {
|
||||
pub schema_version: String,
|
||||
pub identity_id: String,
|
||||
pub public_key: Vec<u8>,
|
||||
pub assurance: String,
|
||||
pub identity_created_at: String,
|
||||
pub source_identity_record_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ServiceIdentitySignature {
|
||||
pub identity_id: String,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The only value holding unprotected key material. It exposes signing, never
|
||||
/// seed export or access to the underlying signing key.
|
||||
pub struct ServiceIdentitySigner<P: ServiceIdentityProfile> {
|
||||
entry: ServiceIdentityPrivateEntry,
|
||||
signing_key: SigningKey,
|
||||
profile: PhantomData<P>,
|
||||
}
|
||||
|
||||
impl<P: ServiceIdentityProfile> ServiceIdentitySigner<P> {
|
||||
pub fn entry(&self) -> &ServiceIdentityPrivateEntry {
|
||||
&self.entry
|
||||
}
|
||||
|
||||
pub fn trust_anchor(&self) -> Result<ServiceIdentityTrustAnchor> {
|
||||
Ok(ServiceIdentityTrustAnchor {
|
||||
schema_version: P::TRUST_ANCHOR_SCHEMA.into(),
|
||||
identity_id: self.entry.identity_id.clone(),
|
||||
public_key: self.entry.public_key.clone(),
|
||||
assurance: self.entry.assurance.clone(),
|
||||
identity_created_at: self.entry.created_at.clone(),
|
||||
source_identity_record_sha256: format!(
|
||||
"sha256-{}",
|
||||
hex(&Sha256::digest(rmp_serde::to_vec(&self.entry)?))
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sign<S: ServiceSignaturePurpose<P>>(&self, payload: &[u8]) -> ServiceIdentitySignature {
|
||||
ServiceIdentitySignature {
|
||||
identity_id: self.entry.identity_id.clone(),
|
||||
signature: self
|
||||
.signing_key
|
||||
.sign(&signing_message::<P, S>(payload))
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enroll_service_identity_at<P: ServiceIdentityProfile>(
|
||||
path: &Path,
|
||||
) -> Result<ServiceIdentitySigner<P>> {
|
||||
if path.exists() {
|
||||
bail!(
|
||||
"service identity store {} already exists; enrollment is immutable",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
prepare_parent(path)?;
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let mut nonce = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
let public_key = signing_key.verifying_key().to_bytes();
|
||||
let binding = platform_binding::<P>()?;
|
||||
let entry = ServiceIdentityPrivateEntry {
|
||||
schema_version: P::PRIVATE_SCHEMA.into(),
|
||||
identity_id: identity_id::<P>(&public_key),
|
||||
public_key: public_key.to_vec(),
|
||||
protected_private_seed: protect_seed::<P>(&seed, &binding)?,
|
||||
protector_kind: platform_protector_kind().into(),
|
||||
protector_binding: binding,
|
||||
protector_version: "v1".into(),
|
||||
assurance: platform_assurance().into(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
enrollment_nonce: nonce.to_vec(),
|
||||
};
|
||||
validate_private::<P>(&entry)?;
|
||||
let envelope = private_envelope::<P>(&entry)?;
|
||||
atomic_create_private_store(path, &envelope)?;
|
||||
harden_store_permissions(path)?;
|
||||
open_service_identity_at::<P>(path)
|
||||
}
|
||||
|
||||
pub fn open_service_identity_at<P: ServiceIdentityProfile>(
|
||||
path: &Path,
|
||||
) -> Result<ServiceIdentitySigner<P>> {
|
||||
if !path.is_file() {
|
||||
bail!("service identity store {} does not exist", path.display());
|
||||
}
|
||||
let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?;
|
||||
if entries.len() != 1 {
|
||||
bail!("service identity store must contain exactly one immutable envelope");
|
||||
}
|
||||
let envelope = &entries[0];
|
||||
if envelope.r#type != P::PRIVATE_TYPE
|
||||
|| envelope.key != P::PRIVATE_KEY
|
||||
|| envelope.schema_id.as_deref() != Some(P::PRIVATE_SCHEMA)
|
||||
{
|
||||
bail!("service identity store belongs to a different profile or schema");
|
||||
}
|
||||
let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload)
|
||||
.context("service identity private payload is malformed MessagePack")?;
|
||||
validate_private::<P>(&entry)?;
|
||||
if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance()
|
||||
{
|
||||
bail!("service identity protector does not belong to this platform implementation");
|
||||
}
|
||||
let binding = platform_binding::<P>()?;
|
||||
if entry.protector_binding != binding {
|
||||
bail!("service identity protector binding does not match this OS installation or profile");
|
||||
}
|
||||
let seed: [u8; 32] = unprotect_seed::<P>(&entry.protected_private_seed, &binding)?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("unprotected service identity seed has invalid length"))?;
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
if signing_key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() {
|
||||
bail!("service identity private seed does not match enrolled public key");
|
||||
}
|
||||
Ok(ServiceIdentitySigner {
|
||||
entry,
|
||||
signing_key,
|
||||
profile: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn export_service_identity_trust_anchor<P: ServiceIdentityProfile>(
|
||||
signer: &ServiceIdentitySigner<P>,
|
||||
path: &Path,
|
||||
) -> Result<ServiceIdentityTrustAnchor> {
|
||||
let anchor = signer.trust_anchor()?;
|
||||
prepare_parent(path)?;
|
||||
let envelope = CultCacheEnvelope {
|
||||
key: P::TRUST_ANCHOR_KEY.into(),
|
||||
r#type: P::TRUST_ANCHOR_TYPE.into(),
|
||||
payload: rmp_serde::to_vec(&anchor)?,
|
||||
stored_at: anchor.identity_created_at.clone(),
|
||||
schema_id: Some(P::TRUST_ANCHOR_SCHEMA.into()),
|
||||
};
|
||||
let mut backing = SingleFileMessagePackBackingStore::new(path);
|
||||
match backing.pull_all()?.as_slice() {
|
||||
[] => backing.push(&envelope)?,
|
||||
[current] if current == &envelope => {}
|
||||
_ => bail!("service identity trust anchor output already contains different state"),
|
||||
}
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
pub fn verify_service_identity_signature<P, S>(
|
||||
anchor: &ServiceIdentityTrustAnchor,
|
||||
payload: &[u8],
|
||||
proof: &ServiceIdentitySignature,
|
||||
) -> Result<()>
|
||||
where
|
||||
P: ServiceIdentityProfile,
|
||||
S: ServiceSignaturePurpose<P>,
|
||||
{
|
||||
validate_anchor::<P>(anchor)?;
|
||||
if proof.identity_id != anchor.identity_id {
|
||||
bail!("service identity signature names a different identity");
|
||||
}
|
||||
let key: [u8; 32] = anchor
|
||||
.public_key
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("service identity public key has invalid length"))?;
|
||||
let signature = Signature::from_slice(&proof.signature)
|
||||
.map_err(|_| anyhow!("service identity signature has invalid length"))?;
|
||||
VerifyingKey::from_bytes(&key)?
|
||||
.verify(&signing_message::<P, S>(payload), &signature)
|
||||
.map_err(|_| anyhow!("service identity signature verification failed"))
|
||||
}
|
||||
|
||||
/// Verify against a public key already admitted by an owning trust store.
|
||||
/// The profile derives the identity, so callers cannot substitute a convenient
|
||||
/// identity string while retaining the same signature bytes.
|
||||
pub fn verify_service_identity_signature_with_public_key<P, S>(
|
||||
public_key: &[u8],
|
||||
payload: &[u8],
|
||||
proof: &ServiceIdentitySignature,
|
||||
) -> Result<()>
|
||||
where
|
||||
P: ServiceIdentityProfile,
|
||||
S: ServiceSignaturePurpose<P>,
|
||||
{
|
||||
if proof.identity_id != derive_service_identity_id::<P>(public_key)? {
|
||||
bail!("service identity signature names a different identity");
|
||||
}
|
||||
let key: [u8; 32] = public_key
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("service identity public key has invalid length"))?;
|
||||
let signature = Signature::from_slice(&proof.signature)
|
||||
.map_err(|_| anyhow!("service identity signature has invalid length"))?;
|
||||
VerifyingKey::from_bytes(&key)?
|
||||
.verify(&signing_message::<P, S>(payload), &signature)
|
||||
.map_err(|_| anyhow!("service identity signature verification failed"))
|
||||
}
|
||||
|
||||
fn validate_private<P: ServiceIdentityProfile>(entry: &ServiceIdentityPrivateEntry) -> Result<()> {
|
||||
if entry.schema_version != P::PRIVATE_SCHEMA
|
||||
|| entry.public_key.len() != 32
|
||||
|| entry.enrollment_nonce.len() != 32
|
||||
|| entry.protected_private_seed.is_empty()
|
||||
|| entry.protector_version != "v1"
|
||||
{
|
||||
bail!("service identity private entry violates its profile schema");
|
||||
}
|
||||
chrono::DateTime::parse_from_rfc3339(&entry.created_at)
|
||||
.map_err(|_| anyhow!("service identity created_at is not RFC3339"))?;
|
||||
if identity_id::<P>(&entry.public_key) != entry.identity_id {
|
||||
bail!("service identity id does not match public key");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_anchor<P: ServiceIdentityProfile>(anchor: &ServiceIdentityTrustAnchor) -> Result<()> {
|
||||
if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA
|
||||
|| anchor.public_key.len() != 32
|
||||
|| derive_service_identity_id::<P>(&anchor.public_key)? != anchor.identity_id
|
||||
{
|
||||
bail!("service identity trust anchor violates its profile schema");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derives the profile-bound identity id from an exact Ed25519 public key.
|
||||
/// Consumers use this instead of accepting a caller-supplied identity string.
|
||||
pub fn derive_service_identity_id<P: ServiceIdentityProfile>(public_key: &[u8]) -> Result<String> {
|
||||
if public_key.len() != 32 {
|
||||
bail!("service identity public key has invalid length");
|
||||
}
|
||||
Ok(hex(&Sha256::digest([P::ID_DOMAIN, public_key].concat())))
|
||||
}
|
||||
|
||||
fn identity_id<P: ServiceIdentityProfile>(key: &[u8]) -> String {
|
||||
derive_service_identity_id::<P>(key).expect("internal service identity key is Ed25519")
|
||||
}
|
||||
|
||||
fn signing_message<P: ServiceIdentityProfile, S: ServiceSignaturePurpose<P>>(
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut out =
|
||||
Vec::with_capacity(P::SIGNATURE_DOMAIN.len() + S::PURPOSE.len() + payload.len() + 16);
|
||||
out.extend_from_slice(P::SIGNATURE_DOMAIN);
|
||||
out.extend_from_slice(&(S::PURPOSE.len() as u64).to_be_bytes());
|
||||
out.extend_from_slice(S::PURPOSE);
|
||||
out.extend_from_slice(&(payload.len() as u64).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
fn private_envelope<P: ServiceIdentityProfile>(
|
||||
entry: &ServiceIdentityPrivateEntry,
|
||||
) -> Result<CultCacheEnvelope> {
|
||||
Ok(CultCacheEnvelope {
|
||||
key: P::PRIVATE_KEY.into(),
|
||||
r#type: P::PRIVATE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec(entry)?,
|
||||
stored_at: entry.created_at.clone(),
|
||||
schema_id: Some(P::PRIVATE_SCHEMA.into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_parent(path: &Path) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("service identity path has no parent"))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enrollment is a one-shot authority transition. `create_new` makes two
|
||||
/// simultaneous enrollments unable to overwrite one another, while the encoded
|
||||
/// envelope vector remains a supported CultCache single-file representation.
|
||||
fn atomic_create_private_store(path: &Path, envelope: &CultCacheEnvelope) -> Result<()> {
|
||||
let bytes = rmp_serde::to_vec(&vec![envelope.clone()])
|
||||
.context("failed to encode immutable service identity store")?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"service identity store {} already exists or cannot be created; enrollment is immutable",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(error).context("failed to persist immutable service identity store");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn harden_store_permissions(_path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_protector_kind() -> &'static str {
|
||||
"windows_dpapi_current_user"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_protector_kind() -> &'static str {
|
||||
"linux_file_mode_machine_id_binding"
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn platform_assurance() -> &'static str {
|
||||
WINDOWS_SERVICE_IDENTITY_ASSURANCE
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_assurance() -> &'static str {
|
||||
LINUX_SERVICE_IDENTITY_ASSURANCE
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_binding<P: ServiceIdentityProfile>() -> Result<String> {
|
||||
Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_binding<P: ServiceIdentityProfile>() -> Result<String> {
|
||||
let raw = std::fs::read_to_string("/etc/machine-id")
|
||||
.or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
|
||||
.context("Linux machine-id is unavailable")?;
|
||||
let id = raw.trim();
|
||||
if id.is_empty() {
|
||||
bail!("Linux machine-id is empty");
|
||||
}
|
||||
Ok(format!(
|
||||
"{}:machine-id-sha256:{}",
|
||||
P::PROTECTOR_CONTEXT,
|
||||
hex(&Sha256::digest(id.as_bytes()))
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn protect_seed<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
|
||||
dpapi::<P>(seed, binding, true)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
|
||||
dpapi::<P>(seed, binding, false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn dpapi<P: ServiceIdentityProfile>(input: &[u8], binding: &str, protect: bool) -> Result<Vec<u8>> {
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
CRYPT_INTEGER_BLOB, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData,
|
||||
};
|
||||
let mut input = input.to_vec();
|
||||
let mut entropy = Sha256::digest(
|
||||
[
|
||||
b"gamecult-service-identity-dpapi-v1\0".as_slice(),
|
||||
P::PROTECTOR_CONTEXT.as_bytes(),
|
||||
binding.as_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.to_vec();
|
||||
let ib = CRYPT_INTEGER_BLOB {
|
||||
cbData: input.len() as u32,
|
||||
pbData: input.as_mut_ptr(),
|
||||
};
|
||||
let eb = CRYPT_INTEGER_BLOB {
|
||||
cbData: entropy.len() as u32,
|
||||
pbData: entropy.as_mut_ptr(),
|
||||
};
|
||||
let mut output = CRYPT_INTEGER_BLOB {
|
||||
cbData: 0,
|
||||
pbData: std::ptr::null_mut(),
|
||||
};
|
||||
let ok = unsafe {
|
||||
if protect {
|
||||
CryptProtectData(
|
||||
&ib,
|
||||
std::ptr::null(),
|
||||
&eb,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut output,
|
||||
)
|
||||
} else {
|
||||
CryptUnprotectData(
|
||||
&ib,
|
||||
std::ptr::null_mut(),
|
||||
&eb,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut output,
|
||||
)
|
||||
}
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("DPAPI service identity operation failed");
|
||||
}
|
||||
let bytes =
|
||||
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
|
||||
unsafe {
|
||||
LocalFree(output.pbData.cast());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn protect_seed<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
|
||||
xor_linux::<P>(seed, binding)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
|
||||
if seed.len() != 32 {
|
||||
bail!("protected Linux service seed has invalid length");
|
||||
}
|
||||
xor_linux::<P>(seed, binding)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn xor_linux<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
|
||||
let mask = Sha256::digest(
|
||||
[
|
||||
b"gamecult-linux-service-seed-v1\0".as_slice(),
|
||||
P::PROTECTOR_CONTEXT.as_bytes(),
|
||||
binding.as_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
Ok(seed.iter().zip(mask).map(|(a, b)| a ^ b).collect())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const D: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(D[(b >> 4) as usize] as char);
|
||||
s.push(D[(b & 15) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
enum OtherPurpose {}
|
||||
impl ServiceSignaturePurpose<IdunnServiceIdentity> for OtherPurpose {
|
||||
const PURPOSE: &'static [u8] = b"idunn.other.v1";
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_enrollment_reopens_and_is_purpose_and_payload_bound() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("idunn.ccmp");
|
||||
let signer = enroll_service_identity_at::<IdunnServiceIdentity>(&path)?;
|
||||
let proof = signer.sign::<IdunnAuthenticatedProviderHealthProjectionPurpose>(b"healthy");
|
||||
let anchor = signer.trust_anchor()?;
|
||||
verify_service_identity_signature::<
|
||||
IdunnServiceIdentity,
|
||||
IdunnAuthenticatedProviderHealthProjectionPurpose,
|
||||
>(&anchor, b"healthy", &proof)?;
|
||||
assert!(
|
||||
verify_service_identity_signature::<
|
||||
IdunnServiceIdentity,
|
||||
IdunnAuthenticatedProviderHealthProjectionPurpose,
|
||||
>(&anchor, b"unhealthy", &proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
verify_service_identity_signature::<IdunnServiceIdentity, OtherPurpose>(
|
||||
&anchor, b"healthy", &proof
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&path).is_err());
|
||||
assert_eq!(
|
||||
derive_service_identity_id::<IdunnServiceIdentity>(&anchor.public_key)?,
|
||||
anchor.identity_id
|
||||
);
|
||||
assert!(derive_service_identity_id::<IdunnServiceIdentity>(&[0; 31]).is_err());
|
||||
assert_eq!(
|
||||
open_service_identity_at::<IdunnServiceIdentity>(&path)?.entry(),
|
||||
signer.entry()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_profile_substituted_state_fails_closed_without_replacement() -> Result<()> {
|
||||
enum Alien {}
|
||||
impl ServiceIdentityProfile for Alien {
|
||||
const PRIVATE_TYPE: &'static str = "alien.private.v1";
|
||||
const PRIVATE_SCHEMA: &'static str = "alien.private.v1";
|
||||
const PRIVATE_KEY: &'static str = "alien";
|
||||
const TRUST_ANCHOR_TYPE: &'static str = "alien.anchor.v1";
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str = "alien.anchor.v1";
|
||||
const TRUST_ANCHOR_KEY: &'static str = "alien-public";
|
||||
const ID_DOMAIN: &'static [u8] = b"alien.id\0";
|
||||
const SIGNATURE_DOMAIN: &'static [u8] = b"alien.sig\0";
|
||||
const PROTECTOR_CONTEXT: &'static str = "alien-v1";
|
||||
}
|
||||
let temp = tempfile::tempdir()?;
|
||||
let malformed = temp.path().join("bad.ccmp");
|
||||
std::fs::write(&malformed, b"rot")?;
|
||||
let before = std::fs::read(&malformed)?;
|
||||
assert!(open_service_identity_at::<IdunnServiceIdentity>(&malformed).is_err());
|
||||
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&malformed).is_err());
|
||||
assert_eq!(before, std::fs::read(&malformed)?);
|
||||
let good = temp.path().join("good.ccmp");
|
||||
enroll_service_identity_at::<IdunnServiceIdentity>(&good)?;
|
||||
assert!(open_service_identity_at::<Alien>(&good).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let signer =
|
||||
enroll_service_identity_at::<IdunnServiceIdentity>(&temp.path().join("private.ccmp"))?;
|
||||
let output = temp.path().join("public.ccmp");
|
||||
let anchor = export_service_identity_trust_anchor(&signer, &output)?;
|
||||
let bytes = std::fs::read(&output)?;
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(signer.entry().protected_private_seed.len())
|
||||
.any(|w| w == signer.entry().protected_private_seed)
|
||||
);
|
||||
export_service_identity_trust_anchor(&signer, &output)?;
|
||||
assert_eq!(bytes, std::fs::read(&output)?);
|
||||
let other =
|
||||
enroll_service_identity_at::<IdunnServiceIdentity>(&temp.path().join("other.ccmp"))?;
|
||||
let proof = other.sign::<IdunnAuthenticatedProviderHealthProjectionPurpose>(b"healthy");
|
||||
assert!(
|
||||
verify_service_identity_signature::<
|
||||
IdunnServiceIdentity,
|
||||
IdunnAuthenticatedProviderHealthProjectionPurpose,
|
||||
>(&anchor, b"healthy", &proof)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_health_profile_is_domain_separated_and_purpose_bound() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let signer = enroll_service_identity_at::<GameCultProviderHealthIdentity>(
|
||||
&temp.path().join("provider-health.cc"),
|
||||
)?;
|
||||
let proof = signer.sign::<IdunnSignedDaemonHealthPurpose>(b"signed-health-record");
|
||||
let anchor = signer.trust_anchor()?;
|
||||
verify_service_identity_signature::<
|
||||
GameCultProviderHealthIdentity,
|
||||
IdunnSignedDaemonHealthPurpose,
|
||||
>(&anchor, b"signed-health-record", &proof)?;
|
||||
verify_service_identity_signature_with_public_key::<
|
||||
GameCultProviderHealthIdentity,
|
||||
IdunnSignedDaemonHealthPurpose,
|
||||
>(&anchor.public_key, b"signed-health-record", &proof)?;
|
||||
assert_eq!(
|
||||
anchor.schema_version,
|
||||
"gamecult.provider_health_identity.trust_anchor.v1"
|
||||
);
|
||||
assert_ne!(
|
||||
derive_service_identity_id::<GameCultProviderHealthIdentity>(&anchor.public_key)?,
|
||||
derive_service_identity_id::<IdunnServiceIdentity>(&anchor.public_key)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
134
vendor/cultnet-rs/src/shard_catalog.rs
vendored
134
vendor/cultnet-rs/src/shard_catalog.rs
vendored
|
|
@ -1,134 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CultNetMessage;
|
||||
use crate::CultNetShardDescriptor;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetShardCatalogOptions {
|
||||
pub schema_ids: Option<Vec<String>>,
|
||||
pub record_keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultNetShardCatalog {
|
||||
shards: BTreeMap<String, CultNetShardDescriptor>,
|
||||
}
|
||||
|
||||
impl CultNetShardCatalog {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, descriptor: CultNetShardDescriptor) -> Result<()> {
|
||||
validate_descriptor(&descriptor)?;
|
||||
self.shards.insert(descriptor.shard_id.clone(), descriptor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, shard_id: &str) -> Option<&CultNetShardDescriptor> {
|
||||
self.shards.get(shard_id)
|
||||
}
|
||||
|
||||
pub fn list(&self, options: &CultNetShardCatalogOptions) -> Vec<CultNetShardDescriptor> {
|
||||
let schema_ids = options
|
||||
.schema_ids
|
||||
.as_ref()
|
||||
.map(|values| values.iter().cloned().collect::<BTreeSet<_>>());
|
||||
let record_keys = options
|
||||
.record_keys
|
||||
.as_ref()
|
||||
.map(|values| values.iter().cloned().collect::<BTreeSet<_>>());
|
||||
|
||||
self.shards
|
||||
.values()
|
||||
.filter(|shard| {
|
||||
let schema_match = schema_ids.as_ref().is_none_or(|requested| {
|
||||
requested
|
||||
.iter()
|
||||
.any(|schema_id| shard.serves(Some(schema_id), None))
|
||||
});
|
||||
let key_match = record_keys.as_ref().is_none_or(|requested| {
|
||||
requested
|
||||
.iter()
|
||||
.any(|record_key| shard.serves(None, Some(record_key)))
|
||||
});
|
||||
schema_match && key_match
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_catalog_response(&self, request: &CultNetMessage) -> Result<CultNetMessage> {
|
||||
let CultNetMessage::ShardCatalogRequest {
|
||||
message_id,
|
||||
schema_ids,
|
||||
record_keys,
|
||||
} = request
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"expected cultnet.shard_catalog_request.v0 for shard discovery"
|
||||
));
|
||||
};
|
||||
|
||||
Ok(CultNetMessage::ShardCatalogResponse {
|
||||
message_id: message_id.clone(),
|
||||
shards: self.list(&CultNetShardCatalogOptions {
|
||||
schema_ids: schema_ids.clone(),
|
||||
record_keys: record_keys.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_response(
|
||||
&mut self,
|
||||
response: &CultNetMessage,
|
||||
) -> Result<Vec<CultNetShardDescriptor>> {
|
||||
let CultNetMessage::ShardCatalogResponse { shards, .. } = response else {
|
||||
return Err(anyhow!(
|
||||
"expected cultnet.shard_catalog_response.v0 for shard discovery"
|
||||
));
|
||||
};
|
||||
|
||||
let mut applied = Vec::with_capacity(shards.len());
|
||||
for shard in shards {
|
||||
self.upsert(shard.clone())?;
|
||||
applied.push(shard.clone());
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_descriptor(descriptor: &CultNetShardDescriptor) -> Result<()> {
|
||||
if descriptor.shard_id.trim().is_empty() {
|
||||
return Err(anyhow!("shard_id must be non-empty"));
|
||||
}
|
||||
if descriptor.owner_runtime_id.trim().is_empty() {
|
||||
return Err(anyhow!("owner_runtime_id must be non-empty"));
|
||||
}
|
||||
for schema_id in &descriptor.schema_ids {
|
||||
if schema_id.trim().is_empty() {
|
||||
return Err(anyhow!("schema_ids must not contain empty values"));
|
||||
}
|
||||
}
|
||||
if descriptor
|
||||
.key_prefix
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!("key_prefix must be non-empty when present"));
|
||||
}
|
||||
for endpoint in descriptor
|
||||
.primary_endpoints
|
||||
.iter()
|
||||
.chain(descriptor.replica_endpoints.iter())
|
||||
.chain(descriptor.read_replica_endpoints.iter())
|
||||
{
|
||||
if endpoint.trim().is_empty() {
|
||||
return Err(anyhow!("shard endpoints must not contain empty values"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
288
vendor/cultnet-rs/src/transport.rs
vendored
288
vendor/cultnet-rs/src/transport.rs
vendored
|
|
@ -1,288 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::CultNetReconnectPolicy;
|
||||
use crate::CultNetTransportChannel;
|
||||
use crate::CultNetTransportDelivery;
|
||||
use crate::CultNetTransportDescriptor;
|
||||
use crate::CultNetTransportOrdering;
|
||||
use crate::CultNetTransportProfile;
|
||||
use crate::CultNetTransportProtocol;
|
||||
use crate::FRAME_HEADER_BYTES;
|
||||
use crate::encode_frame;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetTransportStats {
|
||||
pub bytes_received: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub frames_received: u64,
|
||||
pub frames_sent: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetTransportFrame {
|
||||
pub channel_id: String,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CultNetReconnectPolicyOptions {
|
||||
pub policy_id: String,
|
||||
pub base_delay_ms: u64,
|
||||
pub max_delay_ms: u64,
|
||||
pub max_jitter_ms: u64,
|
||||
pub max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for CultNetReconnectPolicyOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
policy_id: "default".to_string(),
|
||||
base_delay_ms: 1_000,
|
||||
max_delay_ms: 30_000,
|
||||
max_jitter_ms: 250,
|
||||
max_attempts: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_reconnect_policy(options: CultNetReconnectPolicyOptions) -> CultNetReconnectPolicy {
|
||||
CultNetReconnectPolicy {
|
||||
schema_version: "cultnet.reconnect_policy.v0".to_string(),
|
||||
policy_id: if options.policy_id.trim().is_empty() {
|
||||
"default".to_string()
|
||||
} else {
|
||||
options.policy_id
|
||||
},
|
||||
base_delay_ms: options.base_delay_ms,
|
||||
max_delay_ms: options.max_delay_ms,
|
||||
max_jitter_ms: options.max_jitter_ms,
|
||||
max_attempts: options.max_attempts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_reconnect_delay_ms(
|
||||
policy: &CultNetReconnectPolicy,
|
||||
attempt: u32,
|
||||
jitter_ms: u64,
|
||||
) -> u64 {
|
||||
let normalized_attempt = attempt.max(1);
|
||||
let multiplier = 2_u64.saturating_pow(normalized_attempt.saturating_sub(1));
|
||||
let capped_base_delay = policy
|
||||
.base_delay_ms
|
||||
.saturating_mul(multiplier)
|
||||
.min(policy.max_delay_ms);
|
||||
capped_base_delay + jitter_ms.min(policy.max_jitter_ms)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetReconnectDecision {
|
||||
pub attempt: u32,
|
||||
pub should_retry: bool,
|
||||
pub delay_ms: u64,
|
||||
pub next_attempt_at_ms: Option<u64>,
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CultNetReconnectController {
|
||||
pub policy: CultNetReconnectPolicy,
|
||||
attempt: u32,
|
||||
next_attempt_at_ms: Option<u64>,
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
impl Default for CultNetReconnectController {
|
||||
fn default() -> Self {
|
||||
Self::new(create_reconnect_policy(Default::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CultNetReconnectController {
|
||||
pub fn new(policy: CultNetReconnectPolicy) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
attempt: 0,
|
||||
next_attempt_at_ms: None,
|
||||
exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attempt(&self) -> u32 {
|
||||
self.attempt
|
||||
}
|
||||
|
||||
pub fn next_attempt_at_ms(&self) -> Option<u64> {
|
||||
self.next_attempt_at_ms
|
||||
}
|
||||
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.exhausted
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.attempt = 0;
|
||||
self.next_attempt_at_ms = None;
|
||||
self.exhausted = false;
|
||||
}
|
||||
|
||||
pub fn can_attempt(&self, now_ms: u64) -> bool {
|
||||
!self.exhausted
|
||||
&& self
|
||||
.next_attempt_at_ms
|
||||
.is_none_or(|next_attempt_at_ms| now_ms >= next_attempt_at_ms)
|
||||
}
|
||||
|
||||
pub fn record_failure(&mut self, now_ms: u64, jitter_ms: u64) -> CultNetReconnectDecision {
|
||||
let next_attempt = self.attempt.saturating_add(1);
|
||||
if self
|
||||
.policy
|
||||
.max_attempts
|
||||
.is_some_and(|max_attempts| next_attempt > max_attempts)
|
||||
{
|
||||
self.exhausted = true;
|
||||
self.next_attempt_at_ms = None;
|
||||
return CultNetReconnectDecision {
|
||||
attempt: self.attempt,
|
||||
should_retry: false,
|
||||
delay_ms: 0,
|
||||
next_attempt_at_ms: None,
|
||||
exhausted: true,
|
||||
};
|
||||
}
|
||||
|
||||
self.attempt = next_attempt;
|
||||
let delay_ms = compute_reconnect_delay_ms(&self.policy, self.attempt, jitter_ms);
|
||||
let next_attempt_at_ms = now_ms.saturating_add(delay_ms);
|
||||
self.next_attempt_at_ms = Some(next_attempt_at_ms);
|
||||
CultNetReconnectDecision {
|
||||
attempt: self.attempt,
|
||||
should_retry: true,
|
||||
delay_ms,
|
||||
next_attempt_at_ms: Some(next_attempt_at_ms),
|
||||
exhausted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TcpFramedTransportProfileOptions {
|
||||
pub transport_id: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub max_payload_bytes: Option<u32>,
|
||||
pub max_fragment_bytes: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn create_tcp_framed_transport_profile(
|
||||
runtime_id: impl Into<String>,
|
||||
options: TcpFramedTransportProfileOptions,
|
||||
) -> CultNetTransportProfile {
|
||||
CultNetTransportProfile {
|
||||
schema_version: "cultnet.transport_profile.v0".to_string(),
|
||||
runtime_id: runtime_id.into(),
|
||||
transports: vec![CultNetTransportDescriptor {
|
||||
transport_id: options
|
||||
.transport_id
|
||||
.unwrap_or_else(|| "tcp-framed".to_string()),
|
||||
protocol: CultNetTransportProtocol::TcpFramed,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
path: None,
|
||||
discovery_group: None,
|
||||
wire_contracts: Some(vec!["cultnet.schema.v0".to_string()]),
|
||||
reconnect_policy: None,
|
||||
channels: vec![CultNetTransportChannel {
|
||||
channel_id: "schema".to_string(),
|
||||
delivery: CultNetTransportDelivery::Reliable,
|
||||
ordering: CultNetTransportOrdering::Ordered,
|
||||
max_payload_bytes: options.max_payload_bytes,
|
||||
max_fragment_bytes: options.max_fragment_bytes,
|
||||
max_pending_reliable_packets: None,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TcpFramedTransportConnection<TStream> {
|
||||
stream: TStream,
|
||||
pub profile: CultNetTransportProfile,
|
||||
stats: CultNetTransportStats,
|
||||
}
|
||||
|
||||
impl<TStream> TcpFramedTransportConnection<TStream> {
|
||||
pub fn new(stream: TStream, profile: CultNetTransportProfile) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
profile,
|
||||
stats: CultNetTransportStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> CultNetTransportStats {
|
||||
self.stats.clone()
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> TStream {
|
||||
self.stream
|
||||
}
|
||||
}
|
||||
|
||||
impl<TStream> TcpFramedTransportConnection<TStream>
|
||||
where
|
||||
TStream: Write,
|
||||
{
|
||||
pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> {
|
||||
if channel_id != "schema" {
|
||||
return Err(anyhow!(
|
||||
"tcp_framed transport only supports the schema channel, got {channel_id:?}"
|
||||
));
|
||||
}
|
||||
|
||||
let frame = encode_frame(payload)?;
|
||||
self.stream.write_all(&frame)?;
|
||||
self.stream.flush()?;
|
||||
self.stats.bytes_sent += frame.len() as u64;
|
||||
self.stats.frames_sent += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<TStream> TcpFramedTransportConnection<TStream>
|
||||
where
|
||||
TStream: Read,
|
||||
{
|
||||
pub fn receive(&mut self) -> Result<CultNetTransportFrame> {
|
||||
let mut header = [0_u8; FRAME_HEADER_BYTES];
|
||||
self.stream.read_exact(&mut header)?;
|
||||
let payload_len = u32::from_be_bytes(header) as usize;
|
||||
if let Some(max_payload_bytes) = self
|
||||
.profile
|
||||
.transports
|
||||
.iter()
|
||||
.find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed)
|
||||
.and_then(|transport| {
|
||||
transport
|
||||
.channels
|
||||
.iter()
|
||||
.find(|channel| channel.channel_id == "schema")
|
||||
})
|
||||
.and_then(|channel| channel.max_payload_bytes)
|
||||
&& payload_len > max_payload_bytes as usize
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"tcp_framed payload length {payload_len} exceeds advertised maximum {max_payload_bytes}"
|
||||
));
|
||||
}
|
||||
let mut payload = vec![0_u8; payload_len];
|
||||
self.stream.read_exact(&mut payload)?;
|
||||
self.stats.bytes_received += (FRAME_HEADER_BYTES + payload_len) as u64;
|
||||
self.stats.frames_received += 1;
|
||||
Ok(CultNetTransportFrame {
|
||||
channel_id: "schema".to_string(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
2279
vendor/cultnet-rs/tests/cultnet.rs
vendored
2279
vendor/cultnet-rs/tests/cultnet.rs
vendored
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
365
vendor/cultnet-rs/tests/provider_session.rs
vendored
365
vendor/cultnet-rs/tests/provider_session.rs
vendored
|
|
@ -1,365 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use cultnet_rs::*;
|
||||
|
||||
const REGISTRATION_GOLDEN: &str = "hqpwcm92aWRlcklkqGFldGhlcmlhsXNlcnZpY2VJbnN0YW5jZUlkq2FldGhlcmlhLTQyqmVuZHBvaW50SWSvYWV0aGVyaWEtcHVibGljp3ZlcnNlSWSmcHVibGljuHJlcXVlc3RlZExlYXNlRHVyYXRpb25Nc811MLBhdXRob3JpdHlMZWFzZUlkq2F1dGhvcml0eS03";
|
||||
const CONNECT_EVIDENCE_GOLDEN: &str =
|
||||
"gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbrJvZGluLXNlc3Npb24tdG9rZW4=";
|
||||
const TOKENLESS_CONNECT_EVIDENCE_GOLDEN: &str =
|
||||
"gq9jbGllbnRTZXNzaW9uSWSwYW5vbnltb3VzLWNsaWVudKxzZXNzaW9uVG9rZW7A";
|
||||
|
||||
fn registration() -> CultMeshProviderRegistrationWire {
|
||||
CultMeshProviderRegistrationWire {
|
||||
provider_id: "aetheria".to_string(),
|
||||
service_instance_id: "aetheria-42".to_string(),
|
||||
endpoint_id: "aetheria-public".to_string(),
|
||||
verse_id: "public".to_string(),
|
||||
requested_lease_duration_ms: 30_000,
|
||||
authority_lease_id: Some("authority-7".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_matches_csharp_and_typescript_golden_payload() -> Result<()> {
|
||||
let encoded = encode_provider_session_payload(®istration())?;
|
||||
assert_eq!(encoded, REGISTRATION_GOLDEN);
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderRegistrationWire>(&encoded)?,
|
||||
registration()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_evidence_is_a_validated_named_messagepack_map() -> Result<()> {
|
||||
let evidence = CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "aetheria-client-42".to_string(),
|
||||
session_token: Some("odin-session-token".to_string()),
|
||||
};
|
||||
let encoded = encode_provider_connect_evidence(&evidence)?;
|
||||
assert_eq!(
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encoded),
|
||||
CONNECT_EVIDENCE_GOLDEN
|
||||
);
|
||||
assert_eq!(decode_provider_connect_evidence(&encoded)?, evidence);
|
||||
let value: rmpv::Value = rmp_serde::from_slice(&encoded)?;
|
||||
let map = value.as_map().expect("Connect evidence is a named map");
|
||||
assert_eq!(
|
||||
map.iter()
|
||||
.find_map(|(key, value)| (key.as_str() == Some("clientSessionId")).then_some(value))
|
||||
.and_then(rmpv::Value::as_str),
|
||||
Some("aetheria-client-42")
|
||||
);
|
||||
assert_eq!(
|
||||
map.iter()
|
||||
.find_map(|(key, value)| (key.as_str() == Some("sessionToken")).then_some(value))
|
||||
.and_then(rmpv::Value::as_str),
|
||||
Some("odin-session-token")
|
||||
);
|
||||
|
||||
assert!(
|
||||
encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: String::new(),
|
||||
session_token: None,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "client".to_string(),
|
||||
session_token: Some(" ".to_string()),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(decode_provider_connect_evidence(&[]).is_err());
|
||||
let tokenless = CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "anonymous-client".to_string(),
|
||||
session_token: None,
|
||||
};
|
||||
let tokenless_encoded = encode_provider_connect_evidence(&tokenless)?;
|
||||
assert_eq!(
|
||||
base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&tokenless_encoded
|
||||
),
|
||||
TOKENLESS_CONNECT_EVIDENCE_GOLDEN
|
||||
);
|
||||
assert_eq!(
|
||||
decode_provider_connect_evidence(&tokenless_encoded)?,
|
||||
tokenless
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_round_trips_inside_canonical_operation_envelope() -> Result<()> {
|
||||
let request = create_provider_session_request(
|
||||
"register-1",
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
®istration(),
|
||||
Some("aetheria-42".to_string()),
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let decoded_request =
|
||||
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let decoded: CultMeshProviderRegistrationWire = decode_provider_session_request(
|
||||
&decoded_request,
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
)?;
|
||||
assert_eq!(decoded, registration());
|
||||
|
||||
let lease = CultMeshProviderLeaseWire {
|
||||
provider_id: "aetheria".to_string(),
|
||||
service_instance_id: "aetheria-42".to_string(),
|
||||
endpoint_id: "aetheria-public".to_string(),
|
||||
verse_id: "public".to_string(),
|
||||
lease_id: "lease-2".to_string(),
|
||||
valid_from_utc: "2026-07-14T12:00:00Z".to_string(),
|
||||
expires_at_utc: "2026-07-14T12:00:10Z".to_string(),
|
||||
};
|
||||
let response = create_provider_session_response(
|
||||
&decoded_request,
|
||||
CultMeshProviderOperationStatus::Ok,
|
||||
CULTMESH_PROVIDER_LEASE_SCHEMA,
|
||||
&lease,
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
let (status, decoded_lease) = decode_provider_session_response::<CultMeshProviderLeaseWire>(
|
||||
&response,
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_LEASE_SCHEMA,
|
||||
)?;
|
||||
assert_eq!(status, CultMeshProviderOperationStatus::Ok);
|
||||
assert_eq!(decoded_lease, lease);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_and_command_preserve_typed_messagepack_values() -> Result<()> {
|
||||
let publication = CultMeshProviderPublicationPutWire {
|
||||
lease_id: "lease-1".to_string(),
|
||||
publication_id: "surface:pilot".to_string(),
|
||||
document: CultNetRawDocumentRecord {
|
||||
schema_id: "gamecult.eve.surface.v1".to_string(),
|
||||
record_key: "eve:surface:pilot".to_string(),
|
||||
stored_at: String::new(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![0x81, 0xa2, 0x69, 0x64, 0x01],
|
||||
source_runtime_id: Some("aetheria-42".to_string()),
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
};
|
||||
let publication_encoded = encode_provider_session_payload(&publication)?;
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderPublicationPutWire>(
|
||||
&publication_encoded
|
||||
)?,
|
||||
publication
|
||||
);
|
||||
|
||||
let command = CultMeshProviderCommandWire {
|
||||
command_id: "cmd-7".to_string(),
|
||||
command_kind: "pilot.thrust".to_string(),
|
||||
provider_id: "aetheria".to_string(),
|
||||
service_instance_id: "aetheria-42".to_string(),
|
||||
payload: rmpv::Value::Map(vec![(rmpv::Value::from("axis"), rmpv::Value::F64(0.75))]),
|
||||
};
|
||||
let command_bytes = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
encode_provider_session_payload(&command)?,
|
||||
)?;
|
||||
let decoded_command = decode_provider_command_document(&CultNetRawDocumentRecord {
|
||||
schema_id: CULTMESH_PROVIDER_COMMAND_SCHEMA.to_string(),
|
||||
record_key: "provider-command:aetheria:aetheria-42:cmd-7".to_string(),
|
||||
stored_at: String::new(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: command_bytes,
|
||||
source_runtime_id: Some("odin".to_string()),
|
||||
source_agent_id: None,
|
||||
source_role: Some("provider-session-broker".to_string()),
|
||||
tags: None,
|
||||
})?;
|
||||
assert_eq!(decoded_command, command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_validation_rejects_split_or_malformed_authority() {
|
||||
let mismatched = create_provider_session_request(
|
||||
"bad-schema",
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA,
|
||||
®istration(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(mismatched.is_err());
|
||||
|
||||
let malformed_lease = CultMeshProviderLeaseWire {
|
||||
provider_id: "provider".to_string(),
|
||||
service_instance_id: "instance".to_string(),
|
||||
endpoint_id: "endpoint".to_string(),
|
||||
verse_id: "verse".to_string(),
|
||||
lease_id: "lease".to_string(),
|
||||
valid_from_utc: "2026-07-14T12:00:10Z".to_string(),
|
||||
expires_at_utc: "2026-07-14T12:00:00Z".to_string(),
|
||||
};
|
||||
assert!(encode_provider_session_payload(&malformed_lease).is_err());
|
||||
|
||||
let malformed_request = CultNetMessage::OperationRequest {
|
||||
message_id: String::new(),
|
||||
service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(),
|
||||
operation: String::new(),
|
||||
payload_schema: CULTMESH_PROVIDER_REGISTRATION_SCHEMA.to_string(),
|
||||
payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(),
|
||||
payload: REGISTRATION_GOLDEN.to_string(),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
};
|
||||
assert!(
|
||||
create_provider_session_response(
|
||||
&malformed_request,
|
||||
CultMeshProviderOperationStatus::Invalid,
|
||||
CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA,
|
||||
&CultMeshProviderMutationAcceptanceWire {
|
||||
accepted_at_utc: "2026-07-14T12:00:00Z".to_string(),
|
||||
lease_id: None,
|
||||
publication_id: None,
|
||||
command_id: None,
|
||||
receipt_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_lifecycle_payloads_round_trip_as_named_maps() -> Result<()> {
|
||||
let renewal = CultMeshProviderLeaseRenewalWire {
|
||||
lease_id: "lease-1".to_string(),
|
||||
requested_lease_duration_ms: 30_000,
|
||||
};
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderLeaseRenewalWire>(
|
||||
&encode_provider_session_payload(&renewal)?
|
||||
)?,
|
||||
renewal
|
||||
);
|
||||
|
||||
let deletion = CultMeshProviderPublicationDeleteWire {
|
||||
lease_id: "lease-1".to_string(),
|
||||
publication_id: "surface:pilot".to_string(),
|
||||
schema_id: "gamecult.eve.surface.v1".to_string(),
|
||||
record_key: "eve:surface:pilot".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderPublicationDeleteWire>(
|
||||
&encode_provider_session_payload(&deletion)?
|
||||
)?,
|
||||
deletion
|
||||
);
|
||||
|
||||
let receipt = CultMeshProviderReceiptPutWire {
|
||||
lease_id: "lease-1".to_string(),
|
||||
receipt: CultMeshProviderCommandReceiptWire {
|
||||
receipt_id: "receipt-7".to_string(),
|
||||
command_id: "cmd-7".to_string(),
|
||||
command_kind: "pilot.thrust".to_string(),
|
||||
provider_id: "aetheria".to_string(),
|
||||
service_instance_id: "aetheria-42".to_string(),
|
||||
state: CultMeshProviderReceiptStateWire::Applied,
|
||||
completed_at_utc: "2026-07-14T12:01:02.345Z".to_string(),
|
||||
result: Some(rmpv::Value::Map(vec![(
|
||||
rmpv::Value::from("accepted"),
|
||||
rmpv::Value::Boolean(true),
|
||||
)])),
|
||||
error: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderReceiptPutWire>(
|
||||
&encode_provider_session_payload(&receipt)?
|
||||
)?,
|
||||
receipt
|
||||
);
|
||||
|
||||
let withdrawal = CultMeshProviderWithdrawalWire {
|
||||
lease_id: "lease-1".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderWithdrawalWire>(
|
||||
&encode_provider_session_payload(&withdrawal)?
|
||||
)?,
|
||||
withdrawal
|
||||
);
|
||||
|
||||
let acceptance = CultMeshProviderMutationAcceptanceWire {
|
||||
accepted_at_utc: "2026-07-14T12:01:03Z".to_string(),
|
||||
lease_id: Some("lease-1".to_string()),
|
||||
publication_id: Some("surface:pilot".to_string()),
|
||||
command_id: None,
|
||||
receipt_id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
decode_provider_session_payload::<CultMeshProviderMutationAcceptanceWire>(
|
||||
&encode_provider_session_payload(&acceptance)?
|
||||
)?,
|
||||
acceptance
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ok_response_uses_correlated_empty_map_and_diagnostics() -> Result<()> {
|
||||
let request = create_provider_session_request(
|
||||
"register-denied",
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
®istration(),
|
||||
Some("aetheria-42".to_string()),
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
let response = create_provider_session_error_response(
|
||||
&request,
|
||||
CultMeshProviderOperationStatus::Denied,
|
||||
vec!["registration authority rejected the session".to_string()],
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
let CultNetMessage::OperationResponse {
|
||||
message_id,
|
||||
operation,
|
||||
status,
|
||||
payload_schema,
|
||||
payload,
|
||||
diagnostics,
|
||||
..
|
||||
} = response
|
||||
else {
|
||||
panic!("expected operation response");
|
||||
};
|
||||
assert_eq!(message_id, "register-denied");
|
||||
assert_eq!(operation, CULTMESH_PROVIDER_REGISTER_OPERATION);
|
||||
assert_eq!(status, "denied");
|
||||
assert_eq!(payload_schema, CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA);
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload)?;
|
||||
assert_eq!(
|
||||
rmp_serde::from_slice::<rmpv::Value>(&bytes)?,
|
||||
rmpv::Value::Map(Vec::new())
|
||||
);
|
||||
assert!(
|
||||
create_provider_session_error_response(
|
||||
&request,
|
||||
CultMeshProviderOperationStatus::Ok,
|
||||
vec!["not an error".to_string()],
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
459
vendor/cultnet-rs/tests/rudp_server_hub.rs
vendored
459
vendor/cultnet-rs/tests/rudp_server_hub.rs
vendored
|
|
@ -1,459 +0,0 @@
|
|||
use std::net::UdpSocket;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use cultnet_rs::*;
|
||||
|
||||
const CONNECTION_ID: u32 = 0x4355_4c54;
|
||||
|
||||
fn socket() -> Result<UdpSocket> {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(20)))?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn client(remote: std::net::SocketAddr) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let mut options =
|
||||
CultNetRudpSocketTransportOptions::client("provider", socket()?, remote, CONNECTION_ID);
|
||||
options.max_fragment_bytes = Some(2_048);
|
||||
CultNetRudpSocketTransportConnection::new(options)
|
||||
}
|
||||
|
||||
fn connect(
|
||||
hub: &mut CultNetRudpServerHub,
|
||||
client: &mut CultNetRudpSocketTransportConnection,
|
||||
evidence: &[u8],
|
||||
) -> Result<CultNetRudpServerSessionContext> {
|
||||
client.connect(evidence.to_vec())?;
|
||||
let mut connected = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(CultNetRudpServerEvent::Connected { session }) = hub.receive_event_once()? {
|
||||
connected = Some(session);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let session = connected.ok_or_else(|| anyhow!("hub did not receive Connect"))?;
|
||||
let _ = client.receive_once()?;
|
||||
assert!(client.connected());
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn receive_frame(hub: &mut CultNetRudpServerHub) -> Result<CultNetRudpServerEvent> {
|
||||
for _ in 0..20 {
|
||||
if let Some(event @ CultNetRudpServerEvent::Frame { .. }) = hub.receive_event_once()? {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(anyhow!("hub did not receive a frame"))
|
||||
}
|
||||
|
||||
fn receive_message(client: &mut CultNetRudpSocketTransportConnection) -> Result<CultNetMessage> {
|
||||
for _ in 0..20 {
|
||||
if let Some(message) = client.receive_schema_message_once()? {
|
||||
return Ok(message);
|
||||
}
|
||||
}
|
||||
Err(anyhow!("client did not receive a schema message"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_keeps_independent_peer_sessions_and_exposes_connect_evidence() -> Result<()> {
|
||||
let server_socket = socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new(
|
||||
"odin",
|
||||
server_socket,
|
||||
CONNECTION_ID,
|
||||
))?;
|
||||
let mut first = client(server_addr)?;
|
||||
let mut second = client(server_addr)?;
|
||||
|
||||
let first_session = connect(&mut hub, &mut first, b"session-token-one")?;
|
||||
let second_session = connect(&mut hub, &mut second, b"session-token-two")?;
|
||||
assert_ne!(first_session.remote_addr, second_session.remote_addr);
|
||||
assert_eq!(first_session.connect_payload, b"session-token-one");
|
||||
assert_eq!(second_session.connect_payload, b"session-token-two");
|
||||
assert_eq!(hub.sessions().len(), 2);
|
||||
|
||||
let first_request = create_provider_session_request(
|
||||
"register-first",
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
&CultMeshProviderRegistrationWire {
|
||||
provider_id: "first".to_string(),
|
||||
service_instance_id: "first-1".to_string(),
|
||||
endpoint_id: "first-public".to_string(),
|
||||
verse_id: "public".to_string(),
|
||||
requested_lease_duration_ms: 30_000,
|
||||
authority_lease_id: None,
|
||||
},
|
||||
Some("first-1".to_string()),
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
let second_request = create_provider_session_request(
|
||||
"register-second",
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_REGISTRATION_SCHEMA,
|
||||
&CultMeshProviderRegistrationWire {
|
||||
provider_id: "second".to_string(),
|
||||
service_instance_id: "second-1".to_string(),
|
||||
endpoint_id: "second-public".to_string(),
|
||||
verse_id: "public".to_string(),
|
||||
requested_lease_duration_ms: 30_000,
|
||||
authority_lease_id: None,
|
||||
},
|
||||
Some("second-1".to_string()),
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
first.send_schema_message(&first_request)?;
|
||||
second.send_schema_message(&second_request)?;
|
||||
|
||||
for _ in 0..2 {
|
||||
let CultNetRudpServerEvent::Frame { session, frame } = receive_frame(&mut hub)? else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(frame.channel_id, "schema");
|
||||
let request = decode_cultnet_message_from_slice(
|
||||
&frame.payload,
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)?;
|
||||
let CultNetMessage::OperationRequest { message_id, .. } = &request else {
|
||||
return Err(anyhow!("expected operation request"));
|
||||
};
|
||||
let provider_id = if message_id == "register-first" {
|
||||
"first"
|
||||
} else {
|
||||
"second"
|
||||
};
|
||||
let lease = CultMeshProviderLeaseWire {
|
||||
provider_id: provider_id.to_string(),
|
||||
service_instance_id: format!("{provider_id}-1"),
|
||||
endpoint_id: format!("{provider_id}-public"),
|
||||
verse_id: "public".to_string(),
|
||||
lease_id: format!("lease-{provider_id}"),
|
||||
valid_from_utc: "2026-07-14T12:00:00Z".to_string(),
|
||||
expires_at_utc: "2026-07-14T12:00:30Z".to_string(),
|
||||
};
|
||||
let response = create_provider_session_response(
|
||||
&request,
|
||||
CultMeshProviderOperationStatus::Ok,
|
||||
CULTMESH_PROVIDER_LEASE_SCHEMA,
|
||||
&lease,
|
||||
Some("odin".to_string()),
|
||||
)?;
|
||||
hub.send_schema_message(&session, &response)?;
|
||||
}
|
||||
|
||||
let first_response = receive_message(&mut first)?;
|
||||
let second_response = receive_message(&mut second)?;
|
||||
let (_, first_lease) = decode_provider_session_response::<CultMeshProviderLeaseWire>(
|
||||
&first_response,
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_LEASE_SCHEMA,
|
||||
)?;
|
||||
let (_, second_lease) = decode_provider_session_response::<CultMeshProviderLeaseWire>(
|
||||
&second_response,
|
||||
CULTMESH_PROVIDER_REGISTER_OPERATION,
|
||||
CULTMESH_PROVIDER_LEASE_SCHEMA,
|
||||
)?;
|
||||
assert_eq!(first_lease.provider_id, "first");
|
||||
assert_eq!(second_lease.provider_id, "second");
|
||||
assert_eq!(hub.session(first_session.remote_addr), Some(&first_session));
|
||||
assert_eq!(
|
||||
hub.session(second_session.remote_addr),
|
||||
Some(&second_session)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_fences_replaced_generations_and_does_not_replace_connect_retransmits() -> Result<()> {
|
||||
let server_socket = socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new(
|
||||
"odin",
|
||||
server_socket,
|
||||
CONNECTION_ID,
|
||||
))?;
|
||||
let mut provider = client(server_addr)?;
|
||||
let original_evidence =
|
||||
encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "provider-client-1".to_string(),
|
||||
session_token: Some("shared-session-token".to_string()),
|
||||
})?;
|
||||
let replacement_evidence =
|
||||
encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "provider-client-2".to_string(),
|
||||
session_token: Some("shared-session-token".to_string()),
|
||||
})?;
|
||||
let original = connect(&mut hub, &mut provider, &original_evidence)?;
|
||||
|
||||
provider.connect(original_evidence.clone())?;
|
||||
for _ in 0..3 {
|
||||
assert!(hub.receive_event_once()?.is_none());
|
||||
}
|
||||
assert_eq!(hub.session(original.remote_addr), Some(&original));
|
||||
|
||||
provider.connect(replacement_evidence.clone())?;
|
||||
let mut disconnected = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(event @ CultNetRudpServerEvent::Disconnected { .. }) =
|
||||
hub.receive_event_once()?
|
||||
{
|
||||
disconnected = Some(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let disconnected =
|
||||
disconnected.ok_or_else(|| anyhow!("replacement did not disconnect old generation"))?;
|
||||
let CultNetRudpServerEvent::Disconnected { session, .. } = disconnected else {
|
||||
return Err(anyhow!("expected old generation Disconnected event"));
|
||||
};
|
||||
assert_eq!(session, original);
|
||||
let connected = hub
|
||||
.receive_event_once()?
|
||||
.ok_or_else(|| anyhow!("replacement did not connect new generation"))?;
|
||||
let CultNetRudpServerEvent::Connected {
|
||||
session: replacement,
|
||||
} = connected
|
||||
else {
|
||||
return Err(anyhow!("expected replacement Connected event"));
|
||||
};
|
||||
assert_ne!(replacement.session_generation, original.session_generation);
|
||||
let original_decoded = decode_provider_connect_evidence(&original.connect_payload)?;
|
||||
let replacement_decoded = decode_provider_connect_evidence(&replacement.connect_payload)?;
|
||||
assert_eq!(
|
||||
original_decoded.session_token,
|
||||
replacement_decoded.session_token
|
||||
);
|
||||
assert_ne!(
|
||||
original_decoded.client_session_id,
|
||||
replacement_decoded.client_session_id
|
||||
);
|
||||
assert!(
|
||||
hub.send(&original, "schema", vec![0x80])
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no longer active")
|
||||
);
|
||||
assert_eq!(hub.session(replacement.remote_addr), Some(&replacement));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_connect_retransmit_reuses_pending_accept_after_loss() -> Result<()> {
|
||||
let server_socket = socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new(
|
||||
"odin",
|
||||
server_socket,
|
||||
CONNECTION_ID,
|
||||
))?;
|
||||
let client_socket = socket()?;
|
||||
let mut client_session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let evidence = encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire {
|
||||
client_session_id: "provider-client-lost-accept".to_string(),
|
||||
session_token: Some("shared-session-token".to_string()),
|
||||
})?;
|
||||
let connect_packet = client_session.create_connect(0, evidence)?;
|
||||
let connect_wire = encode_rudp_packet(&connect_packet)?;
|
||||
|
||||
client_socket.send_to(&connect_wire, server_addr)?;
|
||||
let CultNetRudpServerEvent::Connected { session } = hub
|
||||
.receive_event_once()?
|
||||
.ok_or_else(|| anyhow!("hub did not accept first Connect"))?
|
||||
else {
|
||||
return Err(anyhow!("hub did not emit Connected"));
|
||||
};
|
||||
let mut buffer = vec![0_u8; 65_535];
|
||||
let (first_len, _) = client_socket.recv_from(&mut buffer)?;
|
||||
let lost_accept = decode_rudp_packet(&buffer[..first_len])?;
|
||||
assert_eq!(lost_accept.packet_type, CultNetRudpPacketType::Accept);
|
||||
|
||||
client_socket.send_to(&connect_wire, server_addr)?;
|
||||
assert!(hub.receive_event_once()?.is_none());
|
||||
let (resent_len, _) = client_socket.recv_from(&mut buffer)?;
|
||||
let resent_accept = decode_rudp_packet(&buffer[..resent_len])?;
|
||||
assert_eq!(resent_accept, lost_accept);
|
||||
assert_eq!(hub.session(session.remote_addr), Some(&session));
|
||||
|
||||
client_session.receive(&resent_accept, 1)?;
|
||||
assert!(client_session.connected());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_reliable_acks_clear_more_than_one_ack_window_of_fragments() -> Result<()> {
|
||||
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let connect_packet = sender.create_connect(0, Vec::new())?;
|
||||
let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?;
|
||||
sender.receive(&accept, 0)?;
|
||||
receiver.receive(&sender.create_ack_for(accept.sequence), 0)?;
|
||||
|
||||
let packets = sender.send_many(
|
||||
"schema",
|
||||
vec![0x5a; 40],
|
||||
CultNetRudpSendOptions {
|
||||
reliable: true,
|
||||
ordered: true,
|
||||
sequenced: false,
|
||||
now_ms: 1,
|
||||
},
|
||||
Some(1),
|
||||
)?;
|
||||
assert_eq!(packets.len(), 40);
|
||||
let mut delivered = Vec::new();
|
||||
for packet in &packets {
|
||||
let result = receiver.receive(packet, 2)?;
|
||||
delivered.extend(result.delivered);
|
||||
sender.receive(&receiver.create_ack_for(packet.sequence), 3)?;
|
||||
}
|
||||
assert_eq!(delivered.len(), 1);
|
||||
assert_eq!(delivered[0].payload, vec![0x5a; 40]);
|
||||
assert!(sender.pending_reliable_sequences().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_and_hub_reject_configured_memory_bounds() -> Result<()> {
|
||||
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
receiver.set_max_payload_bytes(Some(4));
|
||||
receiver.set_max_pending_fragment_sets(1)?;
|
||||
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let connect_packet = sender.create_connect(0, Vec::new())?;
|
||||
let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?;
|
||||
sender.receive(&accept, 0)?;
|
||||
|
||||
let oversized = sender.send_many(
|
||||
"schema",
|
||||
vec![1, 2, 3, 4, 5],
|
||||
CultNetRudpSendOptions {
|
||||
reliable: true,
|
||||
ordered: true,
|
||||
sequenced: false,
|
||||
now_ms: 1,
|
||||
},
|
||||
Some(2),
|
||||
)?;
|
||||
receiver.receive(&oversized[0], 1)?;
|
||||
receiver.receive(&oversized[1], 1)?;
|
||||
assert!(receiver.receive(&oversized[2], 1).is_err());
|
||||
|
||||
let fragment_options = CultNetRudpSendOptions {
|
||||
reliable: true,
|
||||
ordered: true,
|
||||
sequenced: false,
|
||||
now_ms: 2,
|
||||
};
|
||||
let first_set = sender.send_many(
|
||||
"schema",
|
||||
vec![1, 2, 3, 4],
|
||||
fragment_options.clone(),
|
||||
Some(2),
|
||||
)?;
|
||||
let second_set = sender.send_many("schema", vec![5, 6, 7, 8], fragment_options, Some(2))?;
|
||||
receiver.receive(&first_set[0], 2)?;
|
||||
assert!(receiver.receive(&second_set[0], 2).is_err());
|
||||
|
||||
let server_socket = socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let mut options = CultNetRudpServerHubOptions::new("odin", server_socket, CONNECTION_ID);
|
||||
options.max_peers = 1;
|
||||
let mut hub = CultNetRudpServerHub::new(options)?;
|
||||
let mut first = client(server_addr)?;
|
||||
let mut second = client(server_addr)?;
|
||||
connect(&mut hub, &mut first, b"first")?;
|
||||
second.connect(b"second".to_vec())?;
|
||||
for _ in 0..20 {
|
||||
match hub.receive_event_once() {
|
||||
Err(error) => {
|
||||
assert!(error.to_string().contains("peer limit"));
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(anyhow!("hub did not enforce peer limit"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_history_is_bounded_and_idle_hub_sessions_expire() -> Result<()> {
|
||||
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 250,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let connect_packet = sender.create_connect(0, Vec::new())?;
|
||||
let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?;
|
||||
sender.receive(&accept, 0)?;
|
||||
let mut oldest = None;
|
||||
for sequence in 0..4_100 {
|
||||
let packet = sender.send(
|
||||
"realtime",
|
||||
vec![(sequence % 251) as u8],
|
||||
CultNetRudpSendOptions {
|
||||
reliable: false,
|
||||
ordered: false,
|
||||
sequenced: false,
|
||||
now_ms: sequence,
|
||||
},
|
||||
)?;
|
||||
if oldest.is_none() {
|
||||
oldest = Some(packet.clone());
|
||||
}
|
||||
assert_eq!(receiver.receive(&packet, sequence)?.delivered.len(), 1);
|
||||
}
|
||||
assert!(
|
||||
receiver
|
||||
.receive(&oldest.expect("oldest packet"), 4_101)?
|
||||
.delivered
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let server_socket = socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new(
|
||||
"odin",
|
||||
server_socket,
|
||||
CONNECTION_ID,
|
||||
))?;
|
||||
let mut provider = client(server_addr)?;
|
||||
let context = connect(&mut hub, &mut provider, b"expiring-session")?;
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
assert_eq!(hub.remove_timed_out_sessions(0), vec![context]);
|
||||
assert!(hub.sessions().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue