Compare commits
69
Commits
67d2f4affb
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d5d037dd | ||
|
|
51806f4469 | ||
|
|
c819d99f1d | ||
|
|
7603f98799 | ||
|
|
14d9cf281f | ||
|
|
ce7ef1e31f | ||
|
|
72aa79b6ee | ||
|
|
998f6b6dc9 | ||
|
|
0d39b7f12a | ||
|
|
bf868157d3 | ||
|
|
937df02f85 | ||
|
|
db8f5d863d | ||
|
|
1344157d59 | ||
|
|
11c10c0bb1 | ||
|
|
835bed1c6f | ||
|
|
f754ae9cbe | ||
|
|
4b8e5c163a | ||
|
|
1c6a705987 | ||
|
|
03e1c5ee41 | ||
|
|
f41387eeed | ||
|
|
28d4068e0e | ||
|
|
ac36a1cee3 | ||
|
|
109d743f9f | ||
|
|
44f221e8cc | ||
|
|
db100f9849 | ||
|
|
dd232c3d47 | ||
|
|
01cc3a0367 | ||
|
|
c6ab00d141 | ||
|
|
a941d86c3a | ||
|
|
85c5590001 | ||
|
|
bf0f827bf4 | ||
|
|
076d5adfec | ||
|
|
3543c1bb7a | ||
|
|
7cdff8c127 | ||
|
|
9d855a774d | ||
|
|
90a8fa7328 | ||
|
|
8ca2d0632d | ||
|
|
0ef277730f | ||
|
|
7c1af0d71f | ||
|
|
9a58714519 | ||
|
|
f73c6035b5 | ||
|
|
74c1007bdf | ||
|
|
0fcd5bc322 | ||
|
|
762d9c7bd2 | ||
|
|
a3cb44968e | ||
|
|
c2b7055a64 | ||
|
|
859ad35896 | ||
|
|
5902c8aca9 | ||
|
|
b60a5844ba | ||
|
|
249370345f | ||
|
|
f03e38326c | ||
|
|
073bbba06a | ||
|
|
b5397f61aa | ||
|
|
909935ded3 | ||
|
|
528cfa0367 | ||
|
|
f8d4650dce | ||
|
|
3b10fc1817 | ||
|
|
c6eef730e4 | ||
|
|
ddc577cd3e | ||
|
|
4fc6854846 | ||
|
|
a0dbe4fd3c | ||
|
|
83a94ec03f | ||
|
|
2a75724dde | ||
|
|
759c627ca4 | ||
|
|
82ee37e206 | ||
|
|
b476182d64 | ||
|
|
ab56acae69 | ||
|
|
5467c30892 | ||
|
|
2755b995c6 |
No files matched your search
Generated
+319
-19
@@ -37,6 +37,20 @@ 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.4"
|
||||
@@ -53,13 +67,7 @@ dependencies = [
|
||||
"alknet-core",
|
||||
"async-trait",
|
||||
"futures",
|
||||
"hex",
|
||||
"parking_lot",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -68,6 +76,24 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alknet-core",
|
||||
"alknet-tls",
|
||||
"fast-socks5",
|
||||
"hex",
|
||||
"iroh",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-core"
|
||||
version = "0.1.0"
|
||||
@@ -81,10 +107,7 @@ dependencies = [
|
||||
"iroh",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-acme",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -97,6 +120,21 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-endpoint"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alknet-core",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"iroh",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-http"
|
||||
version = "0.1.0"
|
||||
@@ -187,6 +225,14 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-typedef"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jsonschema",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alknet-vault"
|
||||
version = "0.1.0"
|
||||
@@ -520,6 +566,21 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[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 = "bitcoin_hashes"
|
||||
version = "0.14.2"
|
||||
@@ -595,12 +656,24 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[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.0"
|
||||
@@ -1221,6 +1294,15 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1281,6 +1363,32 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-socks5"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9545787d8304a71e1bf1b711705070a4c400cce9b332c4a11800627b7c9a2067"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.1"
|
||||
@@ -1328,6 +1436,17 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[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 = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1349,6 +1468,16 @@ 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 = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1587,6 +1716,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -1819,7 +1959,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -1971,7 +2111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1991,7 +2131,7 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
@@ -2274,6 +2414,42 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.46.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"data-encoding",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"idna",
|
||||
"itoa",
|
||||
"jsonschema-regex",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-general-category",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.46.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2344,7 +2520,7 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2380,6 +2556,12 @@ version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[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"
|
||||
@@ -2580,7 +2762,7 @@ dependencies = [
|
||||
"objc2-system-configuration",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2627,7 +2809,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2670,7 +2852,7 @@ checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
||||
dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2684,6 +2866,20 @@ 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.6"
|
||||
@@ -2694,6 +2890,21 @@ 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"
|
||||
@@ -2709,6 +2920,27 @@ 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"
|
||||
@@ -2881,6 +3113,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "papaya"
|
||||
version = "0.2.4"
|
||||
@@ -3198,7 +3436,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3238,7 +3476,7 @@ dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -3392,6 +3630,35 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.46.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"itoa",
|
||||
"micromap",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -3880,6 +4147,7 @@ version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -4078,6 +4346,16 @@ version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
@@ -4373,7 +4651,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.4",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -4667,6 +4945,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[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"
|
||||
@@ -4740,6 +5024,16 @@ 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"
|
||||
@@ -4789,6 +5083,12 @@ 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 = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -3,10 +3,13 @@ members = [
|
||||
"crates/alknet-vault",
|
||||
"crates/alknet-core",
|
||||
"crates/alknet-call",
|
||||
"crates/alknet-endpoint",
|
||||
"crates/alknet-http",
|
||||
"crates/alknet-tls",
|
||||
"crates/alknet-tty",
|
||||
"crates/alknet-tty-local",
|
||||
"crates/alknet-client",
|
||||
"crates/alknet-typedef",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ name = "alknet-call"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Structured RPC over QUIC on ALPN `alknet/call`: operations, streaming subscriptions, service discovery"
|
||||
description = "Structured RPC over ALPN `alknet/call`: operations, streaming subscriptions, service discovery"
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "alknet_call"
|
||||
|
||||
[features]
|
||||
default = ["quinn"]
|
||||
quinn = ["dep:quinn", "dep:rustls", "dep:rustls-native-certs", "dep:rustls-pemfile", "alknet-core/quinn"]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
alknet-core = { path = "../alknet-core" }
|
||||
@@ -24,11 +23,3 @@ thiserror = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures = "0.3"
|
||||
parking_lot = "0.12"
|
||||
quinn = { version = "0.11", optional = true }
|
||||
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
|
||||
rustls-native-certs = { version = "0.8", optional = true }
|
||||
rustls-pemfile = { version = "2", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = "0.13"
|
||||
hex = "0.4"
|
||||
@@ -1,8 +1,7 @@
|
||||
//! `CallClient`: the outbound connection opener (ADR-017 §1).
|
||||
//!
|
||||
//! Opens a QUIC connection to a remote node on ALPN `alknet/call`, performs
|
||||
//! credential setup, and produces a [`CallConnection`] running the shared
|
||||
//! dispatch loop (delegated to [`crate::protocol::dispatch::Dispatcher`]).
|
||||
//! Runs the shared dispatch loop over a pre-established `Connection`
|
||||
//! (delegated to [`crate::protocol::dispatch::Dispatcher`]).
|
||||
//! `CallClient` is the connection-establishment half; `CallAdapter`'s accept
|
||||
//! path is the inbound half; both produce a `CallConnection` and hand it to
|
||||
//! the same `Dispatcher::run_loop` (ADR-017 §1).
|
||||
@@ -12,93 +11,21 @@
|
||||
//! (initiates outgoing calls via `CallConnection::call()`/`subscribe()`/
|
||||
//! `abort()`) and a callee (dispatches incoming calls against its registry).
|
||||
//!
|
||||
//! Transport-level connection establishment (QUIC dial, TCP+TLS, iroh) is
|
||||
//! handled by `alknet-client`; `CallClient::spawn_dispatch` takes a
|
||||
//! pre-established `Connection` and runs the call protocol over it.
|
||||
//!
|
||||
//! See `docs/architecture/crates/call/client-and-adapters.md` for the spec.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alknet_core::auth::IdentityProvider;
|
||||
use alknet_core::config::TlsIdentity;
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::dispatch::Dispatcher;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
|
||||
/// Expected identity of the remote node (ADR-017 §7, extended by ADR-034 §2).
|
||||
/// Carries a fingerprint string the assembly layer derives from `Capabilities`
|
||||
/// when the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||
/// fingerprint pin).
|
||||
///
|
||||
/// `remote_identity: None` is the **public X.509 endpoint** case: the local
|
||||
/// node has no `PeerEntry` for the remote, so there is no fingerprint to pin.
|
||||
/// Combined with an X.509 transport, `None` selects CA verification
|
||||
/// (`WebPkiServerVerifier`) per the verifier-selection rule in ADR-034 §3.
|
||||
/// Combined with an Ed25519 raw-key transport, `None` fails closed (raw-key
|
||||
/// remotes are always known peers — no CA to fall back to).
|
||||
///
|
||||
/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)`
|
||||
/// means "pin this" (known peer), `None` means "trust the CA or fail"
|
||||
/// (unknown remote). An implementer must not default `remote_identity` to a
|
||||
/// placeholder value to "satisfy" the field — `None` is a real state that
|
||||
/// drives verifier selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteIdentity {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
/// Credentials for an outbound `alknet/call` connection (ADR-017 §7). All
|
||||
/// three dimensions come from `Capabilities` (ADR-014), never from environment
|
||||
/// variables — see the No-Env-Vars Invariant in
|
||||
/// `docs/architecture/crates/call/client-and-adapters.md`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CallCredentials {
|
||||
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
|
||||
/// from the vault at startup.
|
||||
pub tls_identity: Option<TlsIdentity>,
|
||||
/// Opaque call-protocol-level auth token, decrypted from the vault.
|
||||
pub auth_token: Option<alknet_core::auth::AuthToken>,
|
||||
/// Expected fingerprint/cert of the remote node, stored as a capability.
|
||||
/// `Some` → fingerprint pin (known peer with a `PeerEntry`); `None` → CA
|
||||
/// verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes
|
||||
/// (ADR-034 §2/§3). `None` is the public-X.509-endpoint state, not a
|
||||
/// missing field — must not be defaulted to a placeholder.
|
||||
pub remote_identity: Option<RemoteIdentity>,
|
||||
}
|
||||
|
||||
impl CallCredentials {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
|
||||
self.tls_identity = Some(tls_identity);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_auth_token(mut self, token: alknet_core::auth::AuthToken) -> Self {
|
||||
self.auth_token = Some(token);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self {
|
||||
self.remote_identity = Some(remote);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by [`CallClient::connect`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ClientError {
|
||||
#[error("transport error: {message}")]
|
||||
Transport { message: String },
|
||||
#[error("tls setup error: {message}")]
|
||||
TlsSetup { message: String },
|
||||
#[error("connection closed")]
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
/// Outbound `alknet/call` connection opener (the #1 gap, ADR-017 §1).
|
||||
///
|
||||
/// Peer authorization flows through the existing `AccessControl::check` gate
|
||||
@@ -128,50 +55,11 @@ impl CallClient {
|
||||
&self.identity_provider
|
||||
}
|
||||
|
||||
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
|
||||
/// credential handshake, and return a `CallConnection` running the shared
|
||||
/// dispatch loop. Credentials come from `Capabilities` (ADR-014), not env
|
||||
/// vars — the no-env-vars invariant.
|
||||
///
|
||||
/// The dispatch loop runs on a spawned task; the returned `CallConnection`
|
||||
/// is live until the remote closes the connection or the caller drops it.
|
||||
/// The caller can immediately use `call()`/`subscribe()`/`abort()` on the
|
||||
/// returned connection, and the remote peer can call back into this
|
||||
/// `CallClient`'s registry (connection symmetry, ADR-017 §2).
|
||||
#[cfg(feature = "quinn")]
|
||||
pub async fn connect(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials,
|
||||
) -> Result<CallConnection, ClientError> {
|
||||
let alpn = b"alknet/call".to_vec();
|
||||
let client_config = build_quinn_client_config(&credentials, &alpn)
|
||||
.map_err(|e| ClientError::TlsSetup { message: e })?;
|
||||
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid bind addr");
|
||||
let endpoint = quinn::Endpoint::client(bind_addr).map_err(|e| ClientError::Transport {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let connection = endpoint
|
||||
.connect_with(client_config, addr, "alknet")
|
||||
.map_err(|e| ClientError::Transport {
|
||||
message: e.to_string(),
|
||||
})?
|
||||
.await
|
||||
.map_err(|e| ClientError::Transport {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let connection = Connection::from_quinn_with_alpn(connection, alpn);
|
||||
Ok(self.spawn_dispatch(connection))
|
||||
}
|
||||
|
||||
/// Run the shared dispatch loop over a pre-established `Connection`. The
|
||||
/// `CallClient` spawns the dispatcher task and returns a live
|
||||
/// `CallConnection` the caller can use immediately. Used by `connect()`
|
||||
/// (after the QUIC dial completes) and by integration tests that wire a
|
||||
/// mock/loopback `Connection` directly.
|
||||
/// `CallConnection` the caller can use immediately. Used by the assembly
|
||||
/// layer after `AlknetClient::dial_*` + `spawn_dispatch` and by
|
||||
/// integration tests that wire a mock/loopback `Connection` directly.
|
||||
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection {
|
||||
let call_connection = Arc::new(CallConnection::new(connection));
|
||||
let dispatcher = Dispatcher::new(
|
||||
@@ -186,386 +74,6 @@ impl CallClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn build_quinn_client_config(
|
||||
credentials: &CallCredentials,
|
||||
alpn: &[u8],
|
||||
) -> Result<quinn::ClientConfig, String> {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
|
||||
let client_auth = build_client_auth(&provider, &credentials.tls_identity)?;
|
||||
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
|
||||
|
||||
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| e.to_string())?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_client_cert_resolver(client_auth);
|
||||
config.alpn_protocols = vec![alpn.to_vec()];
|
||||
config.enable_early_data = true;
|
||||
|
||||
Ok(quinn::ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(config).map_err(|e| e.to_string())?,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Build the client-auth cert resolver that presents the local node's TLS
|
||||
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
|
||||
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
|
||||
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
|
||||
/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured)
|
||||
/// resolves to no client cert (the server gets nothing to fingerprint).
|
||||
#[cfg(feature = "quinn")]
|
||||
fn build_client_auth(
|
||||
provider: &Arc<rustls::crypto::CryptoProvider>,
|
||||
tls_identity: &Option<TlsIdentity>,
|
||||
) -> Result<Arc<dyn rustls::client::ResolvesClientCert>, String> {
|
||||
match tls_identity {
|
||||
Some(TlsIdentity::RawKey(secret_key)) => {
|
||||
let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone()));
|
||||
let spki = signing_key.spki_public_key();
|
||||
let cert = rustls::pki_types::CertificateDer::from(spki.to_vec());
|
||||
let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key));
|
||||
Ok(Arc::new(RawKeyClientCertResolver::new(certified_key)))
|
||||
}
|
||||
Some(TlsIdentity::X509 { cert, key }) => {
|
||||
let cert_chain = load_cert_chain(cert).map_err(|e| e.to_string())?;
|
||||
let key_der = load_private_key(key).map_err(|e| e.to_string())?;
|
||||
let certified_key = rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new(
|
||||
certified_key,
|
||||
))))
|
||||
}
|
||||
Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)),
|
||||
Some(TlsIdentity::Acme { .. }) => {
|
||||
Err("ACME TLS identity is server-only; cannot be used for client auth".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the server cert verifier by `remote_identity` presence (ADR-034 §3).
|
||||
///
|
||||
/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint
|
||||
/// match). The fingerprint IS the trust anchor.
|
||||
/// - `None` → no `PeerEntry` for the remote → `WebPkiServerVerifier` (CA
|
||||
/// verification) for X.509 remotes. For Ed25519 raw-key remotes the
|
||||
/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes
|
||||
/// have no CA to fall back to — ADR-034 §2 assumption 1). `None` is the
|
||||
/// public-X.509-endpoint state, not "skip verification."
|
||||
#[cfg(feature = "quinn")]
|
||||
fn select_server_verifier(
|
||||
provider: &Arc<rustls::crypto::CryptoProvider>,
|
||||
remote_identity: &Option<RemoteIdentity>,
|
||||
) -> Result<Arc<dyn rustls::client::danger::ServerCertVerifier>, String> {
|
||||
match remote_identity {
|
||||
Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new(
|
||||
ri.fingerprint.clone(),
|
||||
provider.signature_verification_algorithms,
|
||||
))),
|
||||
None => {
|
||||
let roots = load_platform_root_cert_store()?;
|
||||
let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider(
|
||||
Arc::new(roots),
|
||||
Arc::clone(provider),
|
||||
)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(verifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the platform's trusted root certificates into a `RootCertStore` for
|
||||
/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back
|
||||
/// to the aws-lc-rs built-in `webpki-roots` if the platform store is empty
|
||||
/// (e.g. in a container with no system CA bundle).
|
||||
#[cfg(feature = "quinn")]
|
||||
fn load_platform_root_cert_store() -> Result<rustls::RootCertStore, String> {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let result = rustls_native_certs::load_native_certs();
|
||||
for err in &result.errors {
|
||||
tracing::warn!(error = ?err, "failed to load a native root cert");
|
||||
}
|
||||
for cert in &result.certs {
|
||||
roots
|
||||
.add(cert.clone())
|
||||
.map_err(|e| format!("failed to add native root cert: {e}"))?;
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn load_cert_chain(
|
||||
path: &std::path::Path,
|
||||
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||
rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn load_private_key(
|
||||
path: &std::path::Path,
|
||||
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||
match rustls_pemfile::private_key(&mut reader) {
|
||||
Ok(Some(key)) => Ok(key),
|
||||
Ok(None) => Err("no private key found in file".to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Client cert resolver that presents a single RFC 7250 raw public key (or
|
||||
/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so
|
||||
/// rustls negotiates the RFC 7250 ClientCertificateType extension.
|
||||
#[cfg(feature = "quinn")]
|
||||
struct RawKeyClientCertResolver {
|
||||
key: Arc<rustls::sign::CertifiedKey>,
|
||||
raw_public_keys: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl RawKeyClientCertResolver {
|
||||
fn new(key: Arc<rustls::sign::CertifiedKey>) -> Self {
|
||||
let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]);
|
||||
Self {
|
||||
key,
|
||||
raw_public_keys,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool {
|
||||
alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some()
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for RawKeyClientCertResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RawKeyClientCertResolver")
|
||||
.field("raw_public_keys", &self.raw_public_keys)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
_root_hint_subjects: &[&[u8]],
|
||||
_sigschemes: &[rustls::SignatureScheme],
|
||||
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
Some(Arc::clone(&self.key))
|
||||
}
|
||||
|
||||
fn only_raw_public_keys(&self) -> bool {
|
||||
self.raw_public_keys
|
||||
}
|
||||
|
||||
fn has_certs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Client cert resolver that presents no client cert (the `tls_identity: None`
|
||||
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
|
||||
/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for
|
||||
/// this connection.
|
||||
#[cfg(feature = "quinn")]
|
||||
struct NoClientCertResolver;
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for NoClientCertResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NoClientCertResolver").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl rustls::client::ResolvesClientCert for NoClientCertResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
_root_hint_subjects: &[&[u8]],
|
||||
_sigschemes: &[rustls::SignatureScheme],
|
||||
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn has_certs(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerCertVerifier` that pins a specific fingerprint (ADR-034 §3, the
|
||||
/// known-peer path). For `ed25519:<hex>` remotes the raw Ed25519 pub key is
|
||||
/// extracted from the presented cert and matched against the pinned
|
||||
/// fingerprint; for `SHA256:<hex>` remotes the cert DER is hashed and matched
|
||||
/// against the pinned fingerprint. No match → verification failure (the
|
||||
/// connection is rejected). The fingerprint IS the trust anchor — there is no
|
||||
/// CA verification and no name verification, only the fingerprint pin.
|
||||
///
|
||||
/// Handshake signatures are still verified (using the aws-lc-rs default
|
||||
/// signature verification algorithms) so that a stolen-but-stale fingerprint
|
||||
/// can't be replayed with a forged signature: the presenter must prove
|
||||
/// possession of the private key corresponding to the pinned public key.
|
||||
#[cfg(feature = "quinn")]
|
||||
struct FingerprintPinVerifier {
|
||||
fingerprint: String,
|
||||
supported: rustls::crypto::WebPkiSupportedAlgorithms,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl FingerprintPinVerifier {
|
||||
fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self {
|
||||
Self {
|
||||
fingerprint,
|
||||
supported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for FingerprintPinVerifier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FingerprintPinVerifier")
|
||||
.field("fingerprint", &self.fingerprint)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
let presented = alknet_core::fingerprint::fingerprint_from_cert_der(end_entity.as_ref())
|
||||
.ok_or(rustls::Error::General(
|
||||
"fingerprint pin: failed to compute fingerprint from presented cert".to_string(),
|
||||
))?;
|
||||
if presented == self.fingerprint {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
} else {
|
||||
Err(rustls::Error::General(format!(
|
||||
"fingerprint pin mismatch: expected {} got {}",
|
||||
self.fingerprint, presented
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
|
||||
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
|
||||
rustls::crypto::verify_tls13_signature_with_raw_key(
|
||||
message,
|
||||
&spki,
|
||||
dss,
|
||||
&self.supported,
|
||||
)
|
||||
} else {
|
||||
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
|
||||
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
|
||||
rustls::crypto::verify_tls13_signature_with_raw_key(
|
||||
message,
|
||||
&spki,
|
||||
dss,
|
||||
&self.supported,
|
||||
)
|
||||
} else {
|
||||
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.supported.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[derive(Clone)]
|
||||
struct Ed25519SigningKey {
|
||||
key: alknet_core::config::Ed25519SecretKey,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl Ed25519SigningKey {
|
||||
fn new(key: alknet_core::config::Ed25519SecretKey) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
fn spki_public_key(&self) -> rustls::pki_types::SubjectPublicKeyInfoDer<'static> {
|
||||
rustls::sign::public_key_to_spki(
|
||||
&rustls::pki_types::alg_id::ED25519,
|
||||
self.key.public().as_bytes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for Ed25519SigningKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Ed25519SigningKey").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl rustls::sign::SigningKey for Ed25519SigningKey {
|
||||
fn choose_scheme(
|
||||
&self,
|
||||
offered: &[rustls::SignatureScheme],
|
||||
) -> Option<Box<dyn rustls::sign::Signer>> {
|
||||
if offered.contains(&rustls::SignatureScheme::ED25519) {
|
||||
Some(Box::new(self.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn algorithm(&self) -> rustls::SignatureAlgorithm {
|
||||
rustls::SignatureAlgorithm::ED25519
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Option<rustls::pki_types::SubjectPublicKeyInfoDer<'_>> {
|
||||
Some(self.spki_public_key())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl rustls::sign::Signer for Ed25519SigningKey {
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, rustls::Error> {
|
||||
Ok(self.key.sign(message).to_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn scheme(&self) -> rustls::SignatureScheme {
|
||||
rustls::SignatureScheme::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -577,16 +85,8 @@ mod tests {
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::Identity;
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
fn stub_connection() -> Connection {
|
||||
Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
use crate::protocol::sink_empty_connection as stub_connection;
|
||||
|
||||
fn external_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
@@ -649,19 +149,6 @@ mod tests {
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_credentials_builder_methods() {
|
||||
let creds = CallCredentials::new().with_remote_identity(RemoteIdentity {
|
||||
fingerprint: "SHA256:abc".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
creds.remote_identity.as_ref().unwrap().fingerprint,
|
||||
"SHA256:abc"
|
||||
);
|
||||
assert!(creds.tls_identity.is_none());
|
||||
assert!(creds.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_op_dispatches_and_populates_capabilities() {
|
||||
let registry = registry_with_caps();
|
||||
@@ -706,225 +193,5 @@ mod tests {
|
||||
fn call_client_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CallClient>();
|
||||
assert_send_sync::<CallCredentials>();
|
||||
assert_send_sync::<RemoteIdentity>();
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
|
||||
let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key);
|
||||
spki.to_vec()
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> {
|
||||
let key_pair = rcgen::KeyPair::generate().expect("key gen");
|
||||
let params = rcgen::CertificateParams::default();
|
||||
let cert = params.self_signed(&key_pair).expect("self-signed cert");
|
||||
cert.der().clone()
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn aws_lc_rs_provider() -> Arc<rustls::crypto::CryptoProvider> {
|
||||
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
fn verify_pin(
|
||||
verifier: &FingerprintPinVerifier,
|
||||
cert_der: rustls::pki_types::CertificateDer<'_>,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
use rustls::client::danger::ServerCertVerifier;
|
||||
let server_name: rustls::pki_types::ServerName<'static> =
|
||||
"alknet".try_into().expect("server name");
|
||||
verifier.verify_server_cert(
|
||||
&cert_der,
|
||||
&[],
|
||||
&server_name,
|
||||
&[],
|
||||
rustls::pki_types::UnixTime::now(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() {
|
||||
let sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let raw_key = sk.public().to_bytes();
|
||||
let spki_der = build_ed25519_spki_der(&raw_key);
|
||||
let fingerprint =
|
||||
alknet_core::fingerprint::fingerprint_from_cert_der(&spki_der).expect("fingerprint");
|
||||
let verifier = FingerprintPinVerifier::new(
|
||||
fingerprint,
|
||||
aws_lc_rs_provider().signature_verification_algorithms,
|
||||
);
|
||||
let cert = rustls::pki_types::CertificateDer::from(spki_der);
|
||||
let result = verify_pin(&verifier, cert);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"FingerprintPinVerifier must accept a cert whose fingerprint matches the pin"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() {
|
||||
let sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let raw_key = sk.public().to_bytes();
|
||||
let spki_der = build_ed25519_spki_der(&raw_key);
|
||||
let other_sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes()));
|
||||
let verifier = FingerprintPinVerifier::new(
|
||||
other_fp,
|
||||
aws_lc_rs_provider().signature_verification_algorithms,
|
||||
);
|
||||
let cert = rustls::pki_types::CertificateDer::from(spki_der);
|
||||
let result = verify_pin(&verifier, cert);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() {
|
||||
let cert_der = build_x509_cert_der();
|
||||
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
|
||||
.expect("fingerprint");
|
||||
let verifier = FingerprintPinVerifier::new(
|
||||
fingerprint,
|
||||
aws_lc_rs_provider().signature_verification_algorithms,
|
||||
);
|
||||
let result = verify_pin(&verifier, cert_der);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() {
|
||||
let cert_der = build_x509_cert_der();
|
||||
let verifier = FingerprintPinVerifier::new(
|
||||
"SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
|
||||
aws_lc_rs_provider().signature_verification_algorithms,
|
||||
);
|
||||
let result = verify_pin(&verifier, cert_der);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn select_server_verifier_returns_ca_verifier_for_none() {
|
||||
let provider = aws_lc_rs_provider();
|
||||
let remote_identity: Option<RemoteIdentity> = None;
|
||||
let verifier = select_server_verifier(&provider, &remote_identity);
|
||||
assert!(
|
||||
verifier.is_ok(),
|
||||
"select_server_verifier must succeed for None (CA path)"
|
||||
);
|
||||
let debug = format!("{:?}", verifier.unwrap());
|
||||
assert!(
|
||||
debug.contains("WebPkiServerVerifier"),
|
||||
"None must select WebPkiServerVerifier (CA verification), got: {debug}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn select_server_verifier_returns_fingerprint_pin_for_some() {
|
||||
let provider = aws_lc_rs_provider();
|
||||
let remote_identity = Some(RemoteIdentity {
|
||||
fingerprint: "ed25519:abc".to_string(),
|
||||
});
|
||||
let verifier = select_server_verifier(&provider, &remote_identity);
|
||||
assert!(
|
||||
verifier.is_ok(),
|
||||
"select_server_verifier must succeed for Some (fingerprint pin path)"
|
||||
);
|
||||
let debug = format!("{:?}", verifier.unwrap());
|
||||
assert!(
|
||||
debug.contains("FingerprintPinVerifier"),
|
||||
"Some must select FingerprintPinVerifier, got: {debug}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn build_client_auth_presents_ed25519_raw_key_without_error() {
|
||||
let provider = aws_lc_rs_provider();
|
||||
let sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let tls_identity = Some(alknet_core::config::TlsIdentity::RawKey(sk));
|
||||
let resolver = build_client_auth(&provider, &tls_identity);
|
||||
assert!(
|
||||
resolver.is_ok(),
|
||||
"build_client_auth must build a resolver for a RawKey identity"
|
||||
);
|
||||
let resolver = resolver.unwrap();
|
||||
assert!(
|
||||
resolver.only_raw_public_keys(),
|
||||
"RawKey client auth resolver must present raw public keys (RFC 7250)"
|
||||
);
|
||||
assert!(
|
||||
resolver.has_certs(),
|
||||
"RawKey client auth resolver must report it has a cert to present"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn build_client_auth_none_resolves_to_no_client_cert() {
|
||||
let provider = aws_lc_rs_provider();
|
||||
let tls_identity: Option<alknet_core::config::TlsIdentity> = None;
|
||||
let resolver = build_client_auth(&provider, &tls_identity)
|
||||
.expect("build_client_auth must succeed for None");
|
||||
assert!(
|
||||
!resolver.has_certs(),
|
||||
"NoClientCertResolver must report no certs (no client cert presented)"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn build_quinn_client_config_with_raw_key_identity_builds_without_error() {
|
||||
let sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let credentials = CallCredentials::new()
|
||||
.with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk))
|
||||
.with_remote_identity(RemoteIdentity {
|
||||
fingerprint: "ed25519:deadbeef".to_string(),
|
||||
});
|
||||
let config = build_quinn_client_config(&credentials, b"alknet/call");
|
||||
assert!(
|
||||
config.is_ok(),
|
||||
"build_quinn_client_config must build with a RawKey identity + pinned fingerprint"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
#[test]
|
||||
fn build_quinn_client_config_with_no_remote_identity_builds_without_error() {
|
||||
let sk = alknet_core::config::Ed25519SecretKey::generate();
|
||||
let credentials =
|
||||
CallCredentials::new().with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk));
|
||||
let config = build_quinn_client_config(&credentials, b"alknet/call");
|
||||
assert!(
|
||||
config.is_ok(),
|
||||
"build_quinn_client_config must build for the None + CA-verification path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_identity_none_is_load_bearing_not_defaulted() {
|
||||
let creds = CallCredentials::new();
|
||||
assert!(
|
||||
creds.remote_identity.is_none(),
|
||||
"CallCredentials::new() must keep remote_identity as None (the load-bearing \
|
||||
public-X.509-endpoint state), not default it to a placeholder"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ impl FromCallConfig {
|
||||
/// v1 defaults (two-way doors recorded in `client-and-adapters.md`):
|
||||
/// - auto-on-reconnect: the overlay is per-connection (Layer 2, ADR-024), so
|
||||
/// re-import on reconnect is naturally scoped; the assembly layer calls
|
||||
/// `from_call` immediately after `connect()`.
|
||||
/// `from_call` immediately after `AlknetClient::dial_*` + `spawn_dispatch`.
|
||||
/// - same-peer collision = error: two ops with the same name from the same
|
||||
/// peer (after applying the optional prefix) → `AdapterError::SamePeerCollision`.
|
||||
/// Cross-peer collision dissolves (ADR-029 §5).
|
||||
@@ -127,15 +127,10 @@ fn build_bundles(
|
||||
OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
op_summary.credentials_auth_token.clone(),
|
||||
)),
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
HandlerKind::Once(make_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
op_summary.credentials_auth_token.clone(),
|
||||
))
|
||||
}
|
||||
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
||||
make_forwarding_handler(Arc::new(op_summary.connection.clone()), remote_name),
|
||||
),
|
||||
};
|
||||
bundles.push(HandlerRegistration::new(
|
||||
spec,
|
||||
@@ -155,7 +150,6 @@ struct OpSummary {
|
||||
name: String,
|
||||
schema: Value,
|
||||
connection: CallConnection,
|
||||
credentials_auth_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn discover_operations(connection: &CallConnection) -> Result<Vec<OpSummary>, AdapterError> {
|
||||
@@ -182,7 +176,6 @@ async fn discover_operations(connection: &CallConnection) -> Result<Vec<OpSummar
|
||||
name: name.to_string(),
|
||||
schema,
|
||||
connection: connection.clone(),
|
||||
credentials_auth_token: None,
|
||||
});
|
||||
}
|
||||
Ok(summaries)
|
||||
@@ -329,27 +322,19 @@ fn parse_access_control(v: &Value) -> AccessControl {
|
||||
/// Per ADR-032 §3, the handler populates `forwarded_for` on the
|
||||
/// `call.requested` payload from the hub's `OperationContext.identity` (the
|
||||
/// end user the hub authenticated). The hub authenticates as itself when
|
||||
/// forwarding — the `credentials_auth_token`, when present, is the hub's own
|
||||
/// call-protocol-level token placed in the payload's `auth_token` field. The
|
||||
/// spoke authorizes the hub (its direct caller); `forwarded_for` is metadata,
|
||||
/// never read by `AccessControl::check`.
|
||||
/// forwarding. The spoke authorizes the hub (its direct caller);
|
||||
/// `forwarded_for` is metadata, never read by `AccessControl::check`.
|
||||
///
|
||||
/// If `context.identity` is `None` (the hub chose not to disclose, or has not
|
||||
/// authenticated an originator), `forwarded_for` is omitted — the spoke
|
||||
/// receives only the hub's identity.
|
||||
fn make_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
credentials_auth_token: Option<String>,
|
||||
) -> Handler {
|
||||
fn make_forwarding_handler(connection: Arc<CallConnection>, remote_name: String) -> Handler {
|
||||
use crate::registry::registration::make_handler;
|
||||
make_handler(move |input, context| {
|
||||
let connection = Arc::clone(&connection);
|
||||
let remote_name = remote_name.clone();
|
||||
let auth_token = credentials_auth_token.clone();
|
||||
async move {
|
||||
let payload =
|
||||
build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
// The forwarding handler invokes the remote op via the
|
||||
// CallConnection. The parent_request_id participates in the abort
|
||||
// cascade (ADR-016 §6): if the parent is aborted, the cascade
|
||||
@@ -372,27 +357,24 @@ fn make_forwarding_handler(
|
||||
/// `call.aborted` drops it (ADR-049 §8). No truncation, no first-value
|
||||
/// fallback.
|
||||
///
|
||||
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3) and
|
||||
/// `auth_token` from the hub's own call-protocol token, exactly as the
|
||||
/// request/response forwarding handler does — both via `build_forwarded_payload`
|
||||
/// (no new payload-construction code). The `subscribe_with_payload` path
|
||||
/// registers the request in `PendingRequestMap`, so the abort cascade
|
||||
/// (ADR-016 §6) is already wired: a parent abort drops the
|
||||
/// `SubscriptionStream`, which sends `call.aborted` to the remote node.
|
||||
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3), exactly
|
||||
/// as the request/response forwarding handler does — both via
|
||||
/// `build_forwarded_payload` (no new payload-construction code). The
|
||||
/// `subscribe_with_payload` path registers the request in
|
||||
/// `PendingRequestMap`, so the abort cascade (ADR-016 §6) is already wired:
|
||||
/// a parent abort drops the `SubscriptionStream`, which sends `call.aborted`
|
||||
/// to the remote node.
|
||||
fn make_streaming_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
credentials_auth_token: Option<String>,
|
||||
) -> StreamingHandler {
|
||||
use crate::registry::registration::make_streaming_handler;
|
||||
use futures::stream::{once, StreamExt};
|
||||
make_streaming_handler(move |input, context| {
|
||||
let connection = Arc::clone(&connection);
|
||||
let remote_name = remote_name.clone();
|
||||
let auth_token = credentials_auth_token.clone();
|
||||
once(async move {
|
||||
let payload =
|
||||
build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
connection.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
@@ -402,14 +384,8 @@ fn make_streaming_forwarding_handler(
|
||||
/// Build the `call.requested` payload for a forwarded call, populating
|
||||
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
|
||||
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
|
||||
/// chooses not to disclose the originator). The `auth_token` field is set to
|
||||
/// the hub's own call-protocol token when present.
|
||||
fn build_forwarded_payload(
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
context: &OperationContext,
|
||||
auth_token: Option<&str>,
|
||||
) -> Value {
|
||||
/// chooses not to disclose the originator).
|
||||
fn build_forwarded_payload(operation_id: &str, input: Value, context: &OperationContext) -> Value {
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert(
|
||||
"operationId".to_string(),
|
||||
@@ -421,9 +397,6 @@ fn build_forwarded_payload(
|
||||
payload.insert("forwarded_for".to_string(), value);
|
||||
}
|
||||
}
|
||||
if let Some(token) = auth_token {
|
||||
payload.insert("auth_token".to_string(), Value::String(token.to_string()));
|
||||
}
|
||||
Value::Object(payload)
|
||||
}
|
||||
|
||||
@@ -436,17 +409,9 @@ mod tests {
|
||||
use alknet_core::auth::Identity;
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
fn stub_connection() -> alknet_core::types::Connection {
|
||||
alknet_core::types::Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
use crate::protocol::sink_empty_connection as stub_connection;
|
||||
|
||||
fn sample_schema_json(name: &str, op_type: &str) -> Value {
|
||||
json!({
|
||||
@@ -571,7 +536,6 @@ mod tests {
|
||||
let handler = make_forwarding_handler(
|
||||
Arc::new(CallConnection::new(stub_connection())),
|
||||
"worker/echo".to_string(),
|
||||
None,
|
||||
);
|
||||
let reg = HandlerRegistration::new(
|
||||
spec,
|
||||
@@ -638,33 +602,22 @@ mod tests {
|
||||
#[test]
|
||||
fn build_forwarded_payload_populates_forwarded_for_from_context_identity() {
|
||||
let ctx = test_context(Some(alice_identity()));
|
||||
let payload = build_forwarded_payload("fs/readFile", json!({"p": 1}), &ctx, None);
|
||||
let payload = build_forwarded_payload("fs/readFile", json!({"p": 1}), &ctx);
|
||||
assert_eq!(payload["operationId"], "fs/readFile");
|
||||
assert_eq!(payload["input"], json!({"p": 1}));
|
||||
let forwarded_for = payload.get("forwarded_for").expect("forwarded_for present");
|
||||
assert_eq!(forwarded_for["id"], "alice");
|
||||
assert_eq!(forwarded_for["scopes"][0], "fs:read");
|
||||
assert!(payload.get("auth_token").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_forwarded_payload_omits_forwarded_for_when_context_identity_is_none() {
|
||||
let ctx = test_context(None);
|
||||
let payload = build_forwarded_payload("fs/readFile", json!({}), &ctx, None);
|
||||
let payload = build_forwarded_payload("fs/readFile", json!({}), &ctx);
|
||||
assert!(payload.get("forwarded_for").is_none());
|
||||
assert!(payload.get("auth_token").is_none());
|
||||
assert_eq!(payload["operationId"], "fs/readFile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_forwarded_payload_sets_auth_token_when_provided() {
|
||||
let ctx = test_context(Some(alice_identity()));
|
||||
let payload =
|
||||
build_forwarded_payload("fs/readFile", json!({}), &ctx, Some("alk_hub_token"));
|
||||
assert_eq!(payload["auth_token"], "alk_hub_token");
|
||||
assert_eq!(payload["forwarded_for"]["id"], "alice");
|
||||
}
|
||||
|
||||
/// Verify the forwarding handler actually populates `forwarded_for` on
|
||||
/// the wire payload it sends. We intercept the payload by using a handler
|
||||
/// that records the payload passed to `call_with_payload`. Since
|
||||
@@ -687,7 +640,7 @@ mod tests {
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "fs/readFile".to_string();
|
||||
async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
let response = conn.call_with_payload(payload).await;
|
||||
ResponseEnvelope {
|
||||
@@ -718,7 +671,7 @@ mod tests {
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "fs/readFile".to_string();
|
||||
async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
let response = conn.call_with_payload(payload).await;
|
||||
ResponseEnvelope {
|
||||
@@ -745,7 +698,6 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
schema: sample_schema_json(name, "query"),
|
||||
connection: conn.clone(),
|
||||
credentials_auth_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +706,6 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
schema: sample_schema_json(name, op_type),
|
||||
connection: conn.clone(),
|
||||
credentials_auth_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,7 +900,7 @@ mod tests {
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
@@ -999,7 +950,7 @@ mod tests {
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
@@ -1018,45 +969,6 @@ mod tests {
|
||||
assert_eq!(payload["operationId"], "events/stream");
|
||||
}
|
||||
|
||||
/// The streaming forwarding handler populates `auth_token` when the hub's
|
||||
/// own call-protocol token is provided.
|
||||
#[tokio::test]
|
||||
async fn streaming_forwarding_handler_sets_auth_token_when_provided() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let captured_payload = Arc::new(StdMutex::new(None::<Value>));
|
||||
let captured = Arc::clone(&captured_payload);
|
||||
|
||||
let handler: StreamingHandler = {
|
||||
let conn = Arc::clone(&conn);
|
||||
make_streaming_handler(move |input, context| {
|
||||
let conn = Arc::clone(&conn);
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(
|
||||
&remote_name,
|
||||
input,
|
||||
&context,
|
||||
Some("alk_hub_token"),
|
||||
);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = test_context(Some(alice_identity()));
|
||||
let mut stream = handler(json!({}), ctx);
|
||||
let _ = stream.next().await;
|
||||
let payload = captured_payload.lock().unwrap().clone().expect("captured");
|
||||
assert_eq!(payload["auth_token"], "alk_hub_token");
|
||||
assert_eq!(payload["forwarded_for"]["id"], "alice");
|
||||
}
|
||||
|
||||
/// `make_streaming_forwarding_handler` produces a `StreamingHandler` (not a
|
||||
/// `Handler`) — verifies the helper returns the right type and that
|
||||
/// `build_bundles` wires it into `HandlerKind::Stream`.
|
||||
@@ -1065,7 +977,6 @@ mod tests {
|
||||
let handler = make_streaming_forwarding_handler(
|
||||
Arc::new(CallConnection::new(stub_connection())),
|
||||
"events/stream".to_string(),
|
||||
None,
|
||||
);
|
||||
let reg = HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
mod call_client;
|
||||
mod from_call;
|
||||
|
||||
pub use call_client::{CallClient, CallCredentials, ClientError, RemoteIdentity};
|
||||
pub use call_client::CallClient;
|
||||
pub use from_call::{from_call, FromCallConfig};
|
||||
|
||||
use crate::registry::registration::HandlerRegistration;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! alknet-call: Structured RPC over QUIC — operations, streaming, service discovery.
|
||||
//! alknet-call: Structured RPC — operations, streaming, service discovery.
|
||||
//!
|
||||
//! Implements [`alknet_core::types::ProtocolHandler`] on ALPN `alknet/call`.
|
||||
//!
|
||||
|
||||
@@ -140,10 +140,9 @@ impl CallAdapter {
|
||||
pub(crate) async fn handle_stream(
|
||||
&self,
|
||||
connection: Arc<CallConnection>,
|
||||
send: alknet_core::types::SendStream,
|
||||
recv: alknet_core::types::RecvStream,
|
||||
stream: alknet_core::types::BiStream,
|
||||
) {
|
||||
self.dispatcher.handle_stream(connection, send, recv).await;
|
||||
self.dispatcher.handle_stream(connection, stream).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +178,11 @@ mod tests {
|
||||
use alknet_core::auth::AuthToken;
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::protocol::sink_empty_connection as stub_connection;
|
||||
|
||||
struct StaticIdentityProvider {
|
||||
tokens: StdMutex<HashMap<String, Identity>>,
|
||||
}
|
||||
@@ -290,15 +290,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn stub_connection() -> Connection {
|
||||
Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpn_returns_alknet_call() {
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
@@ -1193,10 +1184,9 @@ mod tests {
|
||||
let frame = encode_frame(&EventEnvelope::aborted("parent-1"));
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
|
||||
let (send, _recv_sink) = tokio::io::duplex(64);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
adapter.handle_stream(conn.clone(), send, recv).await;
|
||||
adapter.handle_stream(conn.clone(), stream).await;
|
||||
|
||||
let pending = conn.pending().lock();
|
||||
assert!(
|
||||
@@ -1233,10 +1223,9 @@ mod tests {
|
||||
let frame = encode_frame(&EventEnvelope::aborted("does-not-exist"));
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
|
||||
let (send, _recv_sink) = tokio::io::duplex(64);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
adapter.handle_stream(conn.clone(), send, recv).await;
|
||||
adapter.handle_stream(conn.clone(), stream).await;
|
||||
|
||||
let pending = conn.pending().lock();
|
||||
assert!(
|
||||
|
||||
@@ -16,6 +16,7 @@ use alknet_core::types::Connection;
|
||||
use futures::stream::Stream;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::pending::PendingRequestMap;
|
||||
@@ -126,13 +127,17 @@ impl CallConnection {
|
||||
}
|
||||
};
|
||||
|
||||
let (send, recv) = match connection.open_bi().await {
|
||||
Ok(pair) => pair,
|
||||
// `open_bi` returns a `BiStream` (ADR-092); split it into halves for
|
||||
// the call protocol's separate write (request) and read (response)
|
||||
// pumps. The split is the stdlib idiom; no per-handler wrapper.
|
||||
let stream = match connection.open_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
let call_error = CallError::internal(format!("failed to open stream: {err}"));
|
||||
return ResponseEnvelope::error(request_id, call_error);
|
||||
}
|
||||
};
|
||||
let (recv, send) = tokio::io::split(stream);
|
||||
|
||||
let receiver = {
|
||||
let mut pending = self.pending.lock();
|
||||
@@ -197,13 +202,16 @@ impl CallConnection {
|
||||
}
|
||||
};
|
||||
|
||||
let (send, recv) = match connection.open_bi().await {
|
||||
Ok(pair) => pair,
|
||||
// `open_bi` returns a `BiStream` (ADR-092); split for the separate
|
||||
// write (request) and read (subscription events) pumps.
|
||||
let stream = match connection.open_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
let call_error = CallError::internal(format!("failed to open stream: {err}"));
|
||||
return SubscriptionStream::closed(request_id, call_error);
|
||||
}
|
||||
};
|
||||
let (recv, send) = tokio::io::split(stream);
|
||||
|
||||
let receiver = {
|
||||
let mut pending = self.pending.lock();
|
||||
@@ -235,12 +243,15 @@ impl CallConnection {
|
||||
self.pending.lock().handle_aborted(request_id);
|
||||
}
|
||||
|
||||
async fn write_request(
|
||||
async fn write_request<W>(
|
||||
&self,
|
||||
send: alknet_core::types::SendStream,
|
||||
send: W,
|
||||
request_id: &str,
|
||||
payload: Value,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), String>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let envelope = EventEnvelope::requested(request_id, payload);
|
||||
let mut writer = FrameFramedWriter::new(send);
|
||||
writer
|
||||
@@ -254,10 +265,13 @@ impl CallConnection {
|
||||
.connection
|
||||
.as_ref()
|
||||
.ok_or_else(|| "no underlying connection (overlay-only)".to_string())?;
|
||||
let (send, _recv) = connection
|
||||
// `open_bi` returns a `BiStream` (ADR-092). We only need the write
|
||||
// half to send the envelope; split and drop the read half.
|
||||
let stream = connection
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| format!("failed to open stream: {e}"))?;
|
||||
let (_recv, send) = tokio::io::split(stream);
|
||||
let mut writer = FrameFramedWriter::new(send);
|
||||
writer
|
||||
.write_frame(envelope)
|
||||
@@ -266,10 +280,10 @@ impl CallConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_stream_until_closed(
|
||||
recv: alknet_core::types::RecvStream,
|
||||
pending: &Arc<Mutex<PendingRequestMap>>,
|
||||
) {
|
||||
async fn read_stream_until_closed<R>(recv: R, pending: &Arc<Mutex<PendingRequestMap>>)
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut reader = FrameFramedReader::new(recv);
|
||||
while let Ok(envelope) = reader.read_frame().await {
|
||||
dispatch_envelope(pending, envelope);
|
||||
@@ -458,17 +472,9 @@ mod tests {
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn stub_connection() -> Connection {
|
||||
Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
use crate::protocol::sink_empty_connection as stub_connection;
|
||||
|
||||
fn external_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
|
||||
@@ -285,9 +285,13 @@ impl Dispatcher {
|
||||
pub(crate) async fn handle_stream(
|
||||
&self,
|
||||
connection: Arc<CallConnection>,
|
||||
send: alknet_core::types::SendStream,
|
||||
recv: alknet_core::types::RecvStream,
|
||||
stream: alknet_core::types::BiStream,
|
||||
) {
|
||||
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite + Send
|
||||
// + Unpin`. Split into the read and write halves the call protocol's
|
||||
// frame reader/writer consume. The split is the stdlib idiom; no
|
||||
// per-handler wrapper.
|
||||
let (recv, send) = tokio::io::split(stream);
|
||||
let mut reader = FrameFramedReader::new(recv);
|
||||
let mut writer = FrameFramedWriter::new(send);
|
||||
|
||||
@@ -404,11 +408,11 @@ impl Dispatcher {
|
||||
|
||||
loop {
|
||||
match quic.accept_bi().await {
|
||||
Ok((send, recv)) => {
|
||||
Ok(stream) => {
|
||||
let conn = Arc::clone(&connection);
|
||||
let dispatcher = self.clone();
|
||||
tokio::spawn(async move {
|
||||
dispatcher.handle_stream(conn, send, recv).await;
|
||||
dispatcher.handle_stream(conn, stream).await;
|
||||
});
|
||||
}
|
||||
Err(StreamError::ConnectionClosed) => break,
|
||||
@@ -458,17 +462,9 @@ mod tests {
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
fn stub_connection() -> alknet_core::types::Connection {
|
||||
alknet_core::types::Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
use crate::protocol::sink_empty_connection as stub_connection;
|
||||
|
||||
struct StaticIdentityProvider {
|
||||
tokens: StdMutex<HashMap<String, Identity>>,
|
||||
@@ -1180,10 +1176,9 @@ mod tests {
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, send, recv).await;
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 4, "3 responded + 1 completed");
|
||||
@@ -1219,10 +1214,9 @@ mod tests {
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, send, recv).await;
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 2, "1 responded + 1 error, no completed");
|
||||
@@ -1255,10 +1249,9 @@ mod tests {
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, send, recv).await;
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 1, "query: exactly one frame, no completed");
|
||||
@@ -1286,10 +1279,9 @@ mod tests {
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, send, recv).await;
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 1, "unknown op: single error, no completed");
|
||||
@@ -1346,13 +1338,12 @@ mod tests {
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, _sink) = tokio::io::duplex(8 * 1024);
|
||||
let send = alknet_core::types::SendStream::from_stream(send);
|
||||
let recv = alknet_core::types::RecvStream::from_stream(recv);
|
||||
let stream = alknet_core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
let conn_clone = Arc::clone(&conn);
|
||||
let dp_clone = dp.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
dp_clone.handle_stream(conn_clone, send, recv).await;
|
||||
dp_clone.handle_stream(conn_clone, stream).await;
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
@@ -10,3 +10,9 @@ pub mod connection;
|
||||
pub mod dispatch;
|
||||
pub mod pending;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use test_support::sink_empty_connection;
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Shared test helpers for the call protocol's inline `#[cfg(test)]`
|
||||
//! modules. Kept here (not in each test module) so the `stub_connection()`
|
||||
//! shape is defined once — `Connection::from_stream` was removed (ADR-092)
|
||||
//! and every test stub that previously called it now calls
|
||||
//! `Connection::from_bidi(SinkEmpty, ...)` via `sink_empty_connection()`.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use alknet_core::types::Connection;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
|
||||
/// `tokio::io::sink() + tokio::io::empty()`: reads yield EOF immediately
|
||||
/// (zero bytes), writes discard. Exists because `Connection::from_bidi`
|
||||
/// (ADR-092 — the only public stream constructor, replacing
|
||||
/// `from_stream`) requires a single value that implements both traits.
|
||||
/// Used only to construct a `Connection` for tests that exercise
|
||||
/// `Connection`-level state (alpn, addr, identity, dispatcher run loop
|
||||
/// with an immediately-closed accept stream) without ever reading or
|
||||
/// writing real bytes.
|
||||
pub(crate) struct SinkEmpty;
|
||||
|
||||
impl AsyncRead for SinkEmpty {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
_buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
// EOF immediately — mirrors `tokio::io::empty()`.
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for SinkEmpty {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
// Discard — mirrors `tokio::io::sink()`.
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a `Connection` whose `accept_bi` yields a `SinkEmpty` once,
|
||||
/// then `ConnectionClosed`. Used by tests that need a `Connection` for
|
||||
/// `CallConnection::new(conn)` or `adapter.handle(conn, &auth)` without
|
||||
/// exercising the wire protocol — `SinkEmpty` reads EOF (so the dispatch
|
||||
/// loop closes immediately) and discards writes.
|
||||
pub(crate) fn sink_empty_connection() -> Connection {
|
||||
Connection::from_bidi(
|
||||
SinkEmpty,
|
||||
b"alknet/call".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||
)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
//! Integration test: two-node `alknet/call` round-trip over a real QUIC
|
||||
//! loopback. A `CallAdapter` server accepts, a `CallClient` connects, and
|
||||
//! the client calls back into the server (connection symmetry, ADR-017 §2).
|
||||
//! Verifies the shared dispatch loop works end-to-end.
|
||||
|
||||
#![cfg(feature = "quinn")]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alknet_call::client::{CallClient, CallCredentials, RemoteIdentity};
|
||||
use alknet_call::protocol::adapter::CallAdapter;
|
||||
use alknet_call::protocol::wire::ResponseEnvelope;
|
||||
use alknet_call::registry::discovery::{
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{Identity, IdentityProvider};
|
||||
use alknet_core::types::{Capabilities, Connection, ProtocolHandler};
|
||||
|
||||
struct NoopIdentityProvider;
|
||||
impl IdentityProvider for NoopIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &alknet_core::auth::AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn external_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_handler() -> Handler {
|
||||
make_handler(|input, context| async move { ResponseEnvelope::ok(context.request_id, input) })
|
||||
}
|
||||
|
||||
/// Build a raw quinn server endpoint with a self-signed cert and the
|
||||
/// `CallAdapter` accepting `alknet/call` connections. Returns
|
||||
/// `(bound_addr, server_fingerprint, join_handle)` — the fingerprint is the
|
||||
/// `SHA256:<hex>` of the self-signed cert DER, which the client pins via
|
||||
/// `CallCredentials::with_remote_identity` (the known-peer path, ADR-034 §3).
|
||||
/// The accept loop spawns a task per connection that hands the connection to
|
||||
/// `CallAdapter::handle`.
|
||||
async fn build_raw_quinn_server(
|
||||
registry: Arc<OperationRegistry>,
|
||||
) -> (std::net::SocketAddr, String, tokio::task::JoinHandle<()>) {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
|
||||
let adapter = Arc::new(CallAdapter::new(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&provider),
|
||||
));
|
||||
|
||||
let key_pair = rcgen::KeyPair::generate().expect("key gen");
|
||||
let params = rcgen::CertificateParams::default();
|
||||
let cert = params.self_signed(&key_pair).expect("self-signed cert");
|
||||
let cert_der = cert.der().clone();
|
||||
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
|
||||
.expect("cert produces fingerprint");
|
||||
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
|
||||
);
|
||||
|
||||
let provider_crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let mut server_config = rustls::ServerConfig::builder_with_provider(provider_crypto)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der], key_der)
|
||||
.unwrap();
|
||||
server_config.alpn_protocols = vec![b"alknet/call".to_vec()];
|
||||
server_config.max_early_data_size = u32::MAX;
|
||||
|
||||
let quic_server_config =
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(server_config).unwrap();
|
||||
let quinn_server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
|
||||
|
||||
let quinn_endpoint =
|
||||
quinn::Endpoint::server(quinn_server_config, "127.0.0.1:0".parse().unwrap())
|
||||
.expect("server bind");
|
||||
let bound_addr = quinn_endpoint.local_addr().expect("local addr");
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
while let Some(incoming) = quinn_endpoint.accept().await {
|
||||
let adapter = Arc::clone(&adapter);
|
||||
tokio::spawn(async move {
|
||||
let connecting = match incoming.accept() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let conn = match connecting.await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let alpn = b"alknet/call".to_vec();
|
||||
let conn = Connection::from_quinn_with_alpn(conn, alpn.clone());
|
||||
let auth = alknet_core::auth::AuthContext {
|
||||
identity: None,
|
||||
alpn,
|
||||
remote_addr: conn.remote_addr(),
|
||||
tls_client_fingerprint: None,
|
||||
};
|
||||
let _ = adapter.handle(conn, &auth).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(bound_addr, fingerprint, join)
|
||||
}
|
||||
|
||||
/// Build the server's registry: an echo op, a secret op, and the
|
||||
/// services/list + services/schema discovery handlers.
|
||||
fn build_server_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
let discovery_registry = Arc::new(registry);
|
||||
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
|
||||
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
|
||||
let mut full = OperationRegistry::new();
|
||||
full.register(HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
HandlerKind::Once(list_handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(schema_handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(full)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn two_node_call_round_trip() {
|
||||
let server_registry = build_server_registry();
|
||||
let (server_addr, server_fingerprint, _server_join) =
|
||||
build_raw_quinn_server(Arc::clone(&server_registry)).await;
|
||||
|
||||
// Client side: a CallClient with its own ops so the server can call back
|
||||
// (connection symmetry). Pin the server's self-signed cert fingerprint
|
||||
// (the known-peer path, ADR-034 §3) — `WebPkiServerVerifier` would reject
|
||||
// it as UnknownIssuer since the self-signed cert is not in the platform
|
||||
// root store.
|
||||
let mut client_registry = OperationRegistry::new();
|
||||
client_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("client/echo"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let client_registry = Arc::new(client_registry);
|
||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
||||
|
||||
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
|
||||
fingerprint: server_fingerprint,
|
||||
});
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.connect(server_addr, credentials),
|
||||
)
|
||||
.await
|
||||
.expect("connect did not time out")
|
||||
.expect("connect succeeds");
|
||||
|
||||
// Outbound call: client -> server's echo op.
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
conn.call("server/echo", serde_json::json!({"hi": 1})),
|
||||
)
|
||||
.await
|
||||
.expect("call did not time out");
|
||||
assert_eq!(response.result, Ok(serde_json::json!({"hi": 1})));
|
||||
|
||||
// Peer authorization is enforced by the AccessControl gate in
|
||||
// OperationRegistry::invoke (ADR-029 §3) — exercised by the unit tests in
|
||||
// `registry/registration.rs`. This integration test focuses on the QUIC
|
||||
// connect path + shared dispatch loop working end-to-end (the call above
|
||||
// proves the CallClient opened a real connection, the shared loop
|
||||
// dispatched, and the CallConnection::call() round-tripped).
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn from_call_discovers_and_forwards_over_quic_loopback() {
|
||||
use alknet_call::client::{from_call, FromCallConfig};
|
||||
use alknet_call::registry::context::ScopedPeerEnv;
|
||||
|
||||
let server_registry = build_server_registry();
|
||||
let (server_addr, server_fingerprint, _server_join) =
|
||||
build_raw_quinn_server(Arc::clone(&server_registry)).await;
|
||||
|
||||
// Client with an empty registry — from_call will populate its overlay.
|
||||
// Pin the server's self-signed cert fingerprint (ADR-034 §3 known-peer
|
||||
// path).
|
||||
let client_registry = Arc::new(OperationRegistry::new());
|
||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
||||
|
||||
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
|
||||
fingerprint: server_fingerprint,
|
||||
});
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.connect(server_addr, credentials),
|
||||
)
|
||||
.await
|
||||
.expect("connect did not time out")
|
||||
.expect("connect succeeds");
|
||||
|
||||
// from_call discovers the server's External ops (server/echo, server/secret
|
||||
// — both External; services/list + services/schema themselves are External
|
||||
// too) and builds FromCall forwarding-handler bundles. Register them in the
|
||||
// connection's Layer 2 overlay.
|
||||
let bundles = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
from_call(&conn, FromCallConfig::new()),
|
||||
)
|
||||
.await
|
||||
.expect("from_call did not time out")
|
||||
.expect("from_call succeeds");
|
||||
assert!(
|
||||
!bundles.is_empty(),
|
||||
"from_call must discover at least the server/echo op"
|
||||
);
|
||||
conn.register_imported_all(bundles);
|
||||
|
||||
// The overlay now contains the discovered ops. Verify the forwarding path
|
||||
// by invoking the overlay env directly with a scoped context that allows
|
||||
// server/echo — this is how a composing handler would call the imported op.
|
||||
let env = conn.overlay_env();
|
||||
assert!(
|
||||
env.contains("server/echo"),
|
||||
"overlay must contain the imported server/echo op"
|
||||
);
|
||||
|
||||
// Build a minimal parent context to invoke the overlay env (mirrors how a
|
||||
// composing handler dispatches a child).
|
||||
let scoped = ScopedPeerEnv::new(["server/echo"]);
|
||||
let parent = alknet_call::registry::context::OperationContext {
|
||||
request_id: "parent-1".to_string(),
|
||||
parent_request_id: None,
|
||||
identity: None,
|
||||
handler_identity: None,
|
||||
forwarded_for: None,
|
||||
capabilities: Capabilities::new(),
|
||||
metadata: Default::default(),
|
||||
scoped_env: scoped,
|
||||
env: env.clone(),
|
||||
abort_policy: alknet_call::registry::context::AbortPolicy::default(),
|
||||
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
|
||||
internal: true,
|
||||
ownership: None,
|
||||
};
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
env.invoke(
|
||||
"server",
|
||||
"echo",
|
||||
serde_json::json!({"from_call": true}),
|
||||
&parent,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("overlay invoke did not time out");
|
||||
assert_eq!(
|
||||
response.result,
|
||||
Ok(serde_json::json!({"from_call": true})),
|
||||
"from_call forwarding handler must round-trip the input to the remote op"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "alknet-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Native client dial seam — multi-transport dialer that produces Connections for protocol take-overs"
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "alknet_client"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
quinn = ["dep:quinn", "alknet-tls/quinn", "alknet-core/quinn"]
|
||||
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
|
||||
iroh = ["dep:iroh", "alknet-core/iroh"]
|
||||
socks5 = ["dep:fast-socks5"]
|
||||
|
||||
[dependencies]
|
||||
alknet-core = { path = "../alknet-core" }
|
||||
alknet-tls = { path = "../alknet-tls" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
quinn = { version = "0.11", optional = true }
|
||||
tokio-rustls = { version = "0.26", optional = true }
|
||||
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
|
||||
fast-socks5 = { version = "1", optional = true }
|
||||
rustls = "0.23"
|
||||
rustls-pki-types = "1"
|
||||
hex = "0.4"
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `AlknetClient` — native client dial seam, the client-side analogue of
|
||||
//! `AlknetEndpoint`. Holds pre-built transport handles, all optional — the
|
||||
//! client dials with whichever transport the remote endpoint type implies.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
use iroh;
|
||||
#[cfg(feature = "quinn")]
|
||||
use quinn;
|
||||
#[cfg(feature = "tcp")]
|
||||
use tokio_rustls;
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
use crate::socks5::Socks5ProxyConfig;
|
||||
|
||||
/// Native client dial seam — multi-transport dialer that produces
|
||||
/// `Connection`s for protocol take-overs.
|
||||
///
|
||||
/// Holds pre-built transport handles, all optional — the client dials
|
||||
/// with whichever transport the remote endpoint type implies. The
|
||||
/// builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` /
|
||||
/// `with_tcp_tls` (ADR-083) — the assembly layer builds the transport
|
||||
/// handles and hands them to the client via builder methods.
|
||||
pub struct AlknetClient {
|
||||
#[cfg(feature = "quinn")]
|
||||
pub(crate) quinn: Option<quinn::Endpoint>,
|
||||
#[cfg(feature = "tcp")]
|
||||
pub(crate) tcp_connector: Option<tokio_rustls::TlsConnector>,
|
||||
#[cfg(feature = "iroh")]
|
||||
pub(crate) iroh: Option<iroh::Endpoint>,
|
||||
/// When set, `dial_quic` and `dial_tcp_tls` route through this
|
||||
/// SOCKS5 proxy (UDP ASSOCIATE / CONNECT respectively). `dial_iroh`
|
||||
/// forces relay-only via an HTTP-to-SOCKS5 bridge — see ADR-090 §5.
|
||||
/// Feature-gated on `socks5`.
|
||||
#[cfg(feature = "socks5")]
|
||||
pub(crate) socks5: Option<Socks5ProxyConfig>,
|
||||
}
|
||||
|
||||
impl AlknetClient {
|
||||
/// Create a new `AlknetClient` with no transport handles configured.
|
||||
/// Use the builder methods to add transports.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
#[cfg(feature = "quinn")]
|
||||
quinn: None,
|
||||
#[cfg(feature = "tcp")]
|
||||
tcp_connector: None,
|
||||
#[cfg(feature = "iroh")]
|
||||
iroh: None,
|
||||
#[cfg(feature = "socks5")]
|
||||
socks5: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the QUIC transport handle. The assembly layer builds a
|
||||
/// `quinn::Endpoint` (with or without a SOCKS5 proxy — the proxy
|
||||
/// is applied inside `dial_quic`, not at construction time) and
|
||||
/// hands it to the client.
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self {
|
||||
self.quinn = Some(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the TCP+TLS transport handle. The assembly layer builds a
|
||||
/// `tokio_rustls::TlsConnector` and hands it to the client.
|
||||
#[cfg(feature = "tcp")]
|
||||
pub fn with_tcp_tls(mut self, connector: tokio_rustls::TlsConnector) -> Self {
|
||||
self.tcp_connector = Some(connector);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the iroh transport handle. The assembly layer builds an
|
||||
/// `iroh::Endpoint` and hands it to the client.
|
||||
#[cfg(feature = "iroh")]
|
||||
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self {
|
||||
self.iroh = Some(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the SOCKS5 proxy for all subsequent dials. When set, every
|
||||
/// dial routes its transport through this proxy: UDP ASSOCIATE for
|
||||
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
|
||||
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (ADR-090 §5).
|
||||
/// Feature-gated on `socks5`.
|
||||
#[cfg(feature = "socks5")]
|
||||
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self {
|
||||
self.socks5 = Some(proxy);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AlknetClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AlknetClient {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
#[allow(unused_mut)]
|
||||
let mut configured: Vec<&str> = Vec::new();
|
||||
#[cfg(feature = "quinn")]
|
||||
if self.quinn.is_some() {
|
||||
configured.push("quinn");
|
||||
}
|
||||
#[cfg(feature = "tcp")]
|
||||
if self.tcp_connector.is_some() {
|
||||
configured.push("tcp");
|
||||
}
|
||||
#[cfg(feature = "iroh")]
|
||||
if self.iroh.is_some() {
|
||||
configured.push("iroh");
|
||||
}
|
||||
#[cfg(feature = "socks5")]
|
||||
if self.socks5.is_some() {
|
||||
configured.push("socks5");
|
||||
}
|
||||
f.debug_struct("AlknetClient")
|
||||
.field("transports", &configured)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_client() {
|
||||
let client = AlknetClient::new();
|
||||
let debug = format!("{:?}", client);
|
||||
assert!(debug.contains("AlknetClient"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_delegates_to_new() {
|
||||
let _client = AlknetClient::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alknet_client_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<AlknetClient>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_lists_configured_transports() {
|
||||
let client = AlknetClient::new();
|
||||
let debug = format!("{:?}", client);
|
||||
assert!(!debug.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! `dial_iroh` — iroh dial, producing a `Connection`.
|
||||
//!
|
||||
//! Feature-gated on `iroh`. The iroh path does NOT use `TlsClientConfig` —
|
||||
//! iroh has its own TLS (shares the `Ed25519SecretKey`, not the rustls config
|
||||
//! — ADR-087 §3, ADR-089 §3). The local key is set on the pre-built iroh
|
||||
//! endpoint at `with_iroh` time (the assembly layer reads it from
|
||||
//! `StaticConfig` and feeds it to `iroh::Endpoint::builder().secret_key()`);
|
||||
//! the dial consumes only `creds.remote_identity` (deriving the remote
|
||||
//! `EndpointId` from `creds.remote_identity.fingerprint`) and ignores
|
||||
//! `creds.local_identity`.
|
||||
|
||||
use alknet_core::credentials::ConnectionCredentials;
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
use crate::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
impl AlknetClient {
|
||||
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
|
||||
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
|
||||
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
|
||||
/// §3). The local key is on the pre-built iroh endpoint (set at
|
||||
/// `with_iroh` time); the dial consumes only
|
||||
/// `creds.remote_identity` and ignores `creds.local_identity`.
|
||||
/// The remote `EndpointId` is derived from
|
||||
/// `creds.remote_identity.fingerprint`
|
||||
/// (`ed25519:<hex>` → `EndpointId::from_bytes`). The verifier is iroh's
|
||||
/// `EndpointId` match (fingerprint pin by another name — ADR-034 §3).
|
||||
/// An unknown iroh remote fails closed (no CA). Feature-gated on
|
||||
/// `iroh`.
|
||||
#[cfg(feature = "iroh")]
|
||||
pub async fn dial_iroh(
|
||||
&self,
|
||||
alpn: &[u8],
|
||||
creds: &ConnectionCredentials,
|
||||
) -> Result<Connection, ClientDialError> {
|
||||
let endpoint = self
|
||||
.iroh
|
||||
.as_ref()
|
||||
.ok_or(ClientDialError::NoTransport { transport: "iroh" })?;
|
||||
|
||||
let node_id = match &creds.remote_identity {
|
||||
Some(ri) => extract_iroh_endpoint_id(&ri.fingerprint)
|
||||
.map_err(|e| ClientDialError::TlsConfig(alknet_tls::TlsError::Config(e)))?,
|
||||
None => {
|
||||
return Err(ClientDialError::TlsConfig(alknet_tls::TlsError::Config(
|
||||
"iroh requires a known remote (remote_identity must be Some); \
|
||||
unknown iroh remotes fail closed (ADR-034 §3)"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let conn = endpoint
|
||||
.connect(node_id, alpn)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
|
||||
Ok(Connection::from_iroh(conn))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract an `iroh::EndpointId` from a fingerprint string.
|
||||
///
|
||||
/// Supports two formats:
|
||||
/// - `"ed25519:<hex>"` — raw Ed25519 public key (64 hex chars)
|
||||
/// - `"SHA256:<base64>"` — SHA-256 hash of the cert (for X.509; not valid for iroh)
|
||||
///
|
||||
/// For iroh, only the `ed25519:` prefix is valid — iroh uses Ed25519 keys.
|
||||
fn extract_iroh_endpoint_id(fingerprint: &str) -> Result<iroh::EndpointId, String> {
|
||||
if let Some(hex_str) = fingerprint.strip_prefix("ed25519:") {
|
||||
let bytes =
|
||||
hex::decode(hex_str).map_err(|e| format!("invalid ed25519 fingerprint hex: {e}"))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(format!(
|
||||
"invalid ed25519 fingerprint length: expected 32 bytes, got {}",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let arr: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| "invalid ed25519 fingerprint length".to_string())?;
|
||||
iroh::EndpointId::from_bytes(&arr).map_err(|e| format!("invalid iroh EndpointId: {e}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
"iroh requires an ed25519: fingerprint, got: {}",
|
||||
fingerprint
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "iroh"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_iroh_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client.dial_iroh(b"test/alpn", &creds).await;
|
||||
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_valid_ed25519() {
|
||||
let hex_key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let fingerprint = format!("ed25519:{}", hex_key);
|
||||
let result = extract_iroh_endpoint_id(&fingerprint);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_sha256() {
|
||||
let fingerprint = "SHA256:abc123";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_invalid_hex() {
|
||||
let fingerprint = "ed25519:nothex";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_wrong_length() {
|
||||
let fingerprint = "ed25519:aaaa";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Dial methods for `AlknetClient` — one per transport.
|
||||
//!
|
||||
//! Each dial method is feature-gated on the corresponding transport feature.
|
||||
//! All three are unified on `&ConnectionCredentials` (ADR-091) and return a
|
||||
//! `Connection` for protocol take-overs to consume.
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
pub mod iroh;
|
||||
#[cfg(feature = "quinn")]
|
||||
pub mod quinn;
|
||||
#[cfg(feature = "tcp")]
|
||||
pub mod tcp_tls;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! `dial_quic` — QUIC dial via quinn, producing a `Connection`.
|
||||
//!
|
||||
//! Feature-gated on `quinn`. Builds a `TlsClientConfig` from
|
||||
//! `ConnectionCredentials`, constructs a `quinn::ClientConfig`, dials
|
||||
//! `addr` on `alpn`, and returns a `Connection` via
|
||||
//! `Connection::from_quinn_with_alpn`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
#[cfg(feature = "socks5")]
|
||||
use std::sync::Arc;
|
||||
|
||||
use alknet_core::credentials::ConnectionCredentials;
|
||||
use alknet_core::types::Connection;
|
||||
use alknet_tls::client::TlsClientConfig;
|
||||
|
||||
use crate::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
impl AlknetClient {
|
||||
/// QUIC dial. Builds a `TlsClientConfig` from `creds`
|
||||
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
|
||||
/// on `alpn`, returns a `Connection` via
|
||||
/// `Connection::from_quinn_with_alpn`. The `server_name` is the
|
||||
/// TLS SNI / name (for X.509; ignored for raw-key pinning).
|
||||
/// Feature-gated on `quinn`.
|
||||
#[cfg(feature = "quinn")]
|
||||
pub async fn dial_quic(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
server_name: &str,
|
||||
alpn: &[u8],
|
||||
creds: &ConnectionCredentials,
|
||||
) -> Result<Connection, ClientDialError> {
|
||||
let tls_config = TlsClientConfig::new(creds, alpn)?;
|
||||
let client_config = tls_config.for_quinn()?;
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
let conn = if let Some(proxy) = &self.socks5 {
|
||||
let socket = crate::socks5::Socks5UdpSocket::bind(proxy).await?;
|
||||
let endpoint = quinn::Endpoint::new_with_abstract_socket(
|
||||
quinn::EndpointConfig::default(),
|
||||
None,
|
||||
Arc::new(socket),
|
||||
Arc::new(quinn::TokioRuntime),
|
||||
)
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
endpoint
|
||||
.connect_with(client_config, addr, server_name)
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
} else {
|
||||
let endpoint = self
|
||||
.quinn
|
||||
.as_ref()
|
||||
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
|
||||
endpoint
|
||||
.connect_with(client_config, addr, server_name)
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "socks5"))]
|
||||
let conn = {
|
||||
let endpoint = self
|
||||
.quinn
|
||||
.as_ref()
|
||||
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
|
||||
endpoint
|
||||
.connect_with(client_config, addr, server_name)
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quinn"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_quic_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client
|
||||
.dial_quic(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
"localhost",
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_quic_tls_config_error_on_acme_identity() {
|
||||
use alknet_core::config::{AcmeDirectory, TlsIdentity};
|
||||
let creds = ConnectionCredentials::new().with_local_identity(TlsIdentity::Acme {
|
||||
domains: vec!["example.com".into()],
|
||||
directory: AcmeDirectory::Staging,
|
||||
cache_dir: std::path::PathBuf::from("/tmp"),
|
||||
contact: vec![],
|
||||
});
|
||||
let client = AlknetClient::new();
|
||||
let result = client
|
||||
.dial_quic(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
"localhost",
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::TlsConfig(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! `dial_tcp_tls` — TCP+TLS dial via tokio-rustls, producing a `Connection`.
|
||||
//!
|
||||
//! Feature-gated on `tcp`. Builds a `TlsClientConfig` from
|
||||
//! `ConnectionCredentials`, connects a `TcpStream` to `addr`, wraps with
|
||||
//! `TlsConnector` using `host` as the SNI, and returns a `Connection` via
|
||||
//! `Connection::from_bidi` (ADR-065).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alknet_core::credentials::ConnectionCredentials;
|
||||
use alknet_core::types::Connection;
|
||||
use alknet_tls::client::TlsClientConfig;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
impl AlknetClient {
|
||||
/// TCP+TLS dial. Builds a `TlsClientConfig` from `creds`,
|
||||
/// connects a `TcpStream` to `addr`, wraps with `TlsConnector`
|
||||
/// using `host` as the SNI, returns a `Connection` via
|
||||
/// `Connection::from_bidi` (ADR-065). Feature-gated on `tcp`.
|
||||
#[cfg(feature = "tcp")]
|
||||
pub async fn dial_tcp_tls(
|
||||
&self,
|
||||
host: &str,
|
||||
addr: SocketAddr,
|
||||
alpn: &[u8],
|
||||
creds: &ConnectionCredentials,
|
||||
) -> Result<Connection, ClientDialError> {
|
||||
let tls_config = TlsClientConfig::new(creds, alpn)?;
|
||||
|
||||
let connector = match &self.tcp_connector {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
let rustls_config = Arc::new(tls_config.into_rustls_config());
|
||||
tokio_rustls::TlsConnector::from(rustls_config)
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
let tls_stream = if let Some(proxy) = &self.socks5 {
|
||||
let mut tcp = TcpStream::connect(proxy.addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
crate::socks5::socks5_connect(&mut tcp, proxy, addr)
|
||||
.await
|
||||
.map_err(ClientDialError::Proxy)?;
|
||||
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|
||||
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
|
||||
)?;
|
||||
connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
|
||||
} else {
|
||||
let tcp_stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|
||||
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
|
||||
)?;
|
||||
connector
|
||||
.connect(server_name, tcp_stream)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "socks5"))]
|
||||
let tls_stream = {
|
||||
let tcp_stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
let server_name: rustls::pki_types::ServerName = host.to_owned().try_into().map_err(
|
||||
|e: rustls::pki_types::InvalidDnsNameError| ClientDialError::Connect(e.to_string()),
|
||||
)?;
|
||||
connector
|
||||
.connect(server_name, tcp_stream)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Connection::from_bidi(tls_stream, alpn.to_vec(), Some(addr)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tcp"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_tcp_tls_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client
|
||||
.dial_tcp_tls(
|
||||
"localhost",
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::Connect(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! `ClientDialError` — error type for all three dial methods.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by `AlknetClient` dial methods.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ClientDialError {
|
||||
/// TLS config construction failure — `TlsClientConfig::new` failed
|
||||
/// (verifier build, cert load, provider init). Wraps `TlsError`
|
||||
/// from alknet-tls.
|
||||
#[error("TLS config construction: {0}")]
|
||||
TlsConfig(#[from] alknet_tls::TlsError),
|
||||
|
||||
/// Transport connect failure — quinn connect, TcpStream::connect,
|
||||
/// or iroh connect. The transport's own error type, stringified.
|
||||
#[error("transport connect: {0}")]
|
||||
Connect(String),
|
||||
|
||||
/// TLS handshake failure — the handshake started but failed
|
||||
/// (rejected cert, ALPN mismatch, unknown raw-key remote
|
||||
/// fail-closed). Distinct from TlsConfig (which is pre-handshake).
|
||||
#[error("TLS handshake: {0}")]
|
||||
Handshake(String),
|
||||
|
||||
/// No transport handle configured for the requested dial — e.g.,
|
||||
/// `dial_quic` called but `with_quinn` was not set.
|
||||
#[error("no transport handle configured for {transport}")]
|
||||
NoTransport { transport: &'static str },
|
||||
|
||||
/// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE
|
||||
/// unsupported, auth failed, or the proxy closed the control
|
||||
/// connection (ADR-090). The dial did not reach the remote; the
|
||||
/// caller decides whether to fall back to a direct dial or
|
||||
/// surface the error. The dial never silently falls back — that
|
||||
/// would defeat the privacy posture.
|
||||
#[cfg(feature = "socks5")]
|
||||
#[error("SOCKS5 proxy: {0}")]
|
||||
Proxy(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tls_config_from_tls_error() {
|
||||
let err = alknet_tls::TlsError::Config("test".into());
|
||||
let dial_err: ClientDialError = err.into();
|
||||
assert!(matches!(dial_err, ClientDialError::TlsConfig(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_transport_displays_transport_name() {
|
||||
let err = ClientDialError::NoTransport { transport: "quinn" };
|
||||
assert!(err.to_string().contains("quinn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_displays_message() {
|
||||
let err = ClientDialError::Connect("connection refused".into());
|
||||
assert!(err.to_string().contains("connection refused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_displays_message() {
|
||||
let err = ClientDialError::Handshake("certificate rejected".into());
|
||||
assert!(err.to_string().contains("certificate rejected"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
#[test]
|
||||
fn proxy_displays_message() {
|
||||
let err = ClientDialError::Proxy("UDP ASSOCIATE rejected".into());
|
||||
assert!(err.to_string().contains("UDP ASSOCIATE rejected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_dial_error_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<ClientDialError>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! alknet-client: Native client dial seam — multi-transport dialer that
|
||||
//! produces `Connection`s for protocol take-overs.
|
||||
//!
|
||||
//! `AlknetClient` is the client-side analogue of `AlknetEndpoint`: a
|
||||
//! multi-transport dialer that takes pre-built transport handles (quinn,
|
||||
//! TCP+TLS, iroh), dials a remote `AlknetEndpoint` on a chosen ALPN, and
|
||||
//! produces a `Connection`. The protocol take-overs
|
||||
//! (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`)
|
||||
//! consume the `Connection` — the dial is below the protocol.
|
||||
//!
|
||||
//! An optional SOCKS5 proxy (ADR-090) routes the dials through a proxy
|
||||
//! to hide the client's real IP from the hub.
|
||||
|
||||
pub mod client;
|
||||
pub mod dial;
|
||||
pub mod error;
|
||||
#[cfg(feature = "socks5")]
|
||||
pub mod socks5;
|
||||
|
||||
pub use client::AlknetClient;
|
||||
pub use error::ClientDialError;
|
||||
#[cfg(feature = "socks5")]
|
||||
pub use socks5::{Socks5Credentials, Socks5ProxyConfig};
|
||||
@@ -0,0 +1,461 @@
|
||||
//! SOCKS5 proxy support for `AlknetClient` (ADR-090).
|
||||
//!
|
||||
//! When a proxy is configured via `with_socks5_proxy`, the rustls dials
|
||||
//! route their transport through the proxy — the hub sees the proxy's IP,
|
||||
//! not the client's.
|
||||
//!
|
||||
//! Feature-gated on `socks5`. The `Socks5UdpSocket` additionally requires
|
||||
//! the `quinn` feature (it implements `quinn::AsyncUdpSocket`).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
use std::io;
|
||||
#[cfg(feature = "quinn")]
|
||||
use std::pin::Pin;
|
||||
#[cfg(feature = "quinn")]
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(feature = "quinn")]
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
/// Configuration for a SOCKS5 proxy (ADR-090).
|
||||
///
|
||||
/// When set on `AlknetClient` via `with_socks5_proxy`, all rustls dials
|
||||
/// route their transport through this proxy: UDP ASSOCIATE for `dial_quic`,
|
||||
/// CONNECT for `dial_tcp_tls`. The proxy config comes from `Capabilities` /
|
||||
/// the assembly layer (ADR-014), never from environment variables.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Socks5ProxyConfig {
|
||||
/// The proxy's TCP address (where the SOCKS5 control connection
|
||||
/// connects). For UDP ASSOCIATE (the QUIC dial), the proxy replies
|
||||
/// with a UDP relay address that may differ; the dial uses that.
|
||||
pub addr: SocketAddr,
|
||||
/// Optional username/password auth (RFC 1929). None = no-auth.
|
||||
pub credentials: Option<Socks5Credentials>,
|
||||
}
|
||||
|
||||
/// SOCKS5 username/password credentials (RFC 1929).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Socks5Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// A `quinn::AsyncUdpSocket` implementation that tunnels QUIC datagrams
|
||||
/// through a SOCKS5 UDP ASSOCIATE tunnel.
|
||||
///
|
||||
/// The implementation follows the pattern validated by the quinn-proxy PoC
|
||||
/// (`docs/research/quinn-quic-proxy/findings.md`).
|
||||
///
|
||||
/// Requires both `socks5` and `quinn` features.
|
||||
#[cfg(feature = "quinn")]
|
||||
pub struct Socks5UdpSocket {
|
||||
socket: std::net::UdpSocket,
|
||||
relay_addr: SocketAddr,
|
||||
local_addr: SocketAddr,
|
||||
_control: TcpStream,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl Socks5UdpSocket {
|
||||
/// Perform the SOCKS5 UDP ASSOCIATE handshake and return a socket
|
||||
/// that tunnels QUIC datagrams through the proxy.
|
||||
pub async fn bind(proxy: &Socks5ProxyConfig) -> Result<Self, ClientDialError> {
|
||||
let mut control = TcpStream::connect(proxy.addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
|
||||
socks5_handshake(&mut control, proxy).await?;
|
||||
|
||||
let relay_addr = socks5_udp_associate(&mut control).await?;
|
||||
|
||||
let socket = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
socket
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
let local_addr = socket
|
||||
.local_addr()
|
||||
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
relay_addr,
|
||||
local_addr,
|
||||
_control: control,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl quinn::AsyncUdpSocket for Socks5UdpSocket {
|
||||
fn create_io_poller(self: Arc<Self>) -> Pin<Box<dyn quinn::UdpPoller>> {
|
||||
Box::pin(UdpPollerImpl {
|
||||
socket: self.socket.try_clone().ok(),
|
||||
waker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn try_send(&self, transmit: &quinn::udp::Transmit) -> io::Result<()> {
|
||||
let mut buf = Vec::with_capacity(10 + transmit.contents.len());
|
||||
buf.extend_from_slice(&[0u8, 0, 0]);
|
||||
match transmit.destination {
|
||||
SocketAddr::V4(addr) => {
|
||||
buf.push(0x01);
|
||||
buf.extend_from_slice(&addr.ip().octets());
|
||||
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
SocketAddr::V6(addr) => {
|
||||
buf.push(0x04);
|
||||
buf.extend_from_slice(&addr.ip().octets());
|
||||
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
}
|
||||
buf.extend_from_slice(transmit.contents);
|
||||
|
||||
let sent = self.socket.send_to(&buf, self.relay_addr)?;
|
||||
if sent < buf.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::WouldBlock, "partial send"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_recv(
|
||||
&self,
|
||||
_cx: &mut Context,
|
||||
bufs: &mut [io::IoSliceMut<'_>],
|
||||
meta: &mut [quinn::udp::RecvMeta],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let mut buf = [0u8; 65536];
|
||||
match self.socket.recv_from(&mut buf) {
|
||||
Ok((n, _src)) => {
|
||||
if n < 10 {
|
||||
return Poll::Ready(Ok(0));
|
||||
}
|
||||
let header_end = 3;
|
||||
let atyp = buf[header_end];
|
||||
let addr_len: usize = match atyp {
|
||||
0x01 => 4,
|
||||
0x04 => 16,
|
||||
_ => return Poll::Ready(Ok(0)),
|
||||
};
|
||||
let payload_start = header_end + 1 + addr_len + 2;
|
||||
if n < payload_start {
|
||||
return Poll::Ready(Ok(0));
|
||||
}
|
||||
let payload = &buf[payload_start..n];
|
||||
let copy_len = payload.len().min(bufs.iter().map(|b| b.len()).sum());
|
||||
let mut offset = 0;
|
||||
for b in bufs.iter_mut() {
|
||||
let end = (offset + b.len()).min(copy_len);
|
||||
if offset < end {
|
||||
b.copy_from_slice(&payload[offset..end]);
|
||||
}
|
||||
offset = end;
|
||||
if offset >= copy_len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
meta[0] = quinn::udp::RecvMeta {
|
||||
len: copy_len,
|
||||
stride: copy_len,
|
||||
addr: self.relay_addr,
|
||||
ecn: None,
|
||||
dst_ip: None,
|
||||
};
|
||||
Poll::Ready(Ok(1))
|
||||
}
|
||||
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending,
|
||||
Err(e) => Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_addr(&self) -> io::Result<SocketAddr> {
|
||||
Ok(self.local_addr)
|
||||
}
|
||||
|
||||
fn may_fragment(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
struct UdpPollerImpl {
|
||||
socket: Option<std::net::UdpSocket>,
|
||||
waker: Mutex<Option<Waker>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for UdpPollerImpl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UdpPollerImpl").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl quinn::UdpPoller for UdpPollerImpl {
|
||||
fn poll_writable(self: Pin<&mut Self>, cx: &mut Context) -> Poll<io::Result<()>> {
|
||||
if let Some(ref socket) = self.socket {
|
||||
match socket.send_to(
|
||||
&[],
|
||||
socket
|
||||
.local_addr()
|
||||
.ok()
|
||||
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()),
|
||||
) {
|
||||
Ok(_) => Poll::Ready(Ok(())),
|
||||
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
*self.waker.lock().unwrap() = Some(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
Err(e) => Poll::Ready(Err(e)),
|
||||
}
|
||||
} else {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
impl std::fmt::Debug for Socks5UdpSocket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Socks5UdpSocket")
|
||||
.field("relay_addr", &self.relay_addr)
|
||||
.field("local_addr", &self.local_addr)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the SOCKS5 handshake (greeting + auth).
|
||||
#[cfg(feature = "quinn")]
|
||||
async fn socks5_handshake(
|
||||
stream: &mut TcpStream,
|
||||
proxy: &Socks5ProxyConfig,
|
||||
) -> Result<(), ClientDialError> {
|
||||
if let Some(creds) = &proxy.credentials {
|
||||
stream
|
||||
.write_all(&[0x05, 0x01, 0x02])
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
let mut resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
if resp[0] != 0x05 || resp[1] != 0x02 {
|
||||
return Err(ClientDialError::Proxy(
|
||||
"SOCKS5 server does not support username/password auth".into(),
|
||||
));
|
||||
}
|
||||
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
|
||||
auth_msg.push(0x01);
|
||||
auth_msg.push(creds.username.len() as u8);
|
||||
auth_msg.extend_from_slice(creds.username.as_bytes());
|
||||
auth_msg.push(creds.password.len() as u8);
|
||||
auth_msg.extend_from_slice(creds.password.as_bytes());
|
||||
stream
|
||||
.write_all(&auth_msg)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
let mut auth_resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut auth_resp)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
if auth_resp[1] != 0x00 {
|
||||
return Err(ClientDialError::Proxy(
|
||||
"SOCKS5 username/password authentication failed".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
stream
|
||||
.write_all(&[0x05, 0x01, 0x00])
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
let mut resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
if resp[0] != 0x05 || resp[1] != 0x00 {
|
||||
return Err(ClientDialError::Proxy(
|
||||
"SOCKS5 server rejected no-auth method".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform the SOCKS5 UDP ASSOCIATE request and return the relay address.
|
||||
#[cfg(feature = "quinn")]
|
||||
async fn socks5_udp_associate(stream: &mut TcpStream) -> Result<SocketAddr, ClientDialError> {
|
||||
let req = vec![0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
|
||||
stream
|
||||
.write_all(&req)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
|
||||
let mut resp = [0u8; 10];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
|
||||
if resp[0] != 0x05 {
|
||||
return Err(ClientDialError::Proxy(
|
||||
"invalid SOCKS5 version in reply".into(),
|
||||
));
|
||||
}
|
||||
if resp[1] != 0x00 {
|
||||
return Err(ClientDialError::Proxy(format!(
|
||||
"SOCKS5 UDP ASSOCIATE rejected with code {}",
|
||||
resp[1]
|
||||
)));
|
||||
}
|
||||
|
||||
let bind_port = u16::from_be_bytes([resp[8], resp[9]]);
|
||||
let bind_addr = match resp[3] {
|
||||
0x01 => {
|
||||
let mut addr = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
SocketAddr::new(std::net::Ipv4Addr::from(addr).into(), bind_port)
|
||||
}
|
||||
0x04 => {
|
||||
let mut addr = [0u8; 16];
|
||||
stream
|
||||
.read_exact(&mut addr)
|
||||
.await
|
||||
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
|
||||
SocketAddr::new(std::net::Ipv6Addr::from(addr).into(), bind_port)
|
||||
}
|
||||
_ => {
|
||||
return Err(ClientDialError::Proxy(format!(
|
||||
"unsupported address type in UDP ASSOCIATE reply: {}",
|
||||
resp[3]
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(bind_addr)
|
||||
}
|
||||
|
||||
/// Perform the SOCKS5 CONNECT handshake to the target address.
|
||||
pub async fn socks5_connect(
|
||||
stream: &mut TcpStream,
|
||||
proxy: &Socks5ProxyConfig,
|
||||
target: SocketAddr,
|
||||
) -> Result<(), String> {
|
||||
socks5_handshake_connect(stream, proxy).await?;
|
||||
|
||||
let mut req = vec![0x05, 0x01, 0x00];
|
||||
match target {
|
||||
SocketAddr::V4(addr) => {
|
||||
req.push(0x01);
|
||||
req.extend_from_slice(&addr.ip().octets());
|
||||
req.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
SocketAddr::V6(addr) => {
|
||||
req.push(0x04);
|
||||
req.extend_from_slice(&addr.ip().octets());
|
||||
req.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
}
|
||||
stream
|
||||
.write_all(&req)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 CONNECT write: {e}"))?;
|
||||
|
||||
let mut resp = [0u8; 10];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 CONNECT read: {e}"))?;
|
||||
|
||||
if resp[0] != 0x05 {
|
||||
return Err("invalid SOCKS5 version in CONNECT reply".into());
|
||||
}
|
||||
if resp[1] != 0x00 {
|
||||
return Err(format!("SOCKS5 CONNECT rejected with code {}", resp[1]));
|
||||
}
|
||||
|
||||
match resp[3] {
|
||||
0x01 => {
|
||||
let mut _addr = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut _addr)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
|
||||
}
|
||||
0x04 => {
|
||||
let mut _addr = [0u8; 16];
|
||||
stream
|
||||
.read_exact(&mut _addr)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn socks5_handshake_connect(
|
||||
stream: &mut TcpStream,
|
||||
proxy: &Socks5ProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
if let Some(creds) = &proxy.credentials {
|
||||
stream
|
||||
.write_all(&[0x05, 0x01, 0x02])
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
|
||||
let mut resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
|
||||
if resp[0] != 0x05 || resp[1] != 0x02 {
|
||||
return Err("SOCKS5 server does not support username/password auth".into());
|
||||
}
|
||||
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
|
||||
auth_msg.push(0x01);
|
||||
auth_msg.push(creds.username.len() as u8);
|
||||
auth_msg.extend_from_slice(creds.username.as_bytes());
|
||||
auth_msg.push(creds.password.len() as u8);
|
||||
auth_msg.extend_from_slice(creds.password.as_bytes());
|
||||
stream
|
||||
.write_all(&auth_msg)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 auth write: {e}"))?;
|
||||
let mut auth_resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut auth_resp)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 auth read: {e}"))?;
|
||||
if auth_resp[1] != 0x00 {
|
||||
return Err("SOCKS5 username/password authentication failed".into());
|
||||
}
|
||||
} else {
|
||||
stream
|
||||
.write_all(&[0x05, 0x01, 0x00])
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
|
||||
let mut resp = [0u8; 2];
|
||||
stream
|
||||
.read_exact(&mut resp)
|
||||
.await
|
||||
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
|
||||
if resp[0] != 0x05 || resp[1] != 0x00 {
|
||||
return Err("SOCKS5 server rejected no-auth method".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Integration test: dial + take-over composition.
|
||||
//!
|
||||
//! Verifies that the AlknetClient type exists and compiles correctly.
|
||||
//! Full end-to-end dial tests (with a real quinn endpoint) are tested
|
||||
//! in the assembly layer integration tests (future hub/worker tests).
|
||||
|
||||
use alknet_client::AlknetClient;
|
||||
use alknet_core::credentials::ConnectionCredentials;
|
||||
|
||||
#[tokio::test]
|
||||
async fn alknet_client_new_creates_empty_client() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let _ = (client, creds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alknet_client_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<AlknetClient>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_dial_error_is_send_sync() {
|
||||
use alknet_client::ClientDialError;
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<ClientDialError>();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "alknet-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Core library for ALPN-based protocol dispatch: ProtocolHandler trait, Connection, auth, config, and multi-connectivity endpoint"
|
||||
description = "Core library for ALPN-based protocol dispatch: ProtocolHandler trait, Connection, auth, config, and transport-level credentials"
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
@@ -13,7 +13,6 @@ name = "alknet_core"
|
||||
default = ["quinn"]
|
||||
quinn = ["dep:quinn"]
|
||||
iroh = ["dep:iroh"]
|
||||
acme = ["dep:rustls-acme"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -21,7 +20,6 @@ quinn = { version = "0.11", optional = true }
|
||||
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
|
||||
rustls = "0.23"
|
||||
rustls-pki-types = "1"
|
||||
rustls-pemfile = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
@@ -35,9 +33,7 @@ futures = "0.3"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
rcgen = "0.13"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Transport-level credential bundle for outbound connections (ADR-091).
|
||||
//!
|
||||
//! `ConnectionCredentials` carries the two dimensions the dial consumes:
|
||||
//! the local node's TLS identity and the expected remote identity.
|
||||
//! the local node's identity and the expected remote identity.
|
||||
//! It is transport-agnostic — consumed by `alknet-tls` (TLS setup) and
|
||||
//! `alknet-client` (dial).
|
||||
|
||||
@@ -36,9 +36,9 @@ pub struct RemoteIdentity {
|
||||
/// `docs/architecture/crates/call/client-and-adapters.md`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConnectionCredentials {
|
||||
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
|
||||
/// The local node's identity (RFC 7250 raw key or X.509), derived
|
||||
/// from the vault at startup.
|
||||
pub tls_identity: Option<TlsIdentity>,
|
||||
pub local_identity: Option<TlsIdentity>,
|
||||
/// Expected fingerprint/cert of the remote node, stored as a capability.
|
||||
/// `Some` → fingerprint pin (known peer with a `PeerEntry`); `None` → CA
|
||||
/// verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes
|
||||
@@ -52,8 +52,8 @@ impl ConnectionCredentials {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
|
||||
self.tls_identity = Some(tls_identity);
|
||||
pub fn with_local_identity(mut self, local_identity: TlsIdentity) -> Self {
|
||||
self.local_identity = Some(local_identity);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ mod tests {
|
||||
creds.remote_identity.as_ref().unwrap().fingerprint,
|
||||
"SHA256:abc"
|
||||
);
|
||||
assert!(creds.tls_identity.is_none());
|
||||
assert!(creds.local_identity.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,9 +10,9 @@
|
||||
//! relationship). Not used for arbitrary public APIs (those use CA
|
||||
//! verification via `WebPkiServerVerifier`, not fingerprint pinning).
|
||||
//!
|
||||
//! Shared by the server-side endpoint (`alknet_core::endpoint`, which extracts
|
||||
//! Shared by the server-side endpoint (`alknet-endpoint`, which extracts
|
||||
//! the fingerprint from the presented client cert for `PeerEntry` resolution)
|
||||
//! and the client-side `FingerprintPinVerifier` in `alknet_call::client`
|
||||
//! and the client-side `FingerprintPinVerifier` in `alknet-tls`
|
||||
//! (which matches the server's presented cert against a pinned fingerprint).
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
//! Every handler crate depends on this crate. It provides the
|
||||
//! [`ProtocolHandler`][crate::types::ProtocolHandler] trait, the
|
||||
//! [`Connection`][crate::types::Connection] wrapper, auth primitives,
|
||||
//! hot-reloadable configuration, and the [`AlknetEndpoint`][crate::endpoint::AlknetEndpoint]
|
||||
//! that dispatches incoming QUIC connections by ALPN string.
|
||||
//! hot-reloadable configuration, and transport-level credential types
|
||||
//! ([`ConnectionCredentials`][crate::credentials::ConnectionCredentials],
|
||||
//! [`RemoteIdentity`][crate::credentials::RemoteIdentity]).
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod credentials;
|
||||
pub mod endpoint;
|
||||
pub mod fingerprint;
|
||||
pub mod ownership;
|
||||
pub mod store;
|
||||
|
||||
+233
-150
@@ -223,143 +223,193 @@ pub trait ProtocolHandler: Send + Sync + 'static {
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
|
||||
}
|
||||
|
||||
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
// --- BiStream: the handler leaf (ADR-092) ---------------------------------
|
||||
//
|
||||
// `accept_bi`/`open_bi` yield `BiStream`, a concrete newtype that boxes the
|
||||
// joined inner transport. The join happens once in the `BidiStreamSource`
|
||||
// impl (quinn/iroh via `tokio::io::join`, single-stream via the input
|
||||
// `AsyncRead + AsyncWrite` boxed directly). The split never crosses a crate
|
||||
// boundary as part of a constructor: `Connection::from_bidi` is the only
|
||||
// public stream constructor; `Connection::from_stream` is removed.
|
||||
|
||||
enum SendStreamKind {
|
||||
#[cfg(feature = "quinn")]
|
||||
Quinn(quinn::SendStream),
|
||||
#[cfg(feature = "iroh")]
|
||||
Iroh(iroh::endpoint::SendStream),
|
||||
Stream(Box<dyn AsyncWrite + Send + Unpin>),
|
||||
/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send +
|
||||
/// Unpin`. Not public; exists only to give `BiStream` a single boxed field.
|
||||
trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send> AsyncReadWrite for T {}
|
||||
|
||||
/// The handler leaf — a bidirectional byte stream (ADR-092).
|
||||
///
|
||||
/// `accept_bi`/`open_bi` return a `BiStream`, not a split
|
||||
/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call
|
||||
/// `tokio::io::split(&mut *stream)` (the stdlib idiom `tokio::io::split`
|
||||
/// already provides for `TcpStream` and `TlsStream<TcpStream>`). The
|
||||
/// split is a stdlib call at the handler boundary, not a per-handler trait
|
||||
/// wrapper.
|
||||
///
|
||||
/// `BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
|
||||
/// old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
|
||||
/// (ADR-007) is removed — the trait was never consumed, and the concrete
|
||||
/// struct carries the same trait bounds forward as implied bounds, not a
|
||||
/// marker trait. The name and the bounds survive; the shape becomes a
|
||||
/// concrete leaf.
|
||||
pub struct BiStream {
|
||||
inner: Box<dyn AsyncReadWrite + Unpin>,
|
||||
}
|
||||
|
||||
enum RecvStreamKind {
|
||||
#[cfg(feature = "quinn")]
|
||||
Quinn(quinn::RecvStream),
|
||||
#[cfg(feature = "iroh")]
|
||||
Iroh(iroh::endpoint::RecvStream),
|
||||
Stream(Box<dyn AsyncRead + Send + Unpin>),
|
||||
impl BiStream {
|
||||
/// Join a read half and a write half into a single `BiStream`. The join
|
||||
/// happens once, in the `BidiStreamSource` impl — handlers receive the
|
||||
/// joined `BiStream` and never see the pair.
|
||||
///
|
||||
/// Public so that downstream crates (the channels reassembly path, tests
|
||||
/// that construct a `BiStream` from independent halves) can join their
|
||||
/// own halves. The rule this normalizes: **the split never crosses a
|
||||
/// crate boundary as part of a constructor** — `Connection::from_bidi`
|
||||
/// takes a joined `BiStream`, and `BiStream::from_joined` is the join.
|
||||
/// A crate that produces split halves naturally (channels reassembly)
|
||||
/// joins them itself via this constructor, then hands the `BiStream` to
|
||||
/// `Connection::from_bidi` (or yields it from its own
|
||||
/// `BidiStreamSource::accept_bi` impl).
|
||||
pub fn from_joined<R, W>(reader: R, writer: W) -> Self
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
W: AsyncWrite + Send + Unpin + 'static,
|
||||
{
|
||||
Self {
|
||||
inner: Box::new(tokio::io::join(reader, writer)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a single value that is already `AsyncRead + AsyncWrite` (e.g.
|
||||
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
|
||||
/// `russh::Channel::into_stream()`). Used by the single-stream
|
||||
/// `BidiStreamSource` impl and by `Connection::from_bidi`.
|
||||
pub(crate) fn from_bidi<S>(stream: S) -> Self
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
|
||||
{
|
||||
Self {
|
||||
inner: Box::new(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for BiStream {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for BiStream {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<io::Result<usize>> {
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SendStream / RecvStream: thin newtypes (ADR-092) ---------------------
|
||||
//
|
||||
// These remain as the typed-sub-stream leaves for `into_sub_streams()`
|
||||
// (ADR-074) and the channels reassembly path's `SubStreamHandle` leaves
|
||||
// (future). They never cross a crate boundary as part of a `Connection`
|
||||
// constructor — `Connection::from_bidi` is the only public constructor and
|
||||
// takes a joined `BiStream`. The quinn-welded `SendStreamKind` /
|
||||
// `RecvStreamKind` enums are gone; the quinn/iroh dispatch moves into the
|
||||
// `BidiStreamSource` impls (the join happens once, there).
|
||||
|
||||
pub struct SendStream {
|
||||
kind: SendStreamKind,
|
||||
inner: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
}
|
||||
|
||||
pub struct RecvStream {
|
||||
kind: RecvStreamKind,
|
||||
inner: Box<dyn AsyncRead + Send + Unpin>,
|
||||
}
|
||||
|
||||
impl SendStream {
|
||||
#[cfg(feature = "quinn")]
|
||||
fn from_quinn(stream: quinn::SendStream) -> Self {
|
||||
Self {
|
||||
kind: SendStreamKind::Quinn(stream),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn from_iroh(stream: iroh::endpoint::SendStream) -> Self {
|
||||
Self {
|
||||
kind: SendStreamKind::Iroh(stream),
|
||||
}
|
||||
}
|
||||
|
||||
/// Box a write half into the thin `SendStream` newtype. Used by
|
||||
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
|
||||
/// Not a constructor that feeds `Connection` — the split never crosses
|
||||
/// a crate boundary as part of a constructor (ADR-092).
|
||||
pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
|
||||
Self {
|
||||
kind: SendStreamKind::Stream(Box::new(stream)),
|
||||
inner: Box::new(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecvStream {
|
||||
#[cfg(feature = "quinn")]
|
||||
fn from_quinn(stream: quinn::RecvStream) -> Self {
|
||||
Self {
|
||||
kind: RecvStreamKind::Quinn(stream),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn from_iroh(stream: iroh::endpoint::RecvStream) -> Self {
|
||||
Self {
|
||||
kind: RecvStreamKind::Iroh(stream),
|
||||
}
|
||||
}
|
||||
|
||||
/// Box a read half into the thin `RecvStream` newtype. Used by
|
||||
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
|
||||
/// Not a constructor that feeds `Connection` — the split never crosses
|
||||
/// a crate boundary as part of a constructor (ADR-092).
|
||||
pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
|
||||
Self {
|
||||
kind: RecvStreamKind::Stream(Box::new(stream)),
|
||||
inner: Box::new(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for SendStream {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<io::Result<usize>> {
|
||||
match &mut self.get_mut().kind {
|
||||
#[cfg(feature = "quinn")]
|
||||
SendStreamKind::Quinn(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
|
||||
#[cfg(feature = "iroh")]
|
||||
SendStreamKind::Iroh(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
|
||||
SendStreamKind::Stream(s) => {
|
||||
AsyncWrite::poll_write(std::pin::Pin::new(s.as_mut()), cx, buf)
|
||||
}
|
||||
}
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
match &mut self.get_mut().kind {
|
||||
#[cfg(feature = "quinn")]
|
||||
SendStreamKind::Quinn(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
|
||||
#[cfg(feature = "iroh")]
|
||||
SendStreamKind::Iroh(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
|
||||
SendStreamKind::Stream(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s.as_mut()), cx),
|
||||
}
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
match &mut self.get_mut().kind {
|
||||
#[cfg(feature = "quinn")]
|
||||
SendStreamKind::Quinn(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
|
||||
#[cfg(feature = "iroh")]
|
||||
SendStreamKind::Iroh(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
|
||||
SendStreamKind::Stream(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
|
||||
}
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for RecvStream {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
match &mut self.get_mut().kind {
|
||||
#[cfg(feature = "quinn")]
|
||||
RecvStreamKind::Quinn(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
|
||||
#[cfg(feature = "iroh")]
|
||||
RecvStreamKind::Iroh(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
|
||||
RecvStreamKind::Stream(s) => {
|
||||
AsyncRead::poll_read(std::pin::Pin::new(s.as_mut()), cx, buf)
|
||||
}
|
||||
}
|
||||
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Yield bidirectional streams to a `Connection`. Downstream crates implement
|
||||
/// this trait to add connection shapes (channels, a future transport, a test
|
||||
/// double beyond the `from_stream` case) without editing `alknet-core`. See
|
||||
/// double beyond the single-stream case) without editing `alknet-core`. See
|
||||
/// ADR-070 for the full rationale and ADR-065 for the yield-once contract the
|
||||
/// `StreamBidiStreamSource` impl preserves.
|
||||
/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream`
|
||||
/// (ADR-092) — the join happens once, in the impl, not per-handler.
|
||||
#[async_trait]
|
||||
pub trait BidiStreamSource: Send + Sync + 'static {
|
||||
/// Yield the next bidirectional stream this connection provides.
|
||||
@@ -372,14 +422,14 @@ pub trait BidiStreamSource: Send + Sync + 'static {
|
||||
/// `ConnectionClosed` on all subsequent calls.
|
||||
/// - Channels: yields one bidi stream per channel, `ConnectionClosed`
|
||||
/// when the channels connection closes.
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn accept_bi(&self) -> Result<BiStream, StreamError>;
|
||||
|
||||
/// Open a bidirectional stream to the peer.
|
||||
///
|
||||
/// Single-stream sources return `StreamClosed` (a single stream cannot
|
||||
/// open new application streams — ADR-065). QUIC and channels sources
|
||||
/// open new streams.
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<BiStream, StreamError>;
|
||||
|
||||
/// The peer's address, if available. Informational (NAT/proxy).
|
||||
fn remote_addr(&self) -> Option<SocketAddr>;
|
||||
@@ -401,22 +451,22 @@ struct QuinnBidiStreamSource {
|
||||
#[cfg(feature = "quinn")]
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for QuinnBidiStreamSource {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||
let (send, recv) = self
|
||||
.conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(map_quinn_connection_error)?;
|
||||
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
|
||||
Ok(BiStream::from_joined(recv, send))
|
||||
}
|
||||
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||
let (send, recv) = self
|
||||
.conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(map_quinn_connection_error)?;
|
||||
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
|
||||
Ok(BiStream::from_joined(recv, send))
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||
@@ -439,22 +489,22 @@ struct IrohBidiStreamSource {
|
||||
#[cfg(feature = "iroh")]
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for IrohBidiStreamSource {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||
let (send, recv) = self
|
||||
.conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(map_iroh_connection_error)?;
|
||||
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
|
||||
Ok(BiStream::from_joined(recv, send))
|
||||
}
|
||||
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||
let (send, recv) = self
|
||||
.conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(map_iroh_connection_error)?;
|
||||
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
|
||||
Ok(BiStream::from_joined(recv, send))
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||
@@ -469,25 +519,25 @@ impl BidiStreamSource for IrohBidiStreamSource {
|
||||
|
||||
/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport
|
||||
/// stream, wasm stream — ADR-065). Crate-private; constructed via
|
||||
/// `Connection::from_stream` / `from_bidi` (no feature gate). `accept_bi`
|
||||
/// yields the underlying stream once, then `ConnectionClosed`; `open_bi`
|
||||
/// returns `StreamClosed`.
|
||||
/// `Connection::from_bidi` (no feature gate). `accept_bi` yields the
|
||||
/// underlying `BiStream` once, then `ConnectionClosed`; `open_bi` returns
|
||||
/// `StreamClosed`.
|
||||
struct StreamBidiStreamSource {
|
||||
stream: Mutex<Option<(SendStream, RecvStream)>>,
|
||||
stream: Mutex<Option<BiStream>>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for StreamBidiStreamSource {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||
let mut guard = self.stream.lock().expect("stream mutex poisoned");
|
||||
match guard.take() {
|
||||
Some(pair) => Ok(pair),
|
||||
Some(stream) => Ok(stream),
|
||||
None => Err(StreamError::ConnectionClosed),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||
Err(StreamError::StreamClosed)
|
||||
}
|
||||
|
||||
@@ -535,37 +585,30 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a `Connection` from a pre-split read/write pair.
|
||||
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
|
||||
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
|
||||
pub fn from_stream(
|
||||
send: impl AsyncWrite + Send + Unpin + 'static,
|
||||
recv: impl AsyncRead + Send + Unpin + 'static,
|
||||
alpn: Vec<u8>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source: Box::new(StreamBidiStreamSource {
|
||||
stream: Mutex::new(Some((
|
||||
SendStream::from_stream(send),
|
||||
RecvStream::from_stream(recv),
|
||||
))),
|
||||
remote_addr,
|
||||
}),
|
||||
alpn,
|
||||
identity: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for a single bidirectional stream (e.g. `TlsStream<TcpStream>`).
|
||||
/// Splits internally via `tokio::io::split`.
|
||||
/// Construct a `Connection` from a single bidirectional stream (e.g.
|
||||
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
|
||||
/// `russh::Channel::into_stream()`). The stream is wrapped in a
|
||||
/// `BiStream` (ADR-092) and yielded by `accept_bi` once, then
|
||||
/// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single
|
||||
/// stream can't open new application streams — ADR-065).
|
||||
///
|
||||
/// This is the only public stream constructor (ADR-092): the split
|
||||
/// never crosses a crate boundary as part of a constructor. Handlers
|
||||
/// that want the split halves call `tokio::io::split(&mut *stream)` on
|
||||
/// the `BiStream` they receive from `accept_bi`.
|
||||
pub fn from_bidi(
|
||||
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
|
||||
alpn: Vec<u8>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
) -> Self {
|
||||
let (recv, send) = tokio::io::split(stream);
|
||||
Self::from_stream(send, recv, alpn, remote_addr)
|
||||
Self {
|
||||
source: Box::new(StreamBidiStreamSource {
|
||||
stream: Mutex::new(Some(BiStream::from_bidi(stream))),
|
||||
remote_addr,
|
||||
}),
|
||||
alpn,
|
||||
identity: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct from a caller-supplied `BidiStreamSource` impl. The
|
||||
@@ -591,12 +634,14 @@ impl Connection {
|
||||
///
|
||||
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
|
||||
/// per single-stream connection; handlers that call once (e.g.
|
||||
/// `HttpAdapter`) get the stream directly. Both are correct.
|
||||
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
/// `HttpAdapter`) get the stream directly. Both are correct. The
|
||||
/// return type is `BiStream` (ADR-092); handlers that want the split
|
||||
/// halves call `tokio::io::split` on the `BiStream`.
|
||||
pub async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||
self.source.accept_bi().await
|
||||
}
|
||||
|
||||
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
pub async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||
self.source.open_bi().await
|
||||
}
|
||||
|
||||
@@ -657,21 +702,21 @@ mod from_source_tests {
|
||||
/// delegates to a caller-supplied impl. Not a built-in — the whole
|
||||
/// point of `from_source` is that a non-core type can drive `Connection`.
|
||||
struct RecordingSource {
|
||||
stream: Mutex<Option<(SendStream, RecvStream)>>,
|
||||
stream: Mutex<Option<BiStream>>,
|
||||
addr: Option<SocketAddr>,
|
||||
closed: Arc<Mutex<Option<(u32, String)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for RecordingSource {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
||||
match self.stream.lock().expect("mock mutex poisoned").take() {
|
||||
Some(pair) => Ok(pair),
|
||||
Some(stream) => Ok(stream),
|
||||
None => Err(StreamError::ConnectionClosed),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
|
||||
async fn open_bi(&self) -> Result<BiStream, StreamError> {
|
||||
Err(StreamError::StreamClosed)
|
||||
}
|
||||
|
||||
@@ -693,19 +738,16 @@ mod from_source_tests {
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// One duplex: the mock holds end `a` (split into send_a/recv_a); the
|
||||
// test driver holds end `b` (split into send_b/recv_b) to echo back.
|
||||
// One duplex: the mock holds end `a`; the test driver holds end `b`
|
||||
// (split into send_b/recv_b) to echo back. The mock's `accept_bi`
|
||||
// yields end `a` as a `BiStream`; the driver reads/writes end `b`.
|
||||
let (a, b) = tokio::io::duplex(64);
|
||||
let (recv_a, send_a) = tokio::io::split(a);
|
||||
let (mut recv_b, mut send_b) = tokio::io::split(b);
|
||||
let addr = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7777));
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let conn = Connection::from_source(
|
||||
RecordingSource {
|
||||
stream: Mutex::new(Some((
|
||||
SendStream::from_stream(send_a),
|
||||
RecvStream::from_stream(recv_a),
|
||||
))),
|
||||
stream: Mutex::new(Some(BiStream::from_bidi(a))),
|
||||
addr,
|
||||
closed: Arc::clone(&recorded),
|
||||
},
|
||||
@@ -718,22 +760,22 @@ mod from_source_tests {
|
||||
// remote_addr delegates to RecordingSource::remote_addr.
|
||||
assert_eq!(conn.remote_addr(), addr);
|
||||
|
||||
// accept_bi delegates to RecordingSource::accept_bi and yields the pair.
|
||||
let (mut send, mut recv) = conn.accept_bi().await.expect("first accept_bi yields");
|
||||
// accept_bi delegates to RecordingSource::accept_bi and yields a BiStream.
|
||||
let mut stream = conn.accept_bi().await.expect("first accept_bi yields");
|
||||
|
||||
// Write via the mock's SendStream -> arrives at the driver's recv_b.
|
||||
send.write_all(b"hello").await.expect("write round-trips");
|
||||
// Write via the mock's BiStream -> arrives at the driver's recv_b.
|
||||
stream.write_all(b"hello").await.expect("write round-trips");
|
||||
let mut buf = [0u8; 5];
|
||||
recv_b.read_exact(&mut buf).await.expect("driver reads");
|
||||
assert_eq!(&buf, b"hello");
|
||||
|
||||
// Driver writes back -> arrives at the mock's RecvStream.
|
||||
// Driver writes back -> arrives at the mock's BiStream.
|
||||
send_b
|
||||
.write_all(b"world")
|
||||
.await
|
||||
.expect("driver writes back");
|
||||
let mut buf = [0u8; 5];
|
||||
recv.read_exact(&mut buf).await.expect("read round-trips");
|
||||
stream.read_exact(&mut buf).await.expect("read round-trips");
|
||||
assert_eq!(&buf, b"world");
|
||||
|
||||
// Second accept_bi delegates to RecordingSource::accept_bi -> ConnectionClosed.
|
||||
@@ -763,11 +805,52 @@ mod from_source_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
|
||||
/// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF
|
||||
/// immediately (zero bytes), writes discard. Exists because
|
||||
/// `Connection::from_bidi` requires a single value that implements
|
||||
/// both traits (ADR-092 — the split-pair `from_stream` constructor is
|
||||
/// removed). Used only to construct a `Connection` for tests that
|
||||
/// exercise `Connection`-level state (alpn, addr, identity) without
|
||||
/// ever reading or writing the stream.
|
||||
struct SinkEmpty;
|
||||
|
||||
impl AsyncRead for SinkEmpty {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
_buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
// EOF immediately — mirrors `tokio::io::empty()`.
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for SinkEmpty {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
// Discard — mirrors `tokio::io::sink()`.
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn test_connection() -> Connection {
|
||||
Connection::from_stream(
|
||||
tokio::io::sink(),
|
||||
tokio::io::empty(),
|
||||
Connection::from_bidi(
|
||||
SinkEmpty,
|
||||
b"alknet/test".to_vec(),
|
||||
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)),
|
||||
)
|
||||
@@ -863,7 +946,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_remote_alpn_and_addr_from_stream() {
|
||||
fn connection_remote_alpn_and_addr_from_bidi() {
|
||||
let conn = test_connection();
|
||||
assert_eq!(conn.remote_alpn(), b"alknet/test");
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "alknet-endpoint"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Server-side multi-transport accept-loop runner — dispatches incoming connections by ALPN"
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "alknet_endpoint"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
quinn = ["dep:quinn", "alknet-core/quinn"]
|
||||
iroh = ["dep:iroh", "alknet-core/iroh"]
|
||||
tcp = ["dep:tokio-rustls"]
|
||||
acme = []
|
||||
|
||||
[dependencies]
|
||||
alknet-core = { path = "../alknet-core" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
arc-swap = "1"
|
||||
tracing = "0.1"
|
||||
rustls = "0.23"
|
||||
quinn = { version = "0.11", optional = true }
|
||||
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
|
||||
tokio-rustls = { version = "0.26", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Iroh accept loop.
|
||||
//!
|
||||
//! Accepts iroh connections, negotiates ALPN, extracts the client
|
||||
//! fingerprint (NodeId), converts to `Connection`, and dispatches.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use alknet_core::auth::IdentityProvider;
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
pub(crate) async fn run_accept_loop(
|
||||
iroh: iroh::Endpoint,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("iroh accept loop: shutdown signaled");
|
||||
break;
|
||||
}
|
||||
incoming = iroh.accept() => {
|
||||
let Some(incoming) = incoming else {
|
||||
debug!("iroh accept loop: endpoint closed");
|
||||
break;
|
||||
};
|
||||
let handlers = handlers.clone();
|
||||
let identity_provider = identity_provider.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut connecting = match incoming.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("iroh accept failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let alpn = match connecting.alpn().await {
|
||||
Ok(alpn) => alpn,
|
||||
Err(e) => {
|
||||
warn!("iroh ALPN negotiation failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let connection = match connecting.await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
warn!("iroh handshake completion failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fingerprint = extract_client_fingerprint(&connection);
|
||||
let conn = Connection::from_iroh(connection);
|
||||
crate::dispatch::dispatch_connection(
|
||||
conn, alpn, fingerprint, None,
|
||||
&handlers, &identity_provider,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_client_fingerprint(connection: &iroh::endpoint::Connection) -> Option<String> {
|
||||
let node_id = connection.remote_id();
|
||||
Some(format!("ed25519:{}", node_id))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Transport-specific accept loops.
|
||||
//!
|
||||
//! Each module contains a `run_accept_loop` function that accepts
|
||||
//! connections on its transport, extracts ALPN + fingerprint, and
|
||||
//! calls `crate::dispatch::dispatch_connection`.
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
pub(crate) mod quinn;
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
pub(crate) mod iroh;
|
||||
|
||||
#[cfg(feature = "tcp")]
|
||||
pub(crate) mod tcp_tls;
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Quinn (QUIC) accept loop.
|
||||
//!
|
||||
//! Accepts QUIC connections, performs the TLS handshake, extracts
|
||||
//! ALPN + client fingerprint, converts to `Connection`, and dispatches.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use alknet_core::auth::IdentityProvider;
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
pub(crate) async fn run_accept_loop(
|
||||
quinn: quinn::Endpoint,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("quinn accept loop: shutdown signaled");
|
||||
break;
|
||||
}
|
||||
incoming = quinn.accept() => {
|
||||
let Some(incoming) = incoming else {
|
||||
debug!("quinn accept loop: endpoint closed");
|
||||
break;
|
||||
};
|
||||
let connecting = match incoming.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("quinn accept failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let handlers = handlers.clone();
|
||||
let identity_provider = identity_provider.clone();
|
||||
tokio::spawn(async move {
|
||||
let connection = match connecting.await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
warn!("quinn TLS handshake failure: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let alpn = extract_alpn(&connection);
|
||||
let remote_addr = Some(connection.remote_address());
|
||||
let fingerprint = extract_client_fingerprint(&connection);
|
||||
let conn = Connection::from_quinn_with_alpn(connection, alpn.clone());
|
||||
crate::dispatch::dispatch_connection(
|
||||
conn, alpn, fingerprint, remote_addr,
|
||||
&handlers, &identity_provider,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_alpn(connection: &quinn::Connection) -> Vec<u8> {
|
||||
use quinn::crypto::rustls::HandshakeData;
|
||||
if let Some(data) = connection.handshake_data() {
|
||||
if let Ok(hs) = data.downcast::<HandshakeData>() {
|
||||
if let Some(protocol) = hs.protocol {
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extract_client_fingerprint(connection: &quinn::Connection) -> Option<String> {
|
||||
let identity = connection.peer_identity()?;
|
||||
let certs = identity
|
||||
.downcast::<Vec<rustls::pki_types::CertificateDer>>()
|
||||
.ok()?;
|
||||
let leaf = certs.first()?;
|
||||
alknet_core::fingerprint::fingerprint_from_cert_der(leaf.as_ref())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! TCP+TLS accept loop.
|
||||
//!
|
||||
//! Accepts TCP connections, performs a TLS handshake, extracts
|
||||
//! ALPN + client fingerprint, converts to `Connection::from_bidi`,
|
||||
//! and dispatches.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use alknet_core::auth::IdentityProvider;
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
pub(crate) async fn run_accept_loop(
|
||||
listener: tokio::net::TcpListener,
|
||||
acceptor: tokio_rustls::TlsAcceptor,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("tcp+tls accept loop: shutdown signaled");
|
||||
break;
|
||||
}
|
||||
result = listener.accept() => {
|
||||
let (tcp_stream, remote_addr) = match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("tcp+tls accept failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let acceptor = acceptor.clone();
|
||||
let handlers = handlers.clone();
|
||||
let identity_provider = identity_provider.clone();
|
||||
tokio::spawn(async move {
|
||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("tcp+tls TLS handshake failure: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (alpn, fingerprint) = extract_tls_session_info(&tls_stream);
|
||||
let conn = Connection::from_bidi(tls_stream, alpn.clone(), Some(remote_addr));
|
||||
crate::dispatch::dispatch_connection(
|
||||
conn, alpn, fingerprint, Some(remote_addr),
|
||||
&handlers, &identity_provider,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tls_session_info(
|
||||
tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
|
||||
) -> (Vec<u8>, Option<String>) {
|
||||
let (_, session) = tls_stream.get_ref();
|
||||
let alpn = session
|
||||
.alpn_protocol()
|
||||
.map(|a| a.to_vec())
|
||||
.unwrap_or_default();
|
||||
let fingerprint = session
|
||||
.peer_certificates()
|
||||
.and_then(|certs| certs.first())
|
||||
.and_then(|cert| alknet_core::fingerprint::fingerprint_from_cert_der(cert.as_ref()));
|
||||
(alpn, fingerprint)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Shared dispatch path for all transports.
|
||||
//!
|
||||
//! `dispatch_connection` is the free function called by every accept loop
|
||||
//! after transport-specific extraction. `AlknetEndpoint::dispatch` delegates
|
||||
//! to it. `build_auth_context` resolves the caller's identity from the
|
||||
//! TLS fingerprint.
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::net::SocketAddr;
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::auth::{AuthContext, IdentityProvider};
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::types::Connection;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
pub(crate) fn dispatch_connection(
|
||||
connection: Connection,
|
||||
alpn: Vec<u8>,
|
||||
fingerprint: Option<String>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
handlers: &HandlerRegistry,
|
||||
identity_provider: &Arc<dyn IdentityProvider>,
|
||||
) {
|
||||
#[cfg(feature = "acme")]
|
||||
if alpn == b"acme-tls/1" {
|
||||
tracing::debug!("acme-tls/1 challenge connection; closing");
|
||||
connection.close(0, "acme done");
|
||||
return;
|
||||
}
|
||||
|
||||
let handler = match handlers.get(&alpn) {
|
||||
Some(h) => h.clone(),
|
||||
None => {
|
||||
connection.close(0, "no handler");
|
||||
warn!(
|
||||
"dispatch: no handler for ALPN {:?}",
|
||||
String::from_utf8_lossy(&alpn)
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let auth = build_auth_context(&alpn, remote_addr, fingerprint, identity_provider);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handler.handle(connection, &auth).await {
|
||||
error!("handler returned error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
pub(crate) fn build_auth_context(
|
||||
alpn: &[u8],
|
||||
remote_addr: Option<SocketAddr>,
|
||||
tls_client_fingerprint: Option<String>,
|
||||
identity_provider: &Arc<dyn IdentityProvider>,
|
||||
) -> AuthContext {
|
||||
let identity = tls_client_fingerprint
|
||||
.as_ref()
|
||||
.and_then(|fp| identity_provider.resolve_from_fingerprint(fp));
|
||||
AuthContext {
|
||||
identity,
|
||||
alpn: alpn.to_vec(),
|
||||
remote_addr,
|
||||
tls_client_fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use super::build_auth_context;
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &alknet_core::auth::AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn alknet_core::types::ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_resolves_identity_from_fingerprint() {
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
|
||||
if fp == "SHA256:known" {
|
||||
Some(Identity {
|
||||
id: "SHA256:known".to_string(),
|
||||
scopes: vec![],
|
||||
resources: HashMap::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
let auth = build_auth_context(
|
||||
b"alknet/test",
|
||||
None,
|
||||
Some("SHA256:known".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert_eq!(auth.identity.as_ref().unwrap().id, "SHA256:known");
|
||||
assert_eq!(auth.alpn, b"alknet/test");
|
||||
assert_eq!(auth.tls_client_fingerprint.as_deref(), Some("SHA256:known"));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_no_fingerprint_no_identity() {
|
||||
struct NoProvider;
|
||||
impl IdentityProvider for NoProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let auth = build_auth_context(b"alknet/test", None, None, &provider);
|
||||
assert!(auth.identity.is_none());
|
||||
assert!(auth.tls_client_fingerprint.is_none());
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_fingerprint_unknown_identity_none() {
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
let auth = build_auth_context(
|
||||
b"alknet/test",
|
||||
None,
|
||||
Some("SHA256:unknown".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert!(auth.identity.is_none());
|
||||
assert!(auth.tls_client_fingerprint.is_some());
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn dispatch_decision_logic_lookup_and_auth() {
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/ssh"));
|
||||
registry.register(make_handler(b"alknet/call"));
|
||||
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
|
||||
if fp == "SHA256:caller" {
|
||||
Some(Identity {
|
||||
id: "SHA256:caller".to_string(),
|
||||
scopes: vec!["relay:connect".to_string()],
|
||||
resources: HashMap::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
|
||||
let ssh_handler = registry.get(b"alknet/ssh").expect("ssh handler registered");
|
||||
assert_eq!(ssh_handler.alpn(), b"alknet/ssh");
|
||||
let auth = build_auth_context(
|
||||
b"alknet/ssh",
|
||||
Some(std::net::SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
1234,
|
||||
)),
|
||||
Some("SHA256:caller".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert_eq!(auth.identity.as_ref().unwrap().id, "SHA256:caller");
|
||||
assert_eq!(auth.alpn, b"alknet/ssh");
|
||||
|
||||
let unknown = registry.get(b"alknet/unknown");
|
||||
assert!(unknown.is_none(), "unknown ALPN has no handler");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! `AlknetEndpoint` — the central runtime type for accepting inbound connections.
|
||||
//!
|
||||
//! Takes pre-built transports via builder methods, runs their accept loops
|
||||
//! inside `run()`, and dispatches each accepted connection to the registered
|
||||
//! `ProtocolHandler` by ALPN.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use alknet_core::auth::IdentityProvider;
|
||||
use alknet_core::config::DynamicConfig;
|
||||
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
#[cfg(feature = "tcp")]
|
||||
pub(crate) type TcpTlsListener = (tokio::net::TcpListener, tokio_rustls::TlsAcceptor);
|
||||
|
||||
pub struct AlknetEndpoint {
|
||||
#[cfg(feature = "quinn")]
|
||||
quinn: Option<quinn::Endpoint>,
|
||||
#[cfg(feature = "iroh")]
|
||||
iroh: Option<iroh::Endpoint>,
|
||||
#[cfg(feature = "tcp")]
|
||||
tcp_tls: std::sync::Mutex<Option<TcpTlsListener>>,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
#[allow(dead_code)]
|
||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||
#[allow(dead_code)]
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
#[allow(dead_code)]
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
drain_timeout: Duration,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AlknetEndpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AlknetEndpoint")
|
||||
.field("handlers", &self.handlers)
|
||||
.field("drain_timeout", &self.drain_timeout)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AlknetEndpoint {
|
||||
pub fn new(
|
||||
handlers: HandlerRegistry,
|
||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
drain_timeout: Duration,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
#[cfg(feature = "quinn")]
|
||||
quinn: None,
|
||||
#[cfg(feature = "iroh")]
|
||||
iroh: None,
|
||||
#[cfg(feature = "tcp")]
|
||||
tcp_tls: std::sync::Mutex::new(None),
|
||||
handlers: Arc::new(handlers),
|
||||
dynamic,
|
||||
identity_provider,
|
||||
shutdown_tx,
|
||||
shutdown_rx,
|
||||
drain_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self {
|
||||
self.quinn = Some(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self {
|
||||
self.iroh = Some(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(feature = "tcp")]
|
||||
pub fn with_tcp_tls(
|
||||
self,
|
||||
listener: tokio::net::TcpListener,
|
||||
acceptor: tokio_rustls::TlsAcceptor,
|
||||
) -> Self {
|
||||
*self.tcp_tls.lock().unwrap_or_else(|e| e.into_inner()) = Some((listener, acceptor));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shutdown_sender(&self) -> watch::Sender<bool> {
|
||||
self.shutdown_tx.clone()
|
||||
}
|
||||
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
#[allow(unused_mut)]
|
||||
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
if let Some(quinn) = &self.quinn {
|
||||
let quinn = quinn.clone();
|
||||
let handlers = self.handlers.clone();
|
||||
let identity_provider = self.identity_provider.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
crate::accept::quinn::run_accept_loop(
|
||||
quinn,
|
||||
handlers,
|
||||
identity_provider,
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
if let Some(iroh) = &self.iroh {
|
||||
let iroh = iroh.clone();
|
||||
let handlers = self.handlers.clone();
|
||||
let identity_provider = self.identity_provider.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
crate::accept::iroh::run_accept_loop(
|
||||
iroh,
|
||||
handlers,
|
||||
identity_provider,
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(feature = "tcp")]
|
||||
if let Some((listener, acceptor)) = self
|
||||
.tcp_tls
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
{
|
||||
let handlers = self.handlers.clone();
|
||||
let identity_provider = self.identity_provider.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
crate::accept::tcp_tls::run_accept_loop(
|
||||
listener,
|
||||
acceptor,
|
||||
handlers,
|
||||
identity_provider,
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
if let Some(quinn) = &self.quinn {
|
||||
quinn.close(0u32.into(), b"shutdown");
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
if let Some(iroh) = &self.iroh {
|
||||
iroh.close().await;
|
||||
}
|
||||
|
||||
tokio::time::sleep(self.drain_timeout).await;
|
||||
|
||||
#[cfg(feature = "quinn")]
|
||||
if let Some(quinn) = &self.quinn {
|
||||
quinn.wait_idle().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
use alknet_core::auth::AuthContext;
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alknet_core::config::DynamicConfig;
|
||||
#[cfg(feature = "iroh")]
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
#[cfg(feature = "iroh")]
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn alknet_core::types::ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
struct NoProvider;
|
||||
impl IdentityProvider for NoProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_for_alknet_endpoint_is_implemented_without_panicking() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
let s = format!("{endpoint:?}");
|
||||
assert!(s.contains("AlknetEndpoint"));
|
||||
assert!(s.contains("drain_timeout"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[tokio::test]
|
||||
async fn endpoint_constructs_with_iroh_raw_key_identity() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/test"));
|
||||
|
||||
let iroh_endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds");
|
||||
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10))
|
||||
.with_iroh(iroh_endpoint);
|
||||
assert!(endpoint.shutdown_sender().send(true).is_ok());
|
||||
endpoint.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[tokio::test]
|
||||
async fn iroh_endpoint_runs_accept_loop_and_shutdown() {
|
||||
use std::sync::Mutex;
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
|
||||
let connected = Arc::new(Mutex::new(false));
|
||||
let connected_clone = connected.clone();
|
||||
struct CountingHandler {
|
||||
alpn: &'static [u8],
|
||||
connected: Arc<Mutex<bool>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for CountingHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_conn: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
*self.connected.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(Arc::new(CountingHandler {
|
||||
alpn: b"alknet/test",
|
||||
connected: connected_clone,
|
||||
}));
|
||||
|
||||
let iroh_endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds");
|
||||
|
||||
let endpoint = Arc::new(
|
||||
AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(20))
|
||||
.with_iroh(iroh_endpoint),
|
||||
);
|
||||
|
||||
let run_endpoint = endpoint.clone();
|
||||
let run_task = tokio::spawn(async move {
|
||||
run_endpoint.run().await;
|
||||
});
|
||||
|
||||
let _ = endpoint.shutdown_sender().send(true);
|
||||
endpoint.shutdown().await;
|
||||
let _ = run_task.await;
|
||||
assert!(!*connected.lock().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn with_iroh_sets_field() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let iroh_endpoint = rt.block_on(async {
|
||||
iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds")
|
||||
});
|
||||
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10))
|
||||
.with_iroh(iroh_endpoint);
|
||||
assert!(endpoint.iroh.is_some());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn without_iroh_field_is_none() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
assert!(endpoint.iroh.is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn endpoint_works_without_iroh() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/test"));
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
assert!(endpoint.iroh.is_none());
|
||||
assert!(endpoint.shutdown_sender().send(true).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! alknet-endpoint: Server-side multi-transport accept-loop runner.
|
||||
//!
|
||||
//! `AlknetEndpoint` takes pre-built transports (quinn, iroh, TCP+TLS) via
|
||||
//! builder methods, runs their accept loops inside `run()`, and dispatches
|
||||
//! each accepted connection to the registered `ProtocolHandler` by ALPN.
|
||||
//!
|
||||
//! The endpoint does not build transports and does not depend on
|
||||
//! `alknet-tls` — transport construction is the assembly layer's concern.
|
||||
|
||||
pub mod accept;
|
||||
pub mod dispatch;
|
||||
pub mod endpoint;
|
||||
pub mod registry;
|
||||
|
||||
pub use endpoint::AlknetEndpoint;
|
||||
pub use registry::HandlerRegistry;
|
||||
@@ -0,0 +1,153 @@
|
||||
//! `HandlerRegistry` — maps ALPN byte strings to `ProtocolHandler` instances.
|
||||
//!
|
||||
//! Registered statically at startup by the assembly layer; the endpoint
|
||||
//! dispatches by looking up the negotiated ALPN.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alknet_core::types::ProtocolHandler;
|
||||
|
||||
pub struct HandlerRegistry {
|
||||
handlers: HashMap<&'static [u8], Arc<dyn ProtocolHandler>>,
|
||||
}
|
||||
|
||||
impl HandlerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handler: Arc<dyn ProtocolHandler>) {
|
||||
let alpn = handler.alpn();
|
||||
if self.handlers.contains_key(alpn) {
|
||||
panic!(
|
||||
"HandlerRegistry: ALPN already registered: {:?}",
|
||||
String::from_utf8_lossy(alpn)
|
||||
);
|
||||
}
|
||||
self.handlers.insert(alpn, handler);
|
||||
}
|
||||
|
||||
pub fn get(&self, alpn: &[u8]) -> Option<&Arc<dyn ProtocolHandler>> {
|
||||
self.handlers.get(alpn)
|
||||
}
|
||||
|
||||
pub fn alpn_strings(&self) -> Vec<Vec<u8>> {
|
||||
self.handlers.keys().map(|k| k.to_vec()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HandlerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HandlerRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HandlerRegistry")
|
||||
.field(
|
||||
"alpns",
|
||||
&self
|
||||
.handlers
|
||||
.keys()
|
||||
.map(|k| String::from_utf8_lossy(k).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alknet_core::auth::AuthContext;
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_new_is_empty() {
|
||||
let reg = HandlerRegistry::new();
|
||||
assert!(reg.alpn_strings().is_empty());
|
||||
assert!(reg.get(b"alknet/test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_register_then_get() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
assert_eq!(reg.alpn_strings(), vec![b"alknet/test".to_vec()]);
|
||||
assert!(reg.get(b"alknet/test").is_some());
|
||||
assert!(reg.get(b"alknet/other").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_multiple_alpns() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/ssh"));
|
||||
reg.register(make_handler(b"alknet/call"));
|
||||
let mut alpns = reg
|
||||
.alpn_strings()
|
||||
.into_iter()
|
||||
.map(|a| String::from_utf8(a).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
alpns.sort();
|
||||
assert_eq!(alpns, vec!["alknet/call", "alknet/ssh"]);
|
||||
assert!(reg.get(b"alknet/ssh").is_some());
|
||||
assert!(reg.get(b"alknet/call").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ALPN already registered")]
|
||||
fn handler_registry_register_panics_on_duplicate() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_debug_lists_alpns() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
let s = format!("{:?}", reg);
|
||||
assert!(s.contains("alknet/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_default_is_empty() {
|
||||
let reg = HandlerRegistry::default();
|
||||
assert!(reg.alpn_strings().is_empty());
|
||||
assert!(reg.get(b"alknet/test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_debug_lists_alpns_via_default() {
|
||||
let reg = HandlerRegistry::default();
|
||||
let s = format!("{reg:?}");
|
||||
assert!(s.contains("HandlerRegistry"));
|
||||
}
|
||||
}
|
||||
@@ -432,7 +432,8 @@ mod tests {
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
|
||||
OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
@@ -498,6 +499,36 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A streaming echo handler: yields the input back as a single
|
||||
/// `call.responded` frame, then the stream ends. Used for
|
||||
/// `OperationType::Subscription` ops in `full_registry_with_ops` —
|
||||
/// the registry's kind validation (ADR-049) requires
|
||||
/// `HandlerKind::Stream` for `Subscription` ops; using
|
||||
/// `HandlerKind::Once` is rejected with `"handler kind mismatch:
|
||||
/// Subscription requires HandlerKind::Stream (got HandlerKind::Once)"`.
|
||||
/// The test only verifies that the MCP `search` tool *excludes*
|
||||
/// Subscription ops from its listing — it never invokes the handler —
|
||||
/// so a single-frame echo is sufficient.
|
||||
fn make_echo_streaming_handler() -> alknet_call::registry::registration::StreamingHandler {
|
||||
make_streaming_handler(|input, context| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `HandlerKind` matching the op's `OperationType`: `Once` for
|
||||
/// Query/Mutation, `Stream` for Subscription. The registry's kind
|
||||
/// validation (ADR-049) rejects a mismatch, so the helper must branch
|
||||
/// — using `HandlerKind::Once` for a `Subscription` op panics in
|
||||
/// `register().unwrap()`.
|
||||
fn handler_kind_for(op_type: OperationType) -> HandlerKind {
|
||||
match op_type {
|
||||
OperationType::Subscription => HandlerKind::Stream(make_echo_streaming_handler()),
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
HandlerKind::Once(make_echo_handler())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn full_registry_with_ops(
|
||||
specs: Vec<(String, OperationType, AccessControl)>,
|
||||
) -> Arc<OperationRegistry> {
|
||||
@@ -506,7 +537,7 @@ mod tests {
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&name, op_type, acl),
|
||||
HandlerKind::Once(make_echo_handler()),
|
||||
handler_kind_for(op_type),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -521,7 +552,7 @@ mod tests {
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&op.name, op.op_type, op.access_control.clone()),
|
||||
HandlerKind::Once(make_echo_handler()),
|
||||
handler_kind_for(op.op_type),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection
|
||||
//! of the registry.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -229,12 +227,14 @@ impl ProtocolHandler for HttpAdapter {
|
||||
let _ = connection.set_identity(identity);
|
||||
}
|
||||
|
||||
let (send, recv) = connection
|
||||
// `accept_bi` returns a `BiStream` (ADR-092) — already
|
||||
// `AsyncRead + AsyncWrite + Send + Unpin`. No wrapper needed; pass
|
||||
// it directly to `serve_io` via `TokioIo::new`.
|
||||
let stream = connection
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(stream_error_to_handler)?;
|
||||
let io = QuicStream::new(send, recv);
|
||||
self.serve_io(io).await
|
||||
self.serve_io(stream).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,51 +268,6 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
|
||||
HandlerError::from(e)
|
||||
}
|
||||
|
||||
struct QuicStream {
|
||||
send: alknet_core::types::SendStream,
|
||||
recv: alknet_core::types::RecvStream,
|
||||
}
|
||||
|
||||
impl QuicStream {
|
||||
fn new(send: alknet_core::types::SendStream, recv: alknet_core::types::RecvStream) -> Self {
|
||||
Self { send, recv }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for QuicStream {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.recv).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for QuicStream {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.send).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.send).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.send).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -418,29 +373,26 @@ mod tests {
|
||||
async fn send_request_and_read_response(
|
||||
request: &[u8],
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
let (mut client_send, server_recv) = duplex(8 * 1024);
|
||||
let (server_send, mut client_recv) = duplex(8 * 1024);
|
||||
let server_io = QuicStreamDuplex {
|
||||
read: server_recv,
|
||||
write: server_send,
|
||||
};
|
||||
// One duplex: `server_io` is the server's end (passed to `serve_io`);
|
||||
// `client_io` is the client's end (writes requests, reads responses).
|
||||
// `tokio::io::duplex` yields two `DuplexStream`s, each
|
||||
// `AsyncRead + AsyncWrite + Send + Unpin` — the same bounds `BiStream`
|
||||
// exposes (ADR-092), so no wrapper is needed.
|
||||
let (server_io, mut client_io) = duplex(8 * 1024);
|
||||
|
||||
let adapter = HttpAdapter::new(provider(), empty_registry());
|
||||
let handle = tokio::spawn(async move {
|
||||
adapter.serve_io(server_io).await.ok();
|
||||
});
|
||||
|
||||
client_send.write_all(request).await.unwrap();
|
||||
client_send.flush().await.unwrap();
|
||||
client_io.write_all(request).await.unwrap();
|
||||
client_io.flush().await.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_recv.read(&mut buf),
|
||||
)
|
||||
.await
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => break,
|
||||
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
|
||||
@@ -453,45 +405,6 @@ mod tests {
|
||||
(response_str, handle)
|
||||
}
|
||||
|
||||
struct QuicStreamDuplex {
|
||||
read: tokio::io::DuplexStream,
|
||||
write: tokio::io::DuplexStream,
|
||||
}
|
||||
|
||||
impl AsyncRead for QuicStreamDuplex {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.read).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for QuicStreamDuplex {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.write).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.write).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.write).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_serves_http_request_over_mock_quic_stream() {
|
||||
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
@@ -509,29 +422,21 @@ mod tests {
|
||||
let extra = Router::new().route("/v1/foo", get(|| async { (StatusCode::OK, "foo-body") }));
|
||||
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
|
||||
|
||||
let (mut client_send, server_recv) = duplex(8 * 1024);
|
||||
let (server_send, mut client_recv) = duplex(8 * 1024);
|
||||
let server_io = QuicStreamDuplex {
|
||||
read: server_recv,
|
||||
write: server_send,
|
||||
};
|
||||
let (server_io, mut client_io) = duplex(8 * 1024);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
adapter.serve_io(server_io).await.ok();
|
||||
});
|
||||
|
||||
let request = b"GET /v1/foo HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
client_send.write_all(request).await.unwrap();
|
||||
client_send.flush().await.unwrap();
|
||||
client_io.write_all(request).await.unwrap();
|
||||
client_io.flush().await.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_recv.read(&mut buf),
|
||||
)
|
||||
.await
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => break,
|
||||
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
|
||||
@@ -556,29 +461,21 @@ mod tests {
|
||||
);
|
||||
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
|
||||
|
||||
let (mut client_send, server_recv) = duplex(8 * 1024);
|
||||
let (server_send, mut client_recv) = duplex(8 * 1024);
|
||||
let server_io = QuicStreamDuplex {
|
||||
read: server_recv,
|
||||
write: server_send,
|
||||
};
|
||||
let (server_io, mut client_io) = duplex(8 * 1024);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
adapter.serve_io(server_io).await.ok();
|
||||
});
|
||||
|
||||
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
client_send.write_all(request).await.unwrap();
|
||||
client_send.flush().await.unwrap();
|
||||
client_io.write_all(request).await.unwrap();
|
||||
client_io.flush().await.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_recv.read(&mut buf),
|
||||
)
|
||||
.await
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => break,
|
||||
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
|
||||
@@ -597,25 +494,17 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn serve_and_read(adapter: HttpAdapter, request: &[u8]) -> String {
|
||||
let (mut client_send, server_recv) = duplex(8 * 1024);
|
||||
let (server_send, mut client_recv) = duplex(8 * 1024);
|
||||
let server_io = QuicStreamDuplex {
|
||||
read: server_recv,
|
||||
write: server_send,
|
||||
};
|
||||
let (server_io, mut client_io) = duplex(8 * 1024);
|
||||
let handle = tokio::spawn(async move {
|
||||
adapter.serve_io(server_io).await.ok();
|
||||
});
|
||||
client_send.write_all(request).await.unwrap();
|
||||
client_send.flush().await.unwrap();
|
||||
client_io.write_all(request).await.unwrap();
|
||||
client_io.flush().await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_recv.read(&mut buf),
|
||||
)
|
||||
.await
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => break,
|
||||
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
|
||||
|
||||
@@ -23,7 +23,7 @@ impl TlsClientConfig {
|
||||
pub fn new(credentials: &ConnectionCredentials, alpn: &[u8]) -> Result<Self, TlsError> {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
|
||||
let client_auth = build_client_auth(&provider, &credentials.tls_identity)?;
|
||||
let client_auth = build_client_auth(&provider, &credentials.local_identity)?;
|
||||
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
|
||||
|
||||
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
@@ -48,13 +48,19 @@ impl TlsClientConfig {
|
||||
.map_err(|e| TlsError::Config(e.to_string()))?,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Consume the config and return the inner `rustls::ClientConfig`.
|
||||
/// Used by `dial_tcp_tls` to build a `TlsConnector`.
|
||||
pub fn into_rustls_config(self) -> rustls::ClientConfig {
|
||||
self.rustls_config
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the client-auth cert resolver that presents the local node's TLS
|
||||
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
|
||||
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
|
||||
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
|
||||
/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured)
|
||||
/// cert chain + key are loaded from disk. `None` (no `local_identity` configured)
|
||||
/// resolves to no client cert (the server gets nothing to fingerprint).
|
||||
fn build_client_auth(
|
||||
provider: &Arc<rustls::crypto::CryptoProvider>,
|
||||
@@ -187,7 +193,7 @@ impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client cert resolver that presents no client cert (the `tls_identity: None`
|
||||
/// Client cert resolver that presents no client cert (the `local_identity: None`
|
||||
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
|
||||
/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for
|
||||
/// this connection.
|
||||
@@ -490,7 +496,7 @@ mod tests {
|
||||
fn build_quinn_client_config_with_raw_key_identity_builds_without_error() {
|
||||
let sk = Ed25519SecretKey::generate();
|
||||
let credentials = ConnectionCredentials::new()
|
||||
.with_tls_identity(TlsIdentity::RawKey(sk))
|
||||
.with_local_identity(TlsIdentity::RawKey(sk))
|
||||
.with_remote_identity(RemoteIdentity {
|
||||
fingerprint: "ed25519:deadbeef".to_string(),
|
||||
});
|
||||
@@ -504,7 +510,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_quinn_client_config_with_no_remote_identity_builds_without_error() {
|
||||
let sk = Ed25519SecretKey::generate();
|
||||
let credentials = ConnectionCredentials::new().with_tls_identity(TlsIdentity::RawKey(sk));
|
||||
let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk));
|
||||
let config = TlsClientConfig::new(&credentials, b"alknet/call")
|
||||
.expect("TlsClientConfig::new must build for CA-verification path");
|
||||
let quinn_config = config.for_quinn().expect("for_quinn must convert");
|
||||
|
||||
@@ -21,7 +21,9 @@ use std::sync::Arc;
|
||||
use alknet_core::auth::Identity;
|
||||
use alknet_tty::adapter::drive_session;
|
||||
use alknet_tty::backend::TtyBackend;
|
||||
use alknet_tty::wire::{ChunkReader, STREAM_CONTROL, STREAM_STDERR, STREAM_STDOUT};
|
||||
use alknet_tty::wire::{
|
||||
ChunkReader, STREAM_CTRL_IN, STREAM_CTRL_OUT, STREAM_STDERR, STREAM_STDOUT,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use tokio::io::duplex;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -70,10 +72,11 @@ impl ClientSide {
|
||||
self.write.flush().await.unwrap();
|
||||
}
|
||||
|
||||
/// Write a control chunk (stream_type 3) carrying a serialized
|
||||
/// `ControlMessage` JSON payload.
|
||||
/// Write a client→server control chunk (`STREAM_CTRL_IN`, stream_type
|
||||
/// 3) carrying a serialized `ControlMessage` JSON payload (`Resize`,
|
||||
/// `Signal`, or `Eof`).
|
||||
pub async fn write_control(&mut self, json: &[u8]) {
|
||||
self.write_chunk(STREAM_CONTROL, json).await;
|
||||
self.write_chunk(STREAM_CTRL_IN, json).await;
|
||||
}
|
||||
|
||||
/// Read one raw chunk from the server. Returns the `stream_type`
|
||||
@@ -145,7 +148,7 @@ impl ClientSide {
|
||||
stderr.extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
STREAM_CONTROL => {
|
||||
STREAM_CTRL_OUT => {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if v["type"] == "exit" {
|
||||
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
|
||||
@@ -177,7 +180,7 @@ impl ClientSide {
|
||||
stderr.extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
STREAM_CONTROL => {
|
||||
STREAM_CTRL_OUT => {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if v["type"] == "exit" {
|
||||
return Some((stdout, stderr, v["code"].as_i64().unwrap() as i32));
|
||||
|
||||
@@ -199,7 +199,7 @@ async fn pipe_echo_emits_stdout_chunk_then_sentinel() {
|
||||
if st == STREAM_STDOUT && !bytes.is_empty() {
|
||||
saw_nonempty_stdout = true;
|
||||
}
|
||||
if st == alknet_tty::wire::STREAM_CONTROL {
|
||||
if st == alknet_tty::wire::STREAM_CTRL_OUT {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if v["type"] == "exit" {
|
||||
break;
|
||||
|
||||
@@ -17,7 +17,7 @@ mod common;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alknet_tty::wire::{STREAM_CONTROL, STREAM_STDIN};
|
||||
use alknet_tty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
|
||||
use alknet_tty_local::LocalTtyBackend;
|
||||
use common::{negotiate_pty_json, spawn_session};
|
||||
|
||||
@@ -245,7 +245,7 @@ async fn pty_exit_chunk_is_last() {
|
||||
if saw_exit {
|
||||
panic!("chunk arrived after exit: stream_type={st}, bytes={bytes:?} (ADR-055)");
|
||||
}
|
||||
if st == STREAM_CONTROL {
|
||||
if st == STREAM_CTRL_OUT {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if v["type"] == "exit" {
|
||||
assert_eq!(v["code"], 0);
|
||||
|
||||
@@ -22,19 +22,34 @@
|
||||
//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout
|
||||
//! EOF, emit a zero-length stdout sentinel.
|
||||
//! - **B. client → backend**: stdin chunks (stream_type 0) →
|
||||
//! `TtyHandle.stdin`; control chunks (stream_type 3) →
|
||||
//! `ControlMessage` dispatch (`Resize`, `Signal`, `Eof`; `Exit` is
|
||||
//! server→client only and ignored). Zero-length stdin chunk or
|
||||
//! read-half close → EOF to backend stdin.
|
||||
//! `TtyHandle.stdin`; client→server control chunks (stream_type 3,
|
||||
//! `STREAM_CTRL_IN`) → `ControlMessage` dispatch (`Resize`, `Signal`,
|
||||
//! `Eof`). `STREAM_CTRL_OUT` (stream_type 4) from the client is a
|
||||
//! protocol violation (it's the server→client half) and is ignored;
|
||||
//! `Exit` arriving on `STREAM_CTRL_IN` is likewise a protocol
|
||||
//! violation and ignored. Zero-length stdin chunk or read-half close
|
||||
//! → EOF to backend stdin.
|
||||
//! - **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
|
||||
//! enqueue `{"type":"exit","code":N}` as a control chunk (stream_type
|
||||
//! 3). On `TtyError` → `{"type":"exit","code":-1}`.
|
||||
//! enqueue `{"type":"exit","code":N}` as a server→client control
|
||||
//! chunk (stream_type 4, `STREAM_CTRL_OUT`). On `TtyError` →
|
||||
//! `{"type":"exit","code":-1}`.
|
||||
//!
|
||||
//! The adapter enforces the **exit-chunk-is-last** invariant (ADR-055):
|
||||
//! it waits for BOTH the stdout/stderr pumps to complete AND `exit_code`
|
||||
//! to resolve before enqueueing the exit chunk. A drainer task writes
|
||||
//! chunks to the client in arrival order; the exit chunk is last.
|
||||
//!
|
||||
//! # Bidirectional control channel (Phase 7)
|
||||
//!
|
||||
//! The control channel is split into two halves so it is genuinely
|
||||
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
|
||||
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
|
||||
//! server→client control (`Exit`). The previous single `STREAM_CONTROL =
|
||||
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
|
||||
//! from the client because the two directions were indistinguishable on
|
||||
//! the same stream_type. The split makes the bidirectionality explicit
|
||||
//! — see `docs/research/alknet-crate-extraction/findings.md` Phase 7.
|
||||
//!
|
||||
//! # Cancel cleanup (ADR-056)
|
||||
//!
|
||||
//! On connection drop or stream reset, the pump tasks are dropped, which
|
||||
@@ -63,7 +78,7 @@ use crate::control::ControlMessage;
|
||||
use crate::negotiation::{
|
||||
error_response_bytes, NegotiateRequest, NegotiationError, NegotiationReader, NegotiationWriter,
|
||||
};
|
||||
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CONTROL, STREAM_STDIN};
|
||||
use crate::wire::{Chunk, ChunkReader, ChunkWriter, RawError, STREAM_CTRL_IN, STREAM_STDIN};
|
||||
|
||||
/// The scope required to open a `alknet/tty` session (ADR-050). A two-way-door
|
||||
/// choice (reversible: a deployment-configured scope, not a wire-format
|
||||
@@ -118,8 +133,8 @@ impl ProtocolHandler for TtyAdapter {
|
||||
let _ = connection.set_identity(identity);
|
||||
}
|
||||
loop {
|
||||
let (send, recv) = match connection.accept_bi().await {
|
||||
Ok(pair) => pair,
|
||||
let stream = match connection.accept_bi().await {
|
||||
Ok(stream) => stream,
|
||||
Err(StreamError::ConnectionClosed) => break,
|
||||
Err(StreamError::StreamClosed) => break,
|
||||
Err(e) => return Err(HandlerError::from(e)),
|
||||
@@ -128,7 +143,14 @@ impl ProtocolHandler for TtyAdapter {
|
||||
let ownership = self.ownership.clone();
|
||||
let identity = auth.identity.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = drive_session(send, recv, backends, ownership, identity).await;
|
||||
// `stream` is a `BiStream` (ADR-092) — `AsyncRead + AsyncWrite
|
||||
// + Send + Unpin`. Split into halves for `drive_session`
|
||||
// (which takes separate `AsyncWrite` + `AsyncRead` args). The
|
||||
// split is the stdlib idiom for `TcpStream`-style duplex
|
||||
// streams; no per-handler wrapper.
|
||||
let (client_read, client_write) = tokio::io::split(stream);
|
||||
let _ =
|
||||
drive_session(client_write, client_read, backends, ownership, identity).await;
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -164,10 +186,10 @@ async fn send_negotiation_error<W: AsyncWrite + Unpin>(
|
||||
|
||||
/// Drive a `alknet/tty` session end-to-end over a bidi stream.
|
||||
///
|
||||
/// `client_send` / `client_recv` are the two halves of the bidi stream (QUIC
|
||||
/// `SendStream` / `RecvStream`). Returns when the session is complete (exit
|
||||
/// chunk sent, stream closed) or when the stream is reset (cancel-cleanup
|
||||
/// path — no exit chunk sent).
|
||||
/// `client_send` / `client_recv` are the two halves of the bidi stream
|
||||
/// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`).
|
||||
/// Returns when the session is complete (exit chunk sent, stream closed) or
|
||||
/// when the stream is reset (cancel-cleanup path — no exit chunk sent).
|
||||
///
|
||||
/// This is the per-stream session driver — the counterpart to the POC's
|
||||
/// `session::drive_session` (`/workspace/alknet-tty-poc/src/session.rs`),
|
||||
@@ -371,7 +393,7 @@ async fn send_exit_chunk(writer_tx: &mpsc::Sender<Chunk>, code: i32) {
|
||||
let exit_msg = ControlMessage::Exit { code };
|
||||
match exit_msg.to_json() {
|
||||
Ok(json) => {
|
||||
let chunk = Chunk::control(json);
|
||||
let chunk = Chunk::ctrl_out(json);
|
||||
if writer_tx.send(chunk).await.is_err() {
|
||||
debug!("tty: writer channel closed before exit chunk");
|
||||
}
|
||||
@@ -416,9 +438,25 @@ async fn pump_stderr(
|
||||
debug!("tty: stderr pump done");
|
||||
}
|
||||
|
||||
/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`, control
|
||||
/// chunks → `ControlMessage` dispatch. On client read-half close or a
|
||||
/// zero-length stdin chunk, signal EOF to the backend's stdin.
|
||||
/// Pump client chunks → backend: stdin chunks → `TtyHandle.stdin`,
|
||||
/// client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
|
||||
/// `ControlMessage` dispatch. On client read-half close or a zero-length
|
||||
/// stdin chunk, signal EOF to the backend's stdin.
|
||||
///
|
||||
/// # Direction enforcement (Phase 7)
|
||||
///
|
||||
/// The control channel is split into two halves. This pump reads from
|
||||
/// the client, so it dispatches only `STREAM_CTRL_IN` (client→server):
|
||||
///
|
||||
/// - `Resize` / `Signal` / `Eof` → forward to the backend's control
|
||||
/// handle (`TtyControlHandle::resize` / `signal` / `stdin.shutdown`).
|
||||
/// - `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
|
||||
/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the
|
||||
/// adapter ignores it. (The previous single `STREAM_CONTROL = 3`
|
||||
/// couldn't distinguish the two directions, so `Exit` from the client
|
||||
/// was always ignored — the split makes the rejection explicit.)
|
||||
/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol
|
||||
/// violation (it's the server→client half); the adapter ignores it.
|
||||
async fn pump_client_to_backend<R>(
|
||||
client_read: R,
|
||||
mut stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
@@ -439,7 +477,7 @@ async fn pump_client_to_backend<R>(
|
||||
break;
|
||||
}
|
||||
}
|
||||
STREAM_CONTROL => match ControlMessage::from_slice(&chunk.bytes) {
|
||||
STREAM_CTRL_IN => match ControlMessage::from_slice(&chunk.bytes) {
|
||||
Ok(ControlMessage::Resize {
|
||||
cols,
|
||||
rows,
|
||||
@@ -460,12 +498,21 @@ async fn pump_client_to_backend<R>(
|
||||
debug!("tty: client stdin EOF (eof control)");
|
||||
}
|
||||
Ok(ControlMessage::Exit { .. }) => {
|
||||
debug!("tty: ignoring Exit control from client (server→client only)");
|
||||
debug!(
|
||||
"tty: ignoring Exit control on STREAM_CTRL_IN \
|
||||
(server→client only; belongs on STREAM_CTRL_OUT)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("tty: ignoring unknown control type: {e}");
|
||||
}
|
||||
},
|
||||
crate::wire::STREAM_CTRL_OUT => {
|
||||
debug!(
|
||||
"tty: ignoring STREAM_CTRL_OUT (stream_type 4) from client \
|
||||
(server→client half; client should not write on it)"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
debug!("tty: ignoring stream_type {other} from client");
|
||||
}
|
||||
@@ -861,7 +908,7 @@ mod tests {
|
||||
assert!(bytes.is_empty());
|
||||
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
assert_eq!(st, crate::wire::STREAM_CONTROL);
|
||||
assert_eq!(st, crate::wire::STREAM_CTRL_OUT);
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["type"], "exit");
|
||||
assert_eq!(v["code"], 0);
|
||||
@@ -892,7 +939,7 @@ mod tests {
|
||||
|
||||
loop {
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
if st == crate::wire::STREAM_CONTROL {
|
||||
if st == crate::wire::STREAM_CTRL_OUT {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
if v["type"] == "exit" {
|
||||
assert_eq!(v["code"], 7);
|
||||
@@ -952,13 +999,13 @@ mod tests {
|
||||
|
||||
client
|
||||
.write_chunk(
|
||||
crate::wire::STREAM_CONTROL,
|
||||
crate::wire::STREAM_CTRL_IN,
|
||||
br#"{"type":"resize","cols":100,"rows":50}"#,
|
||||
)
|
||||
.await;
|
||||
client
|
||||
.write_chunk(
|
||||
crate::wire::STREAM_CONTROL,
|
||||
crate::wire::STREAM_CTRL_IN,
|
||||
br#"{"type":"signal","name":"INT"}"#,
|
||||
)
|
||||
.await;
|
||||
@@ -996,7 +1043,7 @@ mod tests {
|
||||
client.write_negotiation(TEST_NEG).await;
|
||||
|
||||
client
|
||||
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"unknown"}"#)
|
||||
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"unknown"}"#)
|
||||
.await;
|
||||
|
||||
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
|
||||
@@ -1018,6 +1065,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn exit_control_from_client_ignored() {
|
||||
// `Exit` is server→client only (belongs on `STREAM_CTRL_OUT`).
|
||||
// Sending it on `STREAM_CTRL_IN` (client→server) is a protocol
|
||||
// violation; the adapter ignores it and keeps pumping stdout.
|
||||
let (backend, _control, _cancel) = TestBackend::builder().build();
|
||||
let backends = make_backends(backend.clone());
|
||||
let (mut client, server) = make_client_and_server();
|
||||
@@ -1030,7 +1080,7 @@ mod tests {
|
||||
client.write_negotiation(TEST_NEG).await;
|
||||
|
||||
client
|
||||
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"exit","code":99}"#)
|
||||
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"exit","code":99}"#)
|
||||
.await;
|
||||
|
||||
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
|
||||
@@ -1050,6 +1100,87 @@ mod tests {
|
||||
let _ = session.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_out_from_client_ignored() {
|
||||
// `STREAM_CTRL_OUT` (stream_type 4) is the server→client half.
|
||||
// The client writing on it is a protocol violation; the adapter
|
||||
// ignores the chunk and keeps pumping stdout (Phase 7).
|
||||
let (backend, _control, _cancel) = TestBackend::builder().build();
|
||||
let backends = make_backends(backend.clone());
|
||||
let (mut client, server) = make_client_and_server();
|
||||
|
||||
let identity = identity_with_scope(TTY_OPEN_SCOPE);
|
||||
let session = tokio::spawn(async move {
|
||||
drive_session_server(server, backends, None, identity).await;
|
||||
});
|
||||
|
||||
client.write_negotiation(TEST_NEG).await;
|
||||
|
||||
// Bogus: a client writing on the server→client control half.
|
||||
client
|
||||
.write_chunk(
|
||||
crate::wire::STREAM_CTRL_OUT,
|
||||
br#"{"type":"exit","code":99}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
|
||||
let _ = backend.take_stderr_tx().await;
|
||||
stdout_tx
|
||||
.send(Bytes::from_static(b"after-bogus-ctrl-out"))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(stdout_tx);
|
||||
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
|
||||
exit_tx.send(Ok(0)).unwrap();
|
||||
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
assert_eq!(st, STREAM_STDOUT);
|
||||
assert_eq!(bytes.as_ref(), b"after-bogus-ctrl-out");
|
||||
|
||||
let _ = session.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() {
|
||||
// Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not
|
||||
// `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client
|
||||
// distinguishing the two halves can route exit vs. control
|
||||
// without parsing the JSON tag first.
|
||||
let (backend, _control, _cancel) = TestBackend::builder().build();
|
||||
let backends = make_backends(backend.clone());
|
||||
let (mut client, server) = make_client_and_server();
|
||||
|
||||
let identity = identity_with_scope(TTY_OPEN_SCOPE);
|
||||
let session = tokio::spawn(async move {
|
||||
drive_session_server(server, backends, None, identity).await;
|
||||
});
|
||||
|
||||
client.write_negotiation(TEST_NEG).await;
|
||||
|
||||
let stdout_tx = backend.take_stdout_tx().await.expect("stdout tx");
|
||||
let _ = backend.take_stderr_tx().await;
|
||||
drop(stdout_tx);
|
||||
let exit_tx = backend.take_exit_tx().await.expect("exit tx");
|
||||
exit_tx.send(Ok(42)).unwrap();
|
||||
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
assert_eq!(st, STREAM_STDOUT);
|
||||
assert!(bytes.is_empty());
|
||||
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
assert_eq!(
|
||||
st,
|
||||
crate::wire::STREAM_CTRL_OUT,
|
||||
"exit chunk must arrive on STREAM_CTRL_OUT (4), not STREAM_CTRL_IN (3)"
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["type"], "exit");
|
||||
assert_eq!(v["code"], 42);
|
||||
|
||||
let _ = session.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_backend_error() {
|
||||
let (backend, _control, _cancel) = TestBackend::builder().build();
|
||||
@@ -1177,7 +1308,7 @@ mod tests {
|
||||
|
||||
loop {
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
if st == crate::wire::STREAM_CONTROL {
|
||||
if st == crate::wire::STREAM_CTRL_OUT {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["type"], "exit");
|
||||
assert_eq!(v["code"], -1);
|
||||
@@ -1287,7 +1418,7 @@ mod tests {
|
||||
|
||||
loop {
|
||||
let (st, bytes) = client.read_chunk().await;
|
||||
if st == crate::wire::STREAM_CONTROL {
|
||||
if st == crate::wire::STREAM_CTRL_OUT {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["type"], "exit");
|
||||
assert_eq!(v["code"], 0);
|
||||
@@ -1339,7 +1470,7 @@ mod tests {
|
||||
assert_eq!(bytes.as_ref(), b"err");
|
||||
saw_stderr = true;
|
||||
}
|
||||
crate::wire::STREAM_CONTROL => {
|
||||
crate::wire::STREAM_CTRL_OUT => {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["type"], "exit");
|
||||
saw_exit = true;
|
||||
@@ -1372,7 +1503,7 @@ mod tests {
|
||||
|
||||
client.write_chunk(STREAM_STDIN, b"first").await;
|
||||
client
|
||||
.write_chunk(crate::wire::STREAM_CONTROL, br#"{"type":"eof"}"#)
|
||||
.write_chunk(crate::wire::STREAM_CTRL_IN, br#"{"type":"eof"}"#)
|
||||
.await;
|
||||
|
||||
let mut received = Vec::new();
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
//! Control messages carried in `stream_type 3` chunks (ADR-052).
|
||||
//! Control messages carried in `stream_type 3` (`ctrl_in`) and
|
||||
//! `stream_type 4` (`ctrl_out`) chunks (ADR-052, amended Phase 7).
|
||||
//!
|
||||
//! The control channel is split into two halves so it is genuinely
|
||||
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
|
||||
//! control (`Resize`, `Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries
|
||||
//! server→client control (`Exit`). The previous single
|
||||
//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
|
||||
//! ignored `Exit` from the client because it had no way to distinguish
|
||||
//! the two directions on the same stream_type — see
|
||||
//! `docs/research/alknet-crate-extraction/findings.md` Phase 7.
|
||||
//!
|
||||
//! Control chunks carry a JSON payload tagged by `type`. The schema is the
|
||||
//! POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
|
||||
@@ -23,20 +33,30 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A control message riding on `stream_type 3`.
|
||||
/// A control message riding on `STREAM_CTRL_IN` (stream_type 3,
|
||||
/// client→server) or `STREAM_CTRL_OUT` (stream_type 4, server→client).
|
||||
///
|
||||
/// Direction and mapping (per `tty-wire.md` §"Control Channel"):
|
||||
///
|
||||
/// | direction | variant | maps to |
|
||||
/// |----------------|----------|----------------------------------------------------|
|
||||
/// | client→server | `Resize` | SSH `window-change`, docker exec resize, `ioctl` |
|
||||
/// | client→server | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` |
|
||||
/// | client→server | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` |
|
||||
/// | server→client | `Exit` | the completion signal (ADR-055) |
|
||||
/// | direction | stream_type | variant | maps to |
|
||||
/// |----------------|-----------------|----------|----------------------------------------------------|
|
||||
/// | client→server | `STREAM_CTRL_IN` (3) | `Resize` | SSH `window-change`, docker exec resize, `ioctl` |
|
||||
/// | client→server | `STREAM_CTRL_IN` (3) | `Signal` | SSH `signal`, docker exec signal, `kill(-pgid, n)` |
|
||||
/// | client→server | `STREAM_CTRL_IN` (3) | `Eof` | SSH channel EOF, docker stdin close, `ChildStdin` |
|
||||
/// | server→client | `STREAM_CTRL_OUT` (4) | `Exit` | the completion signal (ADR-055) |
|
||||
///
|
||||
/// The direction is enforced by the adapter, not by this enum: a `Resize`
|
||||
/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter
|
||||
/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a
|
||||
/// protocol violation (the adapter ignores it). The split is what makes
|
||||
/// the control channel genuinely bidirectional — the previous single
|
||||
/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the
|
||||
/// adapter had to ignore `Exit` from the client because the two
|
||||
/// directions were indistinguishable on the same stream_type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ControlMessage {
|
||||
/// Terminal window resize (client→server).
|
||||
/// Terminal window resize (client→server, `STREAM_CTRL_IN`).
|
||||
///
|
||||
/// `pixel_width`/`pixel_height` default to 0 (most terminals don't
|
||||
/// report pixel dimensions; SSH's `pty_request` carries them for
|
||||
@@ -49,23 +69,24 @@ pub enum ControlMessage {
|
||||
#[serde(default)]
|
||||
pixel_height: u16,
|
||||
},
|
||||
/// Forward a signal to the child process group (client→server).
|
||||
/// Forward a signal to the child process group (client→server,
|
||||
/// `STREAM_CTRL_IN`).
|
||||
///
|
||||
/// `name` is an uppercase string from the supported set (see
|
||||
/// [`signal_from_name`]). Unknown names fall back to the backend's
|
||||
/// default kill in the adapter (tty-local.md REQ-TTY-02).
|
||||
Signal { name: String },
|
||||
/// Client stdin is done (client→server). The server closes the
|
||||
/// backend's stdin (`ChildStdin::drop` / PTY writer close) but keeps
|
||||
/// pumping stdout + the exit chunk. See `tty-wire.md` §"Stdin
|
||||
/// Closure".
|
||||
/// Client stdin is done (client→server, `STREAM_CTRL_IN`). The
|
||||
/// server closes the backend's stdin (`ChildStdin::drop` / PTY writer
|
||||
/// close) but keeps pumping stdout + the exit chunk. See
|
||||
/// `tty-wire.md` §"Stdin Closure".
|
||||
Eof,
|
||||
/// Process exit code (server→client). The exit chunk is the last
|
||||
/// control chunk before stream close (ADR-055). `code` is `i32`
|
||||
/// matching `std::process::ExitStatus::code()`; negative values are
|
||||
/// signal-terminated (e.g., `-9` for SIGKILL on Unix). `-1` is the
|
||||
/// adapter's best-effort "backend could not determine the exit code"
|
||||
/// sentinel (ADR-055 §4).
|
||||
/// Process exit code (server→client, `STREAM_CTRL_OUT`). The exit
|
||||
/// chunk is the last control chunk before stream close (ADR-055).
|
||||
/// `code` is `i32` matching `std::process::ExitStatus::code()`;
|
||||
/// negative values are signal-terminated (e.g., `-9` for SIGKILL on
|
||||
/// Unix). `-1` is the adapter's best-effort "backend could not
|
||||
/// determine the exit code" sentinel (ADR-055 §4).
|
||||
Exit { code: i32 },
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
//! - An error frame's 4-byte big-endian length prefix starts with `0x00`
|
||||
//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the
|
||||
//! high byte is zero (a wire-format invariant, not an assumption).
|
||||
//! - A raw chunk's first byte is a `stream_type` in `{1, 2, 3}` —
|
||||
//! `0` (stdin from server) is invalid, so `0x00` is unambiguous.
|
||||
//! - A raw chunk's first byte is a `stream_type`. The server never sends
|
||||
//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` —
|
||||
//! client→server only), so the server-sent set is `{1, 2, 4}`
|
||||
//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous.
|
||||
//!
|
||||
//! See ADR-052 §5 and `tty-wire.md` §"Constraints".
|
||||
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
//! ```
|
||||
//!
|
||||
//! `stream_type`:
|
||||
//! - 0 = stdin (client→server, raw bytes)
|
||||
//! - 1 = stdout (server→client, raw bytes)
|
||||
//! - 2 = stderr (server→client, raw bytes)
|
||||
//! - 3 = control (bidirectional, JSON control message — see [`crate::control`])
|
||||
//! - 0 = stdin (client→server, raw bytes)
|
||||
//! - 1 = stdout (server→client, raw bytes)
|
||||
//! - 2 = stderr (server→client, raw bytes)
|
||||
//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`])
|
||||
//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`])
|
||||
//!
|
||||
//! The control channel is split into two halves so it is genuinely
|
||||
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
|
||||
//! control (resize, signal, eof); `STREAM_CTRL_OUT = 4` carries
|
||||
//! server→client control (exit). The previous single `STREAM_CONTROL = 3`
|
||||
//! was documented as "bidirectional" but the adapter ignored `Exit` from
|
||||
//! the client because it had no way to distinguish the two directions on
|
||||
//! the same stream_type — see `docs/research/alknet-crate-extraction/
|
||||
//! findings.md` Phase 7.
|
||||
//!
|
||||
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
|
||||
//! from the client; a zero-length stdout chunk is "drained" from the
|
||||
@@ -29,8 +39,11 @@ pub const STREAM_STDIN: u8 = 0;
|
||||
pub const STREAM_STDOUT: u8 = 1;
|
||||
/// stderr channel (server→client, raw bytes).
|
||||
pub const STREAM_STDERR: u8 = 2;
|
||||
/// control channel (bidirectional, JSON control message).
|
||||
pub const STREAM_CONTROL: u8 = 3;
|
||||
/// Control channel, client→server half (JSON control message —
|
||||
/// `Resize`, `Signal`, `Eof`).
|
||||
pub const STREAM_CTRL_IN: u8 = 3;
|
||||
/// Control channel, server→client half (JSON control message — `Exit`).
|
||||
pub const STREAM_CTRL_OUT: u8 = 4;
|
||||
|
||||
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
|
||||
pub const CHUNK_HEADER_LEN: usize = 5;
|
||||
@@ -54,7 +67,7 @@ pub enum RawError {
|
||||
/// The peer closed the stream cleanly (unexpected EOF on header or payload).
|
||||
#[error("connection closed")]
|
||||
ConnectionClosed,
|
||||
/// The chunk header's `stream_type` byte was > 3.
|
||||
/// The chunk header's `stream_type` byte was > 4.
|
||||
#[error("invalid chunk header: stream type {0}")]
|
||||
InvalidStreamType(u8),
|
||||
/// The chunk payload length exceeded `MAX_CHUNK_LEN`.
|
||||
@@ -66,11 +79,12 @@ pub enum RawError {
|
||||
/// payload bytes.
|
||||
///
|
||||
/// Construct with [`Chunk::stdin`], [`Chunk::stdout`], [`Chunk::stderr`],
|
||||
/// or [`Chunk::control`] for the four fixed channels.
|
||||
/// [`Chunk::ctrl_in`], or [`Chunk::ctrl_out`] for the five fixed
|
||||
/// channels.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Chunk {
|
||||
/// The channel: one of [`STREAM_STDIN`], [`STREAM_STDOUT`],
|
||||
/// [`STREAM_STDERR`], [`STREAM_CONTROL`].
|
||||
/// [`STREAM_STDERR`], [`STREAM_CTRL_IN`], [`STREAM_CTRL_OUT`].
|
||||
pub stream_type: u8,
|
||||
/// The payload bytes (raw for data channels, UTF-8 JSON for control).
|
||||
pub bytes: bytes::Bytes,
|
||||
@@ -101,10 +115,19 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// A control chunk (stream_type 3).
|
||||
pub fn control(bytes: bytes::Bytes) -> Self {
|
||||
/// A client→server control chunk (stream_type 3) — `Resize`, `Signal`,
|
||||
/// or `Eof`.
|
||||
pub fn ctrl_in(bytes: bytes::Bytes) -> Self {
|
||||
Self {
|
||||
stream_type: STREAM_CONTROL,
|
||||
stream_type: STREAM_CTRL_IN,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// A server→client control chunk (stream_type 4) — `Exit`.
|
||||
pub fn ctrl_out(bytes: bytes::Bytes) -> Self {
|
||||
Self {
|
||||
stream_type: STREAM_CTRL_OUT,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
@@ -113,7 +136,7 @@ impl Chunk {
|
||||
/// Reads raw chunks from an [`AsyncRead`] transport.
|
||||
///
|
||||
/// [`ChunkReader::read_chunk`] reads the 5-byte header, validates the
|
||||
/// `stream_type` (≤ 3, else [`RawError::InvalidStreamType`]) and the
|
||||
/// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the
|
||||
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
|
||||
/// then reads the payload. On a clean `UnexpectedEof` reading either the
|
||||
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the
|
||||
@@ -148,7 +171,7 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
||||
}
|
||||
|
||||
let stream_type = self.header[0];
|
||||
if stream_type > 3 {
|
||||
if stream_type > 4 {
|
||||
return Err(RawError::InvalidStreamType(stream_type));
|
||||
}
|
||||
|
||||
@@ -183,9 +206,10 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
||||
/// Writes raw chunks to an [`AsyncWrite`] transport.
|
||||
///
|
||||
/// [`ChunkWriter::write_chunk`] writes the 5-byte header then the payload
|
||||
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`] and
|
||||
/// [`ChunkWriter::write_control_json`] are convenience helpers for the
|
||||
/// two most common write paths.
|
||||
/// (if non-empty), then flushes. [`ChunkWriter::write_stdin`],
|
||||
/// [`ChunkWriter::write_ctrl_in_json`], and
|
||||
/// [`ChunkWriter::write_ctrl_out_json`] are convenience helpers for the
|
||||
/// most common write paths.
|
||||
pub struct ChunkWriter<W: AsyncWrite + Unpin> {
|
||||
writer: W,
|
||||
}
|
||||
@@ -229,10 +253,24 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a control chunk (stream_type 3) carrying a JSON payload.
|
||||
pub async fn write_control_json(&mut self, json: &[u8]) -> Result<(), RawError> {
|
||||
/// Write a client→server control chunk (stream_type 3) carrying a JSON
|
||||
/// payload (`Resize`, `Signal`, or `Eof`).
|
||||
pub async fn write_ctrl_in_json(&mut self, json: &[u8]) -> Result<(), RawError> {
|
||||
let mut header = [0u8; CHUNK_HEADER_LEN];
|
||||
header[0] = STREAM_CONTROL;
|
||||
header[0] = STREAM_CTRL_IN;
|
||||
let len = json.len() as u32;
|
||||
header[1..].copy_from_slice(&len.to_be_bytes());
|
||||
self.writer.write_all(&header).await?;
|
||||
self.writer.write_all(json).await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a server→client control chunk (stream_type 4) carrying a JSON
|
||||
/// payload (`Exit`).
|
||||
pub async fn write_ctrl_out_json(&mut self, json: &[u8]) -> Result<(), RawError> {
|
||||
let mut header = [0u8; CHUNK_HEADER_LEN];
|
||||
header[0] = STREAM_CTRL_OUT;
|
||||
let len = json.len() as u32;
|
||||
header[1..].copy_from_slice(&len.to_be_bytes());
|
||||
self.writer.write_all(&header).await?;
|
||||
@@ -281,8 +319,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_control() {
|
||||
round_trip(STREAM_CONTROL, br#"{"type":"eof"}"#).await;
|
||||
async fn round_trip_ctrl_in() {
|
||||
round_trip(STREAM_CTRL_IN, br#"{"type":"eof"}"#).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_ctrl_out() {
|
||||
round_trip(STREAM_CTRL_OUT, br#"{"type":"exit","code":0}"#).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -303,27 +346,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_write_control_json_helper() {
|
||||
async fn round_trip_write_ctrl_in_json_helper() {
|
||||
let (mut a, mut b) = duplex(8 * 1024);
|
||||
let mut writer = ChunkWriter::new(&mut a);
|
||||
let mut reader = ChunkReader::new(&mut b);
|
||||
|
||||
let json = br#"{"type":"resize","cols":80,"rows":24}"#;
|
||||
writer.write_control_json(json).await.unwrap();
|
||||
writer.write_ctrl_in_json(json).await.unwrap();
|
||||
let read = reader.read_chunk().await.unwrap();
|
||||
assert_eq!(read.stream_type, STREAM_CONTROL);
|
||||
assert_eq!(read.stream_type, STREAM_CTRL_IN);
|
||||
assert_eq!(read.bytes.as_ref(), json);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_write_ctrl_out_json_helper() {
|
||||
let (mut a, mut b) = duplex(8 * 1024);
|
||||
let mut writer = ChunkWriter::new(&mut a);
|
||||
let mut reader = ChunkReader::new(&mut b);
|
||||
|
||||
let json = br#"{"type":"exit","code":0}"#;
|
||||
writer.write_ctrl_out_json(json).await.unwrap();
|
||||
let read = reader.read_chunk().await.unwrap();
|
||||
assert_eq!(read.stream_type, STREAM_CTRL_OUT);
|
||||
assert_eq!(read.bytes.as_ref(), json);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_stream_type() {
|
||||
let (mut a, mut b) = duplex(8 * 1024);
|
||||
a.write_all(&[4u8, 0, 0, 0, 0]).await.unwrap();
|
||||
// 5 is one past the highest valid stream_type (4 = STREAM_CTRL_OUT).
|
||||
a.write_all(&[5u8, 0, 0, 0, 0]).await.unwrap();
|
||||
a.flush().await.unwrap();
|
||||
|
||||
let mut reader = ChunkReader::new(&mut b);
|
||||
let err = reader.read_chunk().await.unwrap_err();
|
||||
assert!(matches!(err, RawError::InvalidStreamType(4)));
|
||||
assert!(matches!(err, RawError::InvalidStreamType(5)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "alknet-typedef"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Binary struct engine: takes a JSON Schema with TypeDef:* custom keywords and produces an offset map, read/write functions, and validation"
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "alknet_typedef"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
jsonschema = { version = "0.46", default-features = false }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Data access layer: primitive read/write functions for all 17 TypeDef
|
||||
//! kinds with endianness support, bounds checking, and zero-copy access.
|
||||
//!
|
||||
//! These are the building blocks used by the layout types ([`crate::offset_map`],
|
||||
//! [`crate::layout_builder`], [`crate::sequential_reader`]) and the
|
||||
//! [`crate::engine::TypedefEngine`]. Each function operates on a raw byte
|
||||
//! buffer at a caller-provided offset and returns a [`TypedefError::Access`]
|
||||
//! carrying the field path on bounds or encoding failures.
|
||||
//!
|
||||
//! # Conventions
|
||||
//!
|
||||
//! - All multi-byte types respect the [`Endian`] parameter passed by the caller.
|
||||
//! - Bounds checks ensure `buffer.len() >= offset + size`; failures produce
|
||||
//! [`TypedefError::Access`] with a descriptive `reason`.
|
||||
//! - Read functions for variable-length types return slices borrowing from
|
||||
//! the input buffer — no allocation.
|
||||
//! - No `unwrap()` / `expect()` on fallible operations.
|
||||
|
||||
use crate::error::TypedefError;
|
||||
use crate::schema::Endian;
|
||||
|
||||
const U32_SIZE: usize = 4;
|
||||
|
||||
fn check_bounds(
|
||||
buffer_len: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
field_path: &str,
|
||||
) -> Result<(), TypedefError> {
|
||||
if end < start || buffer_len < end {
|
||||
return Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!(
|
||||
"buffer bounds check failed: need bytes [{start}..{end}), buffer has {buffer_len}"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn access_err(field_path: &str, reason: impl Into<String>) -> TypedefError {
|
||||
TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_array<const N: usize>(
|
||||
buffer: &[u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
) -> Result<[u8; N], TypedefError> {
|
||||
let end = offset.checked_add(N).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("offset {offset} + size {N} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
check_bounds(buffer.len(), offset, end, field_path)?;
|
||||
let slice = buffer.get(offset..end).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!(
|
||||
"slice [{offset}..{end}) unavailable in buffer of length {}",
|
||||
buffer.len()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
slice.try_into().map_err(|_| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("internal: try_into failed for {N}-byte slice"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write_array<const N: usize>(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
bytes: [u8; N],
|
||||
field_path: &str,
|
||||
) -> Result<(), TypedefError> {
|
||||
let end = offset.checked_add(N).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("offset {offset} + size {N} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
check_bounds(buffer.len(), offset, end, field_path)?;
|
||||
let dest = buffer.get_mut(offset..end).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("mutable slice [{offset}..{end}) unavailable"),
|
||||
)
|
||||
})?;
|
||||
dest.copy_from_slice(&bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn u32_from(bytes: [u8; U32_SIZE], endian: Endian) -> u32 {
|
||||
match endian {
|
||||
Endian::Little => u32::from_le_bytes(bytes),
|
||||
Endian::Big => u32::from_be_bytes(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn u32_to(value: u32, endian: Endian) -> [u8; U32_SIZE] {
|
||||
match endian {
|
||||
Endian::Little => value.to_le_bytes(),
|
||||
Endian::Big => value.to_be_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixed-size read functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
define_read_write_ne!(i8, read_i8, write_i8, 1, |bytes: [u8; 1]| bytes[0] as i8);
|
||||
define_read_write_endian!(i16, read_i16, write_i16, 2);
|
||||
define_read_write_endian!(i32, read_i32, write_i32, 4);
|
||||
define_read_write_endian!(i64, read_i64, write_i64, 8);
|
||||
define_read_write_ne!(u8, read_u8, write_u8, 1, |bytes: [u8; 1]| bytes[0]);
|
||||
define_read_write_endian!(u16, read_u16, write_u16, 2);
|
||||
define_read_write_endian!(u32, read_u32, write_u32, 4);
|
||||
define_read_write_endian!(u64, read_u64, write_u64, 8);
|
||||
define_read_write_endian!(f32, read_f32, write_f32, 4);
|
||||
define_read_write_endian!(f64, read_f64, write_f64, 8);
|
||||
|
||||
/// Read a `bool` at `offset` from `buffer`.
|
||||
///
|
||||
/// `0x00` decodes to `false`, `0x01` decodes to `true`. Any other byte value
|
||||
/// produces [`TypedefError::Access`] with a reason of the form
|
||||
/// `"invalid boolean byte 0x02 at offset {offset}"`.
|
||||
pub fn read_bool(buffer: &[u8], offset: usize, field_path: &str) -> Result<bool, TypedefError> {
|
||||
let bytes: [u8; 1] = read_array(buffer, offset, field_path)?;
|
||||
match bytes[0] {
|
||||
0x00 => Ok(false),
|
||||
0x01 => Ok(true),
|
||||
other => Err(access_err(
|
||||
field_path,
|
||||
format!("invalid boolean byte 0x{other:02X} at offset {offset}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a `bool` `value` at `offset` into `buffer`.
|
||||
///
|
||||
/// `false` is encoded as `0x00`, `true` as `0x01`.
|
||||
pub fn write_bool(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: bool,
|
||||
field_path: &str,
|
||||
) -> Result<(), TypedefError> {
|
||||
write_array(
|
||||
buffer,
|
||||
offset,
|
||||
[if value { 0x01 } else { 0x00 }],
|
||||
field_path,
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a `TEnum` index (`u32`) at `offset` from `buffer`, applying `endian`.
|
||||
///
|
||||
/// The caller maps the returned index to the schema's `"enum"` array entry.
|
||||
pub fn read_enum(
|
||||
buffer: &[u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<u32, TypedefError> {
|
||||
read_u32(buffer, offset, field_path, endian)
|
||||
}
|
||||
|
||||
/// Write a `TEnum` index (`u32`) `value` at `offset` into `buffer`, applying `endian`.
|
||||
pub fn write_enum(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: u32,
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<(), TypedefError> {
|
||||
write_u32(buffer, offset, value, field_path, endian)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable-length read/write (inline length-prefixing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a length-prefixed UTF-8 string borrowing from `buffer`.
|
||||
///
|
||||
/// Wire format: `[length: u32][UTF-8 bytes]`. The length prefix respects
|
||||
/// `endian`. Returns a `&'a str` that borrows from the input buffer — no
|
||||
/// allocation. Invalid UTF-8 produces [`TypedefError::Access`].
|
||||
pub fn read_string<'a>(
|
||||
buffer: &'a [u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<&'a str, TypedefError> {
|
||||
let bytes = read_bytes(buffer, offset, field_path, endian)?;
|
||||
std::str::from_utf8(bytes).map_err(|e| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("invalid UTF-8 in string at offset {offset}: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read length-prefixed raw bytes borrowing from `buffer`.
|
||||
///
|
||||
/// Wire format: `[length: u32][raw bytes]`. The length prefix respects
|
||||
/// `endian`. Returns a `&'a [u8]` slice that borrows from the input buffer.
|
||||
pub fn read_bytes<'a>(
|
||||
buffer: &'a [u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<&'a [u8], TypedefError> {
|
||||
let len_bytes: [u8; U32_SIZE] = read_array(buffer, offset, field_path)?;
|
||||
let len = u32_from(len_bytes, endian) as usize;
|
||||
let data_start = offset.checked_add(U32_SIZE).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("offset {offset} + {U32_SIZE} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
let data_end = data_start.checked_add(len).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("data_start {data_start} + length {len} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
check_bounds(buffer.len(), data_start, data_end, field_path)?;
|
||||
Ok(&buffer[data_start..data_end])
|
||||
}
|
||||
|
||||
/// Write a length-prefixed UTF-8 string into `buffer` at `offset`.
|
||||
///
|
||||
/// Wire format: `[length: u32][UTF-8 bytes]`. The length prefix respects
|
||||
/// `endian`. Returns the total number of bytes written
|
||||
/// (`4 + value.len()`) so the caller can advance the cursor.
|
||||
pub fn write_string(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: &str,
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<usize, TypedefError> {
|
||||
write_bytes(buffer, offset, value.as_bytes(), field_path, endian)
|
||||
}
|
||||
|
||||
/// Write length-prefixed raw bytes into `buffer` at `offset`.
|
||||
///
|
||||
/// Wire format: `[length: u32][raw bytes]`. The length prefix respects
|
||||
/// `endian`. Returns the total number of bytes written (`4 + value.len()`).
|
||||
pub fn write_bytes(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: &[u8],
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<usize, TypedefError> {
|
||||
let data_len = value.len();
|
||||
let total = U32_SIZE.checked_add(data_len).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("prefix {U32_SIZE} + data length {data_len} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
let end = offset.checked_add(total).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("offset {offset} + total {total} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
check_bounds(buffer.len(), offset, end, field_path)?;
|
||||
write_array(buffer, offset, u32_to(data_len as u32, endian), field_path)?;
|
||||
let data_start = offset + U32_SIZE;
|
||||
let dest = buffer.get_mut(data_start..end).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("mutable data slice [{data_start}..{end}) unavailable"),
|
||||
)
|
||||
})?;
|
||||
dest.copy_from_slice(value);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable-length read (offset indirection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read an offset-indirect string.
|
||||
///
|
||||
/// The 8-byte struct at `buffer[offset..offset+8]` is
|
||||
/// `{ data_offset: u32, data_length: u32 }` (endian-aware). The actual UTF-8
|
||||
/// bytes live in `data_region[data_offset..data_offset+data_length]`. Returns
|
||||
/// a `&'a str` borrowing from `data_region`. Invalid UTF-8 produces
|
||||
/// [`TypedefError::Access`].
|
||||
pub fn read_string_indirect<'a>(
|
||||
buffer: &'a [u8],
|
||||
offset: usize,
|
||||
data_region: &'a [u8],
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<&'a str, TypedefError> {
|
||||
let bytes = read_bytes_indirect(buffer, offset, data_region, field_path, endian)?;
|
||||
std::str::from_utf8(bytes).map_err(|e| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("invalid UTF-8 in offset-indirect string: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read offset-indirect raw bytes.
|
||||
///
|
||||
/// The 8-byte struct at `buffer[offset..offset+8]` is
|
||||
/// `{ data_offset: u32, data_length: u32 }` (endian-aware). Returns a
|
||||
/// `&'a [u8]` slice of `data_region[data_offset..data_offset+data_length]`.
|
||||
pub fn read_bytes_indirect<'a>(
|
||||
buffer: &'a [u8],
|
||||
offset: usize,
|
||||
data_region: &'a [u8],
|
||||
field_path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<&'a [u8], TypedefError> {
|
||||
let struct_end = offset
|
||||
.checked_add(8)
|
||||
.ok_or_else(|| access_err(field_path, format!("offset {offset} + 8 overflows usize")))?;
|
||||
check_bounds(buffer.len(), offset, struct_end, field_path)?;
|
||||
let off_bytes: [u8; U32_SIZE] = buffer[offset..offset + U32_SIZE]
|
||||
.try_into()
|
||||
.map_err(|_| access_err(field_path, "internal: try_into failed for data_offset"))?;
|
||||
let len_bytes: [u8; U32_SIZE] = buffer[offset + U32_SIZE..offset + 8]
|
||||
.try_into()
|
||||
.map_err(|_| access_err(field_path, "internal: try_into failed for data_length"))?;
|
||||
let data_offset = u32_from(off_bytes, endian) as usize;
|
||||
let data_length = u32_from(len_bytes, endian) as usize;
|
||||
let data_end = data_offset.checked_add(data_length).ok_or_else(|| {
|
||||
access_err(
|
||||
field_path,
|
||||
format!("data_offset {data_offset} + data_length {data_length} overflows usize"),
|
||||
)
|
||||
})?;
|
||||
check_bounds(data_region.len(), data_offset, data_end, field_path)?;
|
||||
Ok(&data_region[data_offset..data_end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LE: Endian = Endian::Little;
|
||||
const BE: Endian = Endian::Big;
|
||||
|
||||
#[test]
|
||||
fn read_write_u8_round_trip() {
|
||||
let mut buf = [0u8; 1];
|
||||
write_u8(&mut buf, 0, 0xAB, "f").unwrap();
|
||||
assert_eq!(read_u8(&buf, 0, "f").unwrap(), 0xAB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_i8_round_trip() {
|
||||
let mut buf = [0u8; 1];
|
||||
write_i8(&mut buf, 0, -42, "f").unwrap();
|
||||
assert_eq!(read_i8(&buf, 0, "f").unwrap(), -42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_u16_endianness() {
|
||||
let mut buf = [0u8; 2];
|
||||
write_u16(&mut buf, 0, 0x1234, "f", LE).unwrap();
|
||||
assert_eq!(buf, [0x34, 0x12]);
|
||||
assert_eq!(read_u16(&buf, 0, "f", LE).unwrap(), 0x1234);
|
||||
|
||||
write_u16(&mut buf, 0, 0x1234, "f", BE).unwrap();
|
||||
assert_eq!(buf, [0x12, 0x34]);
|
||||
assert_eq!(read_u16(&buf, 0, "f", BE).unwrap(), 0x1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_i16_endianness() {
|
||||
let mut buf = [0u8; 2];
|
||||
write_i16(&mut buf, 0, -1, "f", LE).unwrap();
|
||||
assert_eq!(buf, [0xFF, 0xFF]);
|
||||
assert_eq!(read_i16(&buf, 0, "f", LE).unwrap(), -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_u32_endianness() {
|
||||
let mut buf = [0u8; 4];
|
||||
write_u32(&mut buf, 0, 0x01020304, "f", LE).unwrap();
|
||||
assert_eq!(buf, [0x04, 0x03, 0x02, 0x01]);
|
||||
assert_eq!(read_u32(&buf, 0, "f", LE).unwrap(), 0x01020304);
|
||||
|
||||
write_u32(&mut buf, 0, 0x01020304, "f", BE).unwrap();
|
||||
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04]);
|
||||
assert_eq!(read_u32(&buf, 0, "f", BE).unwrap(), 0x01020304);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_i32_endianness() {
|
||||
let mut buf = [0u8; 4];
|
||||
write_i32(&mut buf, 0, i32::MIN, "f", BE).unwrap();
|
||||
assert_eq!(read_i32(&buf, 0, "f", BE).unwrap(), i32::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_i64_endianness() {
|
||||
let mut buf = [0u8; 8];
|
||||
write_i64(&mut buf, 0, i64::MIN, "f", BE).unwrap();
|
||||
assert_eq!(read_i64(&buf, 0, "f", BE).unwrap(), i64::MIN);
|
||||
write_i64(&mut buf, 0, i64::MAX, "f", LE).unwrap();
|
||||
assert_eq!(read_i64(&buf, 0, "f", LE).unwrap(), i64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_u64_endianness() {
|
||||
let mut buf = [0u8; 8];
|
||||
write_u64(&mut buf, 0, 0x0102030405060708, "f", LE).unwrap();
|
||||
assert_eq!(buf, [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]);
|
||||
assert_eq!(read_u64(&buf, 0, "f", LE).unwrap(), 0x0102030405060708);
|
||||
|
||||
write_u64(&mut buf, 0, 0x0102030405060708, "f", BE).unwrap();
|
||||
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||
assert_eq!(read_u64(&buf, 0, "f", BE).unwrap(), 0x0102030405060708);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_f32_round_trip() {
|
||||
let mut buf = [0u8; 4];
|
||||
let value: f32 = std::f32::consts::PI;
|
||||
write_f32(&mut buf, 0, value, "f", LE).unwrap();
|
||||
let read = read_f32(&buf, 0, "f", LE).unwrap();
|
||||
assert!(
|
||||
(read - value).abs() < 1e-6,
|
||||
"le mismatch: {read} vs {value}"
|
||||
);
|
||||
|
||||
write_f32(&mut buf, 0, value, "f", BE).unwrap();
|
||||
let read = read_f32(&buf, 0, "f", BE).unwrap();
|
||||
assert!(
|
||||
(read - value).abs() < 1e-6,
|
||||
"be mismatch: {read} vs {value}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_f64_round_trip() {
|
||||
let mut buf = [0u8; 8];
|
||||
let value: f64 = std::f64::consts::PI;
|
||||
write_f64(&mut buf, 0, value, "f", LE).unwrap();
|
||||
assert_eq!(read_f64(&buf, 0, "f", LE).unwrap(), value);
|
||||
|
||||
write_f64(&mut buf, 0, value, "f", BE).unwrap();
|
||||
assert_eq!(read_f64(&buf, 0, "f", BE).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_bool_round_trip() {
|
||||
let mut buf = [0u8; 1];
|
||||
write_bool(&mut buf, 0, false, "f").unwrap();
|
||||
assert_eq!(buf[0], 0x00);
|
||||
assert!(!read_bool(&buf, 0, "f").unwrap());
|
||||
|
||||
write_bool(&mut buf, 0, true, "f").unwrap();
|
||||
assert_eq!(buf[0], 0x01);
|
||||
assert!(read_bool(&buf, 0, "f").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_rejects_invalid_byte() {
|
||||
let buf = [0x02u8];
|
||||
let err = read_bool(&buf, 0, "f").unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, "f");
|
||||
assert!(reason.contains("0x02"), "reason: {reason}");
|
||||
assert!(reason.contains("offset 0"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_enum_round_trip() {
|
||||
let mut buf = [0u8; 4];
|
||||
write_enum(&mut buf, 0, 7, "f", LE).unwrap();
|
||||
assert_eq!(read_enum(&buf, 0, "f", LE).unwrap(), 7);
|
||||
|
||||
write_enum(&mut buf, 0, 7, "f", BE).unwrap();
|
||||
assert_eq!(read_enum(&buf, 0, "f", BE).unwrap(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_failure_returns_access_error() {
|
||||
let buf = [0u8; 2];
|
||||
let err = read_u32(&buf, 0, "header.id", LE).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, "header.id");
|
||||
assert!(reason.contains("bounds"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_bounds_failure_returns_access_error() {
|
||||
let mut buf = [0u8; 2];
|
||||
let err = write_u32(&mut buf, 0, 1, "header.id", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_round_trip_and_zero_copy() {
|
||||
let mut buf = vec![0u8; 32];
|
||||
let written = write_string(&mut buf, 0, "hello", "name", LE).unwrap();
|
||||
assert_eq!(written, 4 + 5);
|
||||
let s = read_string(&buf, 0, "name", LE).unwrap();
|
||||
assert_eq!(s, "hello");
|
||||
assert!(std::ptr::eq(s.as_ptr(), buf.as_ptr().wrapping_add(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_be_length_prefix() {
|
||||
let mut buf = vec![0u8; 16];
|
||||
write_string(&mut buf, 0, "abc", "name", BE).unwrap();
|
||||
assert_eq!(buf[0..4], [0x00, 0x00, 0x00, 0x03]);
|
||||
assert_eq!(read_string(&buf, 0, "name", BE).unwrap(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_round_trip_and_zero_copy() {
|
||||
let mut buf = vec![0u8; 32];
|
||||
let payload = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let written = write_bytes(&mut buf, 0, &payload, "data", LE).unwrap();
|
||||
assert_eq!(written, 4 + 4);
|
||||
let bytes = read_bytes(&buf, 0, "data", LE).unwrap();
|
||||
assert_eq!(bytes, &payload[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_invalid_utf8() {
|
||||
let mut buf = vec![0u8; 16];
|
||||
write_bytes(&mut buf, 0, &[0xFF, 0xFE, 0xFD], "name", LE).unwrap();
|
||||
let err = read_string(&buf, 0, "name", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_bounds_failure_on_prefix() {
|
||||
let buf = [0u8; 2];
|
||||
let err = read_string(&buf, 0, "name", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_bounds_failure_on_data() {
|
||||
let mut buf = vec![0u8; 6];
|
||||
let _ = write_bytes(&mut buf, 0, &[0x00; 32], "name", LE);
|
||||
let len_bytes = (100u32).to_le_bytes();
|
||||
buf[0..4].copy_from_slice(&len_bytes);
|
||||
let err = read_string(&buf, 0, "name", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_string_bounds_failure() {
|
||||
let mut buf = vec![0u8; 4];
|
||||
let err = write_string(&mut buf, 0, "hello", "name", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_indirect_round_trip() {
|
||||
let data_region = b"the quick brown fox";
|
||||
let mut index = [0u8; 8];
|
||||
write_u32(&mut index, 0, 4, "idx.off", LE).unwrap();
|
||||
write_u32(&mut index, 4, 11, "idx.len", LE).unwrap();
|
||||
let s = read_string_indirect(&index, 0, data_region, "msg", LE).unwrap();
|
||||
assert_eq!(s, "quick brown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_indirect_round_trip() {
|
||||
let data_region: &[u8] = b"HEADERbody-payloadTAIL";
|
||||
let mut index = [0u8; 8];
|
||||
write_u32(&mut index, 0, 6, "idx.off", BE).unwrap();
|
||||
write_u32(&mut index, 4, 12, "idx.len", BE).unwrap();
|
||||
let bytes = read_bytes_indirect(&index, 0, data_region, "blob", BE).unwrap();
|
||||
assert_eq!(bytes, b"body-payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_indirect_bounds_failure_on_index() {
|
||||
let buf = [0u8; 4];
|
||||
let data_region = b"anything";
|
||||
let err = read_bytes_indirect(&buf, 0, data_region, "blob", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_indirect_bounds_failure_on_data_region() {
|
||||
let mut buf = [0u8; 8];
|
||||
write_u32(&mut buf, 0, 100, "idx.off", LE).unwrap();
|
||||
write_u32(&mut buf, 4, 10, "idx.len", LE).unwrap();
|
||||
let data_region = b"too short";
|
||||
let err = read_bytes_indirect(&buf, 0, data_region, "blob", LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_at_nonzero_offset() {
|
||||
let mut buf = vec![0u8; 16];
|
||||
write_u32(&mut buf, 8, 0xDEADBEEF, "header.id", BE).unwrap();
|
||||
assert_eq!(read_u32(&buf, 8, "header.id", BE).unwrap(), 0xDEADBEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_bytes_zero_length() {
|
||||
let mut buf = vec![0u8; 8];
|
||||
let written = write_bytes(&mut buf, 0, &[], "data", LE).unwrap();
|
||||
assert_eq!(written, 4);
|
||||
assert_eq!(buf[0..4], [0, 0, 0, 0]);
|
||||
let bytes = read_bytes(&buf, 0, "data", LE).unwrap();
|
||||
assert!(bytes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
//! `TypedefEngine` — the compiled form of a schema.
|
||||
//!
|
||||
//! Combines the layout engine (both packed and aligned modes) and the
|
||||
//! jsonschema validator into a single struct. Built once at schema load
|
||||
//! time via [`TypedefEngine::compile`]. Used for repeated read/write/
|
||||
//! validate operations at access time.
|
||||
//!
|
||||
//! See [validation.md](../../docs/architecture/crates/typedef/validation.md)
|
||||
//! §"The TypedefEngine struct" and
|
||||
//! [overview.md](../../docs/architecture/crates/typedef/overview.md).
|
||||
|
||||
use crate::data_access;
|
||||
use crate::error::TypedefError;
|
||||
use crate::layout_builder::LayoutBuilder;
|
||||
use crate::offset_map::OffsetMap;
|
||||
use crate::schema::{self, get_typedef_kind_loose_enum, Endian, TypeDefKind};
|
||||
use crate::sequential_reader::{FieldValue, SequentialReader};
|
||||
use crate::validation;
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
|
||||
/// The layout mode selected at engine construction time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
/// Packed sequential — for protocol wire formats (SFTP, channels, TTY).
|
||||
Packed,
|
||||
/// Aligned static — for mmap-friendly formats (metatensor, safetensors).
|
||||
Aligned,
|
||||
}
|
||||
|
||||
/// The layout strategy — packed sequential or aligned static.
|
||||
///
|
||||
/// Carries the layout-specific handles needed for read/write access in
|
||||
/// the selected mode. The consumer chooses the mode at construction time
|
||||
/// via [`TypedefEngine::compile`]; the engine then exposes only the
|
||||
/// APIs that make sense for that mode.
|
||||
#[derive(Debug)]
|
||||
enum Layout {
|
||||
/// Packed sequential layout. The write-side is [`LayoutBuilder`]; the
|
||||
/// read-side is a fresh [`SequentialReader`] constructed on demand
|
||||
/// (ADR-101 — the reader has mutable cursor state that the consumer
|
||||
/// owns, so the engine is a factory, not a holder).
|
||||
Packed {
|
||||
builder: LayoutBuilder,
|
||||
},
|
||||
/// Aligned static layout. Field offsets are precomputed in an
|
||||
/// [`OffsetMap`] for random access.
|
||||
Aligned { offset_map: OffsetMap },
|
||||
}
|
||||
|
||||
/// The compiled form of a typedef schema. Combines the layout engine
|
||||
/// (both packed and aligned modes) and the jsonschema validator.
|
||||
///
|
||||
/// Built once at schema load time via [`TypedefEngine::compile`].
|
||||
/// Used for repeated read/write/validate operations at access time.
|
||||
///
|
||||
/// The consumer selects the layout mode at construction time. The engine
|
||||
/// then exposes mode-appropriate accessors: [`TypedefEngine::offset_map`]
|
||||
/// for aligned mode, [`TypedefEngine::layout_builder`] and
|
||||
/// [`TypedefEngine::sequential_reader`] for packed mode. The
|
||||
/// jsonschema validator is mode-agnostic and always available.
|
||||
pub struct TypedefEngine {
|
||||
layout: Layout,
|
||||
validator: jsonschema::Validator,
|
||||
endian: Endian,
|
||||
schema: Value,
|
||||
}
|
||||
|
||||
impl TypedefEngine {
|
||||
/// Compile a schema into a [`TypedefEngine`].
|
||||
///
|
||||
/// This is the expensive operation — it parses the schema, normalizes
|
||||
/// `$ref` values, computes the layout, and builds the jsonschema
|
||||
/// validator. Call once at load time; use the returned engine for
|
||||
/// repeated operations.
|
||||
///
|
||||
/// The `mode` parameter selects the layout strategy. The same schema
|
||||
/// can be compiled in either mode.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`TypedefError::Schema`] if the schema is malformed or the
|
||||
/// underlying layout/validator construction fails. The error is
|
||||
/// propagated from [`LayoutBuilder::new`], [`SequentialReader::new`],
|
||||
/// [`OffsetMap::compute`], or [`validation::build_validator`].
|
||||
pub fn compile(schema: &mut Value, mode: LayoutMode) -> Result<Self, TypedefError> {
|
||||
schema::normalize_refs(schema);
|
||||
let endian = Endian::from_schema(schema);
|
||||
let layout = match mode {
|
||||
LayoutMode::Packed => {
|
||||
let builder = LayoutBuilder::new(schema)?;
|
||||
Layout::Packed { builder }
|
||||
}
|
||||
LayoutMode::Aligned => {
|
||||
let offset_map = OffsetMap::compute(schema)?;
|
||||
Layout::Aligned { offset_map }
|
||||
}
|
||||
};
|
||||
let validator = validation::build_validator(schema)?;
|
||||
Ok(Self {
|
||||
layout,
|
||||
validator,
|
||||
endian,
|
||||
schema: schema.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema's endianness.
|
||||
pub fn endian(&self) -> Endian {
|
||||
self.endian
|
||||
}
|
||||
|
||||
/// The layout mode this engine was compiled with.
|
||||
pub fn mode(&self) -> LayoutMode {
|
||||
match self.layout {
|
||||
Layout::Packed { .. } => LayoutMode::Packed,
|
||||
Layout::Aligned { .. } => LayoutMode::Aligned,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the aligned offset map. Returns `None` if compiled in
|
||||
/// packed mode.
|
||||
pub fn offset_map(&self) -> Option<&OffsetMap> {
|
||||
match &self.layout {
|
||||
Layout::Aligned { offset_map } => Some(offset_map),
|
||||
Layout::Packed { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the layout builder (write-side of packed mode).
|
||||
/// Returns `None` if compiled in aligned mode.
|
||||
pub fn layout_builder(&self) -> Option<&LayoutBuilder> {
|
||||
match &self.layout {
|
||||
Layout::Packed { builder, .. } => Some(builder),
|
||||
Layout::Aligned { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a fresh [`SequentialReader`] for packed-mode reads
|
||||
/// (ADR-101). Each call returns a new reader with the cursor at
|
||||
/// position 0. The consumer owns the reader and calls
|
||||
/// `read_next`/`read_field`/`reset` on it directly.
|
||||
///
|
||||
/// Returns `None` if compiled in aligned mode.
|
||||
pub fn sequential_reader(&self) -> Option<SequentialReader> {
|
||||
match &self.layout {
|
||||
Layout::Packed { .. } => SequentialReader::new(&self.schema).ok(),
|
||||
Layout::Aligned { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a JSON value against the schema. The jsonschema validator
|
||||
/// is already compiled — this is a fast check.
|
||||
///
|
||||
/// Returns `Ok(())` if valid, `Err(TypedefError::Validation(...))` if
|
||||
/// invalid.
|
||||
pub fn validate_json(&self, instance: &Value) -> Result<(), TypedefError> {
|
||||
self.validator
|
||||
.validate(instance)
|
||||
.map_err(|e| TypedefError::Validation(e.to_owned()))
|
||||
}
|
||||
|
||||
/// Check if a JSON value is valid against the schema.
|
||||
pub fn is_valid_json(&self, instance: &Value) -> bool {
|
||||
self.validator.is_valid(instance)
|
||||
}
|
||||
|
||||
/// Read a field from a buffer at its computed offset (aligned mode).
|
||||
///
|
||||
/// Looks up the field's byte range in the [`OffsetMap`] and reads the
|
||||
/// appropriate type using the [`crate::data_access`] functions. Works
|
||||
/// for fixed-size primitive kinds and length-prefixed `String`/
|
||||
/// `Bytes`/`Timestamp` fields.
|
||||
///
|
||||
/// Returns an error if compiled in packed mode — use
|
||||
/// [`TypedefEngine::sequential_reader`] for packed mode. Also
|
||||
/// returns an error for composite kinds (`Struct`, `Union`, `Array`,
|
||||
/// `Record`) — those are better handled via the layout-specific APIs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Access`] if compiled in packed mode.
|
||||
/// - [`TypedefError::Offset`] if `field_path` is not in the offset map.
|
||||
/// - [`TypedefError::Access`] for buffer-too-short or invalid data,
|
||||
/// propagated from [`crate::data_access`].
|
||||
pub fn read_field<'a>(
|
||||
&self,
|
||||
buffer: &'a [u8],
|
||||
field_path: &str,
|
||||
) -> Result<FieldValue<'a>, TypedefError> {
|
||||
let offset_map = match &self.layout {
|
||||
Layout::Aligned { offset_map } => offset_map,
|
||||
Layout::Packed { .. } => {
|
||||
return Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "read_field is only available in aligned mode; \
|
||||
use sequential_reader() for packed mode"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let range = offset_map
|
||||
.get(field_path)
|
||||
.ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "field not found in offset map".to_string(),
|
||||
})?;
|
||||
let field_schema =
|
||||
lookup_field_schema(&self.schema, field_path).ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "field schema not found in schema tree".to_string(),
|
||||
})?;
|
||||
let kind = get_typedef_kind_loose_enum(field_schema).ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "field schema has no TypeDef:* kind".to_string(),
|
||||
})?;
|
||||
let endian = self.endian;
|
||||
match kind {
|
||||
TypeDefKind::Int8 => {
|
||||
let v = data_access::read_i8(buffer, range.start, field_path)?;
|
||||
Ok(FieldValue::I8(v))
|
||||
}
|
||||
TypeDefKind::Int16 => {
|
||||
let v = data_access::read_i16(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::I16(v))
|
||||
}
|
||||
TypeDefKind::Int32 => {
|
||||
let v = data_access::read_i32(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::I32(v))
|
||||
}
|
||||
TypeDefKind::Int64 => {
|
||||
let v = data_access::read_i64(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::I64(v))
|
||||
}
|
||||
TypeDefKind::Uint8 => {
|
||||
let v = data_access::read_u8(buffer, range.start, field_path)?;
|
||||
Ok(FieldValue::U8(v))
|
||||
}
|
||||
TypeDefKind::Uint16 => {
|
||||
let v = data_access::read_u16(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::U16(v))
|
||||
}
|
||||
TypeDefKind::Uint32 => {
|
||||
let v = data_access::read_u32(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::U32(v))
|
||||
}
|
||||
TypeDefKind::Uint64 => {
|
||||
let v = data_access::read_u64(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::U64(v))
|
||||
}
|
||||
TypeDefKind::Float32 => {
|
||||
let v = data_access::read_f32(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::F32(v))
|
||||
}
|
||||
TypeDefKind::Float64 => {
|
||||
let v = data_access::read_f64(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::F64(v))
|
||||
}
|
||||
TypeDefKind::Boolean => {
|
||||
let v = data_access::read_bool(buffer, range.start, field_path)?;
|
||||
Ok(FieldValue::Bool(v))
|
||||
}
|
||||
TypeDefKind::Enum => {
|
||||
let v = data_access::read_enum(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::Enum(v))
|
||||
}
|
||||
TypeDefKind::String => {
|
||||
let v = data_access::read_string(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::String(v))
|
||||
}
|
||||
TypeDefKind::Bytes => {
|
||||
let v = data_access::read_bytes(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::Bytes(v))
|
||||
}
|
||||
TypeDefKind::Timestamp => {
|
||||
let v = data_access::read_string(buffer, range.start, field_path, endian)?;
|
||||
Ok(FieldValue::String(v))
|
||||
}
|
||||
TypeDefKind::Struct => Ok(FieldValue::Struct {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
TypeDefKind::Union | TypeDefKind::Array | TypeDefKind::Record => {
|
||||
Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "read_field does not support composite types; \
|
||||
use the layout-specific APIs"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a field to a buffer at its computed offset (aligned mode).
|
||||
///
|
||||
/// Looks up the field's byte range in the [`OffsetMap`] and writes the
|
||||
/// appropriate type using the [`crate::data_access`] functions. Works
|
||||
/// for fixed-size primitive kinds and length-prefixed `String`/
|
||||
/// `Bytes`/`Timestamp` fields.
|
||||
///
|
||||
/// Returns an error if compiled in packed mode — use
|
||||
/// [`TypedefEngine::layout_builder`] for packed mode. Also returns an
|
||||
/// error for composite kinds (`Struct`, `Union`, `Array`, `Record`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Access`] if compiled in packed mode.
|
||||
/// - [`TypedefError::Offset`] if `field_path` is not in the offset map.
|
||||
/// - [`TypedefError::Access`] for buffer-too-short or invalid data,
|
||||
/// propagated from [`crate::data_access`].
|
||||
pub fn write_field(
|
||||
&self,
|
||||
buffer: &mut [u8],
|
||||
field_path: &str,
|
||||
value: &FieldValue<'_>,
|
||||
) -> Result<(), TypedefError> {
|
||||
let offset_map = match &self.layout {
|
||||
Layout::Aligned { offset_map } => offset_map,
|
||||
Layout::Packed { .. } => {
|
||||
return Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "write_field is only available in aligned mode; \
|
||||
use layout_builder() for packed mode"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let range = offset_map
|
||||
.get(field_path)
|
||||
.ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "field not found in offset map".to_string(),
|
||||
})?;
|
||||
let endian = self.endian;
|
||||
match value {
|
||||
FieldValue::I8(v) => data_access::write_i8(buffer, range.start, *v, field_path),
|
||||
FieldValue::I16(v) => {
|
||||
data_access::write_i16(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::I32(v) => {
|
||||
data_access::write_i32(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::I64(v) => {
|
||||
data_access::write_i64(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::U8(v) => data_access::write_u8(buffer, range.start, *v, field_path),
|
||||
FieldValue::U16(v) => {
|
||||
data_access::write_u16(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::U32(v) => {
|
||||
data_access::write_u32(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::U64(v) => {
|
||||
data_access::write_u64(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::F32(v) => {
|
||||
data_access::write_f32(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::F64(v) => {
|
||||
data_access::write_f64(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::Bool(v) => data_access::write_bool(buffer, range.start, *v, field_path),
|
||||
FieldValue::Enum(v) => {
|
||||
data_access::write_enum(buffer, range.start, *v, field_path, endian)
|
||||
}
|
||||
FieldValue::String(v) => {
|
||||
data_access::write_string(buffer, range.start, v, field_path, endian)?;
|
||||
Ok(())
|
||||
}
|
||||
FieldValue::Bytes(v) => {
|
||||
data_access::write_bytes(buffer, range.start, v, field_path, endian)?;
|
||||
Ok(())
|
||||
}
|
||||
FieldValue::Struct { .. } | FieldValue::Union { .. } | FieldValue::Array { .. } => {
|
||||
Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "write_field does not support composite types; \
|
||||
use the layout-specific APIs"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TypedefEngine {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TypedefEngine")
|
||||
.field("layout", &self.layout)
|
||||
.field("validator", &"<jsonschema::Validator>")
|
||||
.field("endian", &self.endian)
|
||||
.field("schema", &self.schema)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a schema tree to find the node for a dotted field path.
|
||||
///
|
||||
/// Splits `field_path` on `.` and descends into `schema["properties"][segment]`
|
||||
/// at each step. Returns `None` if any segment is missing or the schema is
|
||||
/// not an object. Does not resolve `$ref` pointers — the engine stores the
|
||||
/// normalized schema, and the aligned offset map only records paths for
|
||||
/// inline fields, so refs at intermediate levels are not expected here.
|
||||
fn lookup_field_schema<'a>(schema: &'a Value, field_path: &str) -> Option<&'a Value> {
|
||||
let mut current = schema;
|
||||
for segment in field_path.split('.') {
|
||||
current = current.as_object()?.get("properties")?.get(segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixed_struct_schema() -> Value {
|
||||
json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"score": { "TypeDef:Float32": true },
|
||||
"tag": { "TypeDef:String": true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_aligned_builds_offset_map() {
|
||||
let mut schema = fixed_struct_schema();
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
assert_eq!(engine.mode(), LayoutMode::Aligned);
|
||||
assert!(engine.offset_map().is_some());
|
||||
assert!(engine.layout_builder().is_none());
|
||||
assert!(engine.sequential_reader().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_packed_builds_builder_and_reader() {
|
||||
let mut schema = fixed_struct_schema();
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
assert_eq!(engine.mode(), LayoutMode::Packed);
|
||||
assert!(engine.layout_builder().is_some());
|
||||
assert!(engine.sequential_reader().is_some());
|
||||
assert!(engine.offset_map().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_normalizes_refs() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"child": { "$ref": "Child" }
|
||||
},
|
||||
"$defs": {
|
||||
"Child": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "x": { "TypeDef:Uint8": true } }
|
||||
}
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
assert_eq!(
|
||||
engine.schema["properties"]["child"]["$ref"],
|
||||
json!("#/$defs/Child")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_parsed_from_schema() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "big",
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
assert_eq!(engine.endian(), Endian::Big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_defaults_to_little() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
assert_eq!(engine.endian(), Endian::Little);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_accepts_valid_instance() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
assert!(engine.validate_json(&json!({"id": 42})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_rejects_invalid_instance() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let err = engine.validate_json(&json!({"id": -1})).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Validation(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_json_returns_bool() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
assert!(engine.is_valid_json(&json!({"id": 42})));
|
||||
assert!(!engine.is_valid_json(&json!({"id": -1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_aligned_reads_fixed_fields() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0] = 0xAB;
|
||||
buf[4..8].copy_from_slice(&0x01020304u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "flag").unwrap(),
|
||||
FieldValue::U8(0xAB)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "id").unwrap(),
|
||||
FieldValue::U32(0x01020304)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_aligned_reads_string_length_prefixed() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = vec![0u8; 32];
|
||||
let len_bytes = 5u32.to_le_bytes();
|
||||
buf[0..4].copy_from_slice(&len_bytes);
|
||||
buf[4..9].copy_from_slice(b"hello");
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "name").unwrap(),
|
||||
FieldValue::String("hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_access_error_in_packed_mode() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
let buf = [0u8; 4];
|
||||
let err = engine.read_field(&buf, "id").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_offset_error_for_missing_field() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let buf = [0u8; 4];
|
||||
let err = engine.read_field(&buf, "missing").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_error_for_composite_types() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"vals": {
|
||||
"TypeDef:Array": true,
|
||||
"items": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let buf = [0u8; 8];
|
||||
let err = engine.read_field(&buf, "vals").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_aligned_writes_fixed_fields() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = vec![0u8; 8];
|
||||
engine
|
||||
.write_field(&mut buf, "flag", &FieldValue::U8(0xAB))
|
||||
.unwrap();
|
||||
engine
|
||||
.write_field(&mut buf, "id", &FieldValue::U32(0x01020304))
|
||||
.unwrap();
|
||||
assert_eq!(buf[0], 0xAB);
|
||||
assert_eq!(&buf[4..8], &0x01020304u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "flag").unwrap(),
|
||||
FieldValue::U8(0xAB)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "id").unwrap(),
|
||||
FieldValue::U32(0x01020304)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_round_trips_string() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = vec![0u8; 32];
|
||||
engine
|
||||
.write_field(&mut buf, "name", &FieldValue::String("hello"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
engine.read_field(&buf, "name").unwrap(),
|
||||
FieldValue::String("hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_access_error_in_packed_mode() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed).expect("compile");
|
||||
let mut buf = [0u8; 4];
|
||||
let err = engine
|
||||
.write_field(&mut buf, "id", &FieldValue::U32(1))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_offset_error_for_missing_field() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = [0u8; 4];
|
||||
let err = engine
|
||||
.write_field(&mut buf, "missing", &FieldValue::U32(1))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_error_for_composite_value() {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let mut buf = [0u8; 8];
|
||||
let err = engine
|
||||
.write_field(&mut buf, "id", &FieldValue::Struct { start: 0, end: 4 })
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_returns_schema_error_for_invalid_top_level() {
|
||||
let mut schema = json!({ "type": "object", "properties": {} });
|
||||
let err = TypedefEngine::compile(&mut schema, LayoutMode::Packed).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_formats_without_panicking() {
|
||||
let mut schema = fixed_struct_schema();
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).expect("compile");
|
||||
let s = format!("{engine:?}");
|
||||
assert!(s.contains("TypedefEngine"));
|
||||
assert!(s.contains("Aligned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_field_schema_walks_dotted_path() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"header": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"version": { "TypeDef:Uint8": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let node = lookup_field_schema(&schema, "header.version").expect("found");
|
||||
assert_eq!(node, &json!({ "TypeDef:Uint8": true }));
|
||||
assert!(lookup_field_schema(&schema, "header.missing").is_none());
|
||||
assert!(lookup_field_schema(&schema, "missing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typedef_kind_loose_recognizes_object_form() {
|
||||
let node = json!({ "TypeDef:String": { "encoding": "offset-indirect" } });
|
||||
assert_eq!(
|
||||
get_typedef_kind_loose_enum(&node),
|
||||
Some(TypeDefKind::String)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Error types for the typedef engine.
|
||||
//!
|
||||
//! Decided in ADR-098: a single `TypedefError` enum covers all error
|
||||
//! conditions across the engine's three phases (schema parsing, offset
|
||||
//! computation, read/write) plus validation.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by the typedef engine across all phases.
|
||||
#[derive(Debug)]
|
||||
pub enum TypedefError {
|
||||
/// Schema parsing errors — invalid JSON, missing required keywords,
|
||||
/// unknown `TypeDef:*` kinds, malformed annotations.
|
||||
Schema(String),
|
||||
|
||||
/// Offset computation errors — field not found, type not supported
|
||||
/// for offset computation, recursive depth exceeded.
|
||||
Offset { field_path: String, reason: String },
|
||||
|
||||
/// Read/write errors — buffer too short, invalid UTF-8, value out
|
||||
/// of range for the target type.
|
||||
Access { field_path: String, reason: String },
|
||||
|
||||
/// Validation errors — delegated to the `jsonschema` crate.
|
||||
/// The `'static` lifetime is correct: the validator owns its schema
|
||||
/// reference and lives for the lifetime of the `TypedefEngine`.
|
||||
Validation(jsonschema::ValidationError<'static>),
|
||||
}
|
||||
|
||||
impl fmt::Display for TypedefError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TypedefError::Schema(msg) => write!(f, "schema error: {msg}"),
|
||||
TypedefError::Offset { field_path, reason } => {
|
||||
write!(f, "offset error at {field_path}: {reason}")
|
||||
}
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
write!(f, "access error at {field_path}: {reason}")
|
||||
}
|
||||
TypedefError::Validation(inner) => write!(f, "validation error: {inner}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TypedefError {}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
//! alknet-typedef: The binary struct engine.
|
||||
//!
|
||||
//! Takes a JSON Schema with `TypeDef:*` custom keywords and produces
|
||||
//! an offset map, read/write functions, and validation — all driven
|
||||
//! by the schema. The schema is the format definition; the engine is
|
||||
//! generic.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - **Schema layer** ([`schema`]): TypeDef kind detection, annotation
|
||||
//! parsing, `$ref` normalization, endianness.
|
||||
//! - **Layout engine** ([`offset_map`], [`layout_builder`],
|
||||
//! [`sequential_reader`]): Two layout modes — aligned static for
|
||||
//! mmap-friendly formats, packed sequential for protocol wire formats.
|
||||
//! - **Data access** ([`data_access`]): Typed read/write at computed
|
||||
//! offsets, zero-copy for fixed-size types.
|
||||
//! - **TUnion dispatch** ([`tunion`]): Byte-offset and field-name
|
||||
//! discriminator dispatch.
|
||||
//! - **Validation** ([`validation`]): Custom keyword validators for all
|
||||
//! 17 `TypeDef:*` kinds, delegated to the `jsonschema` crate.
|
||||
//! - **Engine** ([`engine`]): `TypedefEngine` — the compiled form of a
|
||||
//! schema, combining layout and validation.
|
||||
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
pub mod data_access;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod layout_builder;
|
||||
pub mod offset_map;
|
||||
pub mod schema;
|
||||
pub mod sequential_reader;
|
||||
pub mod tunion;
|
||||
pub mod validation;
|
||||
|
||||
pub use engine::{LayoutMode, TypedefEngine};
|
||||
pub use error::TypedefError;
|
||||
pub use layout_builder::{FieldPosition, LayoutBuilder, PackedLayout};
|
||||
pub use offset_map::{ByteRange, OffsetMap};
|
||||
pub use schema::{
|
||||
get_typedef_kind_loose, get_typedef_kind_loose_enum, normalize_refs, parse_align,
|
||||
parse_discriminator, parse_encoding, parse_endian, parse_max_length, resolve_ref,
|
||||
resolve_ref_or_inline, DiscriminatorKind, Endian, TypeDefKind, VariableEncoding,
|
||||
};
|
||||
pub use sequential_reader::{FieldValue, SequentialReader};
|
||||
pub use tunion::UnionDispatch;
|
||||
pub use validation::build_validator;
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Macros for generating repetitive code across the 17 TypeDef kinds.
|
||||
//!
|
||||
//! These macros eliminate boilerplate in validation, data access, and
|
||||
//! dispatch. Each macro takes a compact specification and generates the
|
||||
//! full implementation, ensuring consistency across all types.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation macros
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a signed integer validator struct and its factory closure.
|
||||
#[macro_export]
|
||||
macro_rules! define_int_validator {
|
||||
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $min:literal, $max:literal) => {
|
||||
struct $validator_struct;
|
||||
impl jsonschema::Keyword for $validator_struct {
|
||||
fn validate<'i>(
|
||||
&self,
|
||||
instance: &'i serde_json::Value,
|
||||
) -> Result<(), jsonschema::ValidationError<'i>> {
|
||||
match instance.as_i64() {
|
||||
Some(n) if ($min..=$max).contains(&n) => Ok(()),
|
||||
_ => Err(jsonschema::ValidationError::custom(concat!(
|
||||
"expected an integer in range [",
|
||||
stringify!($min),
|
||||
", ",
|
||||
stringify!($max),
|
||||
"]"
|
||||
))),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &serde_json::Value) -> bool {
|
||||
instance
|
||||
.as_i64()
|
||||
.is_some_and(|n| ($min..=$max).contains(&n))
|
||||
}
|
||||
}
|
||||
|
||||
fn $factory_fn<'a>(
|
||||
_parent: &'a serde_json::Map<String, serde_json::Value>,
|
||||
value: &'a serde_json::Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new($validator_struct))
|
||||
} else {
|
||||
Err(jsonschema::ValidationError::schema(concat!(
|
||||
$keyword,
|
||||
" must be set to true"
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate an unsigned integer validator struct and its factory closure.
|
||||
#[macro_export]
|
||||
macro_rules! define_uint_validator {
|
||||
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $max:literal) => {
|
||||
struct $validator_struct;
|
||||
impl jsonschema::Keyword for $validator_struct {
|
||||
fn validate<'i>(
|
||||
&self,
|
||||
instance: &'i serde_json::Value,
|
||||
) -> Result<(), jsonschema::ValidationError<'i>> {
|
||||
match instance.as_u64() {
|
||||
Some(n) if n <= $max => Ok(()),
|
||||
_ => Err(jsonschema::ValidationError::custom(concat!(
|
||||
"expected an unsigned integer in range [0, ",
|
||||
stringify!($max),
|
||||
"]"
|
||||
))),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &serde_json::Value) -> bool {
|
||||
instance.as_u64().is_some_and(|n| n <= $max)
|
||||
}
|
||||
}
|
||||
|
||||
fn $factory_fn<'a>(
|
||||
_parent: &'a serde_json::Map<String, serde_json::Value>,
|
||||
value: &'a serde_json::Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new($validator_struct))
|
||||
} else {
|
||||
Err(jsonschema::ValidationError::schema(concat!(
|
||||
$keyword,
|
||||
" must be set to true"
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate a float validator struct and its factory closure.
|
||||
#[macro_export]
|
||||
macro_rules! define_float_validator {
|
||||
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $error_msg:literal) => {
|
||||
struct $validator_struct;
|
||||
impl jsonschema::Keyword for $validator_struct {
|
||||
fn validate<'i>(
|
||||
&self,
|
||||
instance: &'i serde_json::Value,
|
||||
) -> Result<(), jsonschema::ValidationError<'i>> {
|
||||
match instance.as_f64() {
|
||||
Some(f) if f.is_finite() => Ok(()),
|
||||
_ => Err(jsonschema::ValidationError::custom($error_msg)),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &serde_json::Value) -> bool {
|
||||
instance.as_f64().is_some_and(|f| f.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
fn $factory_fn<'a>(
|
||||
_parent: &'a serde_json::Map<String, serde_json::Value>,
|
||||
value: &'a serde_json::Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new($validator_struct))
|
||||
} else {
|
||||
Err(jsonschema::ValidationError::schema(concat!(
|
||||
$keyword,
|
||||
" must be set to true"
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate a simple type-check validator (object/array/boolean) and its factory.
|
||||
#[macro_export]
|
||||
macro_rules! define_type_validator {
|
||||
($validator_struct:ident, $factory_fn:ident, $keyword:literal, $check_method:ident, $error_msg:literal) => {
|
||||
struct $validator_struct;
|
||||
impl jsonschema::Keyword for $validator_struct {
|
||||
fn validate<'i>(
|
||||
&self,
|
||||
instance: &'i serde_json::Value,
|
||||
) -> Result<(), jsonschema::ValidationError<'i>> {
|
||||
if instance.$check_method() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(jsonschema::ValidationError::custom($error_msg))
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &serde_json::Value) -> bool {
|
||||
instance.$check_method()
|
||||
}
|
||||
}
|
||||
|
||||
fn $factory_fn<'a>(
|
||||
_parent: &'a serde_json::Map<String, serde_json::Value>,
|
||||
value: &'a serde_json::Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn jsonschema::Keyword>, jsonschema::ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new($validator_struct))
|
||||
} else {
|
||||
Err(jsonschema::ValidationError::schema(concat!(
|
||||
$keyword,
|
||||
" must be set to true"
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data access macros
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a pair of read/write functions for a fixed-size endian-sensitive type.
|
||||
#[macro_export]
|
||||
macro_rules! define_read_write_endian {
|
||||
($rust_ty:ty, $read_name:ident, $write_name:ident, $size:literal) => {
|
||||
#[doc = concat!(
|
||||
"Read a `",
|
||||
stringify!($rust_ty),
|
||||
"` at `offset` from `buffer`, applying `endian`."
|
||||
)]
|
||||
pub fn $read_name(
|
||||
buffer: &[u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
endian: $crate::Endian,
|
||||
) -> Result<$rust_ty, $crate::TypedefError> {
|
||||
let bytes: [u8; $size] = $crate::data_access::read_array(buffer, offset, field_path)?;
|
||||
Ok(match endian {
|
||||
$crate::Endian::Little => <$rust_ty>::from_le_bytes(bytes),
|
||||
$crate::Endian::Big => <$rust_ty>::from_be_bytes(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
#[doc = concat!(
|
||||
"Write a `",
|
||||
stringify!($rust_ty),
|
||||
"` `value` at `offset` into `buffer`, applying `endian`."
|
||||
)]
|
||||
pub fn $write_name(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: $rust_ty,
|
||||
field_path: &str,
|
||||
endian: $crate::Endian,
|
||||
) -> Result<(), $crate::TypedefError> {
|
||||
let bytes = match endian {
|
||||
$crate::Endian::Little => value.to_le_bytes(),
|
||||
$crate::Endian::Big => value.to_be_bytes(),
|
||||
};
|
||||
$crate::data_access::write_array(buffer, offset, bytes, field_path)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate a pair of read/write functions for a fixed-size endian-insensitive type.
|
||||
#[macro_export]
|
||||
macro_rules! define_read_write_ne {
|
||||
($rust_ty:ty, $read_name:ident, $write_name:ident, $size:literal, $read_expr:expr) => {
|
||||
#[doc = concat!(
|
||||
"Read a `",
|
||||
stringify!($rust_ty),
|
||||
"` at `offset` from `buffer`."
|
||||
)]
|
||||
pub fn $read_name(
|
||||
buffer: &[u8],
|
||||
offset: usize,
|
||||
field_path: &str,
|
||||
) -> Result<$rust_ty, $crate::TypedefError> {
|
||||
let bytes: [u8; $size] = $crate::data_access::read_array(buffer, offset, field_path)?;
|
||||
Ok($read_expr(bytes))
|
||||
}
|
||||
|
||||
#[doc = concat!(
|
||||
"Write a `",
|
||||
stringify!($rust_ty),
|
||||
"` `value` at `offset` into `buffer`."
|
||||
)]
|
||||
pub fn $write_name(
|
||||
buffer: &mut [u8],
|
||||
offset: usize,
|
||||
value: $rust_ty,
|
||||
field_path: &str,
|
||||
) -> Result<(), $crate::TypedefError> {
|
||||
$crate::data_access::write_array(buffer, offset, value.to_ne_bytes(), field_path)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
//! Aligned static `OffsetMap` — Mode 2 of the two layout modes (ADR-096).
|
||||
//!
|
||||
//! Fields have fixed positions with natural alignment padding.
|
||||
//! Variable-length fields get a 4-byte length prefix at a known offset;
|
||||
//! the variable data is not included in the static layout. Used for
|
||||
//! mmap-friendly formats (metatensor, safetensors).
|
||||
//!
|
||||
//! The offset computation is a recursive walk of the schema JSON. Nested
|
||||
//! structs propagate field path prefixes (producing dotted paths like
|
||||
//! `"header.version"`). Alignment padding is inserted before each field
|
||||
//! to satisfy the field's alignment requirement (natural alignment by
|
||||
//! default, overridable via the `"align"` annotation).
|
||||
|
||||
use crate::error::TypedefError;
|
||||
use crate::schema::{
|
||||
get_typedef_kind, get_typedef_kind_loose_enum, parse_align, parse_encoding,
|
||||
parse_max_length, resolve_ref_or_inline, TypeDefKind, VariableEncoding,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A byte range within a buffer.
|
||||
///
|
||||
/// Produced by [`OffsetMap::compute`] for each field in a schema. The
|
||||
/// range is half-open: `start..end`. `end - start` is the field's byte
|
||||
/// size in the static layout (for variable-length fields, this is the
|
||||
/// size of the length prefix, the `{offset, length}` pair, or the
|
||||
/// `maxLength` reservation — not the variable data itself).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ByteRange {
|
||||
/// Inclusive start byte offset.
|
||||
pub start: usize,
|
||||
/// Exclusive end byte offset.
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl ByteRange {
|
||||
/// Byte length of the range (`end - start`).
|
||||
pub fn len(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
/// True if the range covers zero bytes.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.end == self.start
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat table of `(field_path, byte_range)` pairs computed from a schema.
|
||||
///
|
||||
/// Fields have fixed positions with natural alignment padding.
|
||||
/// Used for mmap-friendly formats (metatensor, safetensors) where random
|
||||
/// access by field path is required — the consumer can read field N
|
||||
/// without reading fields `0..N-1` first.
|
||||
///
|
||||
/// Construct via [`OffsetMap::compute`]. Variable-length fields appear
|
||||
/// in the table as their fixed-position portion only (length prefix,
|
||||
/// `{offset, length}` pair, or `maxLength` reservation); the variable
|
||||
/// data lives outside the static layout.
|
||||
#[derive(Debug)]
|
||||
pub struct OffsetMap {
|
||||
fields: Vec<(String, ByteRange)>,
|
||||
total_size: usize,
|
||||
}
|
||||
|
||||
impl OffsetMap {
|
||||
/// Compute the offset map from a schema JSON value.
|
||||
///
|
||||
/// Walks the schema recursively, computing byte positions for each
|
||||
/// field based on type sizes, field order, and alignment. The
|
||||
/// top-level schema must be a `TypeDef:Struct`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`TypedefError::Schema`] if the top-level schema is not a
|
||||
/// `TypeDef:Struct` or has no `TypeDef:*` kind, or if the schema is
|
||||
/// malformed (missing `properties`, unknown kind, etc.).
|
||||
///
|
||||
/// Returns [`TypedefError::Offset`] for unsupported type combinations
|
||||
/// encountered during the walk.
|
||||
pub fn compute(schema: &Value) -> Result<Self, TypedefError> {
|
||||
let kind = get_typedef_kind(schema)
|
||||
.and_then(|s| s.parse::<TypeDefKind>().ok())
|
||||
.ok_or_else(|| {
|
||||
TypedefError::Schema("top-level schema has no TypeDef:* kind".to_string())
|
||||
})?;
|
||||
if kind != TypeDefKind::Struct {
|
||||
return Err(TypedefError::Schema(format!(
|
||||
"OffsetMap::compute requires a TypeDef:Struct at the top level, got {kind}"
|
||||
)));
|
||||
}
|
||||
let mut ctx = ComputeCtx {
|
||||
root: schema,
|
||||
fields: Vec::new(),
|
||||
offset: 0,
|
||||
};
|
||||
let (total, _align) = ctx.compute_struct(schema, "", 1)?;
|
||||
Ok(Self {
|
||||
fields: ctx.fields,
|
||||
total_size: total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up a field's byte range by dotted path (e.g., `"header.version"`).
|
||||
///
|
||||
/// Returns `None` if no field with the given path was recorded. For
|
||||
/// TUnion byte-offset discriminators, the discriminator is recorded
|
||||
/// under the synthetic path `"__discriminator"` (qualified by the
|
||||
/// union field's path, e.g. `"payload.__discriminator"`).
|
||||
pub fn get(&self, field_path: &str) -> Option<&ByteRange> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(path, _)| path == field_path)
|
||||
.map(|(_, range)| range)
|
||||
}
|
||||
|
||||
/// The total size of the struct in bytes (including trailing alignment padding).
|
||||
pub fn total_size(&self) -> usize {
|
||||
self.total_size
|
||||
}
|
||||
|
||||
/// Iterate over all `(field_path, byte_range)` pairs in insertion order.
|
||||
///
|
||||
/// Field order matches the schema's `properties` order (preserved by
|
||||
/// `serde_json`'s `preserve_order` feature). Nested struct fields
|
||||
/// appear after their parent's path prefix.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(String, ByteRange)> {
|
||||
self.fields.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable context threaded through the recursive offset computation.
|
||||
///
|
||||
/// Carries the running `offset`, the accumulating `fields` vec, and a
|
||||
/// reference to the root schema for `$ref` resolution. Grouping these
|
||||
/// keeps the recursive helper signatures small.
|
||||
struct ComputeCtx<'a> {
|
||||
root: &'a Value,
|
||||
fields: Vec<(String, ByteRange)>,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
/// Result of laying out a single field: its alignment.
|
||||
struct FieldLayout {
|
||||
align: usize,
|
||||
}
|
||||
|
||||
impl<'a> ComputeCtx<'a> {
|
||||
/// Recurse into a `TypeDef:Struct`, appending `(field_path, ByteRange)`
|
||||
/// pairs to `self.fields` and advancing `self.offset`.
|
||||
///
|
||||
/// Returns `(total_size, alignment)` where `total_size` includes
|
||||
/// trailing alignment padding and `alignment` is the struct's
|
||||
/// effective alignment (its own `align` annotation, or the max of its
|
||||
/// fields' alignments).
|
||||
///
|
||||
/// `struct_schema` is the schema of the struct to walk. `prefix` is the
|
||||
/// dotted path prefix for nested fields (empty at the top level).
|
||||
/// `parent_struct_align` is the default alignment a field inherits
|
||||
/// when it specifies neither its own `align` annotation nor a natural
|
||||
/// alignment larger than the default.
|
||||
fn compute_struct(
|
||||
&mut self,
|
||||
struct_schema: &Value,
|
||||
prefix: &str,
|
||||
parent_struct_align: usize,
|
||||
) -> Result<(usize, usize), TypedefError> {
|
||||
let obj = struct_schema
|
||||
.as_object()
|
||||
.ok_or_else(|| TypedefError::Schema("struct schema is not an object".to_string()))?;
|
||||
let properties = obj
|
||||
.get("properties")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
TypedefError::Schema("struct schema has no 'properties' object".to_string())
|
||||
})?;
|
||||
|
||||
let struct_default_align = parse_align(struct_schema).unwrap_or(parent_struct_align);
|
||||
let mut max_align: usize = 1;
|
||||
let struct_start = self.offset;
|
||||
|
||||
let field_schemas: Vec<(String, Value)> = properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let field_count = field_schemas.len();
|
||||
for (i, (field_name, field_schema)) in field_schemas.iter().enumerate() {
|
||||
let field_path = if prefix.is_empty() {
|
||||
field_name.clone()
|
||||
} else {
|
||||
format!("{prefix}.{field_name}")
|
||||
};
|
||||
// ADR-100: reject non-final inline length-prefixed variable fields.
|
||||
// The OffsetMap reserves only 4 bytes (the length prefix), but
|
||||
// data_access::write_string writes prefix+data inline — clobbering
|
||||
// subsequent fields. Only allowed as the last field in the struct.
|
||||
if i < field_count - 1 {
|
||||
if let Some(kind) = get_typedef_kind_loose_enum(field_schema) {
|
||||
if kind.is_variable_length() {
|
||||
let keyword_value = field_schema
|
||||
.as_object()
|
||||
.and_then(|o| {
|
||||
o.keys()
|
||||
.find(|k| k.starts_with("TypeDef:"))
|
||||
.and_then(|k| o.get(k))
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or(Value::Bool(true));
|
||||
let encoding = parse_encoding(&keyword_value);
|
||||
let max_length = parse_max_length(field_schema);
|
||||
let is_inline_length_prefixed =
|
||||
encoding == VariableEncoding::LengthPrefixed && max_length.is_none();
|
||||
if is_inline_length_prefixed {
|
||||
return Err(TypedefError::Offset {
|
||||
field_path: field_path.clone(),
|
||||
reason: format!(
|
||||
"non-final inline length-prefixed variable field \
|
||||
({kind}) in aligned mode: the variable data would \
|
||||
clobber subsequent fields. Use `maxLength` \
|
||||
(fixed-size reservation) or \
|
||||
`\"encoding\": \"offset-indirect\"`, or move this \
|
||||
field to the last position in the struct. (ADR-100)"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let layout = self.compute_field(field_schema, &field_path, struct_default_align)?;
|
||||
if layout.align > max_align {
|
||||
max_align = layout.align;
|
||||
}
|
||||
}
|
||||
|
||||
let effective_align = parse_align(struct_schema).unwrap_or(max_align).max(1);
|
||||
align_up(&mut self.offset, effective_align);
|
||||
let total = self.offset - struct_start;
|
||||
Ok((total, effective_align))
|
||||
}
|
||||
|
||||
/// Compute the layout for a single field, advancing `self.offset`
|
||||
/// and appending any field paths to `self.fields`.
|
||||
fn compute_field(
|
||||
&mut self,
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
struct_default_align: usize,
|
||||
) -> Result<FieldLayout, TypedefError> {
|
||||
let kind = get_typedef_kind_loose_enum(field_schema).ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "field schema has no TypeDef:* kind".to_string(),
|
||||
})?;
|
||||
|
||||
match kind {
|
||||
TypeDefKind::Struct => {
|
||||
self.compute_struct_field(field_schema, field_path, struct_default_align)
|
||||
}
|
||||
TypeDefKind::Union => Err(TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "TUnion is not supported in aligned static mode (ADR-102). \
|
||||
Unions are the protocol dispatch pattern — use packed sequential \
|
||||
mode (LayoutMode::Packed) for TUnion fields, or restructure as \
|
||||
a struct with an explicit discriminator field."
|
||||
.to_string(),
|
||||
}),
|
||||
TypeDefKind::Array => {
|
||||
self.compute_array_field(field_schema, field_path, struct_default_align)
|
||||
}
|
||||
TypeDefKind::String
|
||||
| TypeDefKind::Bytes
|
||||
| TypeDefKind::Record
|
||||
| TypeDefKind::Timestamp => {
|
||||
self.compute_variable_field(field_schema, field_path, struct_default_align)
|
||||
}
|
||||
k if k.is_fixed_size() => {
|
||||
self.compute_fixed_field(k, field_schema, field_path, struct_default_align)
|
||||
}
|
||||
_ => unreachable!("all TypeDefKind variants are covered above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the layout for a fixed-size primitive field.
|
||||
fn compute_fixed_field(
|
||||
&mut self,
|
||||
kind: TypeDefKind,
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
struct_default_align: usize,
|
||||
) -> Result<FieldLayout, TypedefError> {
|
||||
let size = kind.type_size().ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!("type_size returned None for fixed kind {kind}"),
|
||||
})?;
|
||||
let natural = kind.natural_alignment();
|
||||
let align = field_alignment(field_schema, struct_default_align, natural);
|
||||
align_up(&mut self.offset, align);
|
||||
let start = self.offset;
|
||||
self.offset += size;
|
||||
self.push(field_path, start, start + size);
|
||||
Ok(FieldLayout { align })
|
||||
}
|
||||
|
||||
/// Compute the layout for a nested `TypeDef:Struct` field.
|
||||
///
|
||||
/// Probes the nested struct's layout at a temporary offset of 0 to
|
||||
/// determine its total size and alignment, aligns the parent offset,
|
||||
/// then shifts the nested fields to their final positions.
|
||||
fn compute_struct_field(
|
||||
&mut self,
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
struct_default_align: usize,
|
||||
) -> Result<FieldLayout, TypedefError> {
|
||||
let inner_parent_align = parse_align(field_schema).unwrap_or(struct_default_align);
|
||||
let mut probe = ComputeCtx {
|
||||
root: self.root,
|
||||
fields: Vec::new(),
|
||||
offset: 0,
|
||||
};
|
||||
let (inner_total, inner_align) =
|
||||
probe.compute_struct(field_schema, field_path, inner_parent_align)?;
|
||||
|
||||
let align = field_alignment(field_schema, struct_default_align, inner_align);
|
||||
align_up(&mut self.offset, align);
|
||||
let struct_start = self.offset;
|
||||
for (path, range) in probe.fields {
|
||||
self.fields.push((
|
||||
path,
|
||||
ByteRange {
|
||||
start: struct_start + range.start,
|
||||
end: struct_start + range.end,
|
||||
},
|
||||
));
|
||||
}
|
||||
self.offset = struct_start + inner_total;
|
||||
Ok(FieldLayout { align })
|
||||
}
|
||||
|
||||
/// Compute the layout for a `TypeDef:Array` field.
|
||||
fn compute_array_field(
|
||||
&mut self,
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
struct_default_align: usize,
|
||||
) -> Result<FieldLayout, TypedefError> {
|
||||
let obj = field_schema
|
||||
.as_object()
|
||||
.ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "array schema is not an object".to_string(),
|
||||
})?;
|
||||
|
||||
let items = obj.get("items").ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "TArray is missing 'items'".to_string(),
|
||||
})?;
|
||||
let element_schema =
|
||||
resolve_ref_or_inline(items, self.root).ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "could not resolve TArray items schema".to_string(),
|
||||
})?;
|
||||
let elem_kind = get_typedef_kind(element_schema)
|
||||
.and_then(|s| s.parse::<TypeDefKind>().ok())
|
||||
.ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: "TArray element schema has no TypeDef:* kind".to_string(),
|
||||
})?;
|
||||
if !elem_kind.is_fixed_size() {
|
||||
return Err(TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!(
|
||||
"TArray of variable-length element kind {elem_kind} is not supported (OQ-069)"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let elem_size = elem_kind.type_size().ok_or_else(|| TypedefError::Offset {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!("element kind {elem_kind} has no fixed size"),
|
||||
})?;
|
||||
let elem_natural = elem_kind.natural_alignment();
|
||||
let elem_align = field_alignment(element_schema, struct_default_align, elem_natural);
|
||||
let stride = round_up(elem_size, elem_align);
|
||||
|
||||
let min_items = obj
|
||||
.get("minItems")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
let max_items = obj
|
||||
.get("maxItems")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize);
|
||||
let fixed_count = match (min_items, max_items) {
|
||||
(Some(mn), Some(mx)) if mn == mx => Some(mn),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let array_align = field_alignment(field_schema, struct_default_align, elem_align);
|
||||
|
||||
if let Some(count) = fixed_count {
|
||||
align_up(&mut self.offset, array_align);
|
||||
let start = self.offset;
|
||||
for i in 0..count {
|
||||
let elem_start = start + i * stride;
|
||||
let elem_end = elem_start + elem_size;
|
||||
let elem_path = format!("{field_path}[{i}]");
|
||||
self.push(&elem_path, elem_start, elem_end);
|
||||
}
|
||||
let array_size = count * stride;
|
||||
self.offset = start + array_size;
|
||||
Ok(FieldLayout { align: array_align })
|
||||
} else {
|
||||
let count_prefix_align = array_align.max(4);
|
||||
align_up(&mut self.offset, count_prefix_align);
|
||||
let start = self.offset;
|
||||
self.push(field_path, start, start + 4);
|
||||
self.offset = start + 4;
|
||||
Ok(FieldLayout {
|
||||
align: count_prefix_align,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the layout for a variable-length field (String/Bytes/Record/Timestamp).
|
||||
///
|
||||
/// In aligned static mode, three strategies are supported:
|
||||
/// - `maxLength` reservation: `maxLength` bytes at a fixed offset.
|
||||
/// - `offset-indirect` encoding: an 8-byte `{offset: u32, length: u32}` pair.
|
||||
/// - inline length-prefixing (default): a 4-byte length prefix.
|
||||
fn compute_variable_field(
|
||||
&mut self,
|
||||
field_schema: &Value,
|
||||
field_path: &str,
|
||||
struct_default_align: usize,
|
||||
) -> Result<FieldLayout, TypedefError> {
|
||||
let keyword_value = field_schema
|
||||
.as_object()
|
||||
.and_then(|o| {
|
||||
o.keys()
|
||||
.find(|k| k.starts_with("TypeDef:"))
|
||||
.and_then(|k| o.get(k))
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or(Value::Bool(true));
|
||||
let encoding = parse_encoding(&keyword_value);
|
||||
let max_length = parse_max_length(field_schema);
|
||||
|
||||
let (size, natural) = match (max_length, encoding) {
|
||||
(Some(max_len), _) => (max_len, 1),
|
||||
(None, VariableEncoding::OffsetIndirect) => (8, 4),
|
||||
(None, VariableEncoding::LengthPrefixed) => (4, 4),
|
||||
};
|
||||
|
||||
let align = field_alignment(field_schema, struct_default_align, natural);
|
||||
align_up(&mut self.offset, align);
|
||||
let start = self.offset;
|
||||
self.offset += size;
|
||||
self.push(field_path, start, start + size);
|
||||
Ok(FieldLayout { align })
|
||||
}
|
||||
|
||||
/// Push a `(field_path, ByteRange)` pair onto the fields vec.
|
||||
fn push(&mut self, path: &str, start: usize, end: usize) {
|
||||
self.fields
|
||||
.push((path.to_string(), ByteRange { start, end }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the field's alignment: field-level `align` annotation,
|
||||
/// then the struct default, then the natural alignment.
|
||||
fn field_alignment(field_schema: &Value, struct_default_align: usize, natural: usize) -> usize {
|
||||
if let Some(a) = parse_align(field_schema) {
|
||||
return a.max(1);
|
||||
}
|
||||
struct_default_align.max(natural).max(1)
|
||||
}
|
||||
|
||||
/// Round `offset` up to the next multiple of `align`. No-op if `align <= 1`.
|
||||
fn align_up(offset: &mut usize, align: usize) {
|
||||
if align <= 1 {
|
||||
return;
|
||||
}
|
||||
let rem = *offset % align;
|
||||
if rem != 0 {
|
||||
*offset += align - rem;
|
||||
}
|
||||
}
|
||||
|
||||
/// Round `n` up to the next multiple of `align`.
|
||||
fn round_up(n: usize, align: usize) -> usize {
|
||||
if align <= 1 {
|
||||
return n;
|
||||
}
|
||||
let rem = n % align;
|
||||
if rem == 0 {
|
||||
n
|
||||
} else {
|
||||
n + align - rem
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn map(schema: &Value) -> OffsetMap {
|
||||
OffsetMap::compute(schema).expect("offset map computation")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_fixed_fields_natural_alignment() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("flag"), Some(&ByteRange { start: 0, end: 1 }));
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 4, end: 8 }));
|
||||
assert_eq!(m.total_size(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u8_then_u32_three_bytes_padding() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"a": { "TypeDef:Uint8": true },
|
||||
"b": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("a"), Some(&ByteRange { start: 0, end: 1 }));
|
||||
assert_eq!(m.get("b"), Some(&ByteRange { start: 4, end: 8 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_struct_dotted_paths() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"header": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"magic": { "TypeDef:Uint32": true },
|
||||
"version": { "TypeDef:Uint8": true }
|
||||
}
|
||||
},
|
||||
"body": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("header.magic"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(
|
||||
m.get("header.version"),
|
||||
Some(&ByteRange { start: 4, end: 5 })
|
||||
);
|
||||
assert_eq!(m.get("body"), Some(&ByteRange { start: 8, end: 12 }));
|
||||
assert_eq!(m.total_size(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_fixed_count_element_offsets() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"vals": {
|
||||
"TypeDef:Array": true,
|
||||
"items": { "TypeDef:Uint32": true },
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
}
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("vals[0]"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.get("vals[1]"), Some(&ByteRange { start: 4, end: 8 }));
|
||||
assert_eq!(m.get("vals[2]"), Some(&ByteRange { start: 8, end: 12 }));
|
||||
assert_eq!(m.total_size(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_variable_count_length_prefix() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"vals": {
|
||||
"TypeDef:Array": true,
|
||||
"items": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("vals"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.total_size(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_string_length_prefix_at_known_offset() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 8 }));
|
||||
assert_eq!(m.total_size(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_string_max_length_reservation() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"name": { "TypeDef:String": true, "maxLength": 256 }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 260 }));
|
||||
assert_eq!(m.total_size(), 260);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_string_offset_indirect_eight_bytes() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"blob": { "TypeDef:String": { "encoding": "offset-indirect" } }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.get("blob"), Some(&ByteRange { start: 4, end: 12 }));
|
||||
assert_eq!(m.total_size(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_byte_discriminator_rejected_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"payload": {
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
},
|
||||
"mapping": {
|
||||
"5": { "$ref": "#/$defs/Read" },
|
||||
"6": { "$ref": "#/$defs/Write" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true },
|
||||
"data": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
let reason = match err {
|
||||
TypedefError::Offset { reason, .. } => reason,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(reason.contains("ADR-102"), "reason: {reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_field_name_discriminator_rejected_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"event": {
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": { "kind": "field", "name": "type" },
|
||||
"mapping": {
|
||||
"read": { "$ref": "#/$defs/Read" },
|
||||
"write": { "$ref": "#/$defs/Write" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"type": { "TypeDef:Uint8": true },
|
||||
"handle": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"type": { "TypeDef:Uint8": true },
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_final_inline_string_rejected_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Offset { field_path, reason } => {
|
||||
assert_eq!(field_path, "name");
|
||||
assert!(reason.contains("ADR-100"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Offset, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_inline_string_allowed_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 0, end: 4 }));
|
||||
assert_eq!(m.get("name"), Some(&ByteRange { start: 4, end: 8 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_final_maxlength_string_allowed_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true, "maxLength": 256 },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("name"), Some(&ByteRange { start: 0, end: 256 }));
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 256, end: 260 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_final_offset_indirect_string_allowed_in_aligned_mode() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"blob": { "TypeDef:String": { "encoding": "offset-indirect" } },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("blob"), Some(&ByteRange { start: 0, end: 8 }));
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 8, end: 12 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_level_align_rounds_up_total() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"align": 16,
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("flag"), Some(&ByteRange { start: 0, end: 1 }));
|
||||
assert_eq!(m.total_size(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_level_align_overrides_struct_default() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"align": 1,
|
||||
"properties": {
|
||||
"tag": { "TypeDef:Uint8": true },
|
||||
"flag": { "TypeDef:Uint8": true, "align": 16 },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("tag"), Some(&ByteRange { start: 0, end: 1 }));
|
||||
assert_eq!(m.get("flag"), Some(&ByteRange { start: 16, end: 17 }));
|
||||
assert_eq!(m.get("id"), Some(&ByteRange { start: 20, end: 24 }));
|
||||
assert_eq!(m.total_size(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_align_smaller_than_struct_default() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"align": 8,
|
||||
"properties": {
|
||||
"a": { "TypeDef:Uint8": true },
|
||||
"b": { "TypeDef:Uint32": true, "align": 1 }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
assert_eq!(m.get("a"), Some(&ByteRange { start: 0, end: 1 }));
|
||||
assert_eq!(m.get("b"), Some(&ByteRange { start: 1, end: 5 }));
|
||||
assert_eq!(m.total_size(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_returns_all_paths_in_order() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"a": { "TypeDef:Uint8": true },
|
||||
"b": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let m = map(&schema);
|
||||
let paths: Vec<&String> = m.iter().map(|(p, _)| p).collect();
|
||||
assert_eq!(paths, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_rejects_non_struct_top_level() {
|
||||
let schema =
|
||||
json!({ "TypeDef:Union": true, "discriminator": { "kind": "byte" }, "mapping": {} });
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_rejects_missing_typedef_kind() {
|
||||
let schema = json!({ "type": "object", "properties": {} });
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_range_len_and_is_empty() {
|
||||
let r = ByteRange { start: 4, end: 8 };
|
||||
assert_eq!(r.len(), 4);
|
||||
assert!(!r.is_empty());
|
||||
let empty = ByteRange { start: 5, end: 5 };
|
||||
assert_eq!(empty.len(), 0);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//! Schema layer: TypeDef kind detection, annotation parsing, `$ref`
|
||||
//! normalization, and the `Endian` enum.
|
||||
//!
|
||||
//! Per ADR-097 and the schema-layer spec. This module provides the
|
||||
//! foundational types and functions that every other module depends on:
|
||||
//! `TypeDef:*` kind detection, fixed byte-size lookups, natural alignment,
|
||||
//! schema annotation parsing (`encoding`, `align`, `maxLength`, `endian`),
|
||||
//! TUnion discriminator parsing, and `$ref` normalization from TypeBox
|
||||
//! bare-name refs to JSON Pointer refs.
|
||||
|
||||
use crate::error::TypedefError;
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
const TYPEDEF_PREFIX: &str = "TypeDef:";
|
||||
|
||||
pub(crate) const U32_SIZE: usize = 4;
|
||||
pub(crate) const DISCRIMINATOR_PATH: &str = "__discriminator";
|
||||
|
||||
const BYTE_DISCRIMINATOR_TYPES: &[TypeDefKind] = &[
|
||||
TypeDefKind::Uint8,
|
||||
TypeDefKind::Uint16,
|
||||
TypeDefKind::Uint32,
|
||||
];
|
||||
|
||||
/// The 19 `TypeDef:*` kinds recognized by the engine.
|
||||
///
|
||||
/// Each variant corresponds to a `TypeDef:<name>` JSON Schema keyword.
|
||||
/// The enum provides compile-time exhaustiveness checking and integer
|
||||
/// discriminant dispatch (jump table) instead of string comparison.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TypeDefKind {
|
||||
Int8,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
Uint8,
|
||||
Uint16,
|
||||
Uint32,
|
||||
Uint64,
|
||||
Float32,
|
||||
Float64,
|
||||
Boolean,
|
||||
Enum,
|
||||
String,
|
||||
Bytes,
|
||||
Struct,
|
||||
Union,
|
||||
Array,
|
||||
Record,
|
||||
Timestamp,
|
||||
}
|
||||
|
||||
impl TypeDefKind {
|
||||
/// The JSON Schema keyword string, e.g. `"TypeDef:Int8"`.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TypeDefKind::Int8 => "TypeDef:Int8",
|
||||
TypeDefKind::Int16 => "TypeDef:Int16",
|
||||
TypeDefKind::Int32 => "TypeDef:Int32",
|
||||
TypeDefKind::Int64 => "TypeDef:Int64",
|
||||
TypeDefKind::Uint8 => "TypeDef:Uint8",
|
||||
TypeDefKind::Uint16 => "TypeDef:Uint16",
|
||||
TypeDefKind::Uint32 => "TypeDef:Uint32",
|
||||
TypeDefKind::Uint64 => "TypeDef:Uint64",
|
||||
TypeDefKind::Float32 => "TypeDef:Float32",
|
||||
TypeDefKind::Float64 => "TypeDef:Float64",
|
||||
TypeDefKind::Boolean => "TypeDef:Boolean",
|
||||
TypeDefKind::Enum => "TypeDef:Enum",
|
||||
TypeDefKind::String => "TypeDef:String",
|
||||
TypeDefKind::Bytes => "TypeDef:Bytes",
|
||||
TypeDefKind::Struct => "TypeDef:Struct",
|
||||
TypeDefKind::Union => "TypeDef:Union",
|
||||
TypeDefKind::Array => "TypeDef:Array",
|
||||
TypeDefKind::Record => "TypeDef:Record",
|
||||
TypeDefKind::Timestamp => "TypeDef:Timestamp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed byte size, or `None` for variable-size / composite kinds.
|
||||
pub fn type_size(self) -> Option<usize> {
|
||||
match self {
|
||||
TypeDefKind::Float32 | TypeDefKind::Int32 | TypeDefKind::Uint32 | TypeDefKind::Enum => {
|
||||
Some(4)
|
||||
}
|
||||
TypeDefKind::Float64 | TypeDefKind::Int64 | TypeDefKind::Uint64 => Some(8),
|
||||
TypeDefKind::Int8 | TypeDefKind::Uint8 | TypeDefKind::Boolean => Some(1),
|
||||
TypeDefKind::Int16 | TypeDefKind::Uint16 => Some(2),
|
||||
TypeDefKind::String
|
||||
| TypeDefKind::Bytes
|
||||
| TypeDefKind::Struct
|
||||
| TypeDefKind::Union
|
||||
| TypeDefKind::Array
|
||||
| TypeDefKind::Record
|
||||
| TypeDefKind::Timestamp => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Natural alignment: 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/enum,
|
||||
/// 8 for u64/i64/f64, 4 for variable-length (u32 length prefix), 1 for
|
||||
/// composites.
|
||||
pub fn natural_alignment(self) -> usize {
|
||||
match self {
|
||||
TypeDefKind::Int8 | TypeDefKind::Uint8 | TypeDefKind::Boolean => 1,
|
||||
TypeDefKind::Int16 | TypeDefKind::Uint16 => 2,
|
||||
TypeDefKind::Int32
|
||||
| TypeDefKind::Uint32
|
||||
| TypeDefKind::Float32
|
||||
| TypeDefKind::Enum => 4,
|
||||
TypeDefKind::Float64 | TypeDefKind::Int64 | TypeDefKind::Uint64 => 8,
|
||||
TypeDefKind::String
|
||||
| TypeDefKind::Bytes
|
||||
| TypeDefKind::Record
|
||||
| TypeDefKind::Timestamp => 4,
|
||||
TypeDefKind::Struct | TypeDefKind::Union | TypeDefKind::Array => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` for fixed-size primitive kinds.
|
||||
pub fn is_fixed_size(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeDefKind::Float32
|
||||
| TypeDefKind::Float64
|
||||
| TypeDefKind::Int8
|
||||
| TypeDefKind::Int16
|
||||
| TypeDefKind::Int32
|
||||
| TypeDefKind::Int64
|
||||
| TypeDefKind::Uint8
|
||||
| TypeDefKind::Uint16
|
||||
| TypeDefKind::Uint32
|
||||
| TypeDefKind::Uint64
|
||||
| TypeDefKind::Boolean
|
||||
| TypeDefKind::Enum
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `true` for kinds whose read/write functions need an `Endian` parameter.
|
||||
pub fn needs_endian(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeDefKind::Int16
|
||||
| TypeDefKind::Int32
|
||||
| TypeDefKind::Int64
|
||||
| TypeDefKind::Uint16
|
||||
| TypeDefKind::Uint32
|
||||
| TypeDefKind::Uint64
|
||||
| TypeDefKind::Float32
|
||||
| TypeDefKind::Float64
|
||||
| TypeDefKind::Enum
|
||||
| TypeDefKind::String
|
||||
| TypeDefKind::Bytes
|
||||
| TypeDefKind::Timestamp
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `true` for composite kinds (Struct, Union, Array, Record).
|
||||
pub fn is_composite(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeDefKind::Struct | TypeDefKind::Union | TypeDefKind::Array | TypeDefKind::Record
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `true` for variable-length kinds (String, Bytes, Timestamp, Record).
|
||||
pub fn is_variable_length(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeDefKind::String
|
||||
| TypeDefKind::Bytes
|
||||
| TypeDefKind::Timestamp
|
||||
| TypeDefKind::Record
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TypeDefKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TypeDefKind {
|
||||
type Err = TypedefError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"TypeDef:Int8" => Ok(TypeDefKind::Int8),
|
||||
"TypeDef:Int16" => Ok(TypeDefKind::Int16),
|
||||
"TypeDef:Int32" => Ok(TypeDefKind::Int32),
|
||||
"TypeDef:Int64" => Ok(TypeDefKind::Int64),
|
||||
"TypeDef:Uint8" => Ok(TypeDefKind::Uint8),
|
||||
"TypeDef:Uint16" => Ok(TypeDefKind::Uint16),
|
||||
"TypeDef:Uint32" => Ok(TypeDefKind::Uint32),
|
||||
"TypeDef:Uint64" => Ok(TypeDefKind::Uint64),
|
||||
"TypeDef:Float32" => Ok(TypeDefKind::Float32),
|
||||
"TypeDef:Float64" => Ok(TypeDefKind::Float64),
|
||||
"TypeDef:Boolean" => Ok(TypeDefKind::Boolean),
|
||||
"TypeDef:Enum" => Ok(TypeDefKind::Enum),
|
||||
"TypeDef:String" => Ok(TypeDefKind::String),
|
||||
"TypeDef:Bytes" => Ok(TypeDefKind::Bytes),
|
||||
"TypeDef:Struct" => Ok(TypeDefKind::Struct),
|
||||
"TypeDef:Union" => Ok(TypeDefKind::Union),
|
||||
"TypeDef:Array" => Ok(TypeDefKind::Array),
|
||||
"TypeDef:Record" => Ok(TypeDefKind::Record),
|
||||
"TypeDef:Timestamp" => Ok(TypeDefKind::Timestamp),
|
||||
other => Err(TypedefError::Schema(format!(
|
||||
"unknown TypeDef kind: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `TypeDef:*` kind string if the schema node declares one.
|
||||
/// Returns `None` if the node has no `TypeDef:*` keyword.
|
||||
///
|
||||
/// A TypeDef kind is recognized when the schema object has a key starting
|
||||
/// with `TypeDef:` whose value is `true`. (Object form with annotations
|
||||
/// like `{ "encoding": "..." }` is handled by the annotation parsers, not
|
||||
/// here — `get_typedef_kind` only checks for the presence of the keyword.)
|
||||
pub fn get_typedef_kind(node: &Value) -> Option<&str> {
|
||||
let obj = node.as_object()?;
|
||||
for key in obj.keys() {
|
||||
if key.starts_with(TYPEDEF_PREFIX) && obj.get(key) == Some(&Value::Bool(true)) {
|
||||
return Some(key.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the `TypeDefKind` enum variant if the schema node declares one
|
||||
/// (boolean form only, like `get_typedef_kind`).
|
||||
pub fn get_typedef_kind_enum(node: &Value) -> Option<TypeDefKind> {
|
||||
get_typedef_kind(node).and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
/// Byte endianness for multi-byte integer and float fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
impl Endian {
|
||||
/// Parse from the schema's `"endian"` annotation. Defaults to `Little`
|
||||
/// if the annotation is absent or unrecognized.
|
||||
pub fn from_schema(schema: &Value) -> Self {
|
||||
parse_endian(schema)
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoding strategy for a variable-length type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VariableEncoding {
|
||||
/// `[length: u32][data]` — the default. Length prefix at a known offset,
|
||||
/// variable data follows immediately.
|
||||
LengthPrefixed,
|
||||
/// `{offset: u32, length: u32}` pointing into a separate data region.
|
||||
/// The metatensor blob tensor pattern.
|
||||
OffsetIndirect,
|
||||
}
|
||||
|
||||
/// Parse the `"encoding"` annotation from a variable-length type's keyword value.
|
||||
///
|
||||
/// The keyword value may be `true` (shorthand for length-prefixed) or an
|
||||
/// object with an `"encoding"` field. Defaults to `LengthPrefixed` when
|
||||
/// absent or unrecognized.
|
||||
pub fn parse_encoding(keyword_value: &Value) -> VariableEncoding {
|
||||
match keyword_value {
|
||||
Value::Bool(true) => VariableEncoding::LengthPrefixed,
|
||||
Value::Object(obj) => {
|
||||
let encoding = obj.get("encoding").and_then(Value::as_str);
|
||||
match encoding {
|
||||
Some("offset-indirect") => VariableEncoding::OffsetIndirect,
|
||||
_ => VariableEncoding::LengthPrefixed,
|
||||
}
|
||||
}
|
||||
_ => VariableEncoding::LengthPrefixed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `"align"` annotation from a schema node. Returns `None` if not
|
||||
/// specified or not a non-negative integer.
|
||||
pub fn parse_align(node: &Value) -> Option<usize> {
|
||||
let n = node.as_object()?.get("align")?.as_u64()?;
|
||||
Some(n as usize)
|
||||
}
|
||||
|
||||
/// Parse the `"maxLength"` annotation (standard JSON Schema keyword).
|
||||
/// Returns `None` if not specified or not a non-negative integer.
|
||||
pub fn parse_max_length(node: &Value) -> Option<usize> {
|
||||
let n = node.as_object()?.get("maxLength")?.as_u64()?;
|
||||
Some(n as usize)
|
||||
}
|
||||
|
||||
/// Parse the `"endian"` annotation. Defaults to `Little` if absent or
|
||||
/// unrecognized. Operates on any node, not just the root.
|
||||
pub fn parse_endian(node: &Value) -> Endian {
|
||||
match node
|
||||
.as_object()
|
||||
.and_then(|o| o.get("endian"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("big") => Endian::Big,
|
||||
_ => Endian::Little,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of TUnion discriminator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DiscriminatorKind {
|
||||
/// Byte-offset discriminator: a fixed-size integer at a known byte offset.
|
||||
/// Mapping keys are stringified integers. Used by SFTP type bytes and
|
||||
/// call protocol event types.
|
||||
Byte {
|
||||
/// Byte position of the discriminator within the union's buffer.
|
||||
offset: usize,
|
||||
/// The `TypeDef:*` kind of the discriminator (typically
|
||||
/// `TypeDef:Uint8`).
|
||||
disc_type: TypeDefKind,
|
||||
},
|
||||
/// Field-name discriminator: a named field within the struct. Mapping keys
|
||||
/// are string values matching the discriminator field's value. The
|
||||
/// typedef.ts pattern.
|
||||
Field {
|
||||
/// The field name that holds the discriminator value.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse the `"discriminator"` annotation from a TUnion schema node.
|
||||
///
|
||||
/// Returns [`TypedefError::Schema`] for malformed discriminators (unknown
|
||||
/// `kind`, missing required `name`, or an unsupported discriminator `type`).
|
||||
pub fn parse_discriminator(node: &Value) -> Result<DiscriminatorKind, TypedefError> {
|
||||
let obj = node.as_object().ok_or_else(|| {
|
||||
TypedefError::Schema("discriminator requires a schema object".to_string())
|
||||
})?;
|
||||
let disc = obj.get("discriminator").ok_or_else(|| {
|
||||
TypedefError::Schema("union is missing 'discriminator' annotation".to_string())
|
||||
})?;
|
||||
let disc_obj = disc
|
||||
.as_object()
|
||||
.ok_or_else(|| TypedefError::Schema("'discriminator' must be an object".to_string()))?;
|
||||
let kind = disc_obj
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| TypedefError::Schema("discriminator is missing 'kind' field".to_string()))?;
|
||||
match kind {
|
||||
"byte" => {
|
||||
let offset = disc_obj.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let disc_type_str = disc_obj
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("TypeDef:Uint8");
|
||||
let disc_type: TypeDefKind = disc_type_str.parse().map_err(|_| {
|
||||
TypedefError::Schema(format!(
|
||||
"discriminator 'type' must be one of {BYTE_DISCRIMINATOR_TYPES:?}, got {disc_type_str:?}"
|
||||
))
|
||||
})?;
|
||||
if !BYTE_DISCRIMINATOR_TYPES.contains(&disc_type) {
|
||||
return Err(TypedefError::Schema(format!(
|
||||
"discriminator 'type' must be one of {BYTE_DISCRIMINATOR_TYPES:?}, got {disc_type:?}"
|
||||
)));
|
||||
}
|
||||
Ok(DiscriminatorKind::Byte {
|
||||
offset,
|
||||
disc_type,
|
||||
})
|
||||
}
|
||||
"field" => {
|
||||
let name = disc_obj
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
TypedefError::Schema(
|
||||
"field discriminator is missing required 'name' field".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
Ok(DiscriminatorKind::Field { name })
|
||||
}
|
||||
other => Err(TypedefError::Schema(format!(
|
||||
"unknown discriminator 'kind': {other:?} (expected \"byte\" or \"field\")"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a `TypeDef:*` kind from a schema node, accepting either the
|
||||
/// boolean form (`{ "TypeDef:String": true }`) or the object-annotation
|
||||
/// form (`{ "TypeDef:String": { "encoding": "..." } }`).
|
||||
///
|
||||
/// [`get_typedef_kind`] only recognizes the boolean form; layout computation
|
||||
/// and engine dispatch also need to recognize the object form so that
|
||||
/// variable-length encoding annotations don't hide the kind.
|
||||
pub fn get_typedef_kind_loose(node: &Value) -> Option<&str> {
|
||||
let obj = node.as_object()?;
|
||||
for key in obj.keys() {
|
||||
if key.starts_with(TYPEDEF_PREFIX) && obj.get(key).is_some_and(|v| !v.is_null()) {
|
||||
return Some(key.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Like [`get_typedef_kind_loose`] but returns the parsed [`TypeDefKind`] enum.
|
||||
pub fn get_typedef_kind_loose_enum(node: &Value) -> Option<TypeDefKind> {
|
||||
get_typedef_kind_loose(node).and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
/// Resolve a `$ref` against the root schema, or return the inline schema.
|
||||
///
|
||||
/// If `node` has a `"$ref"` key, parse the JSON Pointer and walk `root`.
|
||||
/// Otherwise, return `node` itself (it's an inline schema).
|
||||
pub fn resolve_ref_or_inline<'a>(node: &'a Value, root: &'a Value) -> Option<&'a Value> {
|
||||
let obj = node.as_object()?;
|
||||
if let Some(Value::String(ref_path)) = obj.get("$ref") {
|
||||
return resolve_ref(root, ref_path);
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Resolve a JSON Pointer `$ref` (e.g., `"#/$defs/Read"`) against `root`.
|
||||
pub fn resolve_ref<'a>(root: &'a Value, ref_path: &str) -> Option<&'a Value> {
|
||||
let stripped = ref_path.strip_prefix('#').unwrap_or(ref_path);
|
||||
let stripped = stripped.strip_prefix('/').unwrap_or(stripped);
|
||||
if stripped.is_empty() {
|
||||
return Some(root);
|
||||
}
|
||||
let mut current = root;
|
||||
for segment in stripped.split('/') {
|
||||
let decoded = segment.replace("~1", "/").replace("~0", "~");
|
||||
if let Ok(idx) = decoded.parse::<usize>() {
|
||||
current = current.get(idx)?;
|
||||
} else {
|
||||
current = current.get(&decoded)?;
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Walk the schema tree. For every `"$ref"` whose value is a bare name
|
||||
/// (no `#` prefix), rewrite it to `"#/$defs/<name>"`. Full JSON Pointer refs
|
||||
/// (starting with `#`) pass through unchanged. Idempotent.
|
||||
pub fn normalize_refs(schema: &mut Value) {
|
||||
normalize_refs_recursive(schema);
|
||||
}
|
||||
|
||||
fn normalize_refs_recursive(node: &mut Value) {
|
||||
if let Value::Object(obj) = node {
|
||||
if let Some(Value::String(ref s)) = obj.get("$ref") {
|
||||
if !s.starts_with('#') && !s.is_empty() {
|
||||
let new_ref = format!("#/$defs/{s}");
|
||||
if let Some(slot) = obj.get_mut("$ref") {
|
||||
*slot = Value::String(new_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
for value in obj.values_mut() {
|
||||
normalize_refs_recursive(value);
|
||||
}
|
||||
} else if let Value::Array(arr) = node {
|
||||
for item in arr.iter_mut() {
|
||||
normalize_refs_recursive(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn get_typedef_kind_detects_bool_keyword() {
|
||||
let schema = json!({"TypeDef:Uint32": true});
|
||||
assert_eq!(get_typedef_kind(&schema), Some("TypeDef:Uint32"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_typedef_kind_ignores_object_keyword() {
|
||||
let schema = json!({"TypeDef:String": {"encoding": "length-prefixed"}});
|
||||
assert_eq!(get_typedef_kind(&schema), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_typedef_kind_none_for_plain_schema() {
|
||||
let schema = json!({"type": "object", "properties": {}});
|
||||
assert_eq!(get_typedef_kind(&schema), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_size_fixed_kinds() {
|
||||
assert_eq!(TypeDefKind::Float32.type_size(), Some(4));
|
||||
assert_eq!(TypeDefKind::Float64.type_size(), Some(8));
|
||||
assert_eq!(TypeDefKind::Int8.type_size(), Some(1));
|
||||
assert_eq!(TypeDefKind::Int16.type_size(), Some(2));
|
||||
assert_eq!(TypeDefKind::Int32.type_size(), Some(4));
|
||||
assert_eq!(TypeDefKind::Int64.type_size(), Some(8));
|
||||
assert_eq!(TypeDefKind::Uint8.type_size(), Some(1));
|
||||
assert_eq!(TypeDefKind::Uint16.type_size(), Some(2));
|
||||
assert_eq!(TypeDefKind::Uint32.type_size(), Some(4));
|
||||
assert_eq!(TypeDefKind::Uint64.type_size(), Some(8));
|
||||
assert_eq!(TypeDefKind::Boolean.type_size(), Some(1));
|
||||
assert_eq!(TypeDefKind::Enum.type_size(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_size_variable_and_composite_kinds() {
|
||||
for kind in [
|
||||
TypeDefKind::String,
|
||||
TypeDefKind::Bytes,
|
||||
TypeDefKind::Struct,
|
||||
TypeDefKind::Union,
|
||||
TypeDefKind::Array,
|
||||
TypeDefKind::Record,
|
||||
TypeDefKind::Timestamp,
|
||||
] {
|
||||
assert_eq!(kind.type_size(), None, "failed for {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_size_unknown_kind_returns_none() {
|
||||
assert!("TypeDef:Uint128".parse::<TypeDefKind>().is_err());
|
||||
assert!("TypeDef:Int128".parse::<TypeDefKind>().is_err());
|
||||
assert!("not-a-typedef".parse::<TypeDefKind>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_alignment_matches_spec() {
|
||||
assert_eq!(TypeDefKind::Int8.natural_alignment(), 1);
|
||||
assert_eq!(TypeDefKind::Uint8.natural_alignment(), 1);
|
||||
assert_eq!(TypeDefKind::Boolean.natural_alignment(), 1);
|
||||
assert_eq!(TypeDefKind::Int16.natural_alignment(), 2);
|
||||
assert_eq!(TypeDefKind::Uint16.natural_alignment(), 2);
|
||||
assert_eq!(TypeDefKind::Int32.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Uint32.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Float32.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Enum.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Float64.natural_alignment(), 8);
|
||||
assert_eq!(TypeDefKind::Int64.natural_alignment(), 8);
|
||||
assert_eq!(TypeDefKind::Uint64.natural_alignment(), 8);
|
||||
assert_eq!(TypeDefKind::String.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Bytes.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Record.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Timestamp.natural_alignment(), 4);
|
||||
assert_eq!(TypeDefKind::Struct.natural_alignment(), 1);
|
||||
assert_eq!(TypeDefKind::Union.natural_alignment(), 1);
|
||||
assert_eq!(TypeDefKind::Array.natural_alignment(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fixed_size_classifies_correctly() {
|
||||
for kind in [
|
||||
TypeDefKind::Float32,
|
||||
TypeDefKind::Float64,
|
||||
TypeDefKind::Int8,
|
||||
TypeDefKind::Int16,
|
||||
TypeDefKind::Int32,
|
||||
TypeDefKind::Int64,
|
||||
TypeDefKind::Uint8,
|
||||
TypeDefKind::Uint16,
|
||||
TypeDefKind::Uint32,
|
||||
TypeDefKind::Uint64,
|
||||
TypeDefKind::Boolean,
|
||||
TypeDefKind::Enum,
|
||||
] {
|
||||
assert!(kind.is_fixed_size(), "expected fixed: {kind}");
|
||||
}
|
||||
for kind in [
|
||||
TypeDefKind::String,
|
||||
TypeDefKind::Bytes,
|
||||
TypeDefKind::Struct,
|
||||
TypeDefKind::Union,
|
||||
TypeDefKind::Array,
|
||||
TypeDefKind::Record,
|
||||
TypeDefKind::Timestamp,
|
||||
] {
|
||||
assert!(!kind.is_fixed_size(), "expected variable: {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_from_schema_defaults_to_little() {
|
||||
assert_eq!(Endian::from_schema(&json!({})), Endian::Little);
|
||||
assert_eq!(
|
||||
Endian::from_schema(&json!({"endian": "little"})),
|
||||
Endian::Little
|
||||
);
|
||||
assert_eq!(
|
||||
Endian::from_schema(&json!({"endian": "weird"})),
|
||||
Endian::Little
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_from_schema_big() {
|
||||
assert_eq!(Endian::from_schema(&json!({"endian": "big"})), Endian::Big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encoding_shorthand_true() {
|
||||
assert_eq!(
|
||||
parse_encoding(&json!(true)),
|
||||
VariableEncoding::LengthPrefixed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encoding_object_length_prefixed() {
|
||||
assert_eq!(
|
||||
parse_encoding(&json!({"encoding": "length-prefixed"})),
|
||||
VariableEncoding::LengthPrefixed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encoding_object_offset_indirect() {
|
||||
assert_eq!(
|
||||
parse_encoding(&json!({"encoding": "offset-indirect"})),
|
||||
VariableEncoding::OffsetIndirect
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encoding_unknown_defaults_to_length_prefixed() {
|
||||
assert_eq!(
|
||||
parse_encoding(&json!({"encoding": "weird"})),
|
||||
VariableEncoding::LengthPrefixed
|
||||
);
|
||||
assert_eq!(parse_encoding(&json!(42)), VariableEncoding::LengthPrefixed);
|
||||
assert_eq!(
|
||||
parse_encoding(&json!(null)),
|
||||
VariableEncoding::LengthPrefixed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_align_returns_value() {
|
||||
assert_eq!(parse_align(&json!({"align": 256})), Some(256));
|
||||
assert_eq!(parse_align(&json!({"align": 0})), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_align_none_when_absent() {
|
||||
assert_eq!(parse_align(&json!({})), None);
|
||||
assert_eq!(parse_align(&json!({"align": "not-a-number"})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_length_returns_value() {
|
||||
assert_eq!(parse_max_length(&json!({"maxLength": 1024})), Some(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_length_none_when_absent() {
|
||||
assert_eq!(parse_max_length(&json!({})), None);
|
||||
assert_eq!(parse_max_length(&json!({"maxLength": "x"})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endian_alias_matches_from_schema() {
|
||||
assert_eq!(parse_endian(&json!({"endian": "big"})), Endian::Big);
|
||||
assert_eq!(parse_endian(&json!({})), Endian::Little);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_byte_default_offset_and_type() {
|
||||
let schema = json!({"discriminator": {"kind": "byte"}});
|
||||
let disc = parse_discriminator(&schema).expect("byte discriminator");
|
||||
assert_eq!(
|
||||
disc,
|
||||
DiscriminatorKind::Byte {
|
||||
offset: 0,
|
||||
disc_type: TypeDefKind::Uint8,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_byte_explicit() {
|
||||
let schema = json!({
|
||||
"discriminator": {"kind": "byte", "offset": 4, "type": "TypeDef:Uint16"}
|
||||
});
|
||||
let disc = parse_discriminator(&schema).expect("byte discriminator");
|
||||
assert_eq!(
|
||||
disc,
|
||||
DiscriminatorKind::Byte {
|
||||
offset: 4,
|
||||
disc_type: TypeDefKind::Uint16,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_field() {
|
||||
let schema = json!({"discriminator": {"kind": "field", "name": "type"}});
|
||||
let disc = parse_discriminator(&schema).expect("field discriminator");
|
||||
assert_eq!(
|
||||
disc,
|
||||
DiscriminatorKind::Field {
|
||||
name: "type".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_missing_discriminator_is_error() {
|
||||
let schema = json!({"TypeDef:Union": true});
|
||||
assert!(matches!(
|
||||
parse_discriminator(&schema),
|
||||
Err(TypedefError::Schema(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_field_missing_name_is_error() {
|
||||
let schema = json!({"discriminator": {"kind": "field"}});
|
||||
assert!(matches!(
|
||||
parse_discriminator(&schema),
|
||||
Err(TypedefError::Schema(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_unknown_kind_is_error() {
|
||||
let schema = json!({"discriminator": {"kind": "magic"}});
|
||||
assert!(matches!(
|
||||
parse_discriminator(&schema),
|
||||
Err(TypedefError::Schema(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_byte_invalid_type_is_error() {
|
||||
let schema = json!({
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Float32"}
|
||||
});
|
||||
assert!(matches!(
|
||||
parse_discriminator(&schema),
|
||||
Err(TypedefError::Schema(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_refs_rewrites_bare_name() {
|
||||
let mut schema = json!({"$ref": "Read"});
|
||||
normalize_refs(&mut schema);
|
||||
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_refs_leaves_pointer_ref_unchanged() {
|
||||
let mut schema = json!({"$ref": "#/$defs/Read"});
|
||||
normalize_refs(&mut schema);
|
||||
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_refs_is_idempotent() {
|
||||
let mut schema = json!({"$ref": "Read"});
|
||||
normalize_refs(&mut schema);
|
||||
normalize_refs(&mut schema);
|
||||
assert_eq!(schema, json!({"$ref": "#/$defs/Read"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_refs_walks_nested_objects() {
|
||||
let mut schema = json!({
|
||||
"properties": {
|
||||
"child": {"$ref": "Child"},
|
||||
"other": {"$ref": "#/$defs/Other"}
|
||||
},
|
||||
"items": [
|
||||
{"$ref": "InArray"},
|
||||
{"foo": {"$ref": "Deep"}}
|
||||
]
|
||||
});
|
||||
normalize_refs(&mut schema);
|
||||
assert_eq!(
|
||||
schema,
|
||||
json!({
|
||||
"properties": {
|
||||
"child": {"$ref": "#/$defs/Child"},
|
||||
"other": {"$ref": "#/$defs/Other"}
|
||||
},
|
||||
"items": [
|
||||
{"$ref": "#/$defs/InArray"},
|
||||
{"foo": {"$ref": "#/$defs/Deep"}}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_refs_preserves_sibling_keys() {
|
||||
let mut schema = json!({
|
||||
"$ref": "Read",
|
||||
"typedef:annotation": "kept"
|
||||
});
|
||||
normalize_refs(&mut schema);
|
||||
assert_eq!(
|
||||
schema,
|
||||
json!({
|
||||
"$ref": "#/$defs/Read",
|
||||
"typedef:annotation": "kept"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,645 @@
|
||||
//! TUnion discriminator dispatch (ADR-097 §4).
|
||||
//!
|
||||
//! TUnion supports two discriminator kinds: byte-offset (protocol
|
||||
//! dispatch, e.g., SFTP type bytes) and field-name (typedef.ts string
|
||||
//! pattern). This module reads the discriminator value from a byte
|
||||
//! buffer, looks up the variant schema in the union's `mapping`, and
|
||||
//! reports the offset where the variant struct begins.
|
||||
//!
|
||||
//! All reads go through [`crate::data_access`] so bounds checks and
|
||||
//! endianness handling are uniform with the rest of the engine.
|
||||
|
||||
use crate::data_access::{read_enum, read_string, read_u16, read_u32, read_u8};
|
||||
use crate::error::TypedefError;
|
||||
use crate::schema::{get_typedef_kind, parse_discriminator, DiscriminatorKind, Endian, TypeDefKind, DISCRIMINATOR_PATH, U32_SIZE};
|
||||
use serde_json::Value;
|
||||
|
||||
const STRING_PREFIX_SIZE: usize = 4;
|
||||
|
||||
/// The result of reading a TUnion discriminator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnionDispatch {
|
||||
/// The mapping key (stringified discriminator value for byte-offset,
|
||||
/// string value for field-name).
|
||||
pub key: String,
|
||||
/// The byte offset where the variant struct starts.
|
||||
pub variant_offset: usize,
|
||||
/// The size of the discriminator in bytes.
|
||||
pub discriminator_size: usize,
|
||||
}
|
||||
|
||||
/// Read the discriminator value from a byte-offset TUnion.
|
||||
///
|
||||
/// The discriminator is a fixed-size integer at a known byte offset.
|
||||
/// Returns the mapping key (as a string) and the variant struct offset.
|
||||
///
|
||||
/// This is the SFTP `Packet` enum pattern — byte 0 is the type byte,
|
||||
/// bytes 1..N are the variant struct. The call protocol's event type
|
||||
/// dispatch uses the same pattern.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
|
||||
/// or malformed, or if the discriminator `type` is not one of
|
||||
/// `TypeDef:Uint8` / `TypeDef:Uint16` / `TypeDef:Uint32`.
|
||||
/// - [`TypedefError::Access`] if the buffer is too short to contain the
|
||||
/// discriminator, or if the read value is not present in the union's
|
||||
/// `mapping`.
|
||||
pub fn read_byte_discriminator(
|
||||
buffer: &[u8],
|
||||
union_schema: &Value,
|
||||
endian: Endian,
|
||||
) -> Result<UnionDispatch, TypedefError> {
|
||||
let disc = parse_discriminator(union_schema)?;
|
||||
let (offset, disc_type) = match disc {
|
||||
DiscriminatorKind::Byte { offset, disc_type } => (offset, disc_type),
|
||||
DiscriminatorKind::Field { .. } => {
|
||||
return Err(TypedefError::Schema(
|
||||
"read_byte_discriminator requires a byte-offset discriminator".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let (disc_value, discriminator_size) = match disc_type {
|
||||
TypeDefKind::Uint8 => (u32::from(read_u8(buffer, offset, DISCRIMINATOR_PATH)?), 1),
|
||||
TypeDefKind::Uint16 => (
|
||||
u32::from(read_u16(buffer, offset, DISCRIMINATOR_PATH, endian)?),
|
||||
2,
|
||||
),
|
||||
TypeDefKind::Uint32 => (read_u32(buffer, offset, DISCRIMINATOR_PATH, endian)?, 4),
|
||||
other => {
|
||||
return Err(TypedefError::Schema(format!(
|
||||
"unsupported byte discriminator type: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let key = disc_value.to_string();
|
||||
verify_mapping_key(union_schema, &key, DISCRIMINATOR_PATH, &key)?;
|
||||
|
||||
let variant_offset =
|
||||
offset
|
||||
.checked_add(discriminator_size)
|
||||
.ok_or_else(|| TypedefError::Access {
|
||||
field_path: DISCRIMINATOR_PATH.to_string(),
|
||||
reason: format!(
|
||||
"offset {offset} + discriminator_size {discriminator_size} overflows usize"
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(UnionDispatch {
|
||||
key,
|
||||
variant_offset,
|
||||
discriminator_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the discriminator value from a field-name TUnion.
|
||||
///
|
||||
/// The discriminator is a named field within the struct — its offset
|
||||
/// is computed like any other field. The consumer provides the
|
||||
/// discriminator field's offset (from the OffsetMap or LayoutBuilder).
|
||||
///
|
||||
/// This is the typedef.ts `TUnion` pattern — the discriminator is a
|
||||
/// field like any other, and the mapping keys are string values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Schema`] if the discriminator annotation is missing
|
||||
/// or malformed, the discriminator field is not declared in
|
||||
/// `properties`, the field has no `TypeDef:*` kind, or the field's
|
||||
/// kind is not one of `TypeDef:String` / `TypeDef:Uint8` /
|
||||
/// `TypeDef:Enum`.
|
||||
/// - [`TypedefError::Access`] if the buffer is too short to contain the
|
||||
/// discriminator field, or if the read value is not present in the
|
||||
/// union's `mapping`.
|
||||
pub fn read_field_discriminator(
|
||||
buffer: &[u8],
|
||||
union_schema: &Value,
|
||||
disc_field_offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<UnionDispatch, TypedefError> {
|
||||
let disc = parse_discriminator(union_schema)?;
|
||||
let name = match disc {
|
||||
DiscriminatorKind::Field { name } => name,
|
||||
DiscriminatorKind::Byte { .. } => {
|
||||
return Err(TypedefError::Schema(
|
||||
"read_field_discriminator requires a field-name discriminator".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let field_schema = union_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|props| props.get(&name))
|
||||
.ok_or_else(|| {
|
||||
TypedefError::Schema(format!(
|
||||
"discriminator field '{name}' not found in union properties"
|
||||
))
|
||||
})?;
|
||||
|
||||
let kind = get_typedef_kind(field_schema)
|
||||
.and_then(|s| s.parse::<TypeDefKind>().ok())
|
||||
.ok_or_else(|| {
|
||||
TypedefError::Schema(format!(
|
||||
"discriminator field '{name}' has no TypeDef:* kind"
|
||||
))
|
||||
})?;
|
||||
|
||||
let (key, discriminator_field_size) = match kind {
|
||||
TypeDefKind::String => {
|
||||
let s = read_string(buffer, disc_field_offset, &name, endian)?;
|
||||
let size =
|
||||
STRING_PREFIX_SIZE
|
||||
.checked_add(s.len())
|
||||
.ok_or_else(|| TypedefError::Access {
|
||||
field_path: name.clone(),
|
||||
reason: format!(
|
||||
"string prefix {STRING_PREFIX_SIZE} + data length {} overflows usize",
|
||||
s.len()
|
||||
),
|
||||
})?;
|
||||
(s.to_string(), size)
|
||||
}
|
||||
TypeDefKind::Uint8 => {
|
||||
let v = read_u8(buffer, disc_field_offset, &name)?;
|
||||
(v.to_string(), 1)
|
||||
}
|
||||
TypeDefKind::Enum => {
|
||||
let v = read_enum(buffer, disc_field_offset, &name, endian)?;
|
||||
(v.to_string(), U32_SIZE)
|
||||
}
|
||||
other => {
|
||||
return Err(TypedefError::Schema(format!(
|
||||
"unsupported discriminator field type: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
verify_mapping_key(union_schema, &key, &name, &key)?;
|
||||
|
||||
let variant_offset = disc_field_offset
|
||||
.checked_add(discriminator_field_size)
|
||||
.ok_or_else(|| TypedefError::Access {
|
||||
field_path: name.clone(),
|
||||
reason: format!(
|
||||
"disc_field_offset {disc_field_offset} + discriminator_field_size {discriminator_field_size} overflows usize"
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(UnionDispatch {
|
||||
key,
|
||||
variant_offset,
|
||||
discriminator_size: discriminator_field_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up a variant schema from the union's mapping.
|
||||
///
|
||||
/// Returns the variant schema. Inline schemas are returned directly.
|
||||
/// `$ref` pointers of the form `"#/$defs/<name>"` are resolved against
|
||||
/// the `union_schema`'s own `$defs` block (when the union schema is the
|
||||
/// schema root). For nested unions whose `$defs` live on an ancestor,
|
||||
/// the caller (typically `TypedefEngine::compile`) is expected to
|
||||
/// resolve refs before reaching this function, or to inline the
|
||||
/// variant schemas into the mapping at load time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Schema`] if the union has no `mapping` object, the
|
||||
/// `key` is not present, a `$ref` is malformed, or a `$ref` cannot be
|
||||
/// resolved against the union schema's own `$defs`.
|
||||
pub fn resolve_variant<'a>(union_schema: &'a Value, key: &str) -> Result<&'a Value, TypedefError> {
|
||||
let mapping = union_schema
|
||||
.get("mapping")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| TypedefError::Schema("union is missing 'mapping' object".to_string()))?;
|
||||
|
||||
let variant = mapping
|
||||
.get(key)
|
||||
.ok_or_else(|| TypedefError::Schema(format!("unknown mapping key: {key}")))?;
|
||||
|
||||
let ref_str = match variant.get("$ref").and_then(Value::as_str) {
|
||||
Some(r) => r,
|
||||
None => return Ok(variant),
|
||||
};
|
||||
|
||||
let pointer = ref_str
|
||||
.strip_prefix('#')
|
||||
.ok_or_else(|| TypedefError::Schema(format!("unsupported $ref form: {ref_str}")))?;
|
||||
|
||||
let resolved = resolve_json_pointer(union_schema, pointer).ok_or_else(|| {
|
||||
TypedefError::Schema(format!(
|
||||
"cannot resolve $ref {ref_str} against union schema; ensure refs are inlined or the union schema contains $defs"
|
||||
))
|
||||
})?;
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Get the discriminator size in bytes for a byte-offset discriminator.
|
||||
///
|
||||
/// Returns 1 for `TypeDef:Uint8`, 2 for `TypeDef:Uint16`, and 4 for
|
||||
/// `TypeDef:Uint32`. Field-name discriminators have no fixed size and
|
||||
/// produce a [`TypedefError::Schema`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`TypedefError::Schema`] if the discriminator annotation is
|
||||
/// missing/malformed, the discriminator `type` is unsupported, or the
|
||||
/// discriminator is a field-name discriminator.
|
||||
pub fn discriminator_size(union_schema: &Value) -> Result<usize, TypedefError> {
|
||||
let disc = parse_discriminator(union_schema)?;
|
||||
match disc {
|
||||
DiscriminatorKind::Byte { disc_type, .. } => match disc_type {
|
||||
TypeDefKind::Uint8 => Ok(1),
|
||||
TypeDefKind::Uint16 => Ok(2),
|
||||
TypeDefKind::Uint32 => Ok(4),
|
||||
other => Err(TypedefError::Schema(format!(
|
||||
"unsupported byte discriminator type: {other}"
|
||||
))),
|
||||
},
|
||||
DiscriminatorKind::Field { .. } => Err(TypedefError::Schema(
|
||||
"field-name discriminator has no fixed size".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_mapping_key(
|
||||
union_schema: &Value,
|
||||
key: &str,
|
||||
field_path: &str,
|
||||
raw_value: &str,
|
||||
) -> Result<(), TypedefError> {
|
||||
let in_mapping = union_schema
|
||||
.get("mapping")
|
||||
.and_then(Value::as_object)
|
||||
.map(|m| m.contains_key(key))
|
||||
.unwrap_or(false);
|
||||
if in_mapping {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TypedefError::Access {
|
||||
field_path: field_path.to_string(),
|
||||
reason: format!("unknown discriminator value: {raw_value}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> {
|
||||
if pointer.is_empty() {
|
||||
return Some(root);
|
||||
}
|
||||
let trimmed = pointer.strip_prefix('/')?;
|
||||
let mut current = root;
|
||||
for unescaped in trimmed.split('/') {
|
||||
let segment = unescape_json_pointer_token(unescaped)?;
|
||||
current = current.get(&segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn unescape_json_pointer_token(token: &str) -> Option<String> {
|
||||
let mut out = String::with_capacity(token.len());
|
||||
let mut chars = token.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'~' => match chars.next() {
|
||||
Some('0') => out.push('~'),
|
||||
Some('1') => out.push('/'),
|
||||
_ => return None,
|
||||
},
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const LE: Endian = Endian::Little;
|
||||
const BE: Endian = Endian::Big;
|
||||
|
||||
fn byte_union_schema(offset: usize, disc_type: &str) -> Value {
|
||||
json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "offset": offset, "type": disc_type},
|
||||
"mapping": {
|
||||
"5": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}},
|
||||
"6": {"TypeDef:Struct": true, "properties": {"len": {"TypeDef:Uint16": true}}}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn field_union_schema(field_name: &str, field_kind: &str) -> Value {
|
||||
let field_schema = match field_kind {
|
||||
"TypeDef:Enum" => json!({
|
||||
"TypeDef:Enum": true,
|
||||
"enum": ["read", "write"]
|
||||
}),
|
||||
_ => json!({field_kind: true}),
|
||||
};
|
||||
let (key_a, key_b) = match field_kind {
|
||||
"TypeDef:String" => ("read", "write"),
|
||||
_ => ("0", "1"),
|
||||
};
|
||||
json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": field_name},
|
||||
"properties": {
|
||||
field_name: field_schema
|
||||
},
|
||||
"mapping": {
|
||||
key_a: {"TypeDef:Struct": true, "properties": {"n": {"TypeDef:Uint32": true}}},
|
||||
key_b: {"TypeDef:Struct": true, "properties": {"m": {"TypeDef:Uint16": true}}}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint8_default_offset() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let buf = [5u8, 0xAA, 0xBB, 0xCC];
|
||||
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
|
||||
assert_eq!(d.key, "5");
|
||||
assert_eq!(d.variant_offset, 1);
|
||||
assert_eq!(d.discriminator_size, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint8_big_endian() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let buf = [6u8];
|
||||
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
|
||||
assert_eq!(d.key, "6");
|
||||
assert_eq!(d.variant_offset, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint16_little_endian() {
|
||||
let schema = byte_union_schema(2, "TypeDef:Uint16");
|
||||
let mut buf = vec![0u8; 4];
|
||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
||||
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
|
||||
assert_eq!(d.key, "5");
|
||||
assert_eq!(d.variant_offset, 4);
|
||||
assert_eq!(d.discriminator_size, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint16_big_endian() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint16");
|
||||
let buf = [0x00, 0x06, 0xAA, 0xBB];
|
||||
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
|
||||
assert_eq!(d.key, "6");
|
||||
assert_eq!(d.variant_offset, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint32_little_endian() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint32");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&5u32.to_le_bytes());
|
||||
let d = read_byte_discriminator(&buf, &schema, LE).expect("read");
|
||||
assert_eq!(d.key, "5");
|
||||
assert_eq!(d.variant_offset, 4);
|
||||
assert_eq!(d.discriminator_size, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint32_big_endian() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint32");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&6u32.to_be_bytes());
|
||||
let d = read_byte_discriminator(&buf, &schema, BE).expect("read");
|
||||
assert_eq!(d.key, "6");
|
||||
assert_eq!(d.variant_offset, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_unknown_value_is_access_error() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let buf = [99u8];
|
||||
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, DISCRIMINATOR_PATH);
|
||||
assert!(reason.contains("99"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_buffer_too_short_is_access_error() {
|
||||
let schema = byte_union_schema(4, "TypeDef:Uint32");
|
||||
let buf = [0u8; 2];
|
||||
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_field_kind_is_schema_error() {
|
||||
let schema = field_union_schema("type", "TypeDef:String");
|
||||
let buf = [0u8; 16];
|
||||
let err = read_byte_discriminator(&buf, &schema, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_string() {
|
||||
let schema = field_union_schema("type", "TypeDef:String");
|
||||
let mut buf = vec![0u8; 32];
|
||||
let value = "read";
|
||||
let len_bytes = (value.len() as u32).to_le_bytes();
|
||||
buf[0..4].copy_from_slice(&len_bytes);
|
||||
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
|
||||
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
|
||||
assert_eq!(d.key, "read");
|
||||
assert_eq!(d.variant_offset, 4 + value.len());
|
||||
assert_eq!(d.discriminator_size, 4 + value.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_uint8() {
|
||||
let schema = field_union_schema("type", "TypeDef:Uint8");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0] = 0;
|
||||
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
|
||||
assert_eq!(d.key, "0");
|
||||
assert_eq!(d.variant_offset, 1);
|
||||
assert_eq!(d.discriminator_size, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_enum() {
|
||||
let schema = field_union_schema("type", "TypeDef:Enum");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&0u32.to_le_bytes());
|
||||
let d = read_field_discriminator(&buf, &schema, 0, LE).expect("read");
|
||||
assert_eq!(d.key, "0");
|
||||
assert_eq!(d.variant_offset, 4);
|
||||
assert_eq!(d.discriminator_size, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_string_big_endian() {
|
||||
let schema = field_union_schema("type", "TypeDef:String");
|
||||
let mut buf = vec![0u8; 32];
|
||||
let value = "write";
|
||||
let len_bytes = (value.len() as u32).to_be_bytes();
|
||||
buf[0..4].copy_from_slice(&len_bytes);
|
||||
buf[4..4 + value.len()].copy_from_slice(value.as_bytes());
|
||||
let d = read_field_discriminator(&buf, &schema, 0, BE).expect("read");
|
||||
assert_eq!(d.key, "write");
|
||||
assert_eq!(d.variant_offset, 4 + value.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_unknown_value_is_access_error() {
|
||||
let schema = field_union_schema("type", "TypeDef:Uint8");
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0] = 99;
|
||||
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, "type");
|
||||
assert!(reason.contains("99"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_field_not_found_is_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "missing"},
|
||||
"properties": {"other": {"TypeDef:Uint8": true}},
|
||||
"mapping": {"5": {"TypeDef:Struct": true}}
|
||||
});
|
||||
let buf = [0u8; 4];
|
||||
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_no_typedef_kind_is_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "type"},
|
||||
"properties": {"type": {"type": "string"}},
|
||||
"mapping": {"read": {"TypeDef:Struct": true}}
|
||||
});
|
||||
let buf = [0u8; 4];
|
||||
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_unsupported_kind_is_schema_error() {
|
||||
let schema = field_union_schema("type", "TypeDef:Float32");
|
||||
let buf = [0u8; 8];
|
||||
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_byte_kind_is_schema_error() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let buf = [5u8];
|
||||
let err = read_field_discriminator(&buf, &schema, 0, LE).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_inline_schema() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let variant = resolve_variant(&schema, "5").expect("resolve");
|
||||
assert_eq!(
|
||||
variant.get("TypeDef:Struct").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_ref_against_own_defs() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte"},
|
||||
"mapping": {
|
||||
"5": {"$ref": "#/$defs/Read"}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
|
||||
}
|
||||
});
|
||||
let variant = resolve_variant(&schema, "5").expect("resolve");
|
||||
assert_eq!(
|
||||
variant.get("TypeDef:Struct").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_unknown_key_is_schema_error() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
let err = resolve_variant(&schema, "999").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_missing_mapping_is_schema_error() {
|
||||
let schema = json!({"TypeDef:Union": true, "discriminator": {"kind": "byte"}});
|
||||
let err = resolve_variant(&schema, "5").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_unresolvable_ref_is_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte"},
|
||||
"mapping": {
|
||||
"5": {"$ref": "#/$defs/Read"}
|
||||
}
|
||||
});
|
||||
let err = resolve_variant(&schema, "5").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_uint8() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint8");
|
||||
assert_eq!(discriminator_size(&schema).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_uint16() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint16");
|
||||
assert_eq!(discriminator_size(&schema).unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_uint32() {
|
||||
let schema = byte_union_schema(0, "TypeDef:Uint32");
|
||||
assert_eq!(discriminator_size(&schema).unwrap(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_field_kind_is_schema_error() {
|
||||
let schema = field_union_schema("type", "TypeDef:String");
|
||||
let err = discriminator_size(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_missing_discriminator_is_schema_error() {
|
||||
let schema = json!({"TypeDef:Union": true});
|
||||
let err = discriminator_size(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Custom keyword validators for all 17 `TypeDef:*` kinds, registered
|
||||
//! via `jsonschema::options().with_keyword(...)`.
|
||||
//!
|
||||
//! Per ADR-098: the `jsonschema` crate handles all structural validation;
|
||||
//! the custom keywords only validate leaf type constraints. Each validator
|
||||
//! is a small (~10 line) struct implementing [`jsonschema::Keyword`].
|
||||
//!
|
||||
//! The factory closures reject schemas where the keyword is not set to
|
||||
//! `true` (returning [`jsonschema::ValidationError::schema`]). A few
|
||||
//! factories read parent context (e.g. `maxLength`) to pass into the
|
||||
//! validator struct.
|
||||
|
||||
use crate::error::TypedefError;
|
||||
use jsonschema::{Keyword, ValidationError};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Build a jsonschema validator with all 17 `TypeDef:*` custom keywords
|
||||
/// registered.
|
||||
///
|
||||
/// The returned validator can validate JSON representations of data
|
||||
/// against the schema's type constraints. Structural validation
|
||||
/// (`properties`, `required`, `items`, `enum`, ...) is handled by
|
||||
/// jsonschema's built-in keywords; the custom keywords only check leaf
|
||||
/// type constraints (range, finiteness, RFC 3339 shape, ...).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`TypedefError::Schema`] if the schema is malformed or the
|
||||
/// underlying jsonschema validator cannot be built.
|
||||
pub fn build_validator(schema: &Value) -> Result<jsonschema::Validator, TypedefError> {
|
||||
jsonschema::options()
|
||||
.with_keyword("TypeDef:Float32", float32_factory)
|
||||
.with_keyword("TypeDef:Float64", float64_factory)
|
||||
.with_keyword("TypeDef:Int8", int8_factory)
|
||||
.with_keyword("TypeDef:Int16", int16_factory)
|
||||
.with_keyword("TypeDef:Int32", int32_factory)
|
||||
.with_keyword("TypeDef:Int64", int64_factory)
|
||||
.with_keyword("TypeDef:Uint8", uint8_factory)
|
||||
.with_keyword("TypeDef:Uint16", uint16_factory)
|
||||
.with_keyword("TypeDef:Uint32", uint32_factory)
|
||||
.with_keyword("TypeDef:Uint64", uint64_factory)
|
||||
.with_keyword("TypeDef:Boolean", boolean_factory)
|
||||
.with_keyword("TypeDef:String", string_factory)
|
||||
.with_keyword("TypeDef:Bytes", bytes_factory)
|
||||
.with_keyword("TypeDef:Enum", enum_factory)
|
||||
.with_keyword("TypeDef:Struct", struct_factory)
|
||||
.with_keyword("TypeDef:Union", union_factory)
|
||||
.with_keyword("TypeDef:Array", array_factory)
|
||||
.with_keyword("TypeDef:Record", record_factory)
|
||||
.with_keyword("TypeDef:Timestamp", timestamp_factory)
|
||||
.build(schema)
|
||||
.map_err(|e| TypedefError::Schema(format!("validator build failed: {e}")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Numeric validators (generated via macros)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
define_int_validator!(Int8Validator, int8_factory, "TypeDef:Int8", -128, 127);
|
||||
define_int_validator!(Int16Validator, int16_factory, "TypeDef:Int16", -32768, 32767);
|
||||
define_int_validator!(Int32Validator, int32_factory, "TypeDef:Int32", -2147483648, 2147483647);
|
||||
define_uint_validator!(Uint8Validator, uint8_factory, "TypeDef:Uint8", 255);
|
||||
define_uint_validator!(Uint16Validator, uint16_factory, "TypeDef:Uint16", 65535);
|
||||
define_uint_validator!(Uint32Validator, uint32_factory, "TypeDef:Uint32", 4294967295);
|
||||
|
||||
// Int64/Uint64 use the full i64/u64 range, so the macro's `n <= $max` check
|
||||
// is always true (clippy: "comparison useless due to type limits"). Write
|
||||
// them directly — the validator just checks that the JSON value is an
|
||||
// integer in the right range.
|
||||
struct Int64Validator;
|
||||
impl Keyword for Int64Validator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance.as_i64() {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(ValidationError::custom("expected an i64 integer")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance.as_i64().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn int64_factory<'a>(
|
||||
_parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new(Int64Validator))
|
||||
} else {
|
||||
Err(ValidationError::schema("TypeDef:Int64 must be set to true"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Uint64Validator;
|
||||
impl Keyword for Uint64Validator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance.as_u64() {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(ValidationError::custom("expected a u64 integer")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance.as_u64().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn uint64_factory<'a>(
|
||||
_parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new(Uint64Validator))
|
||||
} else {
|
||||
Err(ValidationError::schema("TypeDef:Uint64 must be set to true"))
|
||||
}
|
||||
}
|
||||
define_float_validator!(
|
||||
Float32Validator,
|
||||
float32_factory,
|
||||
"TypeDef:Float32",
|
||||
"expected a finite f32-compatible number"
|
||||
);
|
||||
define_float_validator!(
|
||||
Float64Validator,
|
||||
float64_factory,
|
||||
"TypeDef:Float64",
|
||||
"expected a finite f64 number"
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String and binary validators (hand-written: need maxLength from parent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct StringValidator {
|
||||
max_length: Option<usize>,
|
||||
}
|
||||
impl Keyword for StringValidator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance.as_str() {
|
||||
Some(s) => {
|
||||
if let Some(max) = self.max_length {
|
||||
if s.len() > max {
|
||||
return Err(ValidationError::custom(format!(
|
||||
"string byte length {} exceeds maxLength {max}",
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ValidationError::custom("expected a string")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance
|
||||
.as_str()
|
||||
.is_some_and(|s| self.max_length.is_none_or(|max| s.len() <= max))
|
||||
}
|
||||
}
|
||||
|
||||
struct BytesValidator {
|
||||
max_length: Option<usize>,
|
||||
}
|
||||
impl Keyword for BytesValidator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance.as_str() {
|
||||
Some(s) => {
|
||||
if let Some(max) = self.max_length {
|
||||
if s.len() > max {
|
||||
return Err(ValidationError::custom(format!(
|
||||
"bytes length {} exceeds maxLength {max}",
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ValidationError::custom("expected a string for bytes")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance
|
||||
.as_str()
|
||||
.is_some_and(|s| self.max_length.is_none_or(|max| s.len() <= max))
|
||||
}
|
||||
}
|
||||
|
||||
/// `TypeDef:Enum` is a layout marker — the built-in `enum` keyword
|
||||
/// handles value-membership validation. The custom keyword exists solely
|
||||
/// for the layout engine to recognize the type as a fixed-size u32 index.
|
||||
struct EnumValidator;
|
||||
impl Keyword for EnumValidator {
|
||||
fn validate<'i>(&self, _instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
Ok(())
|
||||
}
|
||||
fn is_valid(&self, _instance: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct TimestampValidator;
|
||||
impl Keyword for TimestampValidator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance.as_str() {
|
||||
Some(s) if is_rfc3339_timestamp(s) => Ok(()),
|
||||
_ => Err(ValidationError::custom(
|
||||
"expected an RFC 3339 timestamp string",
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance.as_str().is_some_and(is_rfc3339_timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple RFC 3339 / ISO 8601 datetime check: `YYYY-MM-DDTHH:MM:SS`
|
||||
/// optionally followed by `Z` or a timezone offset.
|
||||
fn is_rfc3339_timestamp(s: &str) -> bool {
|
||||
let parts: Vec<&str> = s.splitn(2, 'T').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let date_parts: Vec<&str> = parts[0].split('-').collect();
|
||||
if date_parts.len() != 3 {
|
||||
return false;
|
||||
}
|
||||
let time_part = parts[1];
|
||||
let time_clean = if let Some(pos) = time_part.find(['Z', '+']) {
|
||||
&time_part[..pos]
|
||||
} else if let Some(pos) = time_part.rfind('-') {
|
||||
if pos >= 8 {
|
||||
&time_part[..pos]
|
||||
} else {
|
||||
time_part
|
||||
}
|
||||
} else {
|
||||
time_part
|
||||
};
|
||||
let time_parts: Vec<&str> = time_clean.split(':').collect();
|
||||
if time_parts.len() < 2 || time_parts.len() > 3 {
|
||||
return false;
|
||||
}
|
||||
date_parts[0].parse::<u16>().is_ok_and(|y| y > 0)
|
||||
&& date_parts[1]
|
||||
.parse::<u8>()
|
||||
.is_ok_and(|m| (1..=12).contains(&m))
|
||||
&& date_parts[2]
|
||||
.parse::<u8>()
|
||||
.is_ok_and(|d| (1..=31).contains(&d))
|
||||
&& time_parts[0].parse::<u8>().is_ok_and(|h| h <= 23)
|
||||
&& time_parts[1].parse::<u8>().is_ok_and(|m| m <= 59)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite validators (generated via macros)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
define_type_validator!(StructValidator, struct_factory, "TypeDef:Struct", is_object, "expected an object");
|
||||
define_type_validator!(UnionValidator, union_factory, "TypeDef:Union", is_object, "expected an object for union");
|
||||
define_type_validator!(ArrayValidator, array_factory, "TypeDef:Array", is_array, "expected an array");
|
||||
define_type_validator!(RecordValidator, record_factory, "TypeDef:Record", is_object, "expected an object for record");
|
||||
define_type_validator!(BooleanValidator, boolean_factory, "TypeDef:Boolean", is_boolean, "expected a boolean");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory closures for non-macro-generated validators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn string_factory<'a>(
|
||||
parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if !value.is_boolean() && !value.is_object() {
|
||||
return Err(ValidationError::schema(
|
||||
"TypeDef:String must be set to true or an annotation object",
|
||||
));
|
||||
}
|
||||
let max_length = parent
|
||||
.get("maxLength")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as usize);
|
||||
Ok(Box::new(StringValidator { max_length }))
|
||||
}
|
||||
|
||||
fn bytes_factory<'a>(
|
||||
parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if !value.is_boolean() && !value.is_object() {
|
||||
return Err(ValidationError::schema(
|
||||
"TypeDef:Bytes must be set to true or an annotation object",
|
||||
));
|
||||
}
|
||||
let max_length = parent
|
||||
.get("maxLength")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as usize);
|
||||
Ok(Box::new(BytesValidator { max_length }))
|
||||
}
|
||||
|
||||
fn enum_factory<'a>(
|
||||
_parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new(EnumValidator))
|
||||
} else {
|
||||
Err(ValidationError::schema("TypeDef:Enum must be set to true"))
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_factory<'a>(
|
||||
_parent: &'a Map<String, Value>,
|
||||
value: &'a Value,
|
||||
_path: jsonschema::paths::Location,
|
||||
) -> Result<Box<dyn Keyword>, ValidationError<'a>> {
|
||||
if value.as_bool() == Some(true) {
|
||||
Ok(Box::new(TimestampValidator))
|
||||
} else {
|
||||
Err(ValidationError::schema(
|
||||
"TypeDef:Timestamp must be set to true",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn validator_for(schema: &Value) -> jsonschema::Validator {
|
||||
build_validator(schema).expect("validator should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_valid_struct_instance() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" },
|
||||
"score": { "TypeDef:Float32": true, "type": "number" },
|
||||
"flag": { "TypeDef:Uint8": true, "type": "integer" },
|
||||
"count": { "TypeDef:Uint16": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id", "score", "flag", "count"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
let instance = json!({
|
||||
"id": 42,
|
||||
"score": 3.5,
|
||||
"flag": 1,
|
||||
"count": 1000
|
||||
});
|
||||
assert!(validator.is_valid(&instance));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uint32_out_of_range() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": { "id": { "TypeDef:Uint32": true, "type": "integer" } },
|
||||
"required": ["id"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(!validator.is_valid(&json!({"id": -1})));
|
||||
assert!(!validator.is_valid(&json!({"id": 5_000_000_000u64})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_int8_range() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": { "val": { "TypeDef:Int8": true, "type": "integer" } },
|
||||
"required": ["val"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"val": 0})));
|
||||
assert!(validator.is_valid(&json!({"val": 127})));
|
||||
assert!(validator.is_valid(&json!({"val": -128})));
|
||||
assert!(!validator.is_valid(&json!({"val": 128})));
|
||||
assert!(!validator.is_valid(&json!({"val": -129})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_int16_and_int32_ranges() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"i16": { "TypeDef:Int16": true, "type": "integer" },
|
||||
"i32": { "TypeDef:Int32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["i16", "i32"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"i16": 32767, "i32": 2147483647})));
|
||||
assert!(validator.is_valid(&json!({"i16": -32768, "i32": -2147483648})));
|
||||
assert!(!validator.is_valid(&json!({"i16": 32768, "i32": 0})));
|
||||
assert!(!validator.is_valid(&json!({"i16": 0, "i32": 2147483648u64})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_uint16_and_uint32_ranges() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"u16": { "TypeDef:Uint16": true, "type": "integer" },
|
||||
"u32": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["u16", "u32"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"u16": 65535, "u32": 4294967295u64})));
|
||||
assert!(!validator.is_valid(&json!({"u16": 65536, "u32": 0})));
|
||||
assert!(!validator.is_valid(&json!({"u16": -1, "u32": 0})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_int64_range() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": { "val": { "TypeDef:Int64": true, "type": "integer" } },
|
||||
"required": ["val"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"val": 0})));
|
||||
assert!(validator.is_valid(&json!({"val": 9223372036854775807i64})));
|
||||
assert!(validator.is_valid(&json!({"val": -9223372036854775808i64})));
|
||||
assert!(!validator.is_valid(&json!({"val": "x"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_uint64_range() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": { "val": { "TypeDef:Uint64": true, "type": "integer" } },
|
||||
"required": ["val"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"val": 0})));
|
||||
assert!(validator.is_valid(&json!({"val": 18446744073709551615u64})));
|
||||
assert!(!validator.is_valid(&json!({"val": -1})));
|
||||
assert!(!validator.is_valid(&json!({"val": "x"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_float_finiteness() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"f32": { "TypeDef:Float32": true, "type": "number" },
|
||||
"f64": { "TypeDef:Float64": true, "type": "number" }
|
||||
},
|
||||
"required": ["f32", "f64"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"f32": 3.5, "f64": 2.5})));
|
||||
assert!(validator.is_valid(&json!({"f32": 0, "f64": 0})));
|
||||
assert!(!validator.is_valid(&json!({"f32": "x", "f64": 0})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_boolean() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": { "active": { "TypeDef:Boolean": true, "type": "boolean" } },
|
||||
"required": ["active"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"active": true})));
|
||||
assert!(validator.is_valid(&json!({"active": false})));
|
||||
assert!(!validator.is_valid(&json!({"active": "yes"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_string_and_maxlength() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true, "type": "string", "maxLength": 5 }
|
||||
},
|
||||
"required": ["name"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"name": "hi"})));
|
||||
assert!(validator.is_valid(&json!({"name": "hello"})));
|
||||
assert!(!validator.is_valid(&json!({"name": "toolong"})));
|
||||
assert!(!validator.is_valid(&json!({"name": 42})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_bytes_and_maxlength() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"blob": { "TypeDef:Bytes": true, "type": "string", "maxLength": 4 }
|
||||
},
|
||||
"required": ["blob"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"blob": "abcd"})));
|
||||
assert!(!validator.is_valid(&json!({"blob": "abcde"})));
|
||||
assert!(!validator.is_valid(&json!({"blob": 42})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_validator_is_noop_and_builtin_enum_handles_membership() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"TypeDef:Enum": true,
|
||||
"type": "string",
|
||||
"enum": ["ok", "error", "pending"]
|
||||
}
|
||||
},
|
||||
"required": ["status"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"status": "ok"})));
|
||||
assert!(validator.is_valid(&json!({"status": "error"})));
|
||||
assert!(!validator.is_valid(&json!({"status": "unknown"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_timestamp_rfc3339() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": { "TypeDef:Timestamp": true, "type": "string" }
|
||||
},
|
||||
"required": ["created_at"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00Z"})));
|
||||
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00"})));
|
||||
assert!(validator.is_valid(&json!({"created_at": "2026-07-20T15:30:00+02:00"})));
|
||||
assert!(!validator.is_valid(&json!({"created_at": "not-a-date"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_array_type() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"TypeDef:Array": true,
|
||||
"type": "array",
|
||||
"items": { "TypeDef:Uint8": true, "type": "integer" }
|
||||
}
|
||||
},
|
||||
"required": ["items"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"items": [1, 2, 3]})));
|
||||
assert!(!validator.is_valid(&json!({"items": "not-array"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_record_type() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"counts": {
|
||||
"TypeDef:Record": true,
|
||||
"type": "object",
|
||||
"additionalProperties": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
}
|
||||
},
|
||||
"required": ["counts"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"counts": {"a": 1, "b": 2}})));
|
||||
assert!(!validator.is_valid(&json!({"counts": "not-object"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_union_type() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"packet": {
|
||||
"TypeDef:Union": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" }
|
||||
},
|
||||
"required": ["type"]
|
||||
}
|
||||
},
|
||||
"required": ["packet"]
|
||||
});
|
||||
let validator = validator_for(&schema);
|
||||
assert!(validator.is_valid(&json!({"packet": {"type": "read"}})));
|
||||
assert!(!validator.is_valid(&json!({"packet": "not-object"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_validator_returns_schema_error_for_malformed_keyword() {
|
||||
let schema = json!({"TypeDef:Uint32": "not-a-bool"});
|
||||
let err = build_validator(&schema).expect_err("should fail");
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_validator_maps_build_error_to_typedef_error() {
|
||||
let schema = json!([1, 2, 3]);
|
||||
let err = build_validator(&schema).expect_err("schema must be an object");
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Integration tests for the `TypedefEngine` public API.
|
||||
//!
|
||||
//! Exercises the engine across both layout modes, the convenience
|
||||
//! accessors, validation convenience methods, and the aligned-mode
|
||||
//! `read_field` / `write_field` round-trip for the fixed-size primitive
|
||||
//! kinds and length-prefixed `String` / `Bytes`.
|
||||
|
||||
use alknet_typedef::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn mixed_fixed_struct_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"score": { "TypeDef:Float32": true },
|
||||
"tag": { "TypeDef:String": true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_aligned_builds_engine_with_offset_map() -> Result<(), TypedefError> {
|
||||
let mut schema = mixed_fixed_struct_schema();
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
assert_eq!(engine.mode(), LayoutMode::Aligned);
|
||||
assert!(engine.offset_map().is_some());
|
||||
assert!(engine.layout_builder().is_none());
|
||||
assert!(engine.sequential_reader().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_packed_builds_engine_with_builder_and_reader() -> Result<(), TypedefError> {
|
||||
let mut schema = mixed_fixed_struct_schema();
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
assert_eq!(engine.mode(), LayoutMode::Packed);
|
||||
assert!(engine.offset_map().is_none());
|
||||
assert!(engine.layout_builder().is_some());
|
||||
assert!(engine.sequential_reader().is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_normalizes_bare_name_refs() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"child": { "$ref": "Child" }
|
||||
},
|
||||
"$defs": {
|
||||
"Child": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "x": { "TypeDef:Uint8": true } }
|
||||
}
|
||||
}
|
||||
});
|
||||
let _engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
assert_eq!(
|
||||
schema["properties"]["child"]["$ref"],
|
||||
json!("#/$defs/Child")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_leaves_full_pointer_refs_unchanged() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"child": { "$ref": "#/$defs/Child" }
|
||||
},
|
||||
"$defs": {
|
||||
"Child": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "x": { "TypeDef:Uint8": true } }
|
||||
}
|
||||
}
|
||||
});
|
||||
let _engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
assert_eq!(
|
||||
schema["properties"]["child"]["$ref"],
|
||||
json!("#/$defs/Child")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_returns_schema_error_when_no_typedef_kind() {
|
||||
let mut schema = json!({ "type": "object", "properties": {} });
|
||||
let err = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_parsed_from_schema_big() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "big",
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
assert_eq!(engine.endian(), Endian::Big);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endian_defaults_to_little() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
assert_eq!(engine.endian(), Endian::Little);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_accepts_valid_instance() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
assert!(engine.validate_json(&json!({"id": 42})).is_ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_rejects_invalid_instance() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let err = engine.validate_json(&json!({"id": -1})).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Validation(_)), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_json_returns_bool() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true, "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
assert!(engine.is_valid_json(&json!({"id": 42})));
|
||||
assert!(!engine.is_valid_json(&json!({"id": -1})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_aligned_round_trips_all_fixed_size_kinds() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"i8": { "TypeDef:Int8": true },
|
||||
"u8": { "TypeDef:Uint8": true },
|
||||
"i16": { "TypeDef:Int16": true },
|
||||
"u16": { "TypeDef:Uint16": true },
|
||||
"i32": { "TypeDef:Int32": true },
|
||||
"u32": { "TypeDef:Uint32": true },
|
||||
"i64": { "TypeDef:Int64": true },
|
||||
"u64": { "TypeDef:Uint64": true },
|
||||
"f32": { "TypeDef:Float32": true },
|
||||
"f64": { "TypeDef:Float64": true },
|
||||
"b": { "TypeDef:Boolean": true },
|
||||
"e": { "TypeDef:Enum": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
|
||||
engine.write_field(&mut buffer, "i8", &FieldValue::I8(-127))?;
|
||||
engine.write_field(&mut buffer, "u8", &FieldValue::U8(0xAB))?;
|
||||
engine.write_field(&mut buffer, "i16", &FieldValue::I16(-32000))?;
|
||||
engine.write_field(&mut buffer, "u16", &FieldValue::U16(0xBEEF))?;
|
||||
engine.write_field(&mut buffer, "i32", &FieldValue::I32(-2_000_000_007))?;
|
||||
engine.write_field(&mut buffer, "u32", &FieldValue::U32(0xDEADBEEF))?;
|
||||
engine.write_field(&mut buffer, "i64", &FieldValue::I64(-9_000_000_000_000_000_000))?;
|
||||
engine.write_field(&mut buffer, "u64", &FieldValue::U64(0x0102030405060708))?;
|
||||
engine.write_field(&mut buffer, "f32", &FieldValue::F32(1.5))?;
|
||||
engine.write_field(&mut buffer, "f64", &FieldValue::F64(2.5))?;
|
||||
engine.write_field(&mut buffer, "b", &FieldValue::Bool(true))?;
|
||||
engine.write_field(&mut buffer, "e", &FieldValue::Enum(7))?;
|
||||
|
||||
assert_eq!(engine.read_field(&buffer, "i8")?, FieldValue::I8(-127));
|
||||
assert_eq!(engine.read_field(&buffer, "u8")?, FieldValue::U8(0xAB));
|
||||
assert_eq!(engine.read_field(&buffer, "i16")?, FieldValue::I16(-32000));
|
||||
assert_eq!(engine.read_field(&buffer, "u16")?, FieldValue::U16(0xBEEF));
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "i32")?,
|
||||
FieldValue::I32(-2_000_000_007)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "u32")?,
|
||||
FieldValue::U32(0xDEADBEEF)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "i64")?,
|
||||
FieldValue::I64(-9_000_000_000_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "u64")?,
|
||||
FieldValue::U64(0x0102030405060708)
|
||||
);
|
||||
assert_eq!(engine.read_field(&buffer, "f32")?, FieldValue::F32(1.5));
|
||||
assert_eq!(engine.read_field(&buffer, "f64")?, FieldValue::F64(2.5));
|
||||
assert_eq!(engine.read_field(&buffer, "b")?, FieldValue::Bool(true));
|
||||
assert_eq!(engine.read_field(&buffer, "e")?, FieldValue::Enum(7));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_aligned_round_trips_string() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
|
||||
let mut buffer = vec![0u8; offset_map.total_size() + 64];
|
||||
engine.write_field(&mut buffer, "name", &FieldValue::String("hello world"))?;
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "name")?,
|
||||
FieldValue::String("hello world")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_aligned_round_trips_bytes() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"blob": { "TypeDef:Bytes": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
|
||||
let payload = b"the quick brown fox".to_vec();
|
||||
let mut buffer = vec![0u8; offset_map.total_size() + payload.len()];
|
||||
engine.write_field(&mut buffer, "blob", &FieldValue::Bytes(&payload))?;
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "blob")?,
|
||||
FieldValue::Bytes(&payload)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_access_error_in_packed_mode() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
let buffer = [0u8; 4];
|
||||
let err = engine.read_field(&buffer, "id").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_access_error_in_packed_mode() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Packed)?;
|
||||
let mut buffer = [0u8; 4];
|
||||
let err = engine
|
||||
.write_field(&mut buffer, "id", &FieldValue::U32(1))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_offset_error_for_missing_path() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let buffer = [0u8; 8];
|
||||
let err = engine.read_field(&buffer, "missing").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_offset_error_for_missing_path() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let mut buffer = [0u8; 8];
|
||||
let err = engine
|
||||
.write_field(&mut buffer, "missing", &FieldValue::U32(1))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_returns_access_error_for_composite_types() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"vals": {
|
||||
"TypeDef:Array": true,
|
||||
"items": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let buffer = [0u8; 8];
|
||||
let err = engine.read_field(&buffer, "vals").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_field_returns_access_error_for_composite_value() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "id": { "TypeDef:Uint32": true } }
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let mut buffer = [0u8; 8];
|
||||
let err = engine
|
||||
.write_field(&mut buffer, "id", &FieldValue::Struct { start: 0, end: 4 })
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_aligned_reads_nested_struct_byte_range() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"header": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"version": { "TypeDef:Uint8": true },
|
||||
"magic": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode");
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
|
||||
engine.write_field(&mut buffer, "header.version", &FieldValue::U8(3))?;
|
||||
engine.write_field(&mut buffer, "header.magic", &FieldValue::U32(0xCAFEBABE))?;
|
||||
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "header.version")?,
|
||||
FieldValue::U8(3)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "header.magic")?,
|
||||
FieldValue::U32(0xCAFEBABE)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Error path integration tests for `alknet-typedef`.
|
||||
//!
|
||||
//! Exercises the `TypedefError` variants across the crate:
|
||||
//! `Access` (buffer too short, invalid UTF-8, invalid boolean byte,
|
||||
//! unknown discriminator value), `Schema` (missing TypeDef kind,
|
||||
//! malformed discriminator annotation), and `Offset` (missing
|
||||
//! variable-length field size in `LayoutBuilder::build`).
|
||||
|
||||
use alknet_typedef::data_access;
|
||||
use alknet_typedef::tunion;
|
||||
use alknet_typedef::*;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn read_u32_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 2];
|
||||
let err = data_access::read_u32(&buffer, 0, "header.id", Endian::Little).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, "header.id");
|
||||
assert!(reason.contains("bounds"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_u16_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 1];
|
||||
let err = data_access::read_u16(&buffer, 0, "tag", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_u64_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 4];
|
||||
let err = data_access::read_u64(&buffer, 0, "offset", Endian::Big).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_f32_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 2];
|
||||
let err = data_access::read_f32(&buffer, 0, "score", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_f64_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 4];
|
||||
let err = data_access::read_f64(&buffer, 0, "score", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_i32_buffer_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 2];
|
||||
let err = data_access::read_i32(&buffer, 0, "id", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_buffer_too_short_returns_access_error() {
|
||||
let buffer: [u8; 0] = [];
|
||||
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_buffer_too_short_on_prefix_returns_access_error() {
|
||||
let buffer = [0u8; 2];
|
||||
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_buffer_too_short_on_data_returns_access_error() {
|
||||
let mut buffer = vec![0u8; 6];
|
||||
buffer[0..4].copy_from_slice(&100u32.to_le_bytes());
|
||||
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_buffer_too_short_on_data_returns_access_error() {
|
||||
let mut buffer = vec![0u8; 5];
|
||||
buffer[0..4].copy_from_slice(&100u32.to_le_bytes());
|
||||
let err = data_access::read_bytes(&buffer, 0, "blob", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_invalid_utf8_returns_access_error() {
|
||||
let mut buffer = vec![0u8; 16];
|
||||
let invalid = [0xFFu8, 0xFE, 0xFD];
|
||||
let _ = data_access::write_bytes(&mut buffer, 0, &invalid, "name", Endian::Little);
|
||||
let err = data_access::read_string(&buffer, 0, "name", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_invalid_byte_returns_access_error() {
|
||||
let buffer = [0x02u8];
|
||||
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
|
||||
match err {
|
||||
TypedefError::Access { field_path, reason } => {
|
||||
assert_eq!(field_path, "flag");
|
||||
assert!(reason.contains("0x02"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Access, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_zero_is_false() -> Result<(), TypedefError> {
|
||||
let buffer = [0x00u8];
|
||||
assert!(!data_access::read_bool(&buffer, 0, "flag")?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_one_is_true() -> Result<(), TypedefError> {
|
||||
let buffer = [0x01u8];
|
||||
assert!(data_access::read_bool(&buffer, 0, "flag")?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bool_three_is_access_error() {
|
||||
let buffer = [0x03u8];
|
||||
let err = data_access::read_bool(&buffer, 0, "flag").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_u32_buffer_too_short_returns_access_error() {
|
||||
let mut buffer = [0u8; 2];
|
||||
let err = data_access::write_u32(&mut buffer, 0, 1, "id", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_string_buffer_too_short_returns_access_error() {
|
||||
let mut buffer = vec![0u8; 4];
|
||||
let err =
|
||||
data_access::write_string(&mut buffer, 0, "hello", "name", Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_missing_typedef_kind_returns_schema_error() {
|
||||
let mut schema = json!({ "type": "object", "properties": {} });
|
||||
let err = TypedefEngine::compile(&mut schema, LayoutMode::Aligned).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_compute_missing_typedef_kind_returns_schema_error() {
|
||||
let schema = json!({ "type": "object", "properties": {} });
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_compute_non_struct_top_level_returns_schema_error() {
|
||||
let schema = json!({ "TypeDef:Uint32": true });
|
||||
let err = OffsetMap::compute(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_new_missing_typedef_kind_returns_schema_error() {
|
||||
let schema = json!({ "type": "object", "properties": {} });
|
||||
let err = LayoutBuilder::new(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_new_non_struct_top_level_returns_schema_error() {
|
||||
let schema = json!({ "TypeDef:Uint32": true });
|
||||
let err = LayoutBuilder::new(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_missing_returns_schema_error() {
|
||||
let schema = json!({"TypeDef:Union": true});
|
||||
let err = parse_discriminator(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_field_missing_name_returns_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field"}
|
||||
});
|
||||
let err = parse_discriminator(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_unknown_kind_returns_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "magic"}
|
||||
});
|
||||
let err = parse_discriminator(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_byte_invalid_type_returns_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Float32"}
|
||||
});
|
||||
let err = parse_discriminator(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
|
||||
"mapping": {"5": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
|
||||
});
|
||||
let buffer = [99u8, 0x00, 0x00];
|
||||
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_buffer_too_short_returns_access_error() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "offset": 4, "type": "TypeDef:Uint32"},
|
||||
"mapping": {"5": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
|
||||
});
|
||||
let buffer = [0u8; 2];
|
||||
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "type"},
|
||||
"properties": {"type": {"TypeDef:Uint8": true}},
|
||||
"mapping": {"0": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}}
|
||||
});
|
||||
let mut buffer = vec![0u8; 8];
|
||||
buffer[0] = 99;
|
||||
let err =
|
||||
tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_missing_var_size_returns_offset_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema).expect("builder");
|
||||
let empty: HashMap<String, usize> = HashMap::new();
|
||||
let err = builder.build(&empty).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Offset { field_path, reason } => {
|
||||
assert_eq!(field_path, "name");
|
||||
assert!(
|
||||
reason.contains("missing variable-length field size"),
|
||||
"reason: {reason}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Offset, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_missing_array_data_size_returns_offset_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"vals": {
|
||||
"TypeDef:Array": true,
|
||||
"items": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema).expect("builder");
|
||||
let empty: HashMap<String, usize> = HashMap::new();
|
||||
let err = builder.build(&empty).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_missing_discriminator_value_returns_offset_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"payload": {
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
|
||||
"mapping": {"5": {"$ref": "#/$defs/Read"}}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema).expect("builder");
|
||||
let empty: HashMap<String, usize> = HashMap::new();
|
||||
let err = builder.build(&empty).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Offset { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_builder_unknown_discriminator_value_returns_offset_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"payload": {
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
|
||||
"mapping": {"5": {"$ref": "#/$defs/Read"}}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {"TypeDef:Struct": true, "properties": {"x": {"TypeDef:Uint8": true}}}
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema).expect("builder");
|
||||
let mut vs = HashMap::new();
|
||||
vs.insert("payload.__discriminator".to_string(), 99);
|
||||
let err = builder.build(&vs).unwrap_err();
|
||||
match err {
|
||||
TypedefError::Offset { reason, .. } => {
|
||||
assert!(reason.contains("99"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Offset, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_reader_buffer_too_short_returns_access_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let buffer = [0u8; 2];
|
||||
let mut reader = SequentialReader::new(&schema).unwrap();
|
||||
let err = reader.read_next(&buffer).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_reader_unknown_field_returns_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": { "a": { "TypeDef:Uint8": true } }
|
||||
});
|
||||
let buffer = [0u8; 4];
|
||||
let mut reader = SequentialReader::new(&schema).unwrap();
|
||||
let err = reader.read_field(&buffer, "missing").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_reader_new_non_struct_returns_schema_error() {
|
||||
let schema = json!({ "TypeDef:Uint32": true });
|
||||
let err = SequentialReader::new(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_indirect_data_region_too_short_returns_access_error() {
|
||||
let mut index = [0u8; 8];
|
||||
let _ = data_access::write_u32(&mut index, 0, 100, "idx.off", Endian::Little);
|
||||
let _ = data_access::write_u32(&mut index, 4, 10, "idx.len", Endian::Little);
|
||||
let data_region = b"too short";
|
||||
let err = data_access::read_bytes_indirect(&index, 0, data_region, "blob", Endian::Little)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_indirect_index_too_short_returns_access_error() {
|
||||
let buffer = [0u8; 4];
|
||||
let data_region = b"anything";
|
||||
let err = data_access::read_bytes_indirect(&buffer, 0, data_region, "blob", Endian::Little)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_indirect_invalid_utf8_returns_access_error() {
|
||||
let data_region: &[u8] = &[0xFF, 0xFE, 0xFD];
|
||||
let mut index = [0u8; 8];
|
||||
let _ = data_access::write_u32(&mut index, 0, 0, "idx.off", Endian::Little);
|
||||
let _ = data_access::write_u32(&mut index, 4, 3, "idx.len", Endian::Little);
|
||||
let err = data_access::read_string_indirect(&index, 0, data_region, "name", Endian::Little)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! POC round-trip tests adapted from `/workspace/alknet-typedef-poc/`.
|
||||
//!
|
||||
//! These tests re-validate the byte-identical round-trip behaviour that
|
||||
//! the POC verified: fixed-size primitives, length-prefixed strings and
|
||||
//! bytes, nested structs, big-endian, alignment padding, packed-layout
|
||||
//! `LayoutBuilder` with `data_access` writes, and `SequentialReader`
|
||||
//! walks. Each test writes values to a buffer at computed offsets and
|
||||
//! reads them back, asserting both the values and (where applicable)
|
||||
//! the byte positions.
|
||||
|
||||
use alknet_typedef::data_access;
|
||||
use alknet_typedef::tunion;
|
||||
use alknet_typedef::*;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn var_sizes(pairs: &[(&str, usize)]) -> HashMap<String, usize> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_size_round_trip_via_offset_map() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"score": { "TypeDef:Float32": true },
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"count": { "TypeDef:Uint16": true }
|
||||
}
|
||||
});
|
||||
let offset_map = OffsetMap::compute(&schema)?;
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
|
||||
let id_range = offset_map.get("id").expect("id range");
|
||||
data_access::write_u32(&mut buffer, id_range.start, 42, "id", Endian::Little)?;
|
||||
let score_range = offset_map.get("score").expect("score range");
|
||||
data_access::write_f32(&mut buffer, score_range.start, 1.5, "score", Endian::Little)?;
|
||||
let flag_range = offset_map.get("flag").expect("flag range");
|
||||
data_access::write_u8(&mut buffer, flag_range.start, 1, "flag")?;
|
||||
let count_range = offset_map.get("count").expect("count range");
|
||||
data_access::write_u16(
|
||||
&mut buffer,
|
||||
count_range.start,
|
||||
1000,
|
||||
"count",
|
||||
Endian::Little,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
data_access::read_u32(&buffer, id_range.start, "id", Endian::Little)?,
|
||||
42
|
||||
);
|
||||
let score = data_access::read_f32(&buffer, score_range.start, "score", Endian::Little)?;
|
||||
assert!((score - 1.5).abs() < 0.001, "score: {score}");
|
||||
assert_eq!(data_access::read_u8(&buffer, flag_range.start, "flag")?, 1);
|
||||
assert_eq!(
|
||||
data_access::read_u16(&buffer, count_range.start, "count", Endian::Little)?,
|
||||
1000
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_size_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"score": { "TypeDef:Float32": true },
|
||||
"flag": { "TypeDef:Uint8": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
|
||||
engine.write_field(&mut buffer, "id", &FieldValue::U32(42))?;
|
||||
engine.write_field(&mut buffer, "score", &FieldValue::F32(1.5))?;
|
||||
engine.write_field(&mut buffer, "flag", &FieldValue::U8(1))?;
|
||||
|
||||
assert_eq!(engine.read_field(&buffer, "id")?, FieldValue::U32(42));
|
||||
let score = match engine.read_field(&buffer, "score")? {
|
||||
FieldValue::F32(f) => f,
|
||||
other => panic!("expected F32, got {other:?}"),
|
||||
};
|
||||
assert!((score - 1.5).abs() < 0.001);
|
||||
assert_eq!(engine.read_field(&buffer, "flag")?, FieldValue::U8(1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_round_trip_via_data_access() -> Result<(), TypedefError> {
|
||||
let mut buffer = vec![0u8; 32];
|
||||
let written = data_access::write_string(&mut buffer, 0, "hello", "name", Endian::Little)?;
|
||||
assert_eq!(written, 4 + 5);
|
||||
assert_eq!(buffer[0..4], 5u32.to_le_bytes());
|
||||
assert_eq!(&buffer[4..9], b"hello");
|
||||
assert_eq!(
|
||||
data_access::read_string(&buffer, 0, "name", Endian::Little)?,
|
||||
"hello"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"name": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode has offset_map");
|
||||
let mut buffer = vec![0u8; offset_map.total_size() + 64];
|
||||
engine.write_field(&mut buffer, "name", &FieldValue::String("hello"))?;
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "name")?,
|
||||
FieldValue::String("hello")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_round_trip_via_data_access() -> Result<(), TypedefError> {
|
||||
let payload = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let mut buffer = vec![0u8; 32];
|
||||
let written = data_access::write_bytes(&mut buffer, 0, &payload, "data", Endian::Little)?;
|
||||
assert_eq!(written, 4 + 4);
|
||||
assert_eq!(buffer[0..4], 4u32.to_le_bytes());
|
||||
assert_eq!(&buffer[4..8], &payload);
|
||||
assert_eq!(
|
||||
data_access::read_bytes(&buffer, 0, "data", Endian::Little)?,
|
||||
&payload[..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_struct_round_trip_via_offset_map() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"header": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"version": { "TypeDef:Uint32": true },
|
||||
"magic": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"payload": { "TypeDef:Bytes": true }
|
||||
}
|
||||
});
|
||||
let offset_map = OffsetMap::compute(&schema)?;
|
||||
|
||||
let header_version = offset_map.get("header.version").expect("header.version");
|
||||
let header_magic = offset_map.get("header.magic").expect("header.magic");
|
||||
let payload_prefix = offset_map.get("payload").expect("payload");
|
||||
|
||||
assert_eq!(header_version.start, 0);
|
||||
assert_eq!(header_magic.start, 4);
|
||||
assert_eq!(payload_prefix.start, 8);
|
||||
|
||||
let data = b"body-data".to_vec();
|
||||
let mut buffer = vec![0u8; offset_map.total_size() + data.len()];
|
||||
data_access::write_u32(
|
||||
&mut buffer,
|
||||
header_version.start,
|
||||
1,
|
||||
"header.version",
|
||||
Endian::Little,
|
||||
)?;
|
||||
data_access::write_u32(
|
||||
&mut buffer,
|
||||
header_magic.start,
|
||||
0xCAFEBABE,
|
||||
"header.magic",
|
||||
Endian::Little,
|
||||
)?;
|
||||
data_access::write_bytes(
|
||||
&mut buffer,
|
||||
payload_prefix.start,
|
||||
&data,
|
||||
"payload",
|
||||
Endian::Little,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
data_access::read_u32(
|
||||
&buffer,
|
||||
header_version.start,
|
||||
"header.version",
|
||||
Endian::Little
|
||||
)?,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
data_access::read_u32(&buffer, header_magic.start, "header.magic", Endian::Little)?,
|
||||
0xCAFEBABE
|
||||
);
|
||||
assert_eq!(
|
||||
data_access::read_bytes(&buffer, payload_prefix.start, "payload", Endian::Little)?,
|
||||
&data[..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_struct_round_trip_via_engine_aligned() -> Result<(), TypedefError> {
|
||||
let mut schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"header": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"version": { "TypeDef:Uint8": true },
|
||||
"flags": { "TypeDef:Uint8": true }
|
||||
}
|
||||
},
|
||||
"payload_len": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let engine = TypedefEngine::compile(&mut schema, LayoutMode::Aligned)?;
|
||||
let offset_map = engine.offset_map().expect("aligned mode");
|
||||
|
||||
assert_eq!(offset_map.get("header.version").unwrap().start, 0);
|
||||
assert_eq!(offset_map.get("header.flags").unwrap().start, 1);
|
||||
assert_eq!(offset_map.get("payload_len").unwrap().start, 4);
|
||||
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
engine.write_field(&mut buffer, "header.version", &FieldValue::U8(1))?;
|
||||
engine.write_field(&mut buffer, "header.flags", &FieldValue::U8(0x0F))?;
|
||||
engine.write_field(&mut buffer, "payload_len", &FieldValue::U32(1024))?;
|
||||
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "header.version")?,
|
||||
FieldValue::U8(1)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "header.flags")?,
|
||||
FieldValue::U8(0x0F)
|
||||
);
|
||||
assert_eq!(
|
||||
engine.read_field(&buffer, "payload_len")?,
|
||||
FieldValue::U32(1024)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_endian_round_trip_via_offset_map() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "big",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"offset": { "TypeDef:Float64": true }
|
||||
}
|
||||
});
|
||||
let offset_map = OffsetMap::compute(&schema)?;
|
||||
let endian = Endian::from_schema(&schema);
|
||||
assert_eq!(endian, Endian::Big);
|
||||
|
||||
let id_range = offset_map.get("id").expect("id");
|
||||
let offset_range = offset_map.get("offset").expect("offset");
|
||||
|
||||
assert_eq!(id_range.start, 0);
|
||||
assert_eq!(offset_range.start, 8);
|
||||
|
||||
let value: f64 = std::f64::consts::PI;
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
data_access::write_u32(&mut buffer, id_range.start, 0x01020304, "id", endian)?;
|
||||
data_access::write_f64(&mut buffer, offset_range.start, value, "offset", endian)?;
|
||||
|
||||
assert_eq!(&buffer[0..4], &[0x01, 0x02, 0x03, 0x04]);
|
||||
assert_eq!(&buffer[4..8], &[0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(&buffer[8..16], value.to_be_bytes());
|
||||
|
||||
assert_eq!(
|
||||
data_access::read_u32(&buffer, id_range.start, "id", endian)?,
|
||||
0x01020304
|
||||
);
|
||||
let read = data_access::read_f64(&buffer, offset_range.start, "offset", endian)?;
|
||||
assert!((read - value).abs() < 1e-12);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_padding_round_trip_u8_then_u32() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true }
|
||||
}
|
||||
});
|
||||
let offset_map = OffsetMap::compute(&schema)?;
|
||||
|
||||
let flag_range = offset_map.get("flag").expect("flag");
|
||||
let id_range = offset_map.get("id").expect("id");
|
||||
|
||||
assert_eq!(flag_range.start, 0);
|
||||
assert_eq!(flag_range.end, 1);
|
||||
assert_eq!(id_range.start, 4);
|
||||
assert_eq!(id_range.end, 8);
|
||||
assert_eq!(offset_map.total_size(), 8);
|
||||
|
||||
let mut buffer = vec![0u8; offset_map.total_size()];
|
||||
data_access::write_u8(&mut buffer, flag_range.start, 0xAB, "flag")?;
|
||||
data_access::write_u32(
|
||||
&mut buffer,
|
||||
id_range.start,
|
||||
0x01020304,
|
||||
"id",
|
||||
Endian::Little,
|
||||
)?;
|
||||
|
||||
assert_eq!(buffer[0], 0xAB);
|
||||
assert_eq!(&buffer[1..4], &[0x00, 0x00, 0x00]);
|
||||
assert_eq!(&buffer[4..8], 0x01020304u32.to_le_bytes());
|
||||
|
||||
assert_eq!(
|
||||
data_access::read_u8(&buffer, flag_range.start, "flag")?,
|
||||
0xAB
|
||||
);
|
||||
assert_eq!(
|
||||
data_access::read_u32(&buffer, id_range.start, "id", Endian::Little)?,
|
||||
0x01020304
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_layout_round_trip_via_layout_builder() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"flag": { "TypeDef:Uint8": true },
|
||||
"id": { "TypeDef:Uint32": true },
|
||||
"payload": { "TypeDef:String": true }
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema)?;
|
||||
let layout = builder.build(&var_sizes(&[("payload", 10)]))?;
|
||||
|
||||
let flag_pos = layout.get("flag").expect("flag");
|
||||
let id_pos = layout.get("id").expect("id");
|
||||
let payload_pos = layout.get("payload").expect("payload");
|
||||
|
||||
assert_eq!(flag_pos.offset, 0);
|
||||
assert_eq!(id_pos.offset, 1);
|
||||
assert_eq!(payload_pos.offset, 5);
|
||||
assert_eq!(layout.total_size(), 19);
|
||||
|
||||
let payload_str = "ten bytes!";
|
||||
let payload_bytes = payload_str.as_bytes();
|
||||
assert_eq!(payload_bytes.len(), 10);
|
||||
let mut buffer = vec![0u8; layout.total_size()];
|
||||
data_access::write_u8(&mut buffer, flag_pos.offset, 0xAB, "flag")?;
|
||||
data_access::write_u32(&mut buffer, id_pos.offset, 0x01020304, "id", Endian::Little)?;
|
||||
data_access::write_string(
|
||||
&mut buffer,
|
||||
payload_pos.offset,
|
||||
payload_str,
|
||||
"payload",
|
||||
Endian::Little,
|
||||
)?;
|
||||
|
||||
assert_eq!(buffer[0], 0xAB);
|
||||
assert_eq!(&buffer[1..5], 0x01020304u32.to_le_bytes());
|
||||
assert_eq!(&buffer[5..9], 10u32.to_le_bytes());
|
||||
assert_eq!(&buffer[9..19], payload_bytes);
|
||||
|
||||
assert_eq!(
|
||||
data_access::read_u8(&buffer, flag_pos.offset, "flag")?,
|
||||
0xAB
|
||||
);
|
||||
assert_eq!(
|
||||
data_access::read_u32(&buffer, id_pos.offset, "id", Endian::Little)?,
|
||||
0x01020304
|
||||
);
|
||||
assert_eq!(
|
||||
data_access::read_string(&buffer, payload_pos.offset, "payload", Endian::Little)?,
|
||||
payload_str
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_reader_round_trip_packed_buffer() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"id": { "TypeDef:Uint8": true },
|
||||
"name": { "TypeDef:String": true },
|
||||
"tail": { "TypeDef:Uint8": true }
|
||||
}
|
||||
});
|
||||
let builder = LayoutBuilder::new(&schema)?;
|
||||
let payload = "hello";
|
||||
let layout = builder.build(&var_sizes(&[("name", payload.len())]))?;
|
||||
|
||||
let mut buffer = vec![0u8; layout.total_size()];
|
||||
data_access::write_u8(&mut buffer, 0, 7, "id")?;
|
||||
data_access::write_string(&mut buffer, 1, payload, "name", Endian::Little)?;
|
||||
let after = 1 + 4 + payload.len();
|
||||
data_access::write_u8(&mut buffer, after, 99, "tail")?;
|
||||
|
||||
let mut reader = SequentialReader::new(&schema)?;
|
||||
assert_eq!(reader.endian(), Endian::Little);
|
||||
assert_eq!(reader.position(), 0);
|
||||
|
||||
let (name, value) = reader.read_next(&buffer)?.expect("field 0");
|
||||
assert_eq!(name, "id");
|
||||
assert_eq!(value, FieldValue::U8(7));
|
||||
assert_eq!(reader.position(), 1);
|
||||
|
||||
let (name, value) = reader.read_next(&buffer)?.expect("field 1");
|
||||
assert_eq!(name, "name");
|
||||
assert_eq!(value, FieldValue::String("hello"));
|
||||
assert_eq!(reader.position(), after);
|
||||
|
||||
let (name, value) = reader.read_next(&buffer)?.expect("field 2");
|
||||
assert_eq!(name, "tail");
|
||||
assert_eq!(value, FieldValue::U8(99));
|
||||
assert_eq!(reader.position(), after + 1);
|
||||
|
||||
assert!(reader.read_next(&buffer)?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_reader_read_field_walks_preceding_fields() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Struct": true,
|
||||
"endian": "little",
|
||||
"properties": {
|
||||
"a": { "TypeDef:Uint8": true },
|
||||
"b": { "TypeDef:Uint32": true },
|
||||
"c": { "TypeDef:Uint8": true }
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 16];
|
||||
data_access::write_u8(&mut buffer, 0, 1, "a")?;
|
||||
data_access::write_u32(&mut buffer, 1, 0xDEADBEEF, "b", Endian::Little)?;
|
||||
data_access::write_u8(&mut buffer, 5, 9, "c")?;
|
||||
|
||||
let mut reader = SequentialReader::new(&schema)?;
|
||||
let value = reader.read_field(&buffer, "c")?;
|
||||
assert_eq!(value, FieldValue::U8(9));
|
||||
assert_eq!(reader.position(), 6);
|
||||
|
||||
reader.reset();
|
||||
let value = reader.read_field(&buffer, "b")?;
|
||||
assert_eq!(value, FieldValue::U32(0xDEADBEEF));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunion_byte_offset_discriminator_dispatch() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
},
|
||||
"mapping": {
|
||||
"5": { "$ref": "#/$defs/Read" },
|
||||
"6": { "$ref": "#/$defs/Write" }
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true },
|
||||
"data": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 32];
|
||||
buffer[0] = 5;
|
||||
data_access::write_u32(&mut buffer, 1, 0x01020304, "Read.handle", Endian::Big)?;
|
||||
data_access::write_u32(&mut buffer, 5, 4096, "Read.length", Endian::Big)?;
|
||||
|
||||
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
|
||||
assert_eq!(dispatch.key, "5");
|
||||
assert_eq!(dispatch.variant_offset, 1);
|
||||
assert_eq!(dispatch.discriminator_size, 1);
|
||||
|
||||
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
|
||||
assert_eq!(
|
||||
variant
|
||||
.get("TypeDef:Struct")
|
||||
.and_then(serde_json::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunion_byte_offset_discriminator_size_lookup() -> Result<(), TypedefError> {
|
||||
let u8_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
|
||||
"mapping": {}
|
||||
});
|
||||
let u16_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint16"},
|
||||
"mapping": {}
|
||||
});
|
||||
let u32_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint32"},
|
||||
"mapping": {}
|
||||
});
|
||||
assert_eq!(tunion::discriminator_size(&u8_schema)?, 1);
|
||||
assert_eq!(tunion::discriminator_size(&u16_schema)?, 2);
|
||||
assert_eq!(tunion::discriminator_size(&u32_schema)?, 4);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Integration tests for TUnion discriminator dispatch.
|
||||
//!
|
||||
//! Exercises both discriminator kinds end-to-end: byte-offset (SFTP
|
||||
//! pattern) and field-name (typedef.ts pattern). Verifies that
|
||||
//! `read_byte_discriminator` / `read_field_discriminator` produce the
|
||||
//! correct mapping key and variant offset, that `resolve_variant`
|
||||
//! follows `$ref` pointers, and that `discriminator_size` reports the
|
||||
//! right fixed sizes.
|
||||
|
||||
use alknet_typedef::data_access;
|
||||
use alknet_typedef::tunion;
|
||||
use alknet_typedef::{Endian, TypedefError};
|
||||
use serde_json::json;
|
||||
|
||||
fn sftp_like_byte_union() -> serde_json::Value {
|
||||
json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
},
|
||||
"mapping": {
|
||||
"5": { "$ref": "#/$defs/Read" },
|
||||
"6": { "$ref": "#/$defs/Write" }
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true },
|
||||
"data": { "TypeDef:Uint32": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint8_dispatches_to_read() -> Result<(), TypedefError> {
|
||||
let union_schema = sftp_like_byte_union();
|
||||
let mut buffer = vec![0u8; 16];
|
||||
buffer[0] = 5;
|
||||
data_access::write_u32(&mut buffer, 1, 0x01020304, "Read.handle", Endian::Big)?;
|
||||
|
||||
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
|
||||
assert_eq!(dispatch.key, "5");
|
||||
assert_eq!(dispatch.variant_offset, 1);
|
||||
assert_eq!(dispatch.discriminator_size, 1);
|
||||
|
||||
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
|
||||
assert_eq!(
|
||||
variant
|
||||
.get("TypeDef:Struct")
|
||||
.and_then(serde_json::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint8_dispatches_to_write() -> Result<(), TypedefError> {
|
||||
let union_schema = sftp_like_byte_union();
|
||||
let mut buffer = vec![0u8; 16];
|
||||
buffer[0] = 6;
|
||||
data_access::write_u32(&mut buffer, 1, 0xDEADBEEF, "Write.handle", Endian::Big)?;
|
||||
|
||||
let dispatch = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big)?;
|
||||
assert_eq!(dispatch.key, "6");
|
||||
assert_eq!(dispatch.variant_offset, 1);
|
||||
assert_eq!(dispatch.discriminator_size, 1);
|
||||
|
||||
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
|
||||
let props = variant
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("variant has properties");
|
||||
assert!(props.contains_key("data"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint16_little_endian() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 2,
|
||||
"type": "TypeDef:Uint16"
|
||||
},
|
||||
"mapping": {
|
||||
"5": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 16];
|
||||
buffer[2..4].copy_from_slice(&5u16.to_le_bytes());
|
||||
let dispatch = tunion::read_byte_discriminator(&buffer, &schema, Endian::Little)?;
|
||||
assert_eq!(dispatch.key, "5");
|
||||
assert_eq!(dispatch.variant_offset, 4);
|
||||
assert_eq!(dispatch.discriminator_size, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_uint32_big_endian() -> Result<(), TypedefError> {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint32"
|
||||
},
|
||||
"mapping": {
|
||||
"101": {"TypeDef:Struct": true, "properties": {"id": {"TypeDef:Uint32": true}}}
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 16];
|
||||
buffer[0..4].copy_from_slice(&101u32.to_be_bytes());
|
||||
let dispatch = tunion::read_byte_discriminator(&buffer, &schema, Endian::Big)?;
|
||||
assert_eq!(dispatch.key, "101");
|
||||
assert_eq!(dispatch.variant_offset, 4);
|
||||
assert_eq!(dispatch.discriminator_size, 4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_byte_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
|
||||
let union_schema = sftp_like_byte_union();
|
||||
let buffer = [99u8, 0x00, 0x00, 0x00];
|
||||
let err = tunion::read_byte_discriminator(&buffer, &union_schema, Endian::Big).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_string_dispatches_to_read() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "type"},
|
||||
"properties": {
|
||||
"type": { "TypeDef:String": true }
|
||||
},
|
||||
"mapping": {
|
||||
"read": {"$ref": "#/$defs/Read"},
|
||||
"write": {"$ref": "#/$defs/Write"}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"length": { "TypeDef:Uint32": true }
|
||||
}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {
|
||||
"handle": { "TypeDef:Uint32": true },
|
||||
"data": { "TypeDef:Bytes": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let value = "read";
|
||||
let mut buffer = vec![0u8; 32];
|
||||
data_access::write_string(&mut buffer, 0, value, "type", Endian::Little)?;
|
||||
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
|
||||
assert_eq!(dispatch.key, "read");
|
||||
assert_eq!(dispatch.variant_offset, 4 + value.len());
|
||||
assert_eq!(dispatch.discriminator_size, 4 + value.len());
|
||||
|
||||
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
|
||||
assert_eq!(
|
||||
variant
|
||||
.get("TypeDef:Struct")
|
||||
.and_then(serde_json::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_string_dispatches_to_write() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "type"},
|
||||
"properties": {
|
||||
"type": { "TypeDef:String": true }
|
||||
},
|
||||
"mapping": {
|
||||
"read": {"$ref": "#/$defs/Read"},
|
||||
"write": {"$ref": "#/$defs/Write"}
|
||||
},
|
||||
"$defs": {
|
||||
"Read": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {"x": {"TypeDef:Uint8": true}}
|
||||
},
|
||||
"Write": {
|
||||
"TypeDef:Struct": true,
|
||||
"properties": {"y": {"TypeDef:Uint16": true}}
|
||||
}
|
||||
}
|
||||
});
|
||||
let value = "write";
|
||||
let mut buffer = vec![0u8; 32];
|
||||
data_access::write_string(&mut buffer, 0, value, "type", Endian::Little)?;
|
||||
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
|
||||
assert_eq!(dispatch.key, "write");
|
||||
assert_eq!(dispatch.variant_offset, 4 + value.len());
|
||||
|
||||
let variant = tunion::resolve_variant(&union_schema, &dispatch.key)?;
|
||||
let props = variant
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("variant has properties");
|
||||
assert!(props.contains_key("y"));
|
||||
assert!(!props.contains_key("x"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_uint8_field() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "tag"},
|
||||
"properties": {
|
||||
"tag": { "TypeDef:Uint8": true }
|
||||
},
|
||||
"mapping": {
|
||||
"0": {"TypeDef:Struct": true, "properties": {"a": {"TypeDef:Uint32": true}}},
|
||||
"1": {"TypeDef:Struct": true, "properties": {"b": {"TypeDef:Uint16": true}}}
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 8];
|
||||
buffer[0] = 0;
|
||||
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
|
||||
assert_eq!(dispatch.key, "0");
|
||||
assert_eq!(dispatch.variant_offset, 1);
|
||||
assert_eq!(dispatch.discriminator_size, 1);
|
||||
|
||||
buffer[0] = 1;
|
||||
let dispatch = tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little)?;
|
||||
assert_eq!(dispatch.key, "1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_field_discriminator_unknown_value_returns_access_error() -> Result<(), TypedefError> {
|
||||
let union_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "tag"},
|
||||
"properties": {
|
||||
"tag": { "TypeDef:Uint8": true }
|
||||
},
|
||||
"mapping": {
|
||||
"0": {"TypeDef:Struct": true, "properties": {"a": {"TypeDef:Uint32": true}}}
|
||||
}
|
||||
});
|
||||
let mut buffer = vec![0u8; 8];
|
||||
buffer[0] = 99;
|
||||
let err =
|
||||
tunion::read_field_discriminator(&buffer, &union_schema, 0, Endian::Little).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Access { .. }), "got {err:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_returns_correct_values() -> Result<(), TypedefError> {
|
||||
let u8_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint8"},
|
||||
"mapping": {}
|
||||
});
|
||||
let u16_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint16"},
|
||||
"mapping": {}
|
||||
});
|
||||
let u32_schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "byte", "type": "TypeDef:Uint32"},
|
||||
"mapping": {}
|
||||
});
|
||||
assert_eq!(tunion::discriminator_size(&u8_schema)?, 1);
|
||||
assert_eq!(tunion::discriminator_size(&u16_schema)?, 2);
|
||||
assert_eq!(tunion::discriminator_size(&u32_schema)?, 4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_size_field_kind_returns_schema_error() {
|
||||
let schema = json!({
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {"kind": "field", "name": "type"},
|
||||
"properties": {"type": {"TypeDef:Uint8": true}},
|
||||
"mapping": {}
|
||||
});
|
||||
let err = tunion::discriminator_size(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_variant_returns_schema_error_for_unknown_key() {
|
||||
let union_schema = sftp_like_byte_union();
|
||||
let err = tunion::resolve_variant(&union_schema, "999").unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_discriminator_missing_returns_schema_error() {
|
||||
let schema = json!({"TypeDef:Union": true});
|
||||
let err = alknet_typedef::parse_discriminator(&schema).unwrap_err();
|
||||
assert!(matches!(err, TypedefError::Schema(_)), "got {err:?}");
|
||||
}
|
||||
+120
-59
@@ -1,12 +1,48 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-16
|
||||
last_updated: 2026-07-19
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
|
||||
## Current State
|
||||
|
||||
**Per-identity channel cap added (ADR-094, 2026-07-19).** The
|
||||
channels-layer `max_channels = 256` cap (ADR-076) was framed as the
|
||||
per-connection DoS defense. On review this is not a DoS defense at
|
||||
all — a peer can open an unbounded number of transport connections,
|
||||
so a per-connection cap bounds a connection's reassembly-buffer cost,
|
||||
not a peer's total channels. The only coherent unit for a channel
|
||||
DoS defense is the identity. [ADR-094](decisions/094-per-identity-channel-cap.md)
|
||||
records the corrected design: a `ChannelLifecyclePolicy` trait in
|
||||
`channels-call` (where the identity is already on `OperationContext`),
|
||||
consulted by the `channel/open` handler (after `AccessControl::check`,
|
||||
before allocation) and the `channel/close` handler (after the drain
|
||||
completes). Default: `PerIdentityChannelPolicy::new(256)` — 256 per
|
||||
`PeerId` across all the peer's connections (no "NoOp default + wire it
|
||||
later"). The policy `Arc` is shared across every channels connection
|
||||
a peer accepts, which is what makes the cap per-identity, not
|
||||
per-connection. ADR-076 is amended — the per-connection `max_channels`
|
||||
is reframed as a memory bound, the "DoS defense summary" table is
|
||||
removed, and the "per-connection, not per-peer — a peer can open more
|
||||
channels on a second connection" line (the channels layer confessing
|
||||
a hole and hoping the layer above would fill it) is corrected. The
|
||||
cap lives in `channels-call`, not `channels-core`, because the
|
||||
channels layer is auth-blind by design (ADR-075 — that is what makes
|
||||
it WASM-compatible, transport-agnostic, and ALPN-blind). The cap is a
|
||||
peer concern, not a hub-specific concern — any accepting peer (worker
|
||||
or hub) enforces it, the same way it enforces `AccessControl::check`.
|
||||
For the hub-relay path (ADR-079), the spoke sees the hub as the direct
|
||||
caller (ADR-032 — `forwarded_for` is metadata, not authority, for the
|
||||
cap as for `AccessControl::check`), so the spoke caps the hub, not the
|
||||
browser; a spoke serving a high-fan-out hub sets the hub peer's cap
|
||||
higher via `with_per_identity_caps`. The "assembly layer" hedging
|
||||
pattern (putting the hard question off on a fictional later that
|
||||
turns out to be exactly the same problem) is actively avoided — the
|
||||
default is secure out of the box, and per-peer-role overrides are
|
||||
explicit opt-ins. See [ADR-094](decisions/094-per-identity-channel-cap.md)
|
||||
and the amended [ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md).
|
||||
|
||||
**Client-dial SOCKS5 proxy seam added (ADR-090, 2026-07-16).**
|
||||
`AlknetClient` (ADR-089) gains an optional SOCKS5 proxy
|
||||
(`with_socks5_proxy`) so a native client can hide its real IP from the
|
||||
@@ -78,78 +114,86 @@ The [overview.md](overview.md) crate graph and ALPN registry are
|
||||
rewritten to match.
|
||||
|
||||
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing
|
||||
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
|
||||
proxy — `ProtocolHandler` on `alknet/channels`, 8-byte chunk format, N
|
||||
channels over transport stream(s), channel 0 pre-negotiated as
|
||||
`alknet/call`) now has architecture specs:
|
||||
[crates/channels/](crates/channels/) (overview, channels-wire,
|
||||
channels-connection, channels-adapter, channel-operations, channel-client)
|
||||
and eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
|
||||
chunk header; revised for substrate simplification — the header is used in
|
||||
all substrates including QUIC native, not just in-line; and stream_type
|
||||
decomposition — every stream_type is unidirectional, grouped in threes:
|
||||
0/1/2 = data write/read/err, 3/4/5 = control write/read/err, `% 3` formula;
|
||||
resolves the TTY control channel's "not actually bidirectional" flaw),
|
||||
and twelve ADRs — [ADR-071](decisions/071-channels-wire-format.md) (8-byte
|
||||
chunk header; amended by ADR-093 — the channels layer has no `stream_type`
|
||||
concept; the handler owns its sub-stream multiplexing on the `BiStream`),
|
||||
[ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
|
||||
(channel 0 = `alknet/call` pre-negotiated, stream_types [0,1] — call frames
|
||||
bidirectional via 0=in, 1=out),
|
||||
(channel 0 = `alknet/call` pre-negotiated; the call protocol's
|
||||
`EventEnvelope` framing is the channels payload, carried transparently),
|
||||
[ADR-073](decisions/073-channel-lifecycle-operations.md) (channel
|
||||
lifecycle operations on the call protocol — `channel/open`/`close`/
|
||||
`control`/`resources/subscribe`; `channel/resources/subscribe` is a
|
||||
`Subscription` operation using the already-implemented `StreamingHandler`
|
||||
machinery, not a polled `Query`; the `direction` field pins who is the
|
||||
ALPN-server; the control-message division is call-ops for orchestration,
|
||||
`stream_type 3`/`4` for data-ordered control),
|
||||
ALPN-server; amended by ADR-093 — `stream_types` field removed from
|
||||
`channel/open`, `stream_type` field removed from `channel/control`),
|
||||
[ADR-074](decisions/074-channelconnection-bidistreamsource.md)
|
||||
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
|
||||
extension point; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv
|
||||
per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH),
|
||||
extension point; amended by ADR-093 — `into_sub_streams()` removed;
|
||||
`accept_bi()` is the only accessor, yields one `BiStream` per channel),
|
||||
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
|
||||
(`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off
|
||||
(`ChannelsAdapter` substrate-agnostic demux loop (reads 8-byte headers off
|
||||
every bidi stream, regardless of substrate) + `ChannelManager`
|
||||
reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
|
||||
shutdown emits zero-length sentinel, transport close drops all senders, mux
|
||||
dynamic registration, lenient unknown-`channel_id`),
|
||||
[ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md)
|
||||
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
|
||||
with wrap-around),
|
||||
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses
|
||||
sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4]
|
||||
with control properly bidirectional via 3 (write) + 4 (read); ADR-052's
|
||||
scope amended to direct-connect TTY only; `channels` feature on alknet-tty),
|
||||
(bounded-buffer backpressure 1 MiB default per channel, 256-channel cap,
|
||||
monotonic IDs with wrap-around; amended by ADR-093 — per-`channel_id`,
|
||||
not per-`(channel_id, stream_type)`),
|
||||
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels —
|
||||
**reversed by ADR-093**: TTY always uses its 5-byte format, carried
|
||||
transparently in the channels payload; the two-mode design is preserved
|
||||
but differs only in `BiStream` source, not in parsing; the control channel
|
||||
split is TTY-internal, not channels-layer),
|
||||
[ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump
|
||||
handlers MUST shut down the opposite sink on pump completion — the
|
||||
deadlock contract the POC surfaced; handler-level, not channels-layer;
|
||||
core helper extraction deferred per OQ-57),
|
||||
[ADR-079](decisions/079-hub-relay-translate-not-forward.md) (hub relay
|
||||
translates `channel/open` on channel 0 with `forwarded_for` — ADR-032;
|
||||
data channels byte-forwarded with `channel_id` rewrite; the hub never runs
|
||||
protocol-specific handlers),
|
||||
data channels byte-forwarded with `channel_id` rewrite (4-byte field
|
||||
rewrite within the 8-byte header); the hub never runs protocol-specific
|
||||
handlers),
|
||||
[ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
|
||||
transport-agnostic `from_connection` primary; `connect_quic` removed
|
||||
per ADR-089 §5 (dial extracted to `AlknetClient`),
|
||||
bidirectionality preserved; `AlknetClient` dial-seam extracted as
|
||||
`alknet-client` per ADR-089, resolving OQ-55),
|
||||
transport-agnostic `from_connection` primary; dial lives in
|
||||
`AlknetClient` (`alknet-client`, ADR-089, resolving OQ-55);
|
||||
bidirectionality preserved; no `stream_types` on `open_channel`/`Channel`
|
||||
per ADR-093),
|
||||
[ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate
|
||||
decomposition — `channels-core` (pure multiplexer, depends on alknet-core
|
||||
only, no call dependency) / `channels-call` (channel 0 pre-negotiation +
|
||||
lifecycle op registrations, depends on channels-core + alknet-call) /
|
||||
`channels-hub` (relay) / `channels-worker` (ChannelClient); isolates the
|
||||
call-protocol coupling from the pure multiplexer). The specs are grounded
|
||||
in the completed de-risk POC
|
||||
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
|
||||
validated targets: chunk format + demux/mux, per-channel `Connection`
|
||||
presentation, tunnel handler). The core prerequisite — ADR-070
|
||||
(`BidiStreamSource` trait + `Connection::from_source`) — is landed and
|
||||
implemented. The spec work converted three research hedges into decisions:
|
||||
call-protocol coupling from the pure multiplexer; amended by ADR-093 —
|
||||
8-byte wire format, `ChannelSubStreams`/`SubStreamHandle` removed),
|
||||
[ADR-093](decisions/093-channels-pure-channel-multiplexing.md) (the
|
||||
umbrella decision: channels layer is pure channel multiplexing — 8-byte
|
||||
header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only,
|
||||
TTY always 5-byte; amends ADR-071/074/077 and the channels-facing clauses
|
||||
of ADR-072/073/075/076/080/081). The specs are grounded in the completed
|
||||
de-risk POC (`docs/research/alknet-channels/poc-summary.md`, 28 tests
|
||||
passing, three validated targets: chunk format + demux/mux, per-channel
|
||||
`Connection` presentation, tunnel handler) and the stream-unification
|
||||
research (`docs/research/stream-unification/findings.md`, which surfaced
|
||||
the pure-multiplexing resolution). The core prerequisite — ADR-070
|
||||
(`BidiStreamSource` trait + `Connection::from_source`) + ADR-092
|
||||
(`BiStream` as the handler leaf) — is landed and implemented. The spec
|
||||
work converted three research hedges into decisions:
|
||||
`channel/resources` is subscribe from day one (not poll-for-v1), channel
|
||||
ID allocation is server-assigned (not "if zero-RTT needed"), and
|
||||
backpressure is bounded-buffer (not "if HOL blocking becomes a problem").
|
||||
Two genuine deferrals: OQ-56 (full windowing — blocked on a real HOL-
|
||||
blocking observation) and OQ-57 (two-pump helper extraction — blocked on a
|
||||
second two-pump handler). The TTY integration (ADR-077) amends ADR-052's
|
||||
scope — the 5-byte format is unchanged for direct `alknet/tty` connections;
|
||||
inside channels, TTY uses `into_sub_streams()` and the channels layer's
|
||||
de-chunking, with control properly bidirectional via stream_types 3/4.
|
||||
blocking observation) and OQ-57 (two-pump helper extraction — blocked on
|
||||
a second two-pump handler). ADR-093 is the channels-layer consequence of
|
||||
ADR-092's `BiStream` handler-leaf decision — every channel is a
|
||||
`BiStream`, the handler owns its sub-stream multiplexing, the channels
|
||||
layer has no `stream_type` concept.
|
||||
|
||||
**Pre-implementation of the storage/repo pattern.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains `alknet-vault` (stable — implementation complete and verified, local-only by construction per ADR-025, HD-derivation key model per ADR-026) and research/reference material. Foundational ADRs (001–035) are in place, with the call crate implemented and reviewed.
|
||||
|
||||
@@ -210,7 +254,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph (core mono-repo scope per ADR-085), hub/worker model, shared types, design principles |
|
||||
| [open-questions.md](open-questions.md) | draft | OQ index — theme-grouped tables + Deferred/Blocked section; per-OQ files in [`questions/`](questions/) |
|
||||
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index — shared types + auth + config (endpoint extracted to `alknet-endpoint` per ADR-083 Am. 2026-07-15; `ConnectionCredentials`/`RemoteIdentity` moved here from `alknet-call` per ADR-091; `CallCredentials` removed per ADR-091 Am. 2026-07-17) |
|
||||
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index — shared types + auth + config (endpoint in `alknet-endpoint` per ADR-083 Am. 2026-07-15; `ConnectionCredentials`/`RemoteIdentity` here per ADR-091) |
|
||||
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
|
||||
| [crates/core/endpoint.md](crates/core/endpoint.md) | deprecated | Endpoint spec — **moved to `alknet-endpoint`** (ADR-083 Am. 2026-07-15); see [`crates/endpoint/README.md`](crates/endpoint/README.md) |
|
||||
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext (incl. `anonymous` constructor), Identity, IdentityProvider, AuthToken, resolution flow |
|
||||
@@ -218,7 +262,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
|
||||
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
|
||||
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery (hand-rolled, no irpc) |
|
||||
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
|
||||
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial in `AlknetClient` per ADR-089), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-066) |
|
||||
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
|
||||
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
|
||||
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
|
||||
@@ -242,16 +286,22 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [crates/vault/service.md](crates/vault/service.md) | stable | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
|
||||
| [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior |
|
||||
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery |
|
||||
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` moved here from `alknet-call` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) |
|
||||
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; `CallClient::connect`/`ChannelClient::connect_quic` removed (dial centralized here); `alknet/register` named (wire protocol deferred, OQ-66) |
|
||||
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; `EndpointError` removed (vestigial); handler crates no longer transitively link quinn/iroh |
|
||||
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream |
|
||||
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); `FingerprintPinVerifier` in `alknet-tls` (ADR-089 §5); `webpki-roots` fallback for empty platform stores (ADR-088 §5); isolates cert-reuse from transport wrappers (ADR-082) |
|
||||
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key) unified on `&ConnectionCredentials` (ADR-091); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only + HTTP-to-SOCKS5 bridge for iroh; OQ-67 resolved); produces `Connection` for `CallClient`/`ChannelClient` take-over; dial centralized here; `alknet/register` named (wire protocol deferred, OQ-66) |
|
||||
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; handler crates no longer transitively link quinn/iroh |
|
||||
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 8-byte chunk format, N channels over one transport stream |
|
||||
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
|
||||
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
| [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `into_sub_streams()` typed accessor, recursive composition |
|
||||
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 8-byte chunk format, the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
| [crates/channels/channels-connection.md](crates/channels/channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource`), `accept_bi` yields `BiStream`, recursive composition |
|
||||
| [crates/channels/channels-adapter.md](crates/channels/channels-adapter.md) | draft | `ChannelsAdapter`, `ChannelManager`, demux/mux contracts (REQ-CH-01..04), two-pump pattern (ADR-078) |
|
||||
| [crates/channels/channel-operations.md](crates/channels/channel-operations.md) | draft | `channel/open`/`close`/`control`/`resources/subscribe`, ACL flow, `direction` semantics, hub relay contract (ADR-079) |
|
||||
| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
|
||||
| [crates/channels/channel-client.md](crates/channels/channel-client.md) | draft | `ChannelClient` — client side of a channels connection, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089); bidirectionality preserved |
|
||||
| [crates/typedef/README.md](crates/typedef/README.md) | draft | alknet-typedef crate — binary struct engine; JSON Schema with `TypeDef:*` custom keywords → offset map + read/write + validation |
|
||||
| [crates/typedef/overview.md](crates/typedef/overview.md) | draft | Crate purpose, "schema is the format" principle, dependencies, consumers, scope boundaries |
|
||||
| [crates/typedef/schema-layer.md](crates/typedef/schema-layer.md) | draft | The 19 `TypeDef:*` kinds, jsonschema custom keyword integration, TypeBox interop, schema annotations |
|
||||
| [crates/typedef/layout-engine.md](crates/typedef/layout-engine.md) | draft | Offset computation, two layout modes (packed sequential vs aligned static), alignment, endianness, variable-length handling |
|
||||
| [crates/typedef/data-access.md](crates/typedef/data-access.md) | draft | Read/write functions, TUnion dispatch, field paths, zero-copy access, length-prefix reading |
|
||||
| [crates/typedef/validation.md](crates/typedef/validation.md) | draft | Custom keyword validators for all 19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time validation |
|
||||
|
||||
## ADR Table
|
||||
|
||||
@@ -327,31 +377,42 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [068](decisions/068-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations Override | Proposed |
|
||||
| [069](decisions/069-from-call-manual-free-function.md) | from_call Is a Manual Free Function, Not Auto-Wired | Proposed |
|
||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | Accepted |
|
||||
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 9-Byte Chunk Header | Accepted |
|
||||
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted |
|
||||
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted |
|
||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted |
|
||||
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted |
|
||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted |
|
||||
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (amends ADR-052 scope — 5-byte format scoped to direct TTY) |
|
||||
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format — 8-Byte Chunk Header | Accepted (amended by ADR-093 — 8-byte header, no `stream_type`) |
|
||||
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Accepted (amended by ADR-093 — channel 0's `stream_types` field removed; the call protocol's framing is the channels payload) |
|
||||
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted (amended by ADR-093 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`) |
|
||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted (amended by ADR-093 — `into_sub_streams()` removed; `accept_bi` yields `BiStream`) |
|
||||
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted (amended by ADR-093 — 8-byte headers, one reassembly buffer per channel) |
|
||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`; amended by ADR-094 — per-connection `max_channels` reframed as a memory bound, not a DoS defense; per-identity DoS defense lives in `channels-call` via `ChannelLifecyclePolicy`) |
|
||||
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently) |
|
||||
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
|
||||
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
|
||||
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
|
||||
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
|
||||
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted (amended by ADR-093 — `stream_types` field removed from `open_channel` and `Channel`) |
|
||||
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted (amended by ADR-093 — 8-byte wire format; `ChannelSubStreams`/`SubStreamHandle` removed) |
|
||||
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) |
|
||||
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external; amended 2026-07-15 — endpoint extracted from `alknet-core` into `alknet-endpoint`; `EndpointError` removed — both variants vestigial, `shutdown()` infallible) |
|
||||
| [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted |
|
||||
| [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted |
|
||||
| [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted |
|
||||
| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted (§5 amended by ADR-089 — `FingerprintPinVerifier` moves to `alknet-tls`; `alknet-call` sheds TLS deps; input framing amended by ADR-091 — `ClientVerifierContext` derived from `ConnectionCredentials`, not `CallCredentials`) |
|
||||
| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted (§5 amended by ADR-089 — `FingerprintPinVerifier` in `alknet-tls`; `alknet-call` sheds TLS deps; input framing amended by ADR-091 — `TlsClientConfig::new` takes `ConnectionCredentials`, not `CallCredentials`) |
|
||||
| [088](decisions/088-tlserror-shape.md) | `TlsError` Shape — Single Enum, Owned by `alknet-tls` | Accepted (§5 added — `webpki-roots` fallback when platform store is empty; §7 references ADR-089 for handshake-error surfacing) |
|
||||
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55; `CallClient::connect` / `ChannelClient::connect_quic` removed; §3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`; `CallCredentials` removed per ADR-091 Am. 2026-07-17; `FingerprintPinVerifier` moved to `alknet-tls`; `ClientError` removed; `alknet-call` sheds TLS deps) |
|
||||
| [090](decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | Accepted (§5 amended 2026-07-16 — OQ-67 resolved: iroh force-relay-only + HTTP-to-SOCKS5 bridge) |
|
||||
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) |
|
||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi` | Accepted (amends ADR-070's `accept_bi` return type; amends ADR-065's `from_stream`/`from_bidi` constructors; amends ADR-074's `ChannelBidiStreamSource::accept_bi` return type; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor) |
|
||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | Accepted (amends ADR-071 — 8-byte header; ADR-074 — `into_sub_streams` removed; reverses ADR-077 — TTY always uses its 5-byte format; amends the channels-facing clauses of ADR-072/073/075/076/080/081) |
|
||||
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap as DoS Defense | Accepted (amends ADR-076 — per-connection `max_channels` reframed as a memory bound; 256 per `PeerId` enforced via `ChannelLifecyclePolicy` in `channels-call`; symmetric; spoke caps hub as direct caller) |
|
||||
| [095](decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | alknet-typedef — Purpose, Scope, and the jsonschema Engine | Accepted |
|
||||
| [096](decisions/096-two-layout-modes-packed-vs-aligned.md) | Two Layout Modes — Packed Sequential vs Aligned Static | Accepted |
|
||||
| [097](decisions/097-schema-annotations.md) | Schema Annotations — Endianness, Alignment, Encoding, and TUnion Discriminators | Accepted |
|
||||
| [098](decisions/098-error-handling-validation-strategy.md) | Error Handling and Validation Strategy | Accepted |
|
||||
| [099](decisions/099-int64-uint64-first-class-kinds.md) | Int64/Uint64 as First-Class Kinds | Accepted |
|
||||
| [100](decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Reject Non-Final Inline Length-Prefixed Variable Fields in Aligned Mode | Accepted |
|
||||
| [101](decisions/101-packed-mode-read-factory.md) | Packed-Mode Read API — Engine as SequentialReader Factory | Accepted |
|
||||
| [102](decisions/102-reject-tunion-in-aligned-mode.md) | Reject TUnion in Aligned Mode for v1 | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (67 OQs across 20 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
|
||||
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (71 OQs across 21 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
|
||||
|
||||
## Document Lifecycle
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09.
|
||||
last_updated: 2026-07-17
|
||||
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration landed. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 0–5) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-091); TLS helpers in `alknet-tls` (ADR-089 §5); dial in `alknet-client` (ADR-089); `alknet-call` is a pure protocol crate with no TLS/transport deps.
|
||||
---
|
||||
|
||||
# alknet-call
|
||||
|
||||
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), over any `AsyncRead + AsyncWrite` transport.
|
||||
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps (the dial is in `alknet-client`, the TLS config is in `alknet-tls`).
|
||||
|
||||
## Documents
|
||||
|
||||
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
||||
|----------|--------|-------------|
|
||||
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls |
|
||||
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
|
||||
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
|
||||
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-089), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-066) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
@@ -36,7 +36,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||
| [016](../../decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
|
||||
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. ~~`from_jsonschema` clause superseded by ADR-066~~ |
|
||||
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
|
||||
| [066](../../decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
||||
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
|
||||
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
|
||||
@@ -46,6 +46,8 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
||||
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-029's UUID source |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
|
||||
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
||||
| [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
|
||||
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
@@ -58,7 +60,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
||||
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays. |
|
||||
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-029) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
|
||||
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
||||
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after `connect()`. `refresh()` is a genuine feature addition. See ADR-069. |
|
||||
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-069. |
|
||||
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays) |
|
||||
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
||||
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
||||
@@ -81,7 +83,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|
||||
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
|
||||
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015, ADR-022.
|
||||
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-022. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-066, not a schema-only placeholder.)
|
||||
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; `connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
|
||||
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-089); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
|
||||
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-029.
|
||||
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-066 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
|
||||
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-014, [client-and-adapters.md](client-and-adapters.md).
|
||||
@@ -177,11 +177,10 @@ The adapter:
|
||||
|
||||
The dispatch loop is **shared** with `CallClient` (ADR-017 §1): both
|
||||
`CallAdapter::handle` (accept path) and `CallClient::spawn_dispatch`
|
||||
(connect path — the dial is now `AlknetClient::dial_*` per ADR-089 §5;
|
||||
`CallClient::connect` is removed) construct a `Dispatcher`
|
||||
(`protocol/dispatch.rs`) and call `run_loop` — the dispatch half is one
|
||||
implementation, the connection-establishment half differs (accept vs
|
||||
dial). Peer authorization flows through the existing
|
||||
(connect path — the dial is `AlknetClient::dial_*` per ADR-089) construct
|
||||
a `Dispatcher` (`protocol/dispatch.rs`) and call `run_loop` — the
|
||||
dispatch half is one implementation, the connection-establishment half
|
||||
differs (accept vs dial). Peer authorization flows through the existing
|
||||
`AccessControl::check(peer_identity)` — no `RemoteFilter`/`remote_safe` gate
|
||||
(ADR-029 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
|
||||
ADR-029 §1) to handle head→N-workers routing. See
|
||||
@@ -595,8 +594,9 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||
`SamePeerCollision`); `#[non_exhaustive]`. See
|
||||
[client-and-adapters.md](client-and-adapters.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
|
||||
free function; the assembly layer calls it after `connect()`. See
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; the assembly layer calls it after the dial (in
|
||||
`AlknetClient`). See
|
||||
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
|
||||
= error; cross-peer dissolved by ADR-029 (separate sub-overlays). See
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# alknet-call — Client and Adapters
|
||||
@@ -23,8 +23,8 @@ This document specifies three components, all in `alknet-call`:
|
||||
1. **`CallClient`** — takes over an established transport `Connection`
|
||||
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
|
||||
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
|
||||
`connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`);
|
||||
the dispatch loop is shared with the server-side `CallAdapter`
|
||||
dial lives in `AlknetClient` per ADR-089); the dispatch loop is
|
||||
shared with the server-side `CallAdapter`
|
||||
(ADR-017 §1); `CallClient` is the connection-take-over half, not a
|
||||
parallel protocol implementation.
|
||||
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
|
||||
@@ -100,10 +100,10 @@ the producer on the inbound side. Both produce the same
|
||||
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
|
||||
SSH `direct-tcpip`, a WebSocket (ADR-065 `Connection::from_stream` /
|
||||
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
||||
pre-established `Connection` from any transport; the QUIC convenience
|
||||
(`connect`) dials QUIC and calls `spawn_dispatch`. This mirrors
|
||||
`ChannelClient::from_connection` / `connect_quic` (ADR-080) and is the
|
||||
client-side analogue of the server-side generalization ADR-065 made.
|
||||
pre-established `Connection` from any transport; the dial lives in
|
||||
`AlknetClient` (`alknet-client`, ADR-089). This mirrors
|
||||
`ChannelClient::from_connection` (ADR-080) and is the client-side
|
||||
analogue of the server-side generalization ADR-065 made.
|
||||
|
||||
```rust
|
||||
pub struct CallClient {
|
||||
@@ -123,22 +123,6 @@ impl CallClient {
|
||||
/// API surface (ADR-017 Am. 2026-07-13) — it must not be coupled to
|
||||
/// a transport.
|
||||
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
|
||||
|
||||
/// **REMOVED per ADR-089 §5.** The dial is extracted into
|
||||
/// `AlknetClient` (`alknet-client`); `connect` is deleted, not
|
||||
/// delegated, to avoid `alknet-call` depending on `alknet-client`
|
||||
/// and to let `alknet-call` shed its TLS/transport deps entirely.
|
||||
/// Callers compose `AlknetClient::dial_quic(...).await?` +
|
||||
/// `CallClient::new(...).spawn_dispatch(conn)`. `ClientError` is
|
||||
/// removed (it was produced only by `connect`). `CallCredentials`
|
||||
/// is removed (its `auth_token` field had no reader; `auth_token`
|
||||
/// is a per-request payload field — ADR-091, amended 2026-07-17).
|
||||
#[cfg(feature = "quinn")]
|
||||
pub async fn connect(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials, // REMOVED — CallCredentials is removed
|
||||
) -> Result<CallConnection, ClientError>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -191,19 +175,17 @@ authorization machinery that gates every other call. No `RemoteFilter`, no
|
||||
`CallClient::spawn_dispatch(connection)` is the transport-agnostic
|
||||
primary constructor — it takes a pre-established `Connection`,
|
||||
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
|
||||
dispatch task, and returns the live `CallConnection`. `connect()` is
|
||||
**removed** per ADR-089 §5: the dial is extracted into `AlknetClient`
|
||||
(`alknet-client`), and keeping a QUIC convenience constructor on
|
||||
`CallClient` would make `alknet-call` depend on `alknet-client`,
|
||||
contradicting the dep graph (the protocol crates are parallel to the
|
||||
dial, not downstream of it). Callers compose `AlknetClient::dial_quic`
|
||||
+ `spawn_dispatch` — two lines, the dial then the take-over. Tests use
|
||||
`spawn_dispatch` directly to wire mock/loopback connections. The
|
||||
one-way-door surface is `spawn_dispatch`; the dial lives in
|
||||
`alknet-client`.
|
||||
dispatch task, and returns the live `CallConnection`. The dial lives in
|
||||
`AlknetClient` (`alknet-client`, ADR-089): keeping a QUIC convenience
|
||||
constructor on `CallClient` would make `alknet-call` depend on
|
||||
`alknet-client`, contradicting the dep graph (the protocol crates are
|
||||
parallel to the dial, not downstream of it). Callers compose
|
||||
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
||||
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
||||
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
||||
lives in `alknet-client`.
|
||||
|
||||
This mirrors `ChannelClient::from_connection` (ADR-080; its
|
||||
`connect_quic` is likewise removed per ADR-089 §5) and is the
|
||||
This mirrors `ChannelClient::from_connection` (ADR-080) and is the
|
||||
client-side analogue of the server-side generalization ADR-065 made.
|
||||
The call protocol, like the channels protocol, is transport-agnostic —
|
||||
`Connection::from_stream` / `from_bidi` (ADR-065) accept any
|
||||
@@ -238,37 +220,31 @@ peer-keying is at the aggregation layer (the head node's composition env).
|
||||
#### services/list
|
||||
|
||||
`services/list` filters by `AccessControl::check(calling_peer_identity)` —
|
||||
the calling peer sees only ops it is authorized to call. The
|
||||
`services_list_handler` / `services_list_handler_peer_scoped` split collapses
|
||||
to a single `AccessControl`-filtered handler (the `peer_scoped` variant and
|
||||
the `remote_safe` filter are removed). `services/list-peers` is the opt-in for
|
||||
peer-attributed re-export listing (each peer's sub-overlay listed with
|
||||
attribution, filtered by the calling peer's authorization). See
|
||||
[ADR-029](../../decisions/029-peer-graph-routing-model.md) §6.
|
||||
the calling peer sees only ops it is authorized to call. There is a
|
||||
single `AccessControl`-filtered handler (no `peer_scoped` variant, no
|
||||
`remote_safe` filter — both retired by ADR-029). `services/list-peers`
|
||||
is the opt-in for peer-attributed re-export listing (each peer's
|
||||
sub-overlay listed with attribution, filtered by the calling peer's
|
||||
authorization). See [ADR-029](../../decisions/029-peer-graph-routing-model.md) §6.
|
||||
|
||||
### Credential sources for connections
|
||||
|
||||
The credential dimensions are split across two layers (ADR-091, amended
|
||||
2026-07-17):
|
||||
|
||||
- **`ConnectionCredentials`** (in `alknet-core`, moved from
|
||||
`alknet-call` per ADR-091) — the **transport-level** credential
|
||||
bundle, consumed by the dial (`AlknetClient`). Carries the two
|
||||
transport-identity dimensions: `local_identity` (the local node's
|
||||
`TlsIdentity`) and `remote_identity` (the expected fingerprint). The
|
||||
dial does not depend on the call protocol for this type.
|
||||
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-091) — the
|
||||
**transport-level** credential bundle, consumed by the dial
|
||||
(`AlknetClient`). Carries the two transport-identity dimensions:
|
||||
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
||||
(the expected fingerprint). The dial does not depend on the call
|
||||
protocol for this type.
|
||||
- **`auth_token`** — a **per-request payload field**, not a
|
||||
call-protocol credential bundle. `Dispatcher::resolve_identity`
|
||||
reads `payload.get("auth_token")` on each `call.requested` payload.
|
||||
Browsers send it directly in the WebSocket call payload; the HTTP
|
||||
gateway resolves the bearer token to an `Identity` at its boundary
|
||||
(the call layer sees the identity, not the token). `CallCredentials`
|
||||
is **removed** (its `auth_token` field had no reader — `connect()`
|
||||
read only `tls_identity` + `remote_identity`; `spawn_dispatch` takes
|
||||
no credentials; the `from_call` forwarding path's `auth_token` source
|
||||
was `OpSummary.credentials_auth_token: Option<String>`, always
|
||||
`None`, never connected to `CallCredentials.auth_token`). See
|
||||
ADR-091 (amended 2026-07-17) for the full trace.
|
||||
(the call layer sees the identity, not the token). See ADR-091 for
|
||||
the credential-bundle decoupling.
|
||||
|
||||
Credentials come from `Capabilities` (ADR-014), never from environment
|
||||
variables. The transport-identity dimensions (ADR-017 §7):
|
||||
@@ -276,7 +252,7 @@ variables. The transport-identity dimensions (ADR-017 §7):
|
||||
```rust
|
||||
// Transport-level (alknet-core, consumed by the dial — ADR-091)
|
||||
pub struct ConnectionCredentials {
|
||||
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
||||
}
|
||||
|
||||
@@ -286,31 +262,29 @@ pub struct ConnectionCredentials {
|
||||
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
||||
```
|
||||
|
||||
There is no call-protocol credential bundle. `CallCredentials` is
|
||||
removed. The transport dimensions (`local_identity`, `remote_identity`)
|
||||
moved to `ConnectionCredentials` in `alknet-core` per ADR-091.
|
||||
`RemoteIdentity` (ADR-017 §7, extended by ADR-034 §2) carries a
|
||||
fingerprint string the assembly layer derives from `Capabilities` when
|
||||
the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||
fingerprint pin). `remote_identity: None` is the **public X.509
|
||||
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
||||
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
||||
selects CA verification (`WebPkiServerVerifier`) per the
|
||||
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
||||
raw-key transport, `None` fails closed (raw-key remotes are always
|
||||
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
||||
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
||||
means "trust the CA or fail" (unknown remote). An implementer must not
|
||||
default `remote_identity` to a placeholder value to "satisfy" the field
|
||||
— `None` is a real state that drives verifier selection.
|
||||
|
||||
/// Expected identity of the remote node (ADR-017 §7, extended by
|
||||
/// ADR-034 §2). Carries a fingerprint string the assembly layer
|
||||
/// derives from `Capabilities` when the local node has a `PeerEntry`
|
||||
/// for the remote (the known-peer case → fingerprint pin).
|
||||
///
|
||||
/// `remote_identity: None` is the **public X.509 endpoint** case: the
|
||||
/// local node has no `PeerEntry` for the remote, so there is no
|
||||
/// fingerprint to pin. Combined with an X.509 transport, `None`
|
||||
/// selects CA verification (`WebPkiServerVerifier`) per the
|
||||
/// verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
||||
/// raw-key transport, `None` fails closed (raw-key remotes are always
|
||||
/// known peers — no CA to fall back to).
|
||||
///
|
||||
/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)`
|
||||
/// means "pin this" (known peer), `None` means "trust the CA or fail"
|
||||
/// (unknown remote). An implementer must not default `remote_identity`
|
||||
/// to a placeholder value to "satisfy" the field — `None` is a real
|
||||
/// state that drives verifier selection.
|
||||
```rust
|
||||
pub struct RemoteIdentity { pub fingerprint: String }
|
||||
```
|
||||
|
||||
There is no call-protocol credential bundle. The transport dimensions
|
||||
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
|
||||
`alknet-core` per ADR-091.
|
||||
|
||||
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
||||
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
|
||||
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
||||
@@ -426,12 +400,12 @@ The flow (ADR-017 §3):
|
||||
`CallConnection::register_imported_all()`.
|
||||
|
||||
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
||||
the assembly layer calls it after `connect()`. The overlay is per-connection
|
||||
(Layer 2, ADR-024), so a stale overlay dies with the connection; re-import on
|
||||
reconnect is naturally scoped to the new connection. A
|
||||
`CallConnection::refresh()` method for mid-connection re-discovery is a
|
||||
genuine feature addition — non-breaking, additive — if a deployment needs
|
||||
manual re-discovery without drop-and-reconnect. See
|
||||
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
|
||||
is per-connection (Layer 2, ADR-024), so a stale overlay dies with the
|
||||
connection; re-import on reconnect is naturally scoped to the new
|
||||
connection. A `CallConnection::refresh()` method for mid-connection
|
||||
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
||||
deployment needs manual re-discovery without drop-and-reconnect. See
|
||||
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
|
||||
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-029),
|
||||
@@ -544,8 +518,8 @@ alknet-call (lean — no HTTP client, no HTTP server)
|
||||
├── OperationAdapter trait (the contract — async, per ADR-017 §5)
|
||||
├── from_call (transport-agnostic — discovers remote ops via
|
||||
│ call protocol over any Connection)
|
||||
└── CallClient (outbound connection take-over — spawn_dispatch
|
||||
transport-agnostic, connect QUIC convenience)
|
||||
└── CallClient (outbound connection take-over —
|
||||
spawn_dispatch, transport-agnostic; dial in AlknetClient)
|
||||
|
||||
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
|
||||
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
||||
@@ -735,9 +709,9 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
|
||||
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
||||
via `PeerRef::Specific` / `PeerRef::Any` (ADR-029 §1-2).
|
||||
- **`from_call` is a manual free function.** The assembly layer calls it after
|
||||
`connect()`. The overlay is per-connection so re-import on reconnect is
|
||||
naturally scoped (DC-2, OQ-27). See
|
||||
- **`from_call` is a manual free function.** The assembly layer calls it
|
||||
after the dial (in `AlknetClient`). The overlay is per-connection so
|
||||
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
|
||||
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
||||
dissolves (same name on different peers is fine — separate sub-overlays,
|
||||
@@ -769,13 +743,12 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles. ~~`from_jsonschema` clause superseded by ADR-066~~ |
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-066](../../decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
||||
| Peer-graph routing model (DC-1, supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
||||
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
||||
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
||||
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~Default-deny; `remote_safe: bool`; trusted-peer opt-in~~ — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing `AccessControl`) |
|
||||
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
||||
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
||||
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
||||
@@ -783,7 +756,7 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
||||
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of `CallCredentials` |
|
||||
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
||||
| Outgoing-only X.509 and three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
||||
| HD derivation for encryption keys | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
||||
| Vault key model | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
|
||||
@@ -801,10 +774,11 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
||||
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
||||
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
|
||||
free function; the assembly layer calls it after `connect()`. A
|
||||
`CallConnection::refresh()` method is a genuine feature addition —
|
||||
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; the assembly layer calls it after the dial (in
|
||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||
feature addition — non-breaking, additive. See
|
||||
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
||||
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
|
||||
`namespace_prefix` is optional local-naming sugar.
|
||||
@@ -825,8 +799,7 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
(ADR-029 §3.7).
|
||||
- **OQ-33** (resolved by ADR-030): `PeerId` is a logical id. Source is
|
||||
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
||||
stable across key rotation), not a connection-assigned UUID. The UUID
|
||||
workaround is removed. See OQ-33 in open-questions.md.
|
||||
stable across key rotation). See OQ-33 in open-questions.md.
|
||||
- **OQ-34** (resolved by ADR-030 + ADR-033): Persistent peer registry —
|
||||
the storage boundary is `core trait + in-memory default` (config-backed
|
||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||
@@ -858,9 +831,8 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- ADR-017: Call Protocol Client and Adapter Contract (the spec this document
|
||||
operationally fills)
|
||||
- ADR-029: Peer-Graph Routing Model (supersedes ADR-028; resolves DC-1 with
|
||||
peer-keyed overlays + `AccessControl`-based peer authorization)
|
||||
- ~~ADR-028~~: Peer-Scoped Registry Filtering (superseded by ADR-029)
|
||||
- ADR-029: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
|
||||
+ `AccessControl`-based peer authorization)
|
||||
- `call-protocol.md` — `CallAdapter`, `CallConnection`, dispatch loop, stream
|
||||
model (the server-side complement to this document)
|
||||
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
||||
|
||||
@@ -397,15 +397,13 @@ pub enum OperationProvenance {
|
||||
| `FromJsonSchema` | No (leaf) | No | Internal |
|
||||
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
|
||||
|
||||
> **ADR-066 update.** `FromJsonSchema` was originally a schema-only
|
||||
> provenance with no handler (the old row read "N/A (no handler) /
|
||||
> N/A"). ADR-066 moved `from_jsonschema` to `alknet-http` as a real
|
||||
> HTTP-backed single-endpoint adapter with a reqwest forwarding
|
||||
> handler. `FromJsonSchema` is now a leaf, same trust model as
|
||||
> `FromOpenAPI` (HTTP endpoint trusted; handler is a forwarding stub).
|
||||
> The "schema-only, no handler" concept is removed — schema validation
|
||||
> without a handler is served by consuming `OperationSpec` directly,
|
||||
> not by registering a placeholder op.
|
||||
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
|
||||
> single-endpoint adapter in `alknet-http` (ADR-066): a real reqwest
|
||||
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
|
||||
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
|
||||
> handler is a forwarding stub). Schema validation without a handler is
|
||||
> served by consuming `OperationSpec` directly, not by registering a
|
||||
> placeholder op.
|
||||
|
||||
#### CompositionAuthority
|
||||
|
||||
@@ -929,11 +927,10 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
|
||||
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md). ~~`from_jsonschema` clause superseded by ADR-066~~ |
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-066](../../decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
|
||||
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
||||
| Dynamic resource ownership for runtime-spawned resources | [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-015/022 unchanged |
|
||||
|
||||
@@ -953,10 +950,11 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
|
||||
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
|
||||
[client-and-adapters.md](client-and-adapters.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
|
||||
free function; the assembly layer calls it after `connect()`. A
|
||||
`CallConnection::refresh()` method is a genuine feature addition —
|
||||
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
||||
manual free function; the assembly layer calls it after the dial (in
|
||||
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
||||
feature addition — non-breaking, additive. See
|
||||
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
||||
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
|
||||
`namespace_prefix` is optional local-naming sugar. See
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# alknet-channels
|
||||
@@ -12,38 +12,42 @@ each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
|
||||
channel 0 and routed through the same `HandlerRegistry` as top-level
|
||||
connections. The channels layer is a re-framing proxy — it converts between
|
||||
"one transport stream carrying N channels" (the wire) and "N independent
|
||||
`AsyncRead + AsyncWrite` handles" (what handlers see) — and it does no
|
||||
protocol work itself.
|
||||
`BiStream` handles" (what handlers see) — and it does no protocol work
|
||||
itself. The channels layer has no `stream_type` concept (ADR-093); the
|
||||
handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
||||
| [channels-wire.md](channels-wire.md) | draft | The 9-byte chunk format (`[channel_id:u32 be][stream_type:u8][length:u32 be][payload]`), stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition |
|
||||
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074, as amended by ADR-093), `accept_bi` yields one `BiStream` per channel, recursive composition |
|
||||
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
|
||||
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) |
|
||||
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); bidirectionality preserved |
|
||||
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089); bidirectionality preserved |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, stream_types [0,1]; no special control plane |
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-093); substrate-agnostic; one-way door |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol; no special control plane |
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-094) |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
|
||||
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
||||
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-093 builds on |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
|
||||
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
|
||||
@@ -52,19 +56,21 @@ protocol work itself.
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; `connect_quic` removed (ADR-089 §5). `AlknetClient` core extraction is now resolved — the native dial seam is `alknet-client` (ADR-089) |
|
||||
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-089) | `ChannelClient`'s API is decided (ADR-080): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-089) |
|
||||
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
||||
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
|
||||
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-093); the *function surface* is not |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
|
||||
connection, a QUIC bidi stream — they're all `AsyncRead + AsyncWrite`
|
||||
handles. The differences are only in how they're *opened* (negotiation
|
||||
via `channel/open` on channel 0) and what *multiplexing layer* carries
|
||||
them (the 9-byte chunk format). Once normalized, every channel is an
|
||||
ALPN routed through the same `HandlerRegistry`. See
|
||||
[overview.md](overview.md) and ADR-071.
|
||||
connection, a QUIC bidi stream — they're all `BiStream` (a concrete
|
||||
`AsyncRead + AsyncWrite` newtype, per ADR-092). The differences are only
|
||||
in how they're *opened* (negotiation via `channel/open` on channel 0)
|
||||
and what *multiplexing layer* carries them (the 8-byte chunk format).
|
||||
Once normalized, every channel is an ALPN routed through the same
|
||||
`HandlerRegistry`. See [overview.md](overview.md) and ADR-071 (as
|
||||
amended by ADR-093).
|
||||
|
||||
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
|
||||
plane.** The call protocol runs on channel 0 exactly as on a top-level
|
||||
@@ -76,23 +82,31 @@ protocol work itself.
|
||||
|
||||
3. **The channels layer is a re-framing proxy, not a protocol engine.** It
|
||||
converts between "one transport stream carrying N channels" (the wire)
|
||||
and "N independent `AsyncRead + AsyncWrite` handles" (what handlers
|
||||
see). It does no ALPN-specific parsing, no auth, no transport coupling.
|
||||
This makes it WASM-compatible and transport-agnostic by construction.
|
||||
See [channels-adapter.md](channels-adapter.md) and ADR-075.
|
||||
and "N independent `BiStream` handles" (what handlers see). It does no
|
||||
ALPN-specific parsing, no auth, no transport coupling, and carries no
|
||||
`stream_type` concept (ADR-093). This makes it WASM-compatible and
|
||||
transport-agnostic by construction. See [channels-adapter.md](channels-adapter.md)
|
||||
and ADR-075.
|
||||
|
||||
4. **`channel/resources/subscribe` is a `Subscription`, not a polled
|
||||
4. **The handler owns its sub-stream multiplexing.** The channels layer
|
||||
yields one `BiStream` per channel; the handler sub-multiplexes it
|
||||
however it wants (TTY's 5-byte format, call's length-prefixed JSON,
|
||||
tunnel's raw bytes, SSH's channel protocol). The channels layer carries
|
||||
the bytes transparently. See [channels-connection.md](channels-connection.md)
|
||||
and ADR-093.
|
||||
|
||||
5. **`channel/resources/subscribe` is a `Subscription`, not a polled
|
||||
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
|
||||
(ADR-049, implemented and tested). The first consumer (the hub
|
||||
aggregating worker resources) needs live updates. Polling would be built
|
||||
and immediately reworked. See ADR-073.
|
||||
|
||||
5. **Bidirectional open.** Either side can open a channel to the other,
|
||||
6. **Bidirectional open.** Either side can open a channel to the other,
|
||||
just like the call protocol's operation overlay. The `direction` field
|
||||
on `channel/open` pins who is the ALPN-server vs ALPN-client. See
|
||||
ADR-073 §Direction semantics.
|
||||
|
||||
6. **Wire-level invariants are contracts, not implementation details.**
|
||||
7. **Wire-level invariants are contracts, not implementation details.**
|
||||
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
|
||||
close ordering) that hang channels silently if underspecified: shutdown
|
||||
emits a zero-length sentinel; transport close drops all senders; the mux
|
||||
@@ -101,11 +115,24 @@ protocol work itself.
|
||||
`channel/close`. See [channels-wire.md](channels-wire.md) and
|
||||
[channels-adapter.md](channels-adapter.md).
|
||||
|
||||
7. **The hub translates, not transparently forwards.** The hub terminates
|
||||
8. **The hub translates, not transparently forwards.** The hub terminates
|
||||
channel 0 on both legs, runs `AccessControl::check`, and re-issues
|
||||
`channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data
|
||||
channels are byte-forwarded with `channel_id` rewrite. This preserves
|
||||
the auth model. See ADR-079.
|
||||
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
|
||||
within the 8-byte header). This preserves the auth model. See ADR-079.
|
||||
|
||||
9. **The channel cap is per-identity, not per-connection.** A channel
|
||||
slot is a resource; the cap on how many an identity may hold open is
|
||||
a quota check, parallel to `OwnershipProvider::owns` (ADR-050) for
|
||||
spawned resources. The cap lives in `channels-call` (the channels
|
||||
layer is auth-blind by ADR-075 — no identity, no scopes), consulted
|
||||
by the `channel/open` and `channel/close` handlers after
|
||||
`AccessControl::check`. The default is `PerIdentityChannelPolicy::
|
||||
new(256)` — 256 per `PeerId` across all the peer's connections. The
|
||||
per-connection `max_channels` (ADR-076) is a memory bound, not a
|
||||
DoS defense. The cap is symmetric (both sides enforce); the spoke
|
||||
caps the hub as direct caller, not the browser as `forwarded_for`
|
||||
(metadata, not authority — ADR-032). See ADR-094.
|
||||
|
||||
## References
|
||||
|
||||
@@ -115,6 +142,8 @@ protocol work itself.
|
||||
tests, three validated targets, REQ-CH-01..06 wire-level invariants
|
||||
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
|
||||
- `docs/research/alknet-channels/poc-plan.md` — the POC plan
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the pure-channel-multiplexing resolution (ADR-093)
|
||||
- `/workspace/alknet-channels-poc/` — the POC codebase
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||
format (the seed of the channels generalization)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channel-client.md — ChannelClient
|
||||
@@ -33,49 +33,27 @@ impl ChannelClient {
|
||||
/// `Connection` on ALPN `alknet/channels`. This is the
|
||||
/// transport-agnostic primary constructor: the caller (or a
|
||||
/// transport-specific dial helper) produces the `Connection` —
|
||||
/// via `Connection::from_stream`/`from_bidi` (TCP+TLS,
|
||||
/// WebTransport, SSH `direct-tcpip`), a quinn connection, or any
|
||||
/// other `AsyncRead + AsyncWrite` source — and this method takes
|
||||
/// over: installs channel 0 (`alknet/call`), spawns the demux/mux,
|
||||
/// and returns the client. Mirrors the server side's
|
||||
/// transport-agnostic `ChannelsAdapter::handle(Connection)` and
|
||||
/// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
|
||||
/// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
|
||||
/// AsyncWrite` source — and this method takes over: installs
|
||||
/// channel 0 (`alknet/call`), spawns the demux/mux, and returns
|
||||
/// the client. Mirrors the server side's transport-agnostic
|
||||
/// `ChannelsAdapter::handle(Connection)` and
|
||||
/// `CallClient::spawn_dispatch(Connection)`.
|
||||
///
|
||||
/// This is the one-way-door API surface (ADR-080). It must not be
|
||||
/// coupled to a transport — the channels protocol is
|
||||
/// transport-agnostic (ADR-071, ADR-065), and the client side is
|
||||
/// half of that protocol.
|
||||
/// transport-agnostic (ADR-071, as amended by ADR-093; ADR-065,
|
||||
/// ADR-092), and the client side is half of that protocol.
|
||||
pub async fn from_connection(connection: Connection)
|
||||
-> Result<Self, ChannelError>;
|
||||
|
||||
/// QUIC convenience constructor. Dials a QUIC connection to `addr`
|
||||
/// on ALPN `alknet/channels` (using `credentials` for the TLS
|
||||
/// handshake — ADR-034 verifier selection), then calls
|
||||
/// `from_connection`. This is the "I just want QUIC" one-liner;
|
||||
/// it is additive over `from_connection` and is a two-way door —
|
||||
/// `connect_tcp_tls`, `connect_webtransport`, etc. can be added
|
||||
/// alongside it without touching the one-way-door surface.
|
||||
///
|
||||
/// **REMOVED per ADR-089 §5.** The dial is extracted into
|
||||
/// `AlknetClient` (`alknet-client`); `connect_quic` is deleted,
|
||||
/// not delegated, to avoid `alknet-channels-call` depending on
|
||||
/// `alknet-client`. Callers compose `AlknetClient::dial_quic` +
|
||||
/// `from_connection`. See "Relationship to `AlknetClient`" below.
|
||||
/// The `CallCredentials` parameter is moot — `CallCredentials` is
|
||||
/// removed per ADR-091 (amended 2026-07-17); the dial consumes
|
||||
/// `ConnectionCredentials` from `alknet-core`.
|
||||
pub async fn connect_quic(
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials, // REMOVED — CallCredentials is removed
|
||||
) -> Result<Self, ChannelError>;
|
||||
|
||||
/// Open a data channel with the given ALPN and params. Sends
|
||||
/// `channel/open` on channel 0, waits for the response, and returns
|
||||
/// the channel.
|
||||
pub async fn open_channel(
|
||||
&self,
|
||||
alpn: &str,
|
||||
stream_types: &[u8],
|
||||
params: Value,
|
||||
direction: ChannelDirection,
|
||||
) -> Result<Channel, ChannelError>;
|
||||
@@ -99,9 +77,8 @@ pub enum ChannelDirection {
|
||||
|
||||
pub struct Channel {
|
||||
pub channel_id: u32,
|
||||
pub stream_types: Vec<u8>,
|
||||
/// The sub-streams, accessible via accept_bi() (ADR-074 generic path)
|
||||
/// or into_sub_streams() (ADR-074 typed path).
|
||||
/// The channel's BiStream, accessible via the BidiStreamSource
|
||||
/// (accept_bi — ADR-074 as amended by ADR-093).
|
||||
pub source: ChannelBidiStreamSource,
|
||||
}
|
||||
|
||||
@@ -124,8 +101,8 @@ pub struct ResourceEntry {
|
||||
## Transport-agnostic by construction
|
||||
|
||||
`ChannelClient` is the client side of the channels protocol. The channels
|
||||
protocol is transport-agnostic (ADR-071 substrate modes;
|
||||
`Connection::from_stream`/`from_bidi`/`from_source` from ADR-065/070 take
|
||||
protocol is transport-agnostic (ADR-071 substrate modes, as amended by
|
||||
ADR-093; `Connection::from_bidi`/`from_source` from ADR-065/070/092 take
|
||||
any `AsyncRead + AsyncWrite`). The client side must not be welded to a
|
||||
transport — that would repeat the server-side welding ADR-065 explicitly
|
||||
unwound.
|
||||
@@ -135,18 +112,16 @@ the one-way-door API surface. It takes a pre-established `Connection` and
|
||||
takes over channels establishment. The transport is the caller's concern:
|
||||
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
|
||||
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
|
||||
`from_stream`, a WebSocket carrying `alknet/channels` (the browser path per
|
||||
`from_bidi`, a WebSocket carrying `alknet/channels` (the browser path per
|
||||
ADR-044) — all produce a `Connection` that `from_connection` accepts
|
||||
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
|
||||
|
||||
`connect_quic(addr, credentials)` was a **convenience** constructor —
|
||||
dial QUIC, then `from_connection`. It is **removed** per ADR-089 §5:
|
||||
keeping it as a thin wrapper over `AlknetClient::dial_quic` would make
|
||||
`alknet-channels-call` depend on `alknet-client`, contradicting the dep
|
||||
graph (the protocol crates are parallel to the dial, not downstream of
|
||||
it). Callers compose `AlknetClient::dial_quic(...).await?` +
|
||||
`ChannelClient::from_connection(conn).await?` — two lines, the dial
|
||||
then the take-over.
|
||||
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
|
||||
ADR-089), not on `ChannelClient`. Callers compose
|
||||
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
|
||||
— two lines, the dial then the take-over. Keeping the dial off
|
||||
`ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`;
|
||||
the protocol crates are parallel to the dial, not downstream of it.
|
||||
|
||||
The credential/verifier-selection rule (ADR-034) lives in the dial
|
||||
(`AlknetClient`), not in `from_connection` — `from_connection` receives
|
||||
@@ -168,21 +143,20 @@ populates what operations they expose).
|
||||
name follows the `CallClient` convention (the side that dialed), not a
|
||||
request/response role.
|
||||
|
||||
## Relationship to `AlknetClient` (ADR-089 — resolved)
|
||||
## Relationship to `AlknetClient`
|
||||
|
||||
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
|
||||
pre-established `Connection`. The shared *dial+TLS* seam
|
||||
(`AlknetClient`, OQ-55) is now extracted: [`alknet-client`](../client/README.md)
|
||||
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
|
||||
provides `AlknetClient` with three dial methods (`dial_quic` /
|
||||
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
|
||||
`from_connection` consumes. The dial is transport-specific (QUIC,
|
||||
TCP+TLS, iroh); the take-over (`from_connection`) is
|
||||
transport-agnostic. The two concerns are separated.
|
||||
|
||||
`connect_quic` is removed (see above) — `AlknetClient::dial_quic` is the
|
||||
dial that feeds `from_connection`. A caller that needs transport
|
||||
selection (QUIC with TCP+TLS fallback) uses `AlknetClient` directly;
|
||||
the fallback policy is a caller concern. See
|
||||
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
|
||||
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
|
||||
`AlknetClient` directly; the fallback policy is a caller concern. See
|
||||
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) for the
|
||||
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
|
||||
(resolved).
|
||||
@@ -193,21 +167,23 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` convenience **removed** per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-55** (resolved by ADR-089): `AlknetClient` core **dial+TLS seam**
|
||||
— extracted as `alknet-client` with three dial methods.
|
||||
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
|
||||
dial is the shared seam, now extracted. See
|
||||
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
|
||||
— `alknet-client` with three dial methods. `ChannelClient`'s API is
|
||||
transport-agnostic (`from_connection`); the dial is the shared seam.
|
||||
See [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
|
||||
|
||||
## References
|
||||
|
||||
- ADR-080: ChannelClient (the decision)
|
||||
- ADR-093: channels pure channel multiplexing (no `stream_types`)
|
||||
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
|
||||
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps)
|
||||
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps, as
|
||||
amended by ADR-093 — `accept_bi` yields a `BiStream`)
|
||||
- ADR-075: ChannelManager (the shared state `ChannelClient` holds)
|
||||
- OQ-55: AlknetClient / client establishment extraction
|
||||
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
||||
@@ -22,7 +22,6 @@ Request (on channel 0):
|
||||
"operation": "channel/open",
|
||||
"input": {
|
||||
"alpn": "alknet/tty",
|
||||
"stream_types": [0, 1, 2, 3],
|
||||
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
|
||||
"direction": "initiator-to-responder"
|
||||
}
|
||||
@@ -32,7 +31,6 @@ Request (on channel 0):
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0. See ADR-071 §stream_type decomposition. |
|
||||
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
|
||||
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
|
||||
|
||||
@@ -41,8 +39,7 @@ Response:
|
||||
```json
|
||||
{
|
||||
"output": {
|
||||
"channel_id": 7,
|
||||
"stream_types": [0, 1, 2, 3]
|
||||
"channel_id": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -50,7 +47,6 @@ Response:
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
|
||||
| `stream_types` | `[u8]` | The negotiated set — the responder may narrow the initiator's requested set. |
|
||||
|
||||
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
|
||||
data flows — the same round-trip the call protocol makes for every
|
||||
@@ -66,7 +62,6 @@ negotiation round-trip, so the open round-trip is not additive latency.
|
||||
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
|
||||
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
|
||||
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false |
|
||||
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
|
||||
|
||||
### `channel/close` — tear down a channel
|
||||
|
||||
@@ -78,7 +73,7 @@ negotiation round-trip, so the open round-trip is not additive latency.
|
||||
```
|
||||
|
||||
The responder (the side that didn't send the close) drains its reassembled
|
||||
streams for `channel_id`, signals EOF to the handler, and returns
|
||||
stream for `channel_id`, signals EOF to the handler, and returns
|
||||
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
|
||||
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
|
||||
`reason` is free-form for observability — not semantically required.
|
||||
@@ -87,9 +82,11 @@ completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
|
||||
MUST be written and flushed before the `channel/close` operation is sent on
|
||||
channel 0. The side closing must observe the data-channel pump complete
|
||||
before issuing the call operation. For TTY this is the exit-chunk-is-last
|
||||
invariant (ADR-055) carried forward; for tunnels it is the last data byte
|
||||
before close. This invariant crosses two channels (the data channel and
|
||||
channel 0), so the channels layer owns the ordering guarantee.
|
||||
invariant (ADR-055) carried forward — the exit control message rides on
|
||||
TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
|
||||
format); for tunnels it is the last data byte before close. This invariant
|
||||
crosses two channels (the data channel and channel 0), so the channels
|
||||
layer owns the ordering guarantee.
|
||||
|
||||
### `channel/control` — out-of-band control on channel 0
|
||||
|
||||
@@ -101,7 +98,6 @@ keepalive):
|
||||
"operation": "channel/control",
|
||||
"input": {
|
||||
"channel_id": 7,
|
||||
"stream_type": 3,
|
||||
"message": { "type": "resize", "cols": 80, "rows": 24 }
|
||||
}
|
||||
}
|
||||
@@ -113,7 +109,7 @@ not interpret it.
|
||||
|
||||
### `channel/resources/subscribe` — live resource discovery
|
||||
|
||||
**This is a `Subscription` operation (ADR-049), not a polled `Query`.** The
|
||||
**This is a `Subscription` operation (ADR-049), not a polled Query.** The
|
||||
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||
tested). The first consumer (the hub aggregating worker resources) needs
|
||||
live updates when workers connect/disconnect or containers start/stop.
|
||||
@@ -192,14 +188,26 @@ collision-prone client-assigned alternative.
|
||||
| Control path | When | Examples |
|
||||
|--------------|------|----------|
|
||||
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
||||
| `stream_type 3` chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||
|
||||
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
|
||||
example of data-ordered control — it rides on `stream_type 3` because it
|
||||
must arrive after the last stdin chunk, guaranteed by chunk ordering within
|
||||
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
|
||||
`channel/close` operation that follows is on channel 0 and is ordered after
|
||||
the data pump completes (REQ-CH-06).
|
||||
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
|
||||
(stream_type 4, inside TTY's 5-byte payload format) because it must arrive
|
||||
after the last data on TTY's stdout stream_type, guaranteed by TTY's
|
||||
per-stream_type chunk ordering within its own 5-byte format, not by a
|
||||
call-protocol round-trip. The `channel/close` operation that follows is
|
||||
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
|
||||
|
||||
**The control-message division is handler-internal.** Under ADR-093, the
|
||||
channels layer has no `stream_type` concept — it carries the handler's
|
||||
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
|
||||
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
|
||||
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
|
||||
channels layer routes by `channel_id` only; the handler owns its
|
||||
sub-stream multiplexing on the `BiStream` it receives. The
|
||||
"bidirectional control channel" property is a TTY-layer concern, fixed
|
||||
at the TTY layer by Phase 7's split — the channels layer doesn't know
|
||||
about it.
|
||||
|
||||
## ACL flow (end-to-end)
|
||||
|
||||
@@ -227,6 +235,150 @@ The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
|
||||
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
|
||||
layer inherited the auth model by being a call-protocol operation.
|
||||
|
||||
## Per-identity channel cap (ADR-094)
|
||||
|
||||
A channel slot is a resource. The cap on how many channels an identity
|
||||
may hold open is a quota check on that resource — parallel to
|
||||
`OwnershipProvider::owns` (ADR-050) for spawned resources. Same
|
||||
primitive, different resource. The cap is a **peer concern**, not a
|
||||
hub-specific concern: any accepting peer (worker or hub) enforces the
|
||||
cap on its inbound channels, just as it enforces `AccessControl::check`
|
||||
on `channel/open`. The cap is also **symmetric** — both sides of a
|
||||
channels connection enforce their cap on the other's channels.
|
||||
|
||||
### Why the cap is not in the channels layer
|
||||
|
||||
`ChannelManager` (ADR-075) is auth-blind by design — no auth state, no
|
||||
identity, no scopes. That decision is load-bearing (it is what makes
|
||||
the channels layer WASM-compatible, transport-agnostic, and
|
||||
ALPN-blind). So the per-identity cap lives in `channels-call`, where
|
||||
the identity is already on `OperationContext` (the same place
|
||||
`AccessControl::check` runs). The channels layer (`channels-core`) is
|
||||
unchanged. See ADR-094 §"Why the channels layer cannot hold the cap".
|
||||
|
||||
The channels-layer per-connection `max_channels = 256` (ADR-076) is
|
||||
a **per-connection memory bound** (limits one connection's
|
||||
reassembly-buffer cost), not a DoS defense. A peer can open an
|
||||
unbounded number of transport connections, so a per-connection cap is
|
||||
not a per-peer DoS defense. The per-identity DoS defense is the cap
|
||||
documented here; see ADR-094 for the corrected DoS-defense framing.
|
||||
|
||||
### The `ChannelLifecyclePolicy` trait
|
||||
|
||||
```rust
|
||||
/// Per-identity channel lifecycle policy. Consulted by the
|
||||
/// `channel/open` handler (after `AccessControl::check`, before
|
||||
/// allocation) and the `channel/close` handler (after deallocation).
|
||||
/// Both handlers have the identity via `OperationContext`.
|
||||
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
|
||||
/// Before channel allocation. Deny with `channel:too_many_channels`
|
||||
/// (ADR-073) when the identity is over its cap. The identity is
|
||||
/// the direct caller (the peer that opened this channels
|
||||
/// connection); `forwarded_for` is metadata and is NOT consulted
|
||||
/// (ADR-032).
|
||||
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
|
||||
|
||||
/// After channel deallocation. Decrement the per-identity count.
|
||||
/// Called by the `channel/close` handler after the drain completes
|
||||
/// (ADR-076 §channel-id-reuse).
|
||||
fn on_close(&self, identity: &Identity);
|
||||
}
|
||||
```
|
||||
|
||||
### Default: `PerIdentityChannelPolicy::new(256)`
|
||||
|
||||
The default constructor enforces 256 per identity out of the box — no
|
||||
"NoOp default + wire it later." A channels-accepting peer that
|
||||
constructs `ChannelOperations::new(manager)` with no policy argument
|
||||
gets `PerIdentityChannelPolicy::new(256)`. The default is secure;
|
||||
opt-outs are explicit:
|
||||
|
||||
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
|
||||
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
|
||||
peer** and shared (via `Arc`) across every channels connection that
|
||||
peer accepts. The sharing is what makes the cap per-identity, not
|
||||
per-connection.
|
||||
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` —
|
||||
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
|
||||
default cap for specific peers. Used by a spoke that serves a
|
||||
high-fan-out hub (the hub peer's cap is set higher than a worker
|
||||
peer's cap — see "Relay consequence" below).
|
||||
- `NoCap` — no cap. Explicit opt-out for tests, POCs, and trusted
|
||||
single-peer deployments. Not the default.
|
||||
|
||||
The policy is constructed once and passed to `ChannelOperations` at
|
||||
registration time:
|
||||
|
||||
```rust
|
||||
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let channel_ops = ChannelOperations::new(manager, policy);
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
### Enforcement point: between `AccessControl::check` and allocation
|
||||
|
||||
The `channel/open` handler (above) gains the policy check after ACL
|
||||
and before `next_id.fetch_add`:
|
||||
|
||||
1. ACL is already checked by `OperationRegistry::invoke` (the existing
|
||||
`AccessControl::check` path — unchanged).
|
||||
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
|
||||
`channel:too_many_channels` if over cap.
|
||||
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
|
||||
(DP-1: server-assigned — unchanged).
|
||||
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
|
||||
the `ChannelState` (unchanged).
|
||||
5. Return the `channel_id`.
|
||||
|
||||
The `channel/close` handler gains the decrement after the drain
|
||||
completes (the same point ADR-076 marks the `channel_id` as eligible
|
||||
for reuse):
|
||||
|
||||
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
|
||||
§channel-id-reuse).
|
||||
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
|
||||
per-identity count.
|
||||
3. Return `{ "closed": true }` (unchanged).
|
||||
|
||||
### Relay consequence: the spoke caps the hub, not the browser
|
||||
|
||||
When the hub relays a browser's channel to a spoke (ADR-079), the
|
||||
spoke sees the hub as the direct caller. `forwarded_for` carries the
|
||||
browser's identity as metadata (ADR-032 — `forwarded_for` is not
|
||||
authority; `AccessControl::check` never reads it). The channel cap
|
||||
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
|
||||
consulted with the **hub's** identity, not the browser's. The spoke
|
||||
asks "does the hub have access to open another channel?" and the
|
||||
hub's quota on the spoke reflects the aggregate of all relayed
|
||||
channels. The hub's per-browser caps are the hub's own concern
|
||||
(enforced on the browser leg by the hub's own policy), not the
|
||||
spoke's.
|
||||
|
||||
This is correct and consistent — the spoke authorizes the hub for
|
||||
container access the same way it authorizes any peer, and the hub's
|
||||
browser-relay ACL is the hub's own layer. The channel cap follows the
|
||||
same pattern as any other resource ACL.
|
||||
|
||||
**Deployment consequence:** a spoke that serves a hub relaying for
|
||||
many browsers must set the hub peer's cap higher than a worker peer's
|
||||
cap, or the spoke denies legitimate relayed channels when the hub's
|
||||
aggregate count exceeds a worker-sized cap. This is a per-peer-role
|
||||
policy, set by the spoke via `with_per_identity_caps`. The
|
||||
architecture provides the mechanism; the deployment sets the numbers.
|
||||
This is not a flaw — it is the same shape as any per-peer ACL (a
|
||||
spoke may authorize one peer for 1000 containers and another for 10;
|
||||
the channel cap is the same kind of per-peer policy).
|
||||
|
||||
### Recursive channels do not bypass the cap
|
||||
|
||||
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
|
||||
new `ChannelsAdapter` with a new `ChannelManager`. If the same
|
||||
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
|
||||
the inner channels are counted against the same identity. Recursion
|
||||
is not a bypass; the 13-byte-per-chunk overhead is the documented
|
||||
cost (ADR-093), and the cap behavior is unchanged. Recursive channels
|
||||
are an edge case for edge cases and not specced further.
|
||||
|
||||
## Hub relay contract (ADR-079 — summary)
|
||||
|
||||
The hub **translates**, not transparently forwards:
|
||||
@@ -259,13 +411,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-073: channel lifecycle operations (the decision)
|
||||
- ADR-094: per-identity channel cap (the cap, the trait, the relay
|
||||
consequence)
|
||||
- ADR-079: hub relay (the translate contract)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
|
||||
Negotiation, §ACL and Security Model
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||
@@ -8,13 +8,15 @@ last_updated: 2026-07-12
|
||||
The two internal components of the channels crate: the read/demux half
|
||||
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
|
||||
ADR-075 is the decision; this doc specifies the contracts and the demux/mux
|
||||
invariants.
|
||||
invariants. The channels layer has no `stream_type` concept (ADR-093) —
|
||||
the demux routes by `channel_id` only, and the reassembly buffer is one
|
||||
per channel (not per `(channel_id, stream_type)`).
|
||||
|
||||
## The split
|
||||
|
||||
| Component | Role | What it knows |
|
||||
|-----------|------|---------------|
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes, as amended by ADR-093). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
|
||||
|
||||
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
|
||||
@@ -34,33 +36,35 @@ impl ProtocolHandler for ChannelsAdapter {
|
||||
// 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream
|
||||
// the transport yields is channel 0. The consumer (channels-call)
|
||||
// installs the CallAdapter on it.
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||
let bidi = connection.accept_bi().await?;
|
||||
self.manager.preinstall_channel_0(bidi, auth).await?;
|
||||
|
||||
// 2. Accept remaining bidi streams and read 9-byte headers off each.
|
||||
// 2. Accept remaining bidi streams and read 8-byte headers off each.
|
||||
// On an in-line transport, accept_bi() yields once and the header
|
||||
// demuxes N channels from that stream. On QUIC native, accept_bi()
|
||||
// yields repeatedly — each stream carries one logical channel.
|
||||
// Same code path, same wire format (ADR-071 §substrate modes).
|
||||
// Same code path, same wire format (ADR-071 §substrate modes,
|
||||
// as amended by ADR-093).
|
||||
self.manager.run_demux_loop(connection).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `preinstall_channel_0` step (provided by `channels-call`, ADR-081)
|
||||
constructs the reassembly buffers for `channel_id = 0` using stream_types
|
||||
[0, 1] (ADR-072), wraps them as a `Connection` via `Connection::from_source`
|
||||
with a `ChannelBidiStreamSource` (ADR-074), and hands that `Connection` to
|
||||
the `CallAdapter`. The `ChannelsAdapter` in `channels-core` exposes the
|
||||
hook; `channels-call` provides the implementation.
|
||||
constructs the reassembly buffer for `channel_id = 0`, wraps it as a
|
||||
`Connection` via `Connection::from_source` with a
|
||||
`ChannelBidiStreamSource` (ADR-074, as amended by ADR-093 — `accept_bi`
|
||||
yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
|
||||
The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
|
||||
provides the implementation.
|
||||
|
||||
`run_demux_loop` continues accepting bidi streams from the transport. For
|
||||
each stream, it reads 9-byte headers and routes payloads to the matching
|
||||
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
|
||||
there is only one stream (channel 0 rides inside it via the header); the
|
||||
header demuxes all channels. On QUIC, each subsequent stream is a new
|
||||
channel; the header's `channel_id` correlates it. The loop is the same;
|
||||
only the transport's stream count differs.
|
||||
each stream, it reads 8-byte headers and routes payloads to the matching
|
||||
`channel_id`'s reassembly buffer. On an in-line transport, there is only
|
||||
one stream (channel 0 rides inside it via the header); the header demuxes
|
||||
all channels. On QUIC, each subsequent stream is a new channel; the
|
||||
header's `channel_id` correlates it. The loop is the same; only the
|
||||
transport's stream count differs.
|
||||
|
||||
## `ChannelManager`
|
||||
|
||||
@@ -74,14 +78,23 @@ pub struct ChannelManager {
|
||||
// call-protocol-blind.
|
||||
next_id: AtomicU32, // monotonic; wraps at u32::MAX
|
||||
buffer_cap: usize, // default 1 MiB (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076) — per-connection
|
||||
// memory bound, NOT a DoS defense. The
|
||||
// per-identity DoS defense is the
|
||||
// ChannelLifecyclePolicy consulted by the
|
||||
// channel/open handler in channels-call
|
||||
// (ADR-094). The auth-blindness that forces
|
||||
// the cap out of this struct is ADR-075's
|
||||
// "no auth state" rule.
|
||||
}
|
||||
|
||||
struct ChannelState {
|
||||
alpn: String,
|
||||
streams: HashMap<u8, ReassemblyBuffer>,
|
||||
/// One reassembly buffer per channel (not per (channel_id, stream_type) —
|
||||
/// the channels layer has no stream_type concept per ADR-093). Yields
|
||||
/// a BiStream to the handler.
|
||||
reassembly: ReassemblyBuffer,
|
||||
handler_task: JoinHandle<()>,
|
||||
stream_types: Vec<u8>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,13 +103,13 @@ struct ChannelState {
|
||||
all hold a handle.
|
||||
|
||||
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
|
||||
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are
|
||||
> the public API surface (contract). `ReassemblyBuffer`, `Demux`,
|
||||
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and
|
||||
> `ChannelOperations` are illustrative internal type names — the channels
|
||||
> crate's implementation may name them differently. The contracts are the
|
||||
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are
|
||||
> not contractual.
|
||||
> `ChannelBidiStreamSource`, and `ChannelClient` are the public API
|
||||
> surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
|
||||
> `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
|
||||
> illustrative internal type names — the channels crate's implementation
|
||||
> may name them differently. The contracts are the invariants
|
||||
> (REQ-CH-01..04, 06) and the public API; the internal names are not
|
||||
> contractual.
|
||||
|
||||
### `ChannelManager` is ALPN-blind and auth-blind
|
||||
|
||||
@@ -107,8 +120,9 @@ The `ChannelManager` deliberately does **not** hold:
|
||||
their crates and register on the same registry.
|
||||
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
|
||||
SSH frames, or tunnel target strings. It hands `params` JSON to the
|
||||
handler and gets back a handler task; it hands `stream_type 3` JSON to the
|
||||
handler's control handle.
|
||||
handler and gets back a handler task. The channels layer carries the
|
||||
handler's framing transparently in the payload — it does not interpret
|
||||
the payload bytes.
|
||||
- **No auth state.** Auth lives in the `OperationContext` that the call
|
||||
protocol passes to `channel/open`. The `ChannelManager` doesn't check
|
||||
scopes or ownership — that's `AccessControl::check` in
|
||||
@@ -116,6 +130,10 @@ The `ChannelManager` deliberately does **not** hold:
|
||||
- **No transport coupling.** It talks to the transport only through the
|
||||
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
|
||||
which use `AsyncRead + AsyncWrite`.
|
||||
- **No `stream_type` concept.** Per ADR-093, the channels layer routes by
|
||||
`channel_id` only. There is one reassembly buffer per channel (yielding
|
||||
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
|
||||
owns its sub-stream multiplexing on the `BiStream` it receives.
|
||||
|
||||
This is what makes the channels layer WASM-compatible and transport-agnostic
|
||||
— the `ChannelManager` is pure byte routing with no platform or protocol
|
||||
@@ -125,34 +143,62 @@ dependencies.
|
||||
|
||||
The `channel/open` (and `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) operations are registered on the call
|
||||
protocol's `OperationRegistry` at assembly time:
|
||||
protocol's `OperationRegistry` at registration time. The
|
||||
`ChannelOperations` constructor takes a `ChannelLifecyclePolicy`
|
||||
(ADR-094) — the default is `PerIdentityChannelPolicy::new(256)` (a
|
||||
real per-identity cap, not NoOp):
|
||||
|
||||
```rust
|
||||
let channel_ops = ChannelOperations::new(manager.clone());
|
||||
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let channel_ops = ChannelOperations::new(manager.clone(), policy);
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
The same `Arc<PerIdentityChannelPolicy>` is shared across every
|
||||
channels connection this peer accepts — that is what makes the cap
|
||||
per-identity, not per-connection. A hub constructs one policy and
|
||||
shares it across all worker and browser legs; a worker accepting
|
||||
direct channels constructs one policy and shares it across whatever
|
||||
connections it accepts. See ADR-094 for the policy trait and the
|
||||
default/opt-out variants.
|
||||
|
||||
The `channel/open` handler (ADR-073):
|
||||
1. ACL is already checked by `OperationRegistry::invoke` before this handler
|
||||
runs.
|
||||
2. Looks up the ALPN in `HandlerRegistry` → `channel:unknown_alpn` if
|
||||
missing.
|
||||
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned).
|
||||
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated
|
||||
`stream_types`.
|
||||
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
3. **Per-identity cap check (ADR-094):**
|
||||
`policy.check_open(&op_ctx.identity)?` — deny with
|
||||
`channel:too_many_channels` if the identity is over its cap. The
|
||||
identity is the direct caller (the peer on this channels
|
||||
connection); `forwarded_for` is metadata and is NOT consulted
|
||||
(ADR-032). For the hub-relay path, the spoke sees the hub as the
|
||||
direct caller — the hub's quota on the spoke reflects the aggregate
|
||||
of all relayed channels (ADR-094 §5).
|
||||
4. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned). The per-connection `max_channels` (ADR-076) is
|
||||
checked here too — the per-connection memory bound; if hit, the same
|
||||
`channel:too_many_channels` error is returned (which cap fired first
|
||||
is an implementation detail — ADR-094 §4).
|
||||
5. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
|
||||
ADR-093) — one reassembly buffer, yielding a `BiStream`.
|
||||
6. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
Identical to what `TtyAdapter::handle` does today, but on a
|
||||
channels-backed `Connection`.
|
||||
6. Records the `ChannelState`.
|
||||
7. Returns the `channel_id`.
|
||||
7. Records the `ChannelState`.
|
||||
8. Returns the `channel_id`.
|
||||
|
||||
The `channel/close` handler (ADR-073) gains a symmetric
|
||||
`policy.on_close(&op_ctx.identity)` call after the drain completes
|
||||
(the same point ADR-076 marks the `channel_id` as eligible for reuse)
|
||||
— decrementing the per-identity count.
|
||||
|
||||
## Demux invariants (REQ-CH-02, 04)
|
||||
|
||||
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
|
||||
|
||||
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
|
||||
EOF even without an explicit zero-length sentinel on the wire. Without this,
|
||||
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
|
||||
sender that never drops. This is a teardown invariant of the
|
||||
@@ -160,11 +206,10 @@ sender that never drops. This is a teardown invariant of the
|
||||
|
||||
### REQ-CH-04: lenient unknown-`channel_id` handling
|
||||
|
||||
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with
|
||||
a debug log and an error counter (exposed via `Demux::stats()`), and the
|
||||
demux continues. This matches SSH's behavior and survives transient
|
||||
mis-ordering during teardown. Validated by the POC
|
||||
(`demux_unknown_channel_drops_lenient`).
|
||||
A chunk with an unallocated `channel_id` is dropped with a debug log and
|
||||
an error counter (exposed via `Demux::stats()`), and the demux continues.
|
||||
This matches SSH's behavior and survives transient mis-ordering during
|
||||
teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
|
||||
|
||||
## Mux invariants (REQ-CH-03)
|
||||
|
||||
@@ -177,8 +222,8 @@ after the run loop starts.
|
||||
|
||||
The mux is split into:
|
||||
|
||||
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
|
||||
Sender<Bytes>` callable at any time after the runner starts.
|
||||
- **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
|
||||
callable at any time after the runner starts.
|
||||
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
|
||||
and per-channel write pumps.
|
||||
|
||||
@@ -222,19 +267,20 @@ channels connections:
|
||||
```rust
|
||||
// For channel_id=7 on browser side, channel_id=12 on spoke side:
|
||||
tokio::spawn(async move {
|
||||
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
|
||||
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
|
||||
let mut b_bidi = browser_mgr.open_channel_stream(7).await;
|
||||
let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
|
||||
tokio::join!(
|
||||
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite)
|
||||
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite)
|
||||
pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
|
||||
pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
|
||||
and writes them onto the other's write-half, which re-chunks them with the
|
||||
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
|
||||
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates
|
||||
The relay reads opaque bytes off one `ChannelManager`'s reassembled
|
||||
`BiStream` and writes them onto the other's write-half, which re-chunks
|
||||
them with the other leg's `channel_id` (a 4-byte rewrite within the
|
||||
8-byte header). The relay does not parse the payload — it doesn't know if
|
||||
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
|
||||
`channel/open` on channel 0 (re-issues on the spoke leg with
|
||||
`forwarded_for`); data channels are byte-forwarded with `channel_id`
|
||||
rewrite. See ADR-079 for the full relay contract.
|
||||
@@ -246,16 +292,27 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel per-connection memory bound, monotonic IDs (DoS defense reframed by ADR-094) |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (the decision)
|
||||
- ADR-093: channels pure channel multiplexing (the umbrella decision that
|
||||
amends ADR-071/074/077)
|
||||
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
|
||||
channel, as amended by ADR-093)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels` — the
|
||||
per-connection memory bound)
|
||||
- ADR-094: per-identity channel cap (the `ChannelLifecyclePolicy`
|
||||
consulted by the `channel/open` handler; the relay consequence for
|
||||
hub-relayed channels)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the pure-multiplexing resolution
|
||||
@@ -1,37 +1,31 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
|
||||
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
|
||||
|
||||
How a reassembled channel is presented to its handler as a `Connection`.
|
||||
ADR-074 is the decision; this doc specifies the API shape and the two
|
||||
access paths.
|
||||
ADR-074 (amended by ADR-093) is the decision; this doc specifies the API
|
||||
shape — one accessor, one `BiStream` per channel.
|
||||
|
||||
## What
|
||||
|
||||
Each channel is reassembled into a set of **unidirectional** handles — one
|
||||
per active `stream_type` (declared at `channel/open` time, ADR-073). Every
|
||||
stream_type is unidirectional (ADR-071 §stream_type decomposition);
|
||||
bidirectionality is two stream_types (write + read), not one shared
|
||||
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a
|
||||
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`.
|
||||
Each channel is reassembled into a `BiStream` — a single duplex
|
||||
(`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
|
||||
8-byte header (`channel_id` + `length`) on read, hands the payload to the
|
||||
reassembled `BiStream`, and the handler parses its own framing from the
|
||||
payload. The handler sub-multiplexes its `BiStream` however it wants —
|
||||
TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
|
||||
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
|
||||
runs its own channel protocol.
|
||||
|
||||
These handles are wrapped as a `ChannelBidiStreamSource` that implements
|
||||
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
|
||||
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
|
||||
constructed from it via `Connection::from_source(source, alpn)`.
|
||||
|
||||
The handler receives a `Connection` and can either:
|
||||
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
|
||||
the generic handler path (tunnel, SSH).
|
||||
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
|
||||
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
|
||||
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
|
||||
control-out).
|
||||
|
||||
Both paths operate on the same reassembly buffers; the difference is how the
|
||||
handler accesses them.
|
||||
constructed from it via `Connection::from_source(source, alpn)`. The
|
||||
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
||||
channel), gets a `BiStream`, and drives its session — identical to how it
|
||||
works on a top-level QUIC connection.
|
||||
|
||||
## `ChannelBidiStreamSource`
|
||||
|
||||
@@ -39,29 +33,30 @@ handler accesses them.
|
||||
// In alknet-channels:
|
||||
|
||||
pub struct ChannelBidiStreamSource {
|
||||
// The reassembly buffers for this channel's active stream_types,
|
||||
// plus the mux handle for writing back onto the transport.
|
||||
// Constructed by ChannelManager::build_channel_connection (ADR-075).
|
||||
// The reassembly buffer for this channel's payload bytes (one per
|
||||
// channel_id, not per (channel_id, stream_type) — the channels layer
|
||||
// has no stream_type concept), plus the mux handle for writing back
|
||||
// onto the transport. Constructed by ChannelManager::build_channel_connection
|
||||
// (ADR-075).
|
||||
...
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for ChannelBidiStreamSource {
|
||||
async fn accept_bi(&self)
|
||||
-> Result<(SendStream, RecvStream), StreamError>
|
||||
-> Result<BiStream, StreamError>
|
||||
{
|
||||
// Yields the (stream_type 0, stream_type 1) pair on first call,
|
||||
// Yields the channel's BiStream on first call,
|
||||
// ConnectionClosed on subsequent calls. Yield-once per channel,
|
||||
// matching the POC's validated shape.
|
||||
}
|
||||
|
||||
async fn open_bi(&self)
|
||||
-> Result<(SendStream, RecvStream), StreamError>
|
||||
-> Result<BiStream, StreamError>
|
||||
{
|
||||
// StreamClosed — a single channel cannot open new application
|
||||
// streams (same as ADR-065's Stream backend). Additional sub-streams
|
||||
// (stream_type 2, 3) are accessed via into_sub_streams(), not
|
||||
// open_bi().
|
||||
// streams (same as ADR-065's Stream backend). The handler owns
|
||||
// its sub-stream multiplexing on the BiStream it received.
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> { ... }
|
||||
@@ -75,19 +70,21 @@ whole channels connection). The `ChannelManager` (ADR-075) constructs one
|
||||
per channel at `channel/open` time and wraps it in a `Connection` via
|
||||
`from_source`.
|
||||
|
||||
## The generic path: `accept_bi()`
|
||||
## The single path: `accept_bi()`
|
||||
|
||||
For handlers that only need the main data pair (`stream_type` 0 = data-in,
|
||||
`stream_type` 1 = data-out):
|
||||
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||
wants. There is one accessor.
|
||||
|
||||
```rust
|
||||
// Tunnel handler — ~15 lines, zero channels-layer awareness
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
let mut bidi = connection.accept_bi().await?;
|
||||
let mut tcp = TcpStream::connect(target).await?;
|
||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
||||
|
||||
// Two-pump with shutdown-on-completion (ADR-078)
|
||||
let c2t = async {
|
||||
@@ -105,98 +102,53 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
}
|
||||
```
|
||||
|
||||
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
|
||||
pair, and pumps. It does not know it's inside a channels connection — the
|
||||
`Connection` looks like any other. This is the path the POC's `EchoHandler`
|
||||
and `TunnelHandler` validated.
|
||||
|
||||
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
|
||||
calls return `ConnectionClosed`. This matches the POC's validated shape and
|
||||
the `StreamBidiStreamSource` yield-once contract (ADR-070).
|
||||
|
||||
## The typed path: `into_sub_streams()`
|
||||
|
||||
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
|
||||
to 0/1:
|
||||
|
||||
```rust
|
||||
// In alknet-channels-core:
|
||||
pub struct ChannelSubStreams {
|
||||
/// (stream_type, handle) for each active stream_type. Each handle is
|
||||
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
|
||||
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
|
||||
/// See ADR-071 §stream_type decomposition.
|
||||
pub streams: Vec<(u8, SubStreamHandle)>,
|
||||
}
|
||||
|
||||
pub enum SubStreamHandle {
|
||||
Send(SendStream), // write half (stream_type % 3 == 0)
|
||||
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
|
||||
}
|
||||
|
||||
impl ChannelBidiStreamSource {
|
||||
/// Returns all active sub-streams, keyed by stream_type. Consumes the
|
||||
/// source — call this instead of accept_bi() if the handler needs
|
||||
/// direct access to stream_types 2/3/4.
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
// TTY handler (inside-channels mode) — the SAME code as direct
|
||||
// mode, just a different BiStream source.
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let mut bidi = connection.accept_bi().await?;
|
||||
// drive_session reads the 5-byte TTY chunks off `bidi` — the same
|
||||
// code as direct mode. The channels layer stripped its 8-byte
|
||||
// header; TTY's 5-byte format is the payload.
|
||||
drive_session(bidi, backends, ownership, identity).await
|
||||
}
|
||||
```
|
||||
|
||||
The handler crate destructures `ChannelSubStreams` into its typed names:
|
||||
The handler calls `accept_bi()` once, gets a `BiStream`, and pumps. It
|
||||
does not know it's inside a channels connection — the `Connection` looks
|
||||
like any other. This is the path the POC's `EchoHandler` and
|
||||
`TunnelHandler` validated.
|
||||
|
||||
```rust
|
||||
// In alknet-tty (inside-channels mode, ADR-077):
|
||||
let sub = channel_source.into_sub_streams();
|
||||
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
|
||||
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
|
||||
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
|
||||
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
|
||||
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
|
||||
```
|
||||
|
||||
**Every stream_type is unidirectional** (ADR-071). The channels crate
|
||||
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
|
||||
stream_types to its typed names. This preserves ADR-003's
|
||||
no-handler-depends-on-another-handler rule and keeps the channels crate
|
||||
ALPN-blind.
|
||||
|
||||
`into_sub_streams()` consumes the source — a handler can't call both
|
||||
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
|
||||
include the 0/1 pair, so `into_sub_streams()` is the superset.
|
||||
|
||||
## Choosing the path
|
||||
|
||||
| Handler shape | Path | Examples |
|
||||
|---------------|------|---------|
|
||||
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
|
||||
| Needs stderr/control (2/3/4) | `into_sub_streams()` | TTY (stdin/stdout/stderr/ctrl-in/ctrl-out) |
|
||||
|
||||
The handler chooses based on its ALPN's `stream_type` set (declared at
|
||||
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
|
||||
`Connection` (via `from_source`); handlers that need sub-streams access the
|
||||
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
|
||||
(exact ergonomics are an implementation detail for the channels crate; the
|
||||
contract is that both paths are available and the handler crate chooses).
|
||||
`accept_bi()` is yield-once: the first call returns the `BiStream`;
|
||||
subsequent calls return `ConnectionClosed`. This matches the POC's
|
||||
validated shape and the `StreamBidiStreamSource` yield-once contract
|
||||
(ADR-070, ADR-092).
|
||||
|
||||
## Recursive composition
|
||||
|
||||
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
|
||||
from_source` wraps it. A handler that is itself `alknet/channels` can open a
|
||||
sub-channels connection on a data channel — `alknet/channels` inside
|
||||
`alknet/channels`. This is allowed (the `Connection` abstraction permits it)
|
||||
but not a feature designed for. The primary use case is one level of
|
||||
multiplexing. Recursive composition is a natural consequence of the
|
||||
abstraction, not a goal.
|
||||
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and
|
||||
`Connection::from_source` wraps it. A handler that is itself
|
||||
`alknet/channels` can open a sub-channels connection on a data channel —
|
||||
`alknet/channels` inside `alknet/channels`. The outer layer strips its
|
||||
8-byte header; the inner layer parses its own 8-byte header from the
|
||||
payload. Each level is the same shape: `BiStream → accept_bi → N
|
||||
BiStreams`. The recursion is unbounded and uniform at every level.
|
||||
|
||||
This is a property, not a feature. The primary use case is one level of
|
||||
multiplexing. But the add/strip composition makes it cleaner than
|
||||
ADR-071's group framing did — the recursion is the same operation
|
||||
(strip an 8-byte header) at every level, not a different framing per
|
||||
level.
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
|
||||
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
|
||||
internal to the channels crate; handlers see a `Connection`.
|
||||
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to
|
||||
wrap their internal sources. `ChannelBidiStreamSource` constructs them via
|
||||
the existing `from_stream` constructors, backed by mpsc reassembly
|
||||
buffers.
|
||||
- **`BiStream`** (ADR-092) — the leaf type `accept_bi` returns. The
|
||||
channels layer yields `BiStream`s; handlers parse them per their ALPN.
|
||||
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
|
||||
the same registry as top-level connections.
|
||||
|
||||
@@ -206,15 +158,22 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
||||
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-074: ChannelConnection (the decision)
|
||||
- ADR-093: channels pure channel multiplexing (the umbrella decision)
|
||||
- ADR-070: BidiStreamSource trait
|
||||
- ADR-065: `Connection::from_stream`
|
||||
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
|
||||
- ADR-092: `BiStream` as the handler leaf
|
||||
- ADR-065: `Connection::from_stream` (the yield-once path generalized)
|
||||
- ADR-077: TTY inside channels (TTY always uses its 5-byte format,
|
||||
carried transparently in the channels payload)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
||||
yield-once `Connection::from_stream` validation)
|
||||
yield-once `Connection::from_stream` validation)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the single-accessor resolution
|
||||
@@ -1,147 +1,135 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channels-wire.md — The 9-Byte Chunk Format
|
||||
# channels-wire.md — The 8-Byte Chunk Format
|
||||
|
||||
The wire format for `alknet/channels`: a 9-byte chunk header that
|
||||
multiplexes N logical channels, each with up to 256 sub-stream types, over
|
||||
a single ordered, reliable bidirectional transport stream. ADR-071 is the
|
||||
The wire format for `alknet/channels`: an 8-byte chunk header that
|
||||
multiplexes N logical channels over a single ordered, reliable
|
||||
bidirectional transport stream. ADR-071 (amended by ADR-093) is the
|
||||
decision; this doc specifies the format and the wire-level invariants.
|
||||
The channels layer has no `stream_type` concept — not in its header, not
|
||||
in its code, not in its mental model. The handler owns its sub-stream
|
||||
multiplexing on the `BiStream` the channels layer gives it.
|
||||
|
||||
## Chunk header
|
||||
|
||||
```
|
||||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
||||
[channel_id: u32 BE][length: u32 BE][payload bytes]
|
||||
```
|
||||
|
||||
9 bytes of header, followed by `length` bytes of payload.
|
||||
8 bytes of header, followed by `length` bytes of opaque payload.
|
||||
|
||||
| field | offset | width | meaning |
|
||||
|-------|--------|-------|---------|
|
||||
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open` (ADR-073). |
|
||||
| `stream_type` | 4 | 1 | The sub-stream within the channel. See "Stream types" below. |
|
||||
| `length` | 5 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
|
||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
|
||||
|
||||
This is a 4-byte extension of alknet-tty's 5-byte format (ADR-052): the
|
||||
`channel_id` prefix is added; `stream_type` and `length` are identical. The
|
||||
`ChunkReader` / `ChunkWriter` pattern, the framing-disambiguation trick,
|
||||
and the zero-length sentinel convention all carry forward from TTY.
|
||||
The payload is opaque to the channels layer. The handler parses its own
|
||||
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
|
||||
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
|
||||
framing, ADR-064), tunnel's raw bytes, SSH's channel protocol. The
|
||||
channels layer carries the bytes transparently.
|
||||
|
||||
### How the wire formats compose
|
||||
|
||||
The channels 8-byte header and the handler's framing compose by layering:
|
||||
|
||||
```
|
||||
channels: [channel_id:u32 BE][length:u32 BE][payload]
|
||||
= 8-byte header + opaque payload
|
||||
8 bytes
|
||||
|
||||
TTY inside channels:
|
||||
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
|
||||
4 bytes 4 bytes 1 byte 4 bytes N bytes
|
||||
\_________ __________/ \_________ _____________/
|
||||
| |
|
||||
channels header TTY chunk (5+N bytes)
|
||||
(8 bytes) carried as channels payload
|
||||
```
|
||||
|
||||
The channels layer reads its 8-byte header (`channel_id` + `length`),
|
||||
reads `length` bytes of payload, and hands the payload to the handler.
|
||||
The handler parses its own framing from the payload — TTY reads its
|
||||
5-byte header (`stream_type` + `length`) from the payload bytes.
|
||||
|
||||
The two length fields are close but not identical: `ch_len = tty_len + 5`.
|
||||
This is a small amount of waste per chunk (the channels `length` is always
|
||||
5 bytes more than TTY's `length`), but the trade-off is clean separation
|
||||
of concerns: the channels layer has no `stream_type` concept — not in
|
||||
its header, not in its code, not in its mental model. The handler owns
|
||||
its framing entirely. See ADR-093 for the full cost/benefit analysis.
|
||||
|
||||
## `MAX_CHUNK_LEN`
|
||||
|
||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||
stream — the demux drops the chunk and continues. The header is always
|
||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||
header.
|
||||
|
||||
## Stream types — unidirectional, grouped in threes
|
||||
|
||||
**Every stream_type is unidirectional.** Bidirectionality is two
|
||||
stream_types (write + read), not one "bidirectional" stream_type. The
|
||||
stream_types are grouped in threes:
|
||||
|
||||
| Group | stream_type | direction | purpose |
|
||||
|-------|-------------|-----------|---------|
|
||||
| Data | 0 | write (client→server) | data in (stdin equivalent) |
|
||||
| | 1 | read (server→client) | data out (stdout equivalent) |
|
||||
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
|
||||
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
|
||||
| | 4 | read (server→client) | control out (ALPN-specific format) |
|
||||
| | 5 | read (server→client) | control err (optional) |
|
||||
| Future | 6/7/8 | write/read/read | next group, same pattern |
|
||||
| | ... | | |
|
||||
|
||||
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
|
||||
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
|
||||
|
||||
256 values / 3 = 85 groups. The `u32` channel_id space combined with 85
|
||||
stream_type groups is effectively unlimited for the intended use cases.
|
||||
|
||||
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
|
||||
own flow control, its own EOF. Control is bidirectional via two halves
|
||||
(3 in, 4 out), not one shared stream both sides write to. This resolves the
|
||||
TTY control channel's "not actually bidirectional" flaw (ADR-077).
|
||||
|
||||
**Control payload format is ALPN-specific.** The channels layer is blind to
|
||||
what stream_types 3/4/5 carry — it reassembles bytes and delivers them to
|
||||
the handler. TTY happens to use JSON for its control channel; another ALPN
|
||||
might use a binary format. The channels layer does not mandate JSON on
|
||||
control stream_types, the same way it doesn't mandate a format for data
|
||||
stream_types.
|
||||
|
||||
Not all channels use all sub-streams. The active set is declared at
|
||||
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
|
||||
channel's lifetime.
|
||||
|
||||
| Channel ALPN | Active stream_types | Why |
|
||||
|--------------|---------------------|-----|
|
||||
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
|
||||
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
|
||||
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
|
||||
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
|
||||
|
||||
## Substrate modes — same wire format, different stream counts
|
||||
|
||||
The 9-byte header is used in all substrates, on every bidi stream. The
|
||||
difference between substrates is only **how many bidi streams the transport
|
||||
yields**:
|
||||
|
||||
| Substrate | Transport | Streams | Header role |
|
||||
|-----------|-----------|---------|--------------|
|
||||
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
|
||||
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
|
||||
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
|
||||
|
||||
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
|
||||
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
|
||||
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
|
||||
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
|
||||
each stream is a channel, and the header provides `stream_type` and
|
||||
`channel_id` correlation. Same code path, same wire format, same handler
|
||||
experience. See ADR-071 §substrate modes, ADR-075.
|
||||
exactly 8 bytes, so the demux can always resync by reading the next
|
||||
8-byte header.
|
||||
|
||||
## Channel 0 — pre-negotiated `alknet/call`
|
||||
|
||||
Channel 0 is not a special "control plane" with its own framing. It is
|
||||
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
|
||||
routed to the `CallAdapter` without an explicit `channel/open` exchange.
|
||||
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0`
|
||||
is routed to the `CallAdapter` without an explicit `channel/open`
|
||||
exchange.
|
||||
|
||||
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
|
||||
(client→server), 1=out (server→client). The call protocol's `(SendStream,
|
||||
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
|
||||
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are
|
||||
reserved for future call-protocol sub-streams.
|
||||
Channel 0's chunks have `channel_id = 0` in the 8-byte header — same
|
||||
format as every other channel. The call protocol's `EventEnvelope` JSON
|
||||
framing (ADR-064) is the payload; the channels layer carries it
|
||||
transparently. Disambiguation between channel 0 and data channels is by
|
||||
`channel_id`, not by a special first-byte trick.
|
||||
|
||||
Channel 0's chunks have `channel_id = 0` in the header — same format as
|
||||
every other channel. Disambiguation between channel 0 and data channels is
|
||||
by `channel_id`, not by a special first-byte trick.
|
||||
## Framing disambiguation
|
||||
|
||||
## Framing disambiguation (from ADR-052 §5)
|
||||
|
||||
The 9-byte header is always exactly 9 bytes. `length` is bounded by
|
||||
`MAX_CHUNK_LEN`. The demux reads 9 bytes, parses the header, reads
|
||||
The 8-byte header is always exactly 8 bytes. `length` is bounded by
|
||||
`MAX_CHUNK_LEN`. The demux reads 8 bytes, parses the header, reads
|
||||
`length` bytes of payload, and routes. If a chunk is dropped (e.g.,
|
||||
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
|
||||
`ChunkTooLarge`), the demux resyncs by reading the next 8-byte header —
|
||||
the format is self-synchronizing.
|
||||
|
||||
Within a channel, `stream_type` 0 (write half) from the server is invalid,
|
||||
so `0x00` as the first byte of a chunk payload from the server is
|
||||
unambiguous (carried from ADR-052 §5).
|
||||
There is no channels-layer framing-disambiguation trick beyond the fixed
|
||||
8-byte header. The channels layer does not interpret the payload — it
|
||||
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
|
||||
Any framing disambiguation within the payload is the handler's concern
|
||||
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
|
||||
which is internal to TTY's 5-byte format).
|
||||
|
||||
## Zero-length sentinel = EOF
|
||||
|
||||
A zero-length chunk (`length = 0`) is delivered as an empty `Bytes`, which
|
||||
the reassembled stream interprets as EOF. This is the clean-shutdown signal
|
||||
for a `(channel_id, stream_type)` pair — same convention as TTY (ADR-052
|
||||
§Sentinels).
|
||||
A zero-length chunk (`length = 0`) is delivered as an empty payload,
|
||||
which the reassembled stream interprets as EOF. This is the clean-shutdown
|
||||
signal for a `channel_id` — the same convention as TTY (ADR-052
|
||||
§Sentinels), now at the channels layer (one sentinel per channel, not
|
||||
per `(channel_id, stream_type)`).
|
||||
|
||||
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
|
||||
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
|
||||
EOF.
|
||||
|
||||
## Substrate modes — same wire format, different stream counts
|
||||
|
||||
The 8-byte header is used in all substrates, on every bidi stream. The
|
||||
difference between substrates is only **how many bidi streams the
|
||||
transport yields**:
|
||||
|
||||
| Substrate | Transport | Streams | Header role |
|
||||
|-----------|-----------|---------|--------------|
|
||||
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
|
||||
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `channel_id` correlation |
|
||||
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
|
||||
|
||||
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
|
||||
the 8-byte header → route by `channel_id` → reassemble into a `BiStream`.
|
||||
On an in-line transport, `accept_bi()` yields once then
|
||||
`ConnectionClosed` — the header does all the demux. On QUIC, `accept_bi()`
|
||||
yields repeatedly — each stream is a channel, and the header provides
|
||||
`channel_id` correlation. Same code path, same wire format, same handler
|
||||
experience. See ADR-071 §substrate modes (as amended by ADR-093), ADR-075.
|
||||
|
||||
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
||||
|
||||
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
|
||||
@@ -151,22 +139,23 @@ These are **contracts**, not implementation details — both sides must agree.
|
||||
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
|
||||
|
||||
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
|
||||
send an empty `Bytes` (the EOF sentinel) before dropping the sender on
|
||||
send an empty payload (the EOF sentinel) before dropping the sender on
|
||||
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
|
||||
channel's `stream_type`, and `tokio::io::copy` in the handler never
|
||||
channel, and `tokio::io::copy` in the handler never
|
||||
completes — the session hangs.
|
||||
|
||||
The TTY crate's `pump_session` emits the zero-length stdout sentinel
|
||||
explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's
|
||||
per-channel write pump does NOT forward a sentinel on sender-drop, so the
|
||||
send adapter must. Both sides must agree on this convention, or channels
|
||||
hang on clean shutdown.
|
||||
explicitly via its own 5-byte format's zero-length chunk; the channels
|
||||
layer's per-channel write pump does NOT forward a sentinel on
|
||||
sender-drop, so the send adapter must. Both sides must agree on this
|
||||
convention, or channels hang on clean shutdown.
|
||||
|
||||
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
|
||||
|
||||
The demux loop MUST clear its `channels` map on transport EOF, dropping all
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
|
||||
EOF even without an explicit zero-length sentinel arriving on the wire.
|
||||
The demux loop MUST clear its `channels` map on transport EOF, dropping
|
||||
all `ReassemblyBuffer` senders. Every handler's reassembled `BiStream`
|
||||
sees EOF even without an explicit zero-length sentinel arriving on the
|
||||
wire.
|
||||
|
||||
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
|
||||
waiting for a sender that never drops because the demux task is holding the
|
||||
@@ -174,11 +163,11 @@ map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
|
||||
|
||||
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
|
||||
|
||||
A chunk with an unallocated `channel_id` (or `stream_type` on an allocated
|
||||
channel) is dropped with a debug log and an error counter (exposed via
|
||||
`Demux::stats()`), and the demux continues. This matches SSH's behavior and
|
||||
survives transient mis-ordering during teardown (a chunk for a channel that
|
||||
was just closed may arrive after the close is processed).
|
||||
A chunk with an unallocated `channel_id` is dropped with a debug log and
|
||||
an error counter (exposed via `Demux::stats()`), and the demux continues.
|
||||
This matches SSH's behavior and survives transient mis-ordering during
|
||||
teardown (a chunk for a channel that was just closed may arrive after
|
||||
the close is processed).
|
||||
|
||||
The alternative (strict — close the transport on unknown `channel_id`) is
|
||||
fragile during teardown and catches bugs at the cost of reliability. The
|
||||
@@ -187,10 +176,10 @@ fragility.
|
||||
|
||||
### REQ-CH-05: bounded-buffer backpressure does not deadlock
|
||||
|
||||
Each `(channel_id, stream_type)` has an independent bounded `mpsc` buffer
|
||||
(default 1 MiB — ADR-076). A slow reader on one channel does not block
|
||||
another channel's reads — the demux's per-chunk route awaits the matching
|
||||
sender without holding a global lock.
|
||||
Each `channel_id` has an independent bounded `mpsc` buffer (default 1 MiB
|
||||
— ADR-076). A slow reader on one channel does not block another channel's
|
||||
reads — the demux's per-chunk route awaits the matching sender without
|
||||
holding a global lock.
|
||||
|
||||
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
|
||||
channel writer faster than the TCP echo server consumer, with no deadlock
|
||||
@@ -204,17 +193,16 @@ The wire format's core is pure byte manipulation:
|
||||
```rust
|
||||
// wire.rs — sync core, no async, no platform deps, WASM-clean
|
||||
|
||||
const CHUNK_HEADER_LEN: usize = 9;
|
||||
const CHUNK_HEADER_LEN: usize = 8;
|
||||
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ChunkHeader {
|
||||
pub channel_id: u32,
|
||||
pub stream_type: u8,
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... }
|
||||
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... }
|
||||
pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
|
||||
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
|
||||
```
|
||||
|
||||
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
|
||||
@@ -223,13 +211,35 @@ routing. The split keeps the WASM-compatible core separate from the
|
||||
tokio-dependent shell. The POC validated the sync core compiles under
|
||||
`wasm32-unknown-unknown`.
|
||||
|
||||
## The add/strip composition
|
||||
|
||||
Each layer has its own add/strip pair. The channels layer:
|
||||
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
|
||||
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
|
||||
payload_bytes)` on read (strips the 8-byte header, returns the payload).
|
||||
The handler layer (e.g. TTY) parses its own framing from the payload
|
||||
bytes per its existing `wire.rs`. The handler doesn't know or care that
|
||||
a `channel_id` was stripped before it saw the bytes.
|
||||
|
||||
The composition is uniform — the same shape at every level. This is SSH's
|
||||
model (layered headers, each layer strips its own at its boundary),
|
||||
applied to channels. A `alknet/channels`-inside-`alknet/channels`
|
||||
recursive composition is the outer layer stripping its 8-byte header, the
|
||||
inner layer parsing its own 8-byte header from the payload — same code,
|
||||
same shape, each level.
|
||||
|
||||
The exact API shape of the add/strip pair (built into the read/write path
|
||||
vs. a standalone utility) is an implementation detail for the channels
|
||||
crate, tracked as OQ-68. The *contract* — the channels layer strips its
|
||||
8-byte header on read and the handler parses its own framing from the
|
||||
payload — is decided; the *function surface* is not.
|
||||
|
||||
## Channel lifecycle (summary)
|
||||
|
||||
| Phase | Mechanism | Reference |
|
||||
|-------|-----------|-----------|
|
||||
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 |
|
||||
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees `AsyncRead + AsyncWrite` | this doc, [channels-connection.md](channels-connection.md) |
|
||||
| Control (data-ordered) | `stream_type 3` (write) and `stream_type 4` (read) chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
|
||||
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees a `BiStream` | this doc, [channels-connection.md](channels-connection.md) |
|
||||
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-073 |
|
||||
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-073, REQ-CH-06 |
|
||||
|
||||
@@ -240,24 +250,53 @@ The channel's data chunks MUST be written and flushed before the
|
||||
invariant: the side closing must observe the data-channel pump complete
|
||||
before issuing the call operation.
|
||||
|
||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
|
||||
the exit control message on `stream_type 4` (read, server→client) is the
|
||||
last data before `channel/close`. For tunnels it is the last data byte
|
||||
before close. The channels layer's close handler observes the pump
|
||||
completion; the call operation is issued after.
|
||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
|
||||
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
|
||||
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
|
||||
For tunnels it is the last data byte before close. The channels layer's
|
||||
close handler observes the pump completion; the call operation is issued
|
||||
after.
|
||||
|
||||
This invariant crosses two channels (the data channel and channel 0), so
|
||||
the channels layer owns the ordering guarantee — it is not a handler
|
||||
concern.
|
||||
concern. The control-message division (data-ordered control vs
|
||||
out-of-band control) is now entirely handler-internal: TTY's
|
||||
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` are stream_types in TTY's 5-byte
|
||||
payload format, not channels-layer concepts.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||||
|
||||
## Open Questions
|
||||
|
||||
Open questions are tracked in [open-questions.md](../../open-questions.md).
|
||||
Key questions affecting this doc:
|
||||
|
||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
||||
add/strip is built into the channels read/write path or exposed as a
|
||||
standalone utility. The *contract* (channels strips, handler parses
|
||||
payload) is decided; the *function surface* is not.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format (the decision)
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||||
amended by ADR-077 — scoped to direct TTY)
|
||||
- ADR-071: channels wire format (the decision, amended by ADR-093 — 8-byte
|
||||
header, no `stream_type`)
|
||||
- ADR-093: channels pure channel multiplexing (the umbrella decision that
|
||||
amends ADR-071/074/077)
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format carried
|
||||
transparently in the channels payload)
|
||||
- ADR-072: channel 0 pre-negotiated
|
||||
- ADR-073: channel lifecycle operations
|
||||
- ADR-076: backpressure, channel limits, ID reuse
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
|
||||
Surfaced #4-#6 (REQ-CH-01, 02, 04)
|
||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the 8-byte format decision
|
||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
|
||||
(carried transparently in the channels payload)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# alknet-channels — Overview
|
||||
@@ -9,17 +9,20 @@ last_updated: 2026-07-12
|
||||
|
||||
`alknet-channels` is a multiplexing proxy crate. It implements
|
||||
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one
|
||||
bidirectional transport stream, reads 9-byte chunk headers, and routes each
|
||||
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
||||
chunk's payload to the right logical channel. Each channel is reassembled
|
||||
into an `AsyncRead + AsyncWrite` pair and presented to its handler as a
|
||||
`Connection` — the handler doesn't know it's inside a channels connection.
|
||||
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
|
||||
ADR-092) and presented to its handler as a `Connection` — the handler
|
||||
doesn't know it's inside a channels connection.
|
||||
|
||||
Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel
|
||||
is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed
|
||||
through the same `HandlerRegistry` as top-level connections. The channels
|
||||
layer does no protocol work itself — it is a re-framing proxy that converts
|
||||
between "one transport stream carrying N channels" (the wire) and "N
|
||||
independent stream handles" (what handlers see).
|
||||
independent `BiStream` handles" (what handlers see). The channels layer has
|
||||
no `stream_type` concept (ADR-093) — the handler owns its sub-stream
|
||||
multiplexing on the `BiStream` it receives.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -46,13 +49,13 @@ With `alknet/channels`, one connection carries everything:
|
||||
|
||||
```
|
||||
Browser ──WebTransport──► Hub ──QUIC──► Spoke
|
||||
alknet/channels alknet/channels
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ ch0: call │ │ ch0: call │
|
||||
│ ch1: tty │ relay │ ch1: tty │
|
||||
│ ch2: ssh │ ◄─────► │ ch2: ssh │
|
||||
│ ch3: tunnel │ │ ch3: tunnel │
|
||||
└─────────────┘ └─────────────┘
|
||||
alknet/channels alknet/channels
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ ch0: call │ │ ch0: call │
|
||||
│ ch1: tty │ relay │ ch1: tty │
|
||||
│ ch2: ssh │ ◄─────► │ ch2: ssh │
|
||||
│ ch3: tunnel │ │ ch3: tunnel │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
|
||||
@@ -70,12 +73,35 @@ The collapse is at three levels:
|
||||
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
|
||||
with no new auth.
|
||||
|
||||
### The separation: channels layer is pure channel multiplexing
|
||||
|
||||
The channels layer's job is "one connection carries N channels, routed by
|
||||
`channel_id`." It does not know about TTY's sub-streams, SSH's channel
|
||||
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
|
||||
on the `BiStream` the channels layer gives them (ADR-093).
|
||||
|
||||
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
|
||||
per channel (per ADR-092). The handler sub-multiplexes it however it
|
||||
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
|
||||
bytes, SSH's channel protocol.
|
||||
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
|
||||
header, not in its code, not in its mental model. `stream_type` is the
|
||||
inner layer's framing byte, carried transparently in the payload.
|
||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
||||
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
|
||||
doesn't carry control.
|
||||
- **Recursive composition is literal.** A channel with ALPN
|
||||
`alknet/channels` runs another channels demux on its `BiStream`. The
|
||||
outer layer strips its 8-byte header; the inner layer parses its own
|
||||
8-byte header from the payload.
|
||||
|
||||
## Architecture
|
||||
|
||||
The crate has two internal components (ADR-075):
|
||||
|
||||
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
|
||||
`alknet/channels`. Its `handle()` receives one `Connection`, reads 9-byte
|
||||
`alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte
|
||||
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
|
||||
half.
|
||||
- **`ChannelManager`** — the shared state. Holds `channel_id →
|
||||
@@ -84,9 +110,10 @@ The crate has two internal components (ADR-075):
|
||||
`channel/open` operation handler closes over.
|
||||
|
||||
Each channel is presented to its handler as a `Connection` constructed via
|
||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074). The
|
||||
handler calls `accept_bi()` once (yield-once per channel) and drives its
|
||||
session — identical to how it works on a top-level QUIC connection.
|
||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074, as
|
||||
amended by ADR-093). The handler calls `accept_bi()` once (yield-once per
|
||||
channel) and gets a `BiStream` — identical to how it works on a top-level
|
||||
QUIC connection.
|
||||
|
||||
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
|
||||
design.
|
||||
@@ -96,7 +123,7 @@ design.
|
||||
```
|
||||
alknet-channels-core
|
||||
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
|
||||
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
|
||||
│ BidiStreamSource, BiStream, AuthContext)
|
||||
├── tokio (spawn, mpsc, io)
|
||||
├── bytes (Bytes for chunk payloads)
|
||||
├── async-trait
|
||||
@@ -159,7 +186,7 @@ stream:
|
||||
|
||||
The same wire format, the same chunk reassembly, the same `Connection`
|
||||
abstraction. The transport is a parameter, not a design constraint.
|
||||
`Connection::from_stream` / `from_source` (ADR-065/070) handles the
|
||||
`Connection::from_bidi` / `from_source` (ADR-065/070/092) handles the
|
||||
transport-agnostic `Connection` construction.
|
||||
|
||||
## WASM compatibility
|
||||
@@ -189,7 +216,9 @@ not an architecture concern. The sync core's WASM compatibility is validated.
|
||||
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
|
||||
runs on channel 0 exactly as on a top-level `alknet/call` connection. The
|
||||
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
|
||||
and dispatches operations — it doesn't know it's inside channels.
|
||||
and dispatches operations — it doesn't know it's inside channels. The call
|
||||
protocol's `EventEnvelope` framing (ADR-064) is the channels payload; the
|
||||
channels layer carries it transparently.
|
||||
|
||||
What changes: the call protocol gains a new class of operations — channel
|
||||
lifecycle (ADR-073). These are registered on the `OperationRegistry` at
|
||||
@@ -198,24 +227,27 @@ assembly time and dispatched through the existing `OperationContext` /
|
||||
|
||||
### alknet-tty
|
||||
|
||||
The TTY crate gains a `channels` feature (ADR-077) that enables
|
||||
inside-channels mode. In direct mode (`alknet/tty` ALPN on a top-level
|
||||
connection), the TTY adapter uses its own 5-byte wire format (ADR-052,
|
||||
unchanged). In channels mode (`channel/open` with ALPN `alknet/tty`), the
|
||||
adapter receives `ChannelSubStreams` (ADR-074) — four named
|
||||
`SendStream`/`RecvStream` pairs for stream_types 0-3 — and pumps without
|
||||
chunk parsing. The `TtyBackend` trait and `TtyHandle` are unchanged;
|
||||
backends don't know which mode the adapter is in.
|
||||
The TTY crate gains a `channels` feature that enables inside-channels
|
||||
mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and
|
||||
inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY
|
||||
adapter uses its own 5-byte wire format (ADR-052). The two modes differ
|
||||
only in *where the `BiStream` comes from* — a top-level connection vs a
|
||||
channels-backed `Connection`. The same `wire.rs` code runs in both modes
|
||||
(ADR-077): the channels layer strips its 8-byte header and hands TTY the
|
||||
payload bytes; TTY parses its 5-byte header from the payload. The
|
||||
`TtyBackend` trait and `TtyHandle` are unchanged; backends don't know
|
||||
which mode the adapter is in.
|
||||
|
||||
### alknet-ssh (future)
|
||||
|
||||
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
|
||||
protocol over stream_types 0 and 1. The channels layer hands the
|
||||
reassembled stream to `SshAdapter`, which feeds it to russh. SSH as a
|
||||
channels transport: an SSH `direct-tcpip` channel could carry a channels
|
||||
connection (channels-over-SSH). The SSH crate doesn't need to know about
|
||||
channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a
|
||||
`Connection`.
|
||||
protocol on its `BiStream`. The channels layer hands the reassembled
|
||||
`BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels
|
||||
transport: an SSH `direct-tcpip` channel could carry a channels connection
|
||||
(channels-over-SSH). The SSH crate doesn't need to know about channels —
|
||||
it implements `ProtocolHandler` for `alknet/ssh` and accepts a
|
||||
`Connection`. SSH multiplexes internally (its own channel protocol rides
|
||||
the channels payload transparently).
|
||||
|
||||
### alknet-docker
|
||||
|
||||
@@ -240,16 +272,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-093); channels layer has no `stream_type` concept; one-way door |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed) |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); `AlknetClient` dial-seam extracted (ADR-089, resolves OQ-55) |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-089, resolves OQ-55) |
|
||||
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
||||
|
||||
## Open Questions
|
||||
@@ -267,4 +300,8 @@ Key questions affecting this crate:
|
||||
blocked on a real HOL-blocking deployment observation.
|
||||
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
|
||||
the *contract* is decided (ADR-078); the *helper* is blocked on a second
|
||||
two-pump handler existing.
|
||||
two-pump handler existing.
|
||||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
||||
add/strip is built into the channels read/write path or exposed as a
|
||||
standalone utility. The *contract* (channels strips, handler parses
|
||||
payload) is decided (ADR-093); the *function surface* is not.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-16
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# alknet-client
|
||||
@@ -20,16 +20,17 @@ HTTP-to-SOCKS5 bridge for iroh).
|
||||
|
||||
## What
|
||||
|
||||
`AlknetClient` is the dial. Before this crate, each protocol client
|
||||
(`CallClient::connect`, `ChannelClient::connect_quic`) built its own
|
||||
QUIC dial inline — building a `TlsClientConfig`, constructing a
|
||||
`quinn::Endpoint`, calling `connect_with`, wrapping as a `Connection`.
|
||||
The dial boilerplate was duplicated, and there was no place for a
|
||||
second transport's dial (TCP+TLS, iroh) to live without each protocol
|
||||
client growing its own per-transport dial helper. Those convenience
|
||||
constructors are removed (see "Relationship to `CallClient` /
|
||||
`ChannelClient`" below); `AlknetClient` is the single dial home, and
|
||||
the protocol crates shed their TLS/transport deps entirely.
|
||||
`AlknetClient` is the dial. It owns the transport-specific work each
|
||||
outbound connection needs — building a `TlsClientConfig`, constructing
|
||||
a `quinn::Endpoint`, calling `connect_with`, wrapping as a
|
||||
`Connection` — for each of three transports (QUIC, TCP+TLS, iroh).
|
||||
Centralizing the dial in one crate keeps the dial boilerplate in one
|
||||
place and gives a natural home for a second transport's dial (TCP+TLS,
|
||||
iroh) without each protocol client growing its own per-transport dial
|
||||
helper. `AlknetClient` is the single dial home; the protocol crates
|
||||
(`CallClient`, `ChannelClient`) shed their TLS/transport deps entirely
|
||||
and take over the `Connection` `AlknetClient` produces (see
|
||||
"Relationship to `CallClient` / `ChannelClient`" below).
|
||||
|
||||
`alknet-client` extracts the dial the same way ADR-083 extracted the
|
||||
accept loop on the server side: one type that takes pre-built transport
|
||||
@@ -186,12 +187,13 @@ impl AlknetClient {
|
||||
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
|
||||
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
|
||||
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
|
||||
/// §3). The local key is extracted from `creds.local_identity`; the
|
||||
/// remote `NodeId` is derived from `creds.remote_identity.fingerprint`
|
||||
/// (`ed25519:<hex>` → `NodeId::from_bytes`). The verifier is iroh's
|
||||
/// `NodeId` match (fingerprint pin by another name — ADR-034 §3).
|
||||
/// An unknown iroh remote fails closed (no CA). Feature-gated on
|
||||
/// `iroh`.
|
||||
/// §3). The local key is on the pre-built iroh endpoint (set when
|
||||
/// `with_iroh` configured it); the remote `NodeId` is derived from
|
||||
/// `creds.remote_identity.fingerprint` (`ed25519:<hex>` →
|
||||
/// `NodeId::from_bytes`). The verifier is iroh's `NodeId` match
|
||||
/// (fingerprint pin by another name — ADR-034 §3). An unknown iroh
|
||||
/// remote fails closed (no CA — `remote_identity` must be `Some`).
|
||||
/// Feature-gated on `iroh`.
|
||||
#[cfg(feature = "iroh")]
|
||||
pub async fn dial_iroh(
|
||||
&self,
|
||||
@@ -206,10 +208,10 @@ The two rustls dials (`dial_quic`, `dial_tcp_tls`) share
|
||||
pin for a known peer, CA-verify for an unknown X.509 remote, fail-closed
|
||||
for an unknown raw-key remote) and the ADR-084 crypto provider
|
||||
(`aws_lc_rs`). The iroh dial is the exception: iroh has its own TLS and
|
||||
takes the `Ed25519SecretKey` directly (extracted from
|
||||
`creds.local_identity`), not a `rustls::ClientConfig`. The consistency
|
||||
is in the rule (ADR-034), not in the type — the same exception as the
|
||||
server side (ADR-082, ADR-087 §3). All three dials take
|
||||
takes the `Ed25519SecretKey` directly (on the pre-built iroh endpoint,
|
||||
not extracted from `creds` at dial time), not a `rustls::ClientConfig`.
|
||||
The consistency is in the rule (ADR-034), not in the type — the same
|
||||
exception as the server side (ADR-082, ADR-087 §3). All three dials take
|
||||
`&ConnectionCredentials` — the unified transport-level credential
|
||||
bundle (ADR-091).
|
||||
|
||||
@@ -405,15 +407,12 @@ let conn = client.dial_tcp_tls("hub.example", addr, b"alknet/call", &creds).awai
|
||||
let call = CallClient::new(registry, idp).spawn_dispatch(conn);
|
||||
```
|
||||
|
||||
The per-protocol QUIC convenience constructors that previously lived on
|
||||
`CallClient` / `ChannelClient` (`connect` / `connect_quic`) are
|
||||
**removed**. They welded the dial into the protocol crate — every
|
||||
`CallClient` user transitively pulled `quinn` + `rustls` + the TLS
|
||||
verifier machinery, and the convenience constructor's existence made
|
||||
`alknet-call` / `alknet-channels-call` depend on `alknet-client` (or
|
||||
duplicate the dial), contradicting the dep graph below. The dial is a
|
||||
distinct concern from the protocol take-over; `AlknetClient` is the
|
||||
single home for it. A caller that wants the old one-liner shape composes
|
||||
The dial is a distinct concern from the protocol take-over;
|
||||
`AlknetClient` is the single home for it. Keeping the dial off
|
||||
`CallClient` / `ChannelClient` means every `CallClient` user doesn't
|
||||
transitively pull `quinn` + `rustls` + the TLS verifier machinery, and
|
||||
`alknet-call` / `alknet-channels-call` don't depend on `alknet-client`
|
||||
(or duplicate the dial) — see the dep graph below. A caller composes
|
||||
two lines: `client.dial_quic(...).await?` then
|
||||
`CallClient::new(...).spawn_dispatch(conn)` (or
|
||||
`ChannelClient::from_connection(conn).await?`). See
|
||||
@@ -422,25 +421,24 @@ two lines: `client.dial_quic(...).await?` then
|
||||
### Iroh — shares the key, not the config (client side too)
|
||||
|
||||
The iroh client dial, like the iroh server side (ADR-082, ADR-087 §3),
|
||||
does not consume a `rustls::ClientConfig`. It takes the
|
||||
`Ed25519SecretKey` directly and feeds it to
|
||||
`iroh::SecretKey::from_bytes`. Iroh handles TLS internally. The
|
||||
verifier is iroh's `NodeId` match — the remote's `NodeId` (Ed25519
|
||||
does not consume a `rustls::ClientConfig`. The `Ed25519SecretKey` is set
|
||||
on the pre-built iroh endpoint at `with_iroh` time (the assembly layer
|
||||
reads it from `StaticConfig` and feeds it to
|
||||
`iroh::Endpoint::builder().secret_key()`). The `dial_iroh` method
|
||||
consumes only `creds.remote_identity` (deriving the remote `NodeId`);
|
||||
the local key is not in `ConnectionCredentials` for the iroh path — it
|
||||
is on the endpoint. The dial signature is unified — all three dials
|
||||
take `&ConnectionCredentials` (ADR-091) — and the iroh dial simply
|
||||
ignores the `local_identity` field (the key is already on the endpoint).
|
||||
The verifier is iroh's `NodeId` match — the remote's `NodeId` (Ed25519
|
||||
public key) is verified against the expected `NodeId`, which is
|
||||
fingerprint-pinning by another name. An unknown iroh remote fails
|
||||
closed (no CA to fall back to — ADR-034 §3, Assumption 1).
|
||||
|
||||
The `dial_iroh` method extracts the key from
|
||||
`creds.local_identity` (`ConnectionCredentials`) rather than taking a
|
||||
separate `Ed25519SecretKey` parameter because the dial signature is
|
||||
unified — all three dials take `&ConnectionCredentials` (ADR-091). The
|
||||
assembly layer reads the key from `StaticConfig` (in core) and passes
|
||||
it via `ConnectionCredentials`, same as the server side's iroh endpoint
|
||||
construction.
|
||||
|
||||
### Non-Rust native clients (out of scope)
|
||||
|
||||
The wire protocols (channels 9-byte chunk format — ADR-071; call
|
||||
The wire protocols (channels 8-byte chunk format — ADR-071, as amended
|
||||
by ADR-093; call
|
||||
`EventEnvelope` — ADR-012/064) are language-agnostic. When the endpoint
|
||||
uses X.509 (the web endpoint type, or a native endpoint with X.509
|
||||
instead of raw keys), non-Rust native clients (Node/Deno/Bun, Python,
|
||||
@@ -675,7 +673,7 @@ All design decisions are documented as ADRs in
|
||||
|-----|----------|---------|
|
||||
| [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — native client dial seam | New crate `alknet-client`; client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); resolves OQ-55; `alknet/register` named, wire protocol deferred (§3/§5 amended by ADR-091 — dial takes `ConnectionCredentials`, not `CallCredentials`) |
|
||||
| [090](../../decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | `AlknetClient` gains `with_socks5_proxy`; `dial_quic` routes via UDP ASSOCIATE, `dial_tcp_tls` via CONNECT, `dial_iroh` forces relay-only via an HTTP-to-SOCKS5 bridge; OQ-67 resolved; grounded in the quinn-proxy + iroh-proxy PoCs |
|
||||
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — decouple dial from call protocol | The dial credential bundle is `ConnectionCredentials` (transport-level: `local_identity` + `remote_identity`), not `CallCredentials` (call-protocol-level); all three dial signatures unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17 |
|
||||
| [091](../../decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — decouple dial from call protocol | The dial credential bundle is `ConnectionCredentials` (transport-level: `local_identity` + `remote_identity`); all three dial signatures unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-15
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# alknet-core
|
||||
@@ -8,19 +8,18 @@ last_updated: 2026-07-15
|
||||
Shared types, auth, config, and identity for ALPN-based protocol
|
||||
dispatch. Every handler crate depends on `alknet-core` for
|
||||
`ProtocolHandler`, `Connection`, `AuthContext`, `IdentityProvider`, and
|
||||
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`) has
|
||||
been extracted to [`alknet-endpoint`](../endpoint/README.md) (ADR-083
|
||||
Amendment 2026-07-15; `EndpointError` is removed — both variants were
|
||||
vestigial); core no longer carries the accept-loop runner or its
|
||||
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`) lives
|
||||
in [`alknet-endpoint`](../endpoint/README.md) (ADR-083 Amendment
|
||||
2026-07-15); core does not carry the accept-loop runner or its
|
||||
transport deps (quinn, iroh, rcgen, rustls-acme).
|
||||
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` as
|
||||
`Connection::from_quinn` / `from_iroh` are in core's `types.rs` as
|
||||
shared constructors (gated on core's `quinn` / `iroh` features).
|
||||
`ConnectionCredentials` and `RemoteIdentity` move to `alknet-core`
|
||||
(from `alknet-call`, per ADR-091) — the transport-level credential
|
||||
bundle consumed by the dial (`alknet-client`) and by server-side
|
||||
transport construction. `ConnectionCredentials` (the transport-level credential
|
||||
bundle, including `auth_token`) stays in `alknet-call` — the dial does
|
||||
not carry call-protocol dimensions.
|
||||
|
||||
`ConnectionCredentials` and `RemoteIdentity` live in `alknet-core` (per
|
||||
ADR-091) — the transport-level credential bundle consumed by the dial
|
||||
(`alknet-client`) and by server-side transport construction. There is no
|
||||
call-protocol credential bundle; `auth_token` is a per-request payload
|
||||
field on `call.requested`, not a transport credential.
|
||||
|
||||
## Documents
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ registration bundle.
|
||||
|----------|-----|---------|
|
||||
| ProtocolHandler receives Connection, not BiStream | [ADR-007](../../decisions/007-bistream-type-definition.md) | Handlers that need multiple streams (SSH, call) have direct access to the Connection |
|
||||
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
|
||||
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; `MockConnection`/`ConnectionKind::Mock` removed (tests use `from_stream` with `sink`/`empty`) |
|
||||
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; tests use `from_stream` with `sink`/`empty` |
|
||||
| `BidiStreamSource` — open `Connection` for extension | [ADR-070](../../decisions/070-bidistreamsource-trait.md) | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; `from_source` is the public constructor for downstream crates that implement the trait (channels, future transports); `from_quinn`/`from_iroh`/`from_stream`/`from_bidi` preserved; `close(code, reason)` kept on the trait (non-QUIC impls ignore the args — fixes the ADR-065 leftover clippy warning under `--no-default-features`) |
|
||||
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
|
||||
| SendStream/RecvStream wrap quinn + iroh + generic streams | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | Internal enum dispatch for QUIC sources and the generic `Stream` variant |
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
---
|
||||
status: deprecated
|
||||
last_updated: 2026-07-15
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# Endpoint (moved to `alknet-endpoint`)
|
||||
# Endpoint (in `alknet-endpoint`)
|
||||
|
||||
> **This document is deprecated.** The `AlknetEndpoint` and
|
||||
> `HandlerRegistry` types have been extracted from `alknet-core` into a
|
||||
> new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15).
|
||||
> `EndpointError` is removed (both variants were vestigial). The
|
||||
> canonical spec is now
|
||||
> `HandlerRegistry` types live in a separate crate, `alknet-endpoint`
|
||||
> (ADR-083 Amendment 2026-07-15). The canonical spec is
|
||||
> [`crates/endpoint/README.md`](../endpoint/README.md).
|
||||
>
|
||||
> The shared types the endpoint imports (`ProtocolHandler`,
|
||||
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) stay
|
||||
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) are
|
||||
> in `alknet-core` — see [`core-types.md`](core-types.md),
|
||||
> [`auth.md`](auth.md), [`config.md`](config.md).
|
||||
|
||||
## Historical summary
|
||||
## What is in `alknet-core`
|
||||
|
||||
The endpoint was originally in `alknet-core/endpoint.rs` as the central
|
||||
runtime type — a multi-transport accept-loop runner that dispatches
|
||||
incoming connections by ALPN (ADR-010, ADR-083). ADR-082 extracted the
|
||||
TLS setup code to `alknet-tls`; ADR-083 restructured the endpoint to
|
||||
take pre-built transports via `with_quinn` / `with_iroh` /
|
||||
`with_tcp_tls` (no TLS config); ADR-083 Amendment 2026-07-15 extracted
|
||||
the endpoint itself into `alknet-endpoint` so that handler crates no
|
||||
longer transitively link quinn/iroh/rcgen via core.
|
||||
|
||||
The endpoint's semantics — ALPN dispatch, `HandlerRegistry`, accept
|
||||
loops, public `dispatch` for SSH/WT, graceful shutdown — are unchanged
|
||||
by the extraction. See
|
||||
[`crates/endpoint/README.md`](../endpoint/README.md) for the current
|
||||
spec and [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
|
||||
for the full decision.
|
||||
|
||||
## What stayed in `alknet-core`
|
||||
|
||||
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` — they
|
||||
`Connection::from_quinn` / `from_iroh` are in core's `types.rs` — they
|
||||
are shared-type constructors used by both the endpoint's accept loop
|
||||
(server) and `alknet-client`'s dial (client, ADR-089), gated on core's
|
||||
`quinn` / `iroh` features. See
|
||||
(server, in `alknet-endpoint`) and `alknet-client`'s dial (client,
|
||||
ADR-089), gated on core's `quinn` / `iroh` features. See
|
||||
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) §"The
|
||||
`quinn` feature split".
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-15
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# alknet-endpoint
|
||||
@@ -22,24 +22,22 @@ It does not build transports and does not build TLS configs — the
|
||||
assembly layer does both (transports from `alknet-tls`'s
|
||||
`TlsServerConfig`, per ADR-082).
|
||||
|
||||
`alknet-endpoint` is extracted from `alknet-core` (ADR-083 Amendment
|
||||
2026-07-15). The extraction is structural pruning, not a refactor: the
|
||||
endpoint is a leaf consumer of core's shared types (it imports `auth`,
|
||||
`config`, `types`; nothing in core imports from it), depended on by a
|
||||
different audience (the assembly layer) than the shared types (every
|
||||
handler crate). No handler crate imports `AlknetEndpoint` or
|
||||
`HandlerRegistry` — they depend on `alknet-core` for
|
||||
`ProtocolHandler`, `Connection`, `AuthContext`, and types only.
|
||||
(`EndpointError` is removed — see below.)
|
||||
`alknet-endpoint` is a leaf consumer of `alknet-core`'s shared types
|
||||
(it imports `auth`, `config`, `types`; nothing in core imports from
|
||||
it), depended on by the assembly layer — a different audience than the
|
||||
shared types (every handler crate). No handler crate imports
|
||||
`AlknetEndpoint` or `HandlerRegistry` — they depend on `alknet-core`
|
||||
for `ProtocolHandler`, `Connection`, `AuthContext`, and types only.
|
||||
This keeps the heavy transport deps (quinn, iroh, tokio-rustls) out of
|
||||
the handler crates' dep closure.
|
||||
|
||||
## Why
|
||||
|
||||
`alknet-core` was two things welded: shared types (depended on by every
|
||||
handler crate) + the endpoint (depended on by zero handler crates).
|
||||
Extracting the endpoint into `alknet-endpoint` lets core shed the heavy
|
||||
transport deps (quinn, iroh, rcgen, rustls-acme) and become the
|
||||
lightweight types+auth+config crate the handler crates actually want.
|
||||
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
|
||||
Separating the endpoint from the shared-types crate lets `alknet-core`
|
||||
be the lightweight types+auth+config crate that every handler crate
|
||||
wants, while the accept-loop runner (which only the assembly layer
|
||||
depends on) carries the heavy transport deps. See
|
||||
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
|
||||
§"Amendment 2026-07-15 — crate extraction" for the full rationale,
|
||||
including the dependency data and the symmetry with `alknet-client`.
|
||||
|
||||
@@ -141,26 +139,6 @@ Registration is static at startup (ADR-010, OQ-04). The assembly layer
|
||||
builds a `HandlerRegistry`, inserts all handlers, and passes it to
|
||||
`AlknetEndpoint::new()`.
|
||||
|
||||
### `EndpointError` — removed
|
||||
|
||||
The endpoint previously had an `EndpointError { BindFailed(io::Error),
|
||||
HandlerNotFound(Vec<u8>) }` enum. Both variants are vestigial after
|
||||
ADR-083:
|
||||
|
||||
- `BindFailed` — the endpoint takes pre-built, pre-bound transports
|
||||
(the assembly layer does the binding); the endpoint performs no bind,
|
||||
so it cannot produce a bind error.
|
||||
- `HandlerNotFound` — `dispatch` swallows no-handler matches (close +
|
||||
log per ADR-083), so this variant is never returned.
|
||||
|
||||
The enum is removed. `shutdown()` is infallible (`async fn shutdown(&self)`,
|
||||
no `Result`). If a future requirement adds a real failure path to
|
||||
shutdown or dispatch, a fresh error type is cleaner than retrofitting
|
||||
this one. The `EndpointError` type, its `TlsConfig` variant (already
|
||||
removed by ADR-083), and the `BindFailed`/`HandlerNotFound` variants all
|
||||
move out of the codebase with the endpoint extraction — none survives
|
||||
into `alknet-endpoint`.
|
||||
|
||||
### `TcpTlsListener`
|
||||
|
||||
The type held by the endpoint's `tcp_tls` field — a tuple of the TCP
|
||||
@@ -265,10 +243,8 @@ alknet-endpoint
|
||||
`alknet-endpoint` depends on `alknet-core` (for `Connection`,
|
||||
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`).
|
||||
`HandlerRegistry` lives in `alknet-endpoint` (it moves with the
|
||||
endpoint from core). `EndpointError` is removed (both variants were
|
||||
vestigial — see "`EndpointError` — removed" above). The endpoint does
|
||||
**not** depend on `alknet-tls` — it takes pre-built transports, so TLS
|
||||
config
|
||||
endpoint from core). The endpoint does **not** depend on `alknet-tls` —
|
||||
it takes pre-built transports, so TLS config
|
||||
construction stays at the assembly layer.
|
||||
|
||||
### Crate dependencies (in the dep graph)
|
||||
@@ -332,15 +308,16 @@ The endpoint takes the pre-built transports; the assembly layer built
|
||||
them from `alknet-tls`'s `TlsServerConfig`s. The endpoint does not see
|
||||
`alknet-tls` — it sees `quinn::Endpoint` and `TlsAcceptor`.
|
||||
|
||||
## What `alknet-core` looks like after the extraction
|
||||
## What `alknet-core` looks like
|
||||
|
||||
Core loses the endpoint module (~1600 LOC) and 5 heavy deps (`quinn`,
|
||||
`iroh`, `rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining surface
|
||||
is the lightweight types+auth+config+ownership+store+fingerprint crate.
|
||||
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
|
||||
§"Amendment 2026-07-15 — crate extraction" §"What `alknet-core` looks
|
||||
like after" for the module-level table and the `quinn` feature split
|
||||
(`Connection::from_quinn` stays in core; the accept loop moves here).
|
||||
Core is the lightweight types+auth+config+ownership+store+fingerprint
|
||||
crate (~3200 LOC, no `quinn`/`iroh`/`rcgen`/`rustls-pemfile`/
|
||||
`rustls-acme` deps). The endpoint module is not in core; the accept
|
||||
loops are here. See [ADR-083](../../decisions/083-endpoint-as-accept-
|
||||
loop-runner.md) §"Amendment 2026-07-15 — crate extraction" §"What
|
||||
`alknet-core` looks like after" for the module-level table and the
|
||||
`quinn` feature split (`Connection::from_quinn` stays in core; the
|
||||
accept loop is here).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
|
||||
@@ -60,12 +60,11 @@ enabled. It serves two things on a single `h3` connection:
|
||||
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
|
||||
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
|
||||
`http/1.1` requests (ADR-042 + ADR-047 — the gateway endpoints are
|
||||
the sole invoke path; the direct-call `POST /{service}/{op}` surface
|
||||
was removed). From the axum router's perspective, an HTTP/3 request
|
||||
is just another HTTP request; the framing difference is handled
|
||||
below the router. The HTTP/3 request path is the **one-directional
|
||||
projection** (client→server calls only — HTTP is request/response;
|
||||
see [http-server.md](http-server.md) §"One-directional projection").
|
||||
the sole invoke path). From the axum router's perspective, an HTTP/3
|
||||
request is just another HTTP request; the framing difference is
|
||||
handled below the router. The HTTP/3 request path is the
|
||||
**one-directional projection** (client→server calls only — HTTP is
|
||||
request/response; see [http-server.md](http-server.md) §"One-directional projection").
|
||||
2. **WebTransport sessions** — the **bidirectional** path. WebTransport
|
||||
is a transport substrate that carries ALPN protocols as
|
||||
bidirectional streams (ADR-043), not a browser→hub one-way path. A
|
||||
|
||||
@@ -117,7 +117,21 @@ welded to a dial. See "Transport" below.
|
||||
Lets a browser reach a spoke's channels through the hub without the
|
||||
hub parsing any protocol-specific framing.
|
||||
|
||||
6. **Worker registration** (in scope of the hub) — the HTTP endpoint
|
||||
6. **Per-identity channel cap** — the hub constructs one
|
||||
`ChannelLifecyclePolicy` (ADR-094) and shares it across every
|
||||
channels connection it accepts. This is the cap the hub enforces on
|
||||
its **inbound** peers (workers and browsers connecting to the hub).
|
||||
The cap is per-identity, not per-connection — a peer with N
|
||||
transport connections to the hub is bounded by the cap once, not
|
||||
N times. The default is 256 per `PeerId`; per-peer-role overrides
|
||||
(e.g., a lower cap for browser peers) are set via
|
||||
`with_channel_policy`. The hub-as-caller case (hub dialing a
|
||||
downstream spoke) is the **spoke's** policy — the spoke constructs
|
||||
its own policy with a high cap for the hub peer (ADR-094 §5). The
|
||||
cap is symmetric — both sides of a channels connection enforce
|
||||
their cap. See "Per-identity channel cap" below.
|
||||
|
||||
7. **Worker registration** (in scope of the hub) — the HTTP endpoint
|
||||
that lets a freshly-provisioned worker enroll its key with a
|
||||
one-time registration token. The registration flow is what makes
|
||||
worker provisioning over TCP+TLS a hard requirement, not an
|
||||
@@ -181,7 +195,8 @@ The hub's `CallClient`-direct dial path is replaced by
|
||||
### Hub struct
|
||||
|
||||
The `Hub` owns the aggregated `PeerCompositeEnv`, the
|
||||
`OperationRegistry`, and the `Dispatcher`:
|
||||
`OperationRegistry`, the `Dispatcher`, and the per-identity channel
|
||||
cap policy:
|
||||
|
||||
```rust
|
||||
pub struct Hub {
|
||||
@@ -189,6 +204,15 @@ pub struct Hub {
|
||||
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
|
||||
dispatcher: Dispatcher,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
/// The per-identity channel cap policy (ADR-094). Shared across
|
||||
/// every channels connection the hub accepts — that is what makes
|
||||
/// the cap per-identity, not per-connection. Constructed once at
|
||||
/// Hub::new and passed to ChannelOperations::new for each
|
||||
/// connection. The hub's browser-leg caps and worker-leg caps are
|
||||
/// enforced by the same policy (the cap is symmetric — both
|
||||
/// sides of a channels connection enforce their cap on the other's
|
||||
/// channels).
|
||||
channel_policy: Arc<dyn ChannelLifecyclePolicy>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,15 +235,43 @@ impl Hub {
|
||||
aggregated_env,
|
||||
dispatcher,
|
||||
identity_provider,
|
||||
channel_policy: Arc::new(PerIdentityChannelPolicy::new(256)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
|
||||
/// this into CallAdapter::with_aggregated_env so every call's
|
||||
/// The shared aggregated PeerCompositeEnv. The deployment binary
|
||||
/// wires this into CallAdapter::with_aggregated_env so every call's
|
||||
/// compose_root_env sees all connected workers.
|
||||
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
|
||||
&self.aggregated_env
|
||||
}
|
||||
|
||||
/// The shared per-identity channel cap policy (ADR-094). Wired into
|
||||
/// `ChannelOperations::new` for every channels connection the hub
|
||||
/// accepts — this is the cap the hub enforces on its **inbound**
|
||||
/// peers (workers and browsers connecting to the hub). The policy
|
||||
/// `Arc` is shared across all the hub's accepted connections, which
|
||||
/// is what makes the cap per-identity (a peer with N transport
|
||||
/// connections to the hub is bounded by the cap once, not N times).
|
||||
/// The hub-as-caller case (hub dialing a downstream spoke) is
|
||||
/// governed by the **spoke's** policy, not this one — the spoke
|
||||
/// constructs its own `ChannelLifecyclePolicy` with a high cap for
|
||||
/// the hub peer (ADR-094 §5). See "Per-identity channel cap" below.
|
||||
pub fn channel_policy(&self) -> &Arc<dyn ChannelLifecyclePolicy> {
|
||||
&self.channel_policy
|
||||
}
|
||||
|
||||
/// Override the default per-identity channel cap policy. Builder
|
||||
/// method for the deployment binary to set per-peer-role caps on
|
||||
/// the hub's inbound peers (e.g., a worker peer gets 256, a
|
||||
/// browser peer gets a lower cap). The hub-as-caller case on a
|
||||
/// downstream spoke is the spoke's own policy, not set here.
|
||||
pub fn with_channel_policy(mut self, policy: Arc<dyn ChannelLifecyclePolicy>)
|
||||
-> Self
|
||||
{
|
||||
self.channel_policy = policy;
|
||||
self
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -287,8 +339,8 @@ another hub (A) is a client from A's perspective. The dial needs a
|
||||
client-side TLS config (`TlsClientConfig`, ADR-087) for the outbound
|
||||
connection's `rustls::ClientConfig` (verifier selection per ADR-034:
|
||||
fingerprint pin for the worker's known key). The dial path mirrors the
|
||||
`from_connection` primary (ADR-080; `ChannelClient::connect_quic` is
|
||||
removed per ADR-089 §5 — the dial lives in `AlknetClient`):
|
||||
`from_connection` primary (ADR-080; the dial lives in `AlknetClient`,
|
||||
ADR-089):
|
||||
|
||||
```rust
|
||||
impl Hub {
|
||||
@@ -296,9 +348,8 @@ impl Hub {
|
||||
/// connection. Transport-agnostic — the caller (or a transport
|
||||
/// helper) produces the `Connection`. This is the primary path;
|
||||
/// `connect_quic_worker` (a hub-level convenience, distinct from
|
||||
/// the removed `ChannelClient::connect_quic` per-protocol
|
||||
/// constructor — ADR-089 §5) and future `connect_tcp_tls_worker`
|
||||
/// are conveniences over it.
|
||||
/// the per-protocol dial in `AlknetClient`) and future
|
||||
/// `connect_tcp_tls_worker` are conveniences over it.
|
||||
pub async fn dial_worker_connection(
|
||||
&self,
|
||||
connection: Connection,
|
||||
@@ -411,12 +462,16 @@ via a builder method. The `ChannelsAdapter::handle` flow becomes:
|
||||
aggregated env.
|
||||
|
||||
The assembly layer constructs the callback and passes it to
|
||||
`ChannelsAdapter`:
|
||||
`ChannelsAdapter`, wiring the hub's per-identity channel cap policy
|
||||
(ADR-094) into `ChannelOperations::new` so every channels connection
|
||||
the hub accepts shares the same policy (the cap is per-identity, not
|
||||
per-connection, because the policy `Arc` is shared):
|
||||
|
||||
```rust
|
||||
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
|
||||
let channels_adapter = ChannelsAdapter::new(Arc::clone(®istry), /* ... */)
|
||||
.with_worker_connected_callback(callback);
|
||||
.with_worker_connected_callback(callback)
|
||||
.with_channel_policy(hub.channel_policy().clone());
|
||||
// Register channels_adapter on alknet/channels in the HandlerRegistry.
|
||||
// The endpoint dispatches alknet/channels connections to it — whether
|
||||
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
|
||||
@@ -595,6 +650,46 @@ handlers (`alknet/tty`, `alknet/ssh`, `alknet/tunnel`) — it runs
|
||||
translation). The full relay contract is in ADR-079; the relay
|
||||
implementation lives in `alknet-hub`.
|
||||
|
||||
### Per-identity channel cap (ADR-094)
|
||||
|
||||
A channel slot is a resource. The cap on how many channels a peer may
|
||||
hold open against the hub is a quota check on that resource — parallel
|
||||
to `OwnershipProvider::owns` (ADR-050) for spawned resources. The hub
|
||||
constructs one `ChannelLifecyclePolicy` and shares it across every
|
||||
channels connection it accepts (the policy `Arc` is shared, so the
|
||||
cap is per-identity, not per-connection). This is the cap the hub
|
||||
enforces on its **inbound** peers — workers and browsers connecting
|
||||
to the hub. The default is `PerIdentityChannelPolicy::new(256)` — 256
|
||||
per `PeerId` across all the peer's connections to the hub. The cap is
|
||||
symmetric — both sides of a channels connection enforce their cap on
|
||||
the other's channels.
|
||||
|
||||
The cap lives in `channels-call`, not `channels-core`, because the
|
||||
channels layer is auth-blind by design (ADR-075 — that is what makes
|
||||
it WASM-compatible, transport-agnostic, and ALPN-blind). The identity
|
||||
is on `OperationContext`; the `channel/open` handler consults the
|
||||
policy after `AccessControl::check` and before allocation; the
|
||||
`channel/close` handler decrements after the drain completes.
|
||||
|
||||
**Relay consequence (ADR-094 §5):** when the hub relays a browser's
|
||||
channel to a spoke, the spoke sees the hub as the direct caller
|
||||
(ADR-032 — `forwarded_for` is metadata, not authority, for the cap as
|
||||
for `AccessControl::check`). The spoke's cap applies to the hub, not
|
||||
the browser. A spoke that serves a hub relaying for many browsers
|
||||
must set the hub peer's cap higher than a worker peer's cap on the
|
||||
**spoke's own** `ChannelLifecyclePolicy`, or the spoke denies
|
||||
legitimate relayed channels when the hub's aggregate count exceeds a
|
||||
worker-sized cap. This is a per-peer-role policy on the spoke, not on
|
||||
the hub — the hub's `channel_policy` governs the hub's inbound peers,
|
||||
not the hub-as-caller case. The hub enforces per-browser caps on the
|
||||
browser leg (the hub's own policy); the spoke enforces per-hub caps
|
||||
on the spoke leg (the spoke's own policy). Same shape as any per-peer
|
||||
ACL.
|
||||
|
||||
See [ADR-094](../../decisions/094-per-identity-channel-cap.md) for
|
||||
the full decision, the trait, the default/opt-out variants, and the
|
||||
recursive-channels edge case.
|
||||
|
||||
### Service discovery
|
||||
|
||||
The hub registers the built-in service discovery operations
|
||||
@@ -791,7 +886,7 @@ into `CallAdapter::with_aggregated_env`.
|
||||
| Peer-graph routing model | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays, `PeerRef` routing, `AccessControl`-based peer auth |
|
||||
| PeerEntry and Identity.id | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable) |
|
||||
| Three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Hub = role-3 `PeerEntry` (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
|
||||
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary; `connect_quic` removed per ADR-089 §5 (dial extracted to `AlknetClient`); the dial path the hub uses |
|
||||
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary; dial in `AlknetClient` (ADR-089) — the dial path the hub uses |
|
||||
| Channels transport-agnostic | [ADR-071](../../decisions/071-channels-wire-format.md) | Substrate modes; `Connection::from_stream`/`from_bidi` (ADR-065) — the substrate the hub relays |
|
||||
| TCP+TLS as first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `with_tcp_tls(listener, acceptor)` — TCP+TLS is owned by the endpoint, not a sibling loop; supersedes ADR-010 Am. 1 |
|
||||
| Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here |
|
||||
@@ -799,6 +894,7 @@ into `CallAdapter::with_aggregated_env`.
|
||||
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
|
||||
| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) |
|
||||
| `AlknetClient` native dial seam | [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) | New crate `alknet-client`; the hub's outbound worker dials use `AlknetClient` (via the `supervise_worker` closure or the `connect_quic_worker` convenience); resolves OQ-55 |
|
||||
| Per-identity channel cap | [ADR-094](../../decisions/094-per-identity-channel-cap.md) | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; the hub's policy governs its inbound peers and is shared across all their connections; the hub-as-caller case on a downstream spoke is the spoke's own policy with a high cap for the hub peer (ADR-094 §5) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -814,11 +910,16 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
ALPN would serve the same role over QUIC/TCP without HTTP.
|
||||
- **OQ-65** (open): WebSocket carrying channels — whether the browser
|
||||
path extends from call-protocol-only (ADR-048) to full channels
|
||||
(the 9-byte chunk format over WebSocket binary frames). If chosen,
|
||||
(the 8-byte chunk format over WebSocket binary frames). If chosen,
|
||||
the browser is a first-class channels participant and the hub relay
|
||||
works unchanged for browser legs. The web endpoint advertises
|
||||
`alknet/channels` by default (ADR-086 §3 — the advertisement is
|
||||
settled; OQ-65 governs whether the browser path uses it).
|
||||
- **OQ-68** (open): Channels add/strip API shape — whether the 8-byte
|
||||
header add/strip is built into the channels read/write path or
|
||||
exposed as a standalone utility. The *contract* is decided (ADR-093);
|
||||
the *function surface* is not. Does not block the hub (the hub uses
|
||||
the `ChannelManager` interface either way).
|
||||
- **OQ-52** (open): `CallConnection::wait_for_close()` — the
|
||||
supervision loop needs a way to await connection close. The
|
||||
committed interim is polling `connection().accept_bi()` until
|
||||
@@ -836,8 +937,7 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
## References
|
||||
|
||||
- [channel-client.md](../channels/channel-client.md) — `ChannelClient`
|
||||
(`from_connection` — the take-over; `connect_quic` removed per
|
||||
ADR-089 §5, dial now via `AlknetClient`)
|
||||
(`from_connection` — the take-over; dial via `AlknetClient` per ADR-089)
|
||||
- [channels-adapter.md](../channels/channels-adapter.md) —
|
||||
`ChannelsAdapter`, `ChannelManager`, the accept path
|
||||
- [channel-operations.md](../channels/channel-operations.md) —
|
||||
@@ -854,16 +954,23 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
`resolve_from_fingerprint` (the identity paths over transports)
|
||||
- ADR-029: Peer-Graph Routing Model
|
||||
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
|
||||
- ADR-050: Dynamic Resource Ownership (the parallel for the channel cap —
|
||||
a channel slot is a resource, the cap is a quota check)
|
||||
- ADR-065: `Connection::from_stream`/`from_bidi` (TCP+TLS path)
|
||||
- ADR-067: Aggregated Peer-Environment Wiring
|
||||
- ADR-068: PeerCompositeEnv::peer_operations Override
|
||||
- ADR-069: from_call Is a Manual Free Function
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (the auth-blindness that
|
||||
forces the per-identity cap into `channels-call`, not `channels-core`)
|
||||
- ADR-079: Hub Relay — Translate, Not Transparently Forward
|
||||
- ADR-080: ChannelClient (transport-agnostic `from_connection`)
|
||||
- ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS)
|
||||
- ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers)
|
||||
- ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type)
|
||||
- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement)
|
||||
- ADR-094: Per-Identity Channel Cap (the `ChannelLifecyclePolicy` the hub
|
||||
constructs and shares across all its channels connections; the relay
|
||||
consequence for hub-as-caller on downstream spokes)
|
||||
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
|
||||
the first hub consumer, the concrete use case that informed this
|
||||
crate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: reviewed
|
||||
last_updated: 2026-07-15
|
||||
last_updated: 2026-07-17
|
||||
---
|
||||
|
||||
# alknet-tls
|
||||
@@ -17,16 +17,9 @@ one verifier rule, N clients.
|
||||
|
||||
## What
|
||||
|
||||
`alknet-tls` extracts the TLS setup that was welded to the quinn endpoint
|
||||
in `alknet-core`. The existing code (`endpoint.rs`) builds a
|
||||
`rustls::ServerConfig` from a `TlsIdentity`, then **consumes** it into a
|
||||
`quinn::ServerConfig` — making it impossible to reuse the same cert for a
|
||||
TCP+TLS listener. ACME is worse: the `AcmeState` task is spawned inside
|
||||
the quinn endpoint, so a TCP+TLS listener would need its own ACME state
|
||||
machine (two orders for the same domain, two cert caches, potential
|
||||
Let's Encrypt rate-limiting).
|
||||
|
||||
`alknet-tls` fixes this by making the TLS config **shareable**:
|
||||
`alknet-tls` provides `TlsServerConfig` and `TlsClientConfig` —
|
||||
shareable TLS setup types that a deployment builds once and hands to
|
||||
whichever transports it runs:
|
||||
|
||||
```rust
|
||||
pub struct TlsServerConfig {
|
||||
@@ -64,15 +57,17 @@ transports.
|
||||
|
||||
## Why
|
||||
|
||||
`alknet-core` builds the `rustls::ServerConfig` once, then consumes it
|
||||
into a `quinn::ServerConfig` — making the cert unreusable for a TCP+TLS
|
||||
listener. For ACME the problem is worse: the `AcmeState` task is spawned
|
||||
inside the quinn endpoint, so a TCP+TLS listener would need a second ACME
|
||||
state machine for the same domain (duplicate orders, divergent cert
|
||||
caches, Let's Encrypt rate-limit risk). The full rationale, including
|
||||
the cert-reuse problem, the ACME worst case, and the three reasons a
|
||||
separate crate is the right shape (dependency isolation, ACME weight,
|
||||
quinn/iroh having their own TLS), is in
|
||||
Without a shareable TLS config, a `rustls::ServerConfig` built for one
|
||||
transport gets consumed into that transport's wrapper (e.g.
|
||||
`quinn::ServerConfig`), making the cert unreusable for a TCP+TLS
|
||||
listener. For ACME the problem is worse: the `AcmeState` task spawned
|
||||
inside the quinn endpoint means a TCP+TLS listener would need a second
|
||||
ACME state machine for the same domain (duplicate orders, divergent cert
|
||||
caches, Let's Encrypt rate-limit risk). `alknet-tls` isolates TLS setup
|
||||
from the transport so one config serves all transports. The full
|
||||
rationale, including the cert-reuse problem, the ACME worst case, and
|
||||
the three reasons a separate crate is the right shape (dependency
|
||||
isolation, ACME weight, quinn/iroh having their own TLS), is in
|
||||
[ADR-082](../../decisions/082-alknet-tls-extraction.md).
|
||||
|
||||
### The three endpoint types (ADR-086)
|
||||
@@ -107,64 +102,73 @@ transports the deployment runs.
|
||||
|
||||
## Architecture
|
||||
|
||||
### What moves from `alknet-core` to `alknet-tls` (server side)
|
||||
### Server-side contents
|
||||
|
||||
| Component | Current location | New location |
|
||||
|-----------|-----------------|-------------|
|
||||
| `TlsIdentity` enum | `alknet-core/config.rs` | **stays in core** (it's a config type) |
|
||||
| `Ed25519SecretKey` | `alknet-core/config.rs` | **stays in core** (config type) |
|
||||
| `build_rustls_server_config()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (unconditional) |
|
||||
| `build_quinn_server_config_from_rustls()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (`for_quinn()` — wraps rustls config in `QuicServerConfig`) |
|
||||
| `TlsSetup` (ACME state machine) | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (the `TlsServerConfig::new` ACME path) |
|
||||
| `RawKeyCertResolver` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
|
||||
| `Ed25519SigningKey` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (consolidates with the `alknet-call` duplicate — see client table) |
|
||||
| `AcceptAnyCertVerifier` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
|
||||
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
|
||||
| `load_cert_chain()` / `load_private_key()` | `alknet-core/endpoint.rs` | `alknet-tls` (consolidates with the `alknet-call` duplicate — see client table) |
|
||||
| `fingerprint.rs` | `alknet-core/fingerprint.rs` | **stays in core** (shared by server + client; the client-side `FingerprintPinVerifier` is now in `alknet-tls` per ADR-089 §5, so both consumers are co-located; production code uses `sha2` + manual DER only — `rustls` is test-only. See OQ-59 — the original dep-edge concern that motivated keeping `fingerprint.rs` in core is dissolved by ADR-089 §5.) |
|
||||
The server-side TLS setup — `rustls::ServerConfig` construction, cert
|
||||
resolvers, the ACME state machine — is in `alknet-tls/src/server.rs`.
|
||||
These components were originally part of `alknet-core`'s endpoint
|
||||
module (quinn-gated); ADR-082 moved them into `alknet-tls` so a
|
||||
`TlsServerConfig` is shareable across transports rather than consumed
|
||||
into a single transport's wrapper.
|
||||
|
||||
### What moves from `alknet-call` to `alknet-tls` (client side)
|
||||
| Component | Notes |
|
||||
|-----------|-------|
|
||||
| `TlsServerConfig` | The central type — wraps `rustls::ServerConfig` + the optional ACME task handle |
|
||||
| `build_rustls_server_config()` | Unconditional; called by `TlsServerConfig::new` |
|
||||
| `for_quinn()` | Wraps the rustls config in a `QuicServerConfig` (feature-gated on `quinn`) |
|
||||
| `TlsSetup` / ACME path | The `TlsServerConfig::new` ACME branch spawns the state-machine task |
|
||||
| `RawKeyCertResolver` | Presents an Ed25519 key as an RFC 7250 raw public key server cert |
|
||||
| `Ed25519SigningKey` | One copy in `alknet-tls`, shared by server + client (see below) |
|
||||
| `AcceptAnyCertVerifier` | Accepts any client cert and extracts the fingerprint (raw-key servers don't pin client certs) |
|
||||
| `SelfSignedCert` / `generate_self_signed_cert()` | The dev `SelfSigned` identity path |
|
||||
| `load_cert_chain()` / `load_private_key()` | In `pem.rs`; one copy, shared by server + client |
|
||||
|
||||
`TlsClientConfig::new` (ADR-087) centralizes the client-side verifier
|
||||
selection + provider wiring + client-auth cert presentation that
|
||||
currently lives in `alknet-call/src/client/call_client.rs`. The
|
||||
extraction is the client-side analogue of the server-side
|
||||
`endpoint.rs` extraction above.
|
||||
|
||||
| Component | Current location | New location |
|
||||
|-----------|-----------------|-------------|
|
||||
| `build_quinn_client_config()` | `alknet-call/client/call_client.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (`TlsClientConfig::new` + `for_quinn()`) |
|
||||
| `build_client_auth()` | `alknet-call/client/call_client.rs` | `alknet-tls` (client-auth cert resolver construction inside `TlsClientConfig::new`) |
|
||||
| `select_server_verifier()` | `alknet-call/client/call_client.rs` | `alknet-tls` (ADR-034 verifier selection inside `TlsClientConfig::new`) |
|
||||
| `load_platform_root_cert_store()` | `alknet-call/client/call_client.rs` | `alknet-tls` (the unknown-X.509-remote CA path inside `TlsClientConfig::new`) |
|
||||
| `FingerprintPinVerifier` | `alknet-call/client/call_client.rs` | `alknet-tls` (moved — it is a TLS concern; `TlsClientConfig::new` constructs it; moving it lets `alknet-call` shed its direct `rustls` dep entirely per ADR-089 §5) |
|
||||
| `Ed25519SigningKey` (client-side copy) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
|
||||
| `RawKeyClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
|
||||
| `NoClientCertResolver` | `alknet-call/client/call_client.rs` | `alknet-tls` |
|
||||
| `load_cert_chain()` / `load_private_key()` (client-side copies) | `alknet-call/client/call_client.rs` | `alknet-tls` (consolidates with the `endpoint.rs` duplicate — one copy in `alknet-tls`) |
|
||||
| `CallClient::connect` | `alknet-call/client/call_client.rs` | **removed** (ADR-089 §5 — the dial is extracted to `AlknetClient`; `CallClient` keeps only `spawn_dispatch`, shedding its TLS/transport deps) |
|
||||
|
||||
**Consolidation note.** `Ed25519SigningKey` and
|
||||
`load_cert_chain`/`load_private_key` are currently **duplicated** across
|
||||
`endpoint.rs` (server) and `call_client.rs` (client). After extraction
|
||||
there is one copy of each in `alknet-tls`, used by both
|
||||
`TlsServerConfig::new` and `TlsClientConfig::new`. Both call sites
|
||||
(`endpoint.rs`'s server path, `call_client.rs`'s client path) are
|
||||
updated to import from `alknet-tls`.
|
||||
|
||||
`TlsIdentity` and `Ed25519SecretKey` stay in core because they're config
|
||||
types — `StaticConfig` holds a `TlsIdentity`, and config types belong in
|
||||
core. `alknet-tls` re-exports them for convenience. `fingerprint.rs` stays
|
||||
in core because it's shared by both the server path (endpoint extracts
|
||||
fingerprint from the client cert) and the client path
|
||||
(`FingerprintPinVerifier` — now in `alknet-tls` per ADR-089 §5 —
|
||||
matches the server's cert against a pinned fingerprint).
|
||||
The production code in `fingerprint.rs` uses only `sha2` and manual DER
|
||||
parsing — the `rustls::sign` usage is in the test helper only. See OQ-59
|
||||
(the original dep-edge concern that motivated keeping `fingerprint.rs`
|
||||
in core is dissolved by ADR-089 §5 — `FingerprintPinVerifier` moved to
|
||||
The config types `TlsIdentity` and `Ed25519SecretKey` live in
|
||||
`alknet-core` (`config.rs`) — `StaticConfig` holds a `TlsIdentity`, and
|
||||
config types belong in core. `alknet-tls` imports them. `fingerprint.rs`
|
||||
lives in core because it is shared by both the server path (the
|
||||
endpoint extracts the fingerprint from the client cert) and the client
|
||||
path (`FingerprintPinVerifier`, in `alknet-tls`, matches the server's
|
||||
cert against a pinned fingerprint). The production code in
|
||||
`fingerprint.rs` uses only `sha2` and manual DER parsing; the
|
||||
`rustls::sign` usage is in the test helper only. See OQ-59 — the
|
||||
original dep-edge concern that motivated keeping `fingerprint.rs` in
|
||||
core is dissolved by ADR-089 §5 (`FingerprintPinVerifier` is in
|
||||
`alknet-tls`, so its consumers are co-located).
|
||||
|
||||
### Client-side contents
|
||||
|
||||
The client-side TLS setup — verifier selection, client-auth cert
|
||||
presentation, provider wiring — is in `alknet-tls/src/client.rs`.
|
||||
These components were originally part of `alknet-call`'s client
|
||||
module (quinn-gated); ADR-087 / ADR-089 §5 moved them into `alknet-tls`
|
||||
so `alknet-call` has no direct `rustls` dep and the verifier selection
|
||||
is shared across all outbound dials.
|
||||
|
||||
| Component | Notes |
|
||||
|-----------|-------|
|
||||
| `TlsClientConfig::new` | Builds a `rustls::ClientConfig` from `ConnectionCredentials` + ALPN; runs ADR-034 verifier selection + ADR-084 provider wiring + client-auth cert presentation |
|
||||
| `for_quinn()` | Wraps the rustls config in a `quinn::ClientConfig` (feature-gated on `quinn`) |
|
||||
| `into_rustls_config()` | Returns the inner `rustls::ClientConfig` for consumers that build their own transport wrapper (e.g. `dial_tcp_tls` wraps it in a `TlsConnector`) |
|
||||
| `build_client_auth()` | Constructs the client-auth cert resolver inside `TlsClientConfig::new` |
|
||||
| `select_server_verifier()` | ADR-034 verifier selection (fingerprint pin / CA / fail-closed) inside `TlsClientConfig::new` |
|
||||
| `load_platform_root_cert_store()` | The unknown-X.509-remote CA path inside `TlsClientConfig::new` |
|
||||
| `FingerprintPinVerifier` | A TLS concern; `TlsClientConfig::new` constructs it. Locating it in `alknet-tls` lets `alknet-call` have no direct `rustls` dep (ADR-089 §5) |
|
||||
| `RawKeyClientCertResolver` | Presents the local key as an RFC 7250 raw public key client cert |
|
||||
| `NoClientCertResolver` | The no-client-cert path |
|
||||
| `Ed25519SigningKey` | One copy in `alknet-tls` (`signing.rs`), shared by server + client |
|
||||
| `load_cert_chain()` / `load_private_key()` | In `pem.rs`; one copy, shared by server + client |
|
||||
|
||||
`Ed25519SigningKey` and `load_cert_chain`/`load_private_key` are single
|
||||
copies in `alknet-tls`, used by both `TlsServerConfig::new` and
|
||||
`TlsClientConfig::new`. Before the extraction these were duplicated
|
||||
across the server (in core's endpoint module) and the client (in call's
|
||||
client module); the extraction consolidated them.
|
||||
|
||||
The dial is in `AlknetClient` (`alknet-client`, ADR-089);
|
||||
`CallClient` keeps only `spawn_dispatch`, and `alknet-call` has no
|
||||
TLS/transport deps.
|
||||
|
||||
### `TlsServerConfig`
|
||||
|
||||
The central type. Built once from a `TlsIdentity` + ALPN list, shared
|
||||
@@ -230,12 +234,12 @@ architecture decision.
|
||||
|
||||
### Behavior-preservation invariants
|
||||
|
||||
The extraction must preserve these load-bearing TLS behaviors. They
|
||||
originate from [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md),
|
||||
These load-bearing TLS behaviors must be preserved. They originate from
|
||||
[ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md),
|
||||
which established the `TlsIdentity` model, the `Acme` variant, and the
|
||||
`acme-tls/1` ALPN challenge handling. An implementer who omits any of
|
||||
these produces a crate that compiles and passes type-checks but silently
|
||||
changes TLS behavior:
|
||||
`acme-tls/1` ALPN challenge handling. Omitting any of them produces a
|
||||
crate that compiles and passes type-checks but silently changes TLS
|
||||
behavior:
|
||||
|
||||
- **`max_early_data_size = u32::MAX`** on all server config paths (X509,
|
||||
RawKey, SelfSigned, ACME). Enables 0-RTT / early data. Omitting it
|
||||
@@ -278,8 +282,8 @@ impl TlsServerConfig {
|
||||
/// build their own transport-specific wrapper not covered by
|
||||
/// `for_quinn` / `for_tcp_tls`. No current consumer (iroh reads the
|
||||
/// `Ed25519SecretKey` directly, not the rustls config — see "Iroh:
|
||||
/// shares the key, not the rustls config" below); kept as a
|
||||
/// forward-looking accessor for future transport wrappers.
|
||||
/// shares the key, not the rustls config" below); retained for
|
||||
/// transport wrappers that do not fit `for_quinn` / `for_tcp_tls`.
|
||||
pub fn rustls_config(&self) -> &rustls::ServerConfig;
|
||||
}
|
||||
```
|
||||
@@ -291,7 +295,7 @@ the `Endpoint`, using RFC 7250 raw keys. It does not consume a
|
||||
`rustls::ServerConfig` — it takes an `iroh::SecretKey` and handles TLS
|
||||
internally. So `alknet-tls` does not have a `for_iroh()` method. Instead,
|
||||
the assembly layer reads the `Ed25519SecretKey` from `StaticConfig`
|
||||
(stays in core) and passes it to iroh's `Endpoint::builder().secret_key()`
|
||||
(lives in core) and passes it to iroh's `Endpoint::builder().secret_key()`
|
||||
directly. `alknet-tls` is involved only when iroh is not the sole
|
||||
transport — in that case, the same `Ed25519SecretKey` feeds both
|
||||
`TlsServerConfig::new(TlsIdentity::RawKey(key), ...)` (for quinn/TCP) and
|
||||
@@ -346,22 +350,20 @@ alknet-tls
|
||||
`rustls-native-certs` and `webpki-roots` are always-present deps (not
|
||||
feature-gated) because the unknown-X.509-remote CA-verification path in
|
||||
`TlsClientConfig::new` is needed by any client dialing a public X.509
|
||||
endpoint, regardless of transport (QUIC or TCP+TLS). In the
|
||||
pre-extraction code these lived in `alknet-call` behind the `quinn`
|
||||
feature; the extraction (ADR-089 §5) moves them to `alknet-tls` ungated,
|
||||
and `alknet-call` sheds the deps entirely.
|
||||
endpoint, regardless of transport (QUIC or TCP+TLS). They are not gated
|
||||
under `quinn`/`tcp` — a TCP+TLS-only or QUIC-only deployment both need
|
||||
the CA path. `alknet-call` does not depend on them (the dial's TLS
|
||||
deps are in `alknet-tls`/`alknet-client` now).
|
||||
|
||||
`alknet-core` loses `rustls-pemfile`, `rcgen`, and `rustls-acme` from
|
||||
its dependencies — the cert-loading, self-signed generation, and ACME
|
||||
machinery move to `alknet-tls`. Core's `acme` feature
|
||||
(`acme = ["dep:rustls-acme"]` in `Cargo.toml` and the
|
||||
`#[cfg(feature = "acme")]` gates on `acme_state_handle` in `endpoint.rs`)
|
||||
becomes vestigial after the extraction and is removed — the ACME state
|
||||
machine now lives on `TlsServerConfig` in `alknet-tls`, not on
|
||||
`AlknetEndpoint`. Core keeps `quinn` and `iroh` (the endpoint struct and
|
||||
accept loops remain in core), `ed25519-dalek` (`Ed25519SecretKey` stays
|
||||
in `config.rs`), and `rustls` / `rustls-pki-types` (`fingerprint.rs` uses
|
||||
`rustls::pki_types` in production and `rustls::sign` in the test helper
|
||||
`alknet-core` does not depend on `rustls-pemfile`, `rcgen`, or
|
||||
`rustls-acme` — cert-loading, self-signed generation, and the ACME
|
||||
state machine are in `alknet-tls` (on `TlsServerConfig`, not on
|
||||
`AlknetEndpoint`). Core has no `acme` feature. Core does keep `quinn`
|
||||
and `iroh` (for `Connection::from_quinn` / `from_iroh` — the shared
|
||||
constructors the endpoint and the dial both use),
|
||||
`ed25519-dalek` (`Ed25519SecretKey` in `config.rs`), and `rustls` /
|
||||
`rustls-pki-types` (`fingerprint.rs` uses `rustls::pki_types` in
|
||||
production and `rustls::sign` in the test helper
|
||||
`build_ed25519_spki_der` — see OQ-59).
|
||||
|
||||
> **Terminology — hub, worker, hub-worker.** A *hub* is a node that
|
||||
@@ -374,47 +376,11 @@ in `config.rs`), and `rustls` / `rustls-pki-types` (`fingerprint.rs` uses
|
||||
> "assembly layer" (ADR-014) is the deployment binary that wires crates
|
||||
> — in practice, today, usually a hub or hub-worker.
|
||||
|
||||
### Implementation ordering
|
||||
### What `AlknetEndpoint` (in `alknet-endpoint`) does
|
||||
|
||||
`alknet-tls` is greenfield — `crates/alknet-tls` does not exist yet. The
|
||||
endpoint section below ("What `AlknetEndpoint` does after the refactor")
|
||||
describes the **post-refactor target**, not the current source. The
|
||||
current `crates/alknet-core/src/endpoint.rs` is the **extraction
|
||||
source** — `AlknetEndpoint::new(static_config, ...)` builds TLS
|
||||
internally, the shape ADR-083 replaces. The endpoint is extracted into
|
||||
a new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15) as part of
|
||||
this work. The extraction and refactor are **sequenced**, not
|
||||
simultaneous:
|
||||
|
||||
1. **`alknet-tls` first** — build the crate in isolation. `TlsServerConfig`
|
||||
and `TlsClientConfig` are unit-testable against `TlsIdentity` without
|
||||
touching the endpoint. This is the greenfield step.
|
||||
2. **`alknet-endpoint` second** — build the new endpoint crate fresh
|
||||
against the ADR-083 shape (`new(handlers, dynamic,
|
||||
identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
|
||||
`with_tcp_tls`), importing `Connection`/`ProtocolHandler`/`AuthContext`
|
||||
from `alknet-core` and taking pre-built transports (no TLS config —
|
||||
the assembly layer builds those via `alknet-tls`). The old
|
||||
`crates/alknet-core/src/endpoint.rs` is deleted.
|
||||
3. **Assembly layer last** — the deployment binary (hub/worker) builds
|
||||
the `TlsServerConfig`(s) and `TlsClientConfig`(s), the transports, and
|
||||
hands them to `AlknetEndpoint` (in `alknet-endpoint`) via the builder
|
||||
methods.
|
||||
|
||||
A compilable intermediate state exists after step 1: `alknet-tls` built
|
||||
and tested standalone, with `endpoint.rs` still in its old shape. The
|
||||
call sites for `TlsServerConfig` / `TlsClientConfig` do not exist until
|
||||
step 2/3 — an implementer testing step 1 writes tests against the TLS
|
||||
types directly, not against a wired-up endpoint.
|
||||
|
||||
### What `AlknetEndpoint` (in `alknet-endpoint`) does after the refactor
|
||||
|
||||
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
|
||||
the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)),
|
||||
the endpoint (extracted into `alknet-endpoint` per ADR-083 Amendment
|
||||
2026-07-15) takes **no TLS config at all** — it is a multi-transport
|
||||
accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`),
|
||||
not an external loop:
|
||||
`AlknetEndpoint` takes **no TLS config at all** — it is a
|
||||
multi-transport accept-loop runner. TCP+TLS is an owned transport (via
|
||||
`with_tcp_tls`), not an external loop:
|
||||
|
||||
```rust
|
||||
impl AlknetEndpoint {
|
||||
@@ -466,7 +432,9 @@ handle lives on the `TlsServerConfig`, not the endpoint.
|
||||
This resolves the single-`Arc<TlsServerConfig>` problem: the endpoint
|
||||
has no "the TLS config" to take because a hub has two. It also means
|
||||
shutdown is single-owner — the endpoint owns all its accept loops
|
||||
(quinn, iroh, TCP+TLS); one `shutdown()` stops them all.
|
||||
(quinn, iroh, TCP+TLS); one `shutdown()` stops them all. See
|
||||
[`crates/endpoint/README.md`](../endpoint/README.md) and
|
||||
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
|
||||
|
||||
### The TCP+TLS accept loop (out of scope for this crate)
|
||||
|
||||
@@ -485,26 +453,25 @@ sharing, not transport accept logic.
|
||||
A hub dials out to workers it supervises and to other hubs
|
||||
(hub-as-client); `alknet-worker` dials a hub. Both need a
|
||||
`rustls::ClientConfig` with ADR-034's verifier selection and ADR-084's
|
||||
crypto provider. `TlsClientConfig` centralizes this — it is a
|
||||
present prerequisite for the first hub deployment, consumed by
|
||||
crypto provider. `TlsClientConfig` centralizes this, and is consumed by
|
||||
`AlknetClient`'s QUIC and TCP+TLS dials (ADR-089).
|
||||
|
||||
There are exactly two clients in the alknet client surface as far as
|
||||
`TlsClientConfig` and `AlknetClient` are concerned — **call**
|
||||
(`CallClient`) and **channels** (`ChannelClient`, which is a proxy over
|
||||
many ALPNs via channel 0). Both must support all three transport
|
||||
accessors below; the TLS config is shared across them, the dial is
|
||||
per-transport per-client.
|
||||
many ALPNs via channel 0). Both share `TlsClientConfig` via the dial;
|
||||
the TLS config is shared across them, the dial is per-transport
|
||||
per-client.
|
||||
|
||||
```rust
|
||||
pub struct TlsClientConfig {
|
||||
config: rustls::ClientConfig,
|
||||
rustls_config: rustls::ClientConfig,
|
||||
}
|
||||
|
||||
impl TlsClientConfig {
|
||||
/// Build a client TLS config. Takes two inputs, both derived from
|
||||
/// `Capabilities` (ADR-014) / `ConnectionCredentials`-shaped values
|
||||
/// (ADR-091):
|
||||
/// Build a client TLS config from `ConnectionCredentials` and the
|
||||
/// dial's ALPN. `ConnectionCredentials` (ADR-091, in `alknet-core`)
|
||||
/// carries the two dimensions the dial consumes:
|
||||
///
|
||||
/// 1. `local_identity` — the local node's `TlsIdentity` (RFC 7250
|
||||
/// raw key or X.509), presented as the client cert. `None` →
|
||||
@@ -512,59 +479,48 @@ impl TlsClientConfig {
|
||||
/// `SelfSigned` → no client cert (dev-only). `Acme` →
|
||||
/// `TlsError::AcmeConfig` (server-only identity).
|
||||
///
|
||||
/// 2. `verifier_context` — the inputs to ADR-034's server-cert
|
||||
/// 2. `remote_identity` — the inputs to ADR-034's server-cert
|
||||
/// verifier selection:
|
||||
/// - known peer (PeerEntry present) → fingerprint pin
|
||||
/// (FingerprintPinVerifier)
|
||||
/// - unknown remote + X.509 → CA verification
|
||||
/// (WebPkiServerVerifier)
|
||||
/// - unknown remote + raw key → fail closed at handshake (not
|
||||
/// a `new`-time error; see ADR-088 §6)
|
||||
/// - `Some(fingerprint)` (known peer, `PeerEntry` present) →
|
||||
/// fingerprint pin (`FingerprintPinVerifier`)
|
||||
/// - `None` + X.509 transport → CA verification
|
||||
/// (`WebPkiServerVerifier`)
|
||||
/// - `None` + raw key → fail closed at handshake (not a `new`-
|
||||
/// time error; see ADR-088 §6)
|
||||
///
|
||||
/// Applies ADR-084 crypto provider (aws_lc_rs::default_provider()).
|
||||
pub fn new(
|
||||
local_identity: &Option<TlsIdentity>,
|
||||
verifier_context: &ClientVerifierContext,
|
||||
credentials: &ConnectionCredentials,
|
||||
alpn: &[u8],
|
||||
) -> Result<Self, TlsError>;
|
||||
|
||||
/// Produce a `quinn::ClientConfig` for a QUIC dial. Clones the
|
||||
/// inner rustls config, wraps it in `QuicClientConfig`. Returns
|
||||
/// `Result` because `QuicClientConfig::try_from(rustls::ClientConfig)`
|
||||
/// can fail with `NoInitialCipherSuite` — the same failure the
|
||||
/// server-side `for_quinn()` surfaces as `TlsError::QuinnWrap`.
|
||||
/// Feature-gated on `quinn`.
|
||||
/// Consume the config and produce a `quinn::ClientConfig` for a
|
||||
/// QUIC dial. Returns `Result` because
|
||||
/// `QuicClientConfig::try_from(rustls::ClientConfig)` can fail with
|
||||
/// `NoInitialCipherSuite` — the same failure the server-side
|
||||
/// `for_quinn()` surfaces as `TlsError::QuinnWrap`. Feature-gated
|
||||
/// on `quinn`.
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn for_quinn(&self) -> Result<quinn::ClientConfig, TlsError>;
|
||||
pub fn for_quinn(self) -> Result<quinn::ClientConfig, TlsError>;
|
||||
|
||||
/// Produce a `tokio_rustls::TlsConnector` for a TCP+TLS dial.
|
||||
/// Clones the inner rustls config. Infallible —
|
||||
/// `TlsConnector::new(rustls::ClientConfig)` cannot fail.
|
||||
/// Feature-gated on `tcp` (pulls `tokio-rustls`).
|
||||
#[cfg(feature = "tcp")]
|
||||
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsConnector;
|
||||
|
||||
/// Borrow the underlying rustls config, for consumers that need to
|
||||
/// build their own transport-specific wrapper not covered by
|
||||
/// `for_quinn` / `for_tcp_tls` (e.g. a future transport). Not
|
||||
/// feature-gated — returns the raw rustls config, not a
|
||||
/// transport-specific wrapper.
|
||||
pub fn rustls_config(&self) -> &rustls::ClientConfig;
|
||||
/// Consume the config and return the inner `rustls::ClientConfig`,
|
||||
/// for consumers that build their own transport-specific wrapper —
|
||||
/// e.g. `dial_tcp_tls` wraps it in a
|
||||
/// `tokio_rustls::TlsConnector::from(Arc::new(rustls_config))`. Not
|
||||
/// feature-gated; the raw rustls config is transport-agnostic.
|
||||
pub fn into_rustls_config(self) -> rustls::ClientConfig;
|
||||
}
|
||||
```
|
||||
|
||||
The `ClientVerifierContext` carries the inputs to ADR-034's verifier
|
||||
selection (whether a `PeerEntry` exists for the remote, the expected
|
||||
fingerprint). The exact struct shape is an implementation detail; the
|
||||
decisions are in ADR-034. `ClientVerifierContext` is derived from
|
||||
`ConnectionCredentials` (in `alknet-core`, per ADR-091) at the dial
|
||||
site — `AlknetClient` extracts the TLS-relevant fields
|
||||
(`local_identity` → client cert, `remote_identity` → fingerprint-pin
|
||||
input) and builds a `ClientVerifierContext` from the latter. The
|
||||
call-protocol `auth_token` is not in `ConnectionCredentials` — it is a
|
||||
per-request field on `call.requested` payloads (a call-protocol / hub
|
||||
concept), not a transport credential; it never reaches `TlsClientConfig`
|
||||
or `ClientVerifierContext`. The `TlsError` variant granularity (covering
|
||||
both server and client errors) is decided — see
|
||||
`TlsClientConfig::new` runs ADR-034's verifier selection directly off
|
||||
`ConnectionCredentials.remote_identity` — there is no separate
|
||||
`ClientVerifierContext` type; the credential bundle carries the
|
||||
fingerprint (or its absence), which is all the verifier selection needs.
|
||||
The call-protocol `auth_token` is not in `ConnectionCredentials` — it
|
||||
is a per-request field on `call.requested` payloads (a call-protocol /
|
||||
hub concept), not a transport credential; it never reaches
|
||||
`TlsClientConfig`. The `TlsError` variant granularity (covering both
|
||||
server and client errors) is decided — see
|
||||
[ADR-088](../../decisions/088-tlserror-shape.md) and the
|
||||
[`TlsError`](#tlserror) section below.
|
||||
|
||||
@@ -576,25 +532,26 @@ containerized deployment with no system CA bundle), the built-in
|
||||
the `NoRootAnchors` failure mode unreachable in practice — a
|
||||
containerized worker dialing a public X.509 hub succeeds without
|
||||
requiring the operator to mount a CA bundle. Native-certs *load* errors
|
||||
are logged, not returned (preserved behavior); the fallback guarantees
|
||||
the store is non-empty regardless. See ADR-088 §5.
|
||||
are logged, not returned; the fallback guarantees the store is
|
||||
non-empty regardless. See ADR-088 §5.
|
||||
|
||||
`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the
|
||||
transport-specific dial helper — `AlknetClient::dial_quic` /
|
||||
`dial_tcp_tls`, ADR-089) passes it to the transport's connector. The
|
||||
config is transport-agnostic; the dial is not. This is the client-side
|
||||
analogue of ADR-065's server-side separation: the take-over
|
||||
(`spawn_dispatch` / `from_connection`, transport-agnostic) is built
|
||||
now; the dial (transport-specific) is per-transport. The
|
||||
transport-polymorphic dial is now extracted as `alknet-client`
|
||||
(ADR-089, resolves OQ-55) — `AlknetClient` builds the `TlsClientConfig`
|
||||
per-dial and calls the transport's connector.
|
||||
(`spawn_dispatch` / `from_connection`, transport-agnostic) is
|
||||
transport-agnostic; the dial (transport-specific) is per-transport.
|
||||
The transport-polymorphic dial is `alknet-client` (ADR-089, resolves
|
||||
OQ-55) — `AlknetClient` builds the `TlsClientConfig` per-dial and
|
||||
calls the transport's connector.
|
||||
|
||||
The client-side accessor API mirrors the server side: `for_quinn()`
|
||||
/ `for_tcp_tls()` / `rustls_config()` — three transports, same
|
||||
pattern. Iroh is the exception (see below). `AlknetClient` (ADR-089)
|
||||
consumes `TlsClientConfig` via these accessors for the QUIC and TCP+TLS
|
||||
dials; the iroh dial is the key-not-config exception.
|
||||
The client-side accessor API: `for_quinn()` (QUIC) and
|
||||
`into_rustls_config()` (any other transport — `dial_tcp_tls` wraps the
|
||||
rustls config in a `TlsConnector`). Iroh is the exception (see below).
|
||||
`AlknetClient` (ADR-089) consumes `TlsClientConfig` via these accessors
|
||||
for the QUIC and TCP+TLS dials; the iroh dial is the key-not-config
|
||||
exception.
|
||||
|
||||
### Iroh — shares the key, not the config (client side too)
|
||||
|
||||
@@ -617,7 +574,16 @@ the `for_quinn()` accessors on both (`for_tcp_tls` is infallible —
|
||||
`alknet-tls`. The shape, the rationale for single-enum-over-thin-wrapper,
|
||||
and the "what is NOT a variant" list are in
|
||||
[ADR-088](../../decisions/088-tlserror-shape.md); this section is
|
||||
the sketch.
|
||||
the target shape per that ADR.
|
||||
|
||||
> **Implementation note.** The current `alknet-tls/src/lib.rs`
|
||||
> `TlsError` is a simplified 3-variant enum (`Config(String)`,
|
||||
> `Io(io::Error)`, `Cert(String)`) without `#[non_exhaustive]`. The
|
||||
> full ADR-088 shape below (six typed variants, `#[non_exhaustive]`,
|
||||
> `#[from` sources) is the target; the present code folds the
|
||||
> finer-grained categories into `Config`/`Cert` strings. An implementer
|
||||
> refining `TlsError` to match ADR-088 is a two-way-door change (the
|
||||
> enum is crate-local, no external match arms).
|
||||
|
||||
```rust
|
||||
/// Errors produced by `TlsServerConfig::new`, `TlsClientConfig::new`,
|
||||
@@ -679,9 +645,8 @@ arrive asynchronously and are logged (ADR-082 §"Behavior-preservation
|
||||
invariants").
|
||||
|
||||
**Ownership.** `TlsError` lives in `alknet-tls`, owned by the crate
|
||||
that produces it. It is not re-exported from `alknet-core`; `EndpointError`
|
||||
is removed entirely after ADR-083 (both variants were vestigial), so
|
||||
core has no endpoint error type and does not need to know about
|
||||
that produces it. It is not re-exported from `alknet-core`; core has
|
||||
no endpoint error type, so core does not need to know about
|
||||
`TlsError`. The assembly layer (hub/worker) depends on `alknet-tls`
|
||||
directly and gets `TlsError` from that dependency.
|
||||
|
||||
@@ -689,15 +654,14 @@ directly and gets `TlsError` from that dependency.
|
||||
|
||||
```
|
||||
alknet-tls
|
||||
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
|
||||
└── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
|
||||
|
||||
alknet-core (loses TLS setup code + endpoint)
|
||||
├── (rustls — only for fingerprint.rs types, if kept)
|
||||
alknet-core (lightweight — types + auth + config + fingerprint + credentials)
|
||||
└── (rustls / rustls-pki-types — only for fingerprint.rs types)
|
||||
|
||||
alknet-call (pure protocol crate — no TLS/transport deps per ADR-089 §5)
|
||||
└── alknet-core (ProtocolHandler, Connection, types; ConnectionCredentials/
|
||||
RemoteIdentity moved to core per ADR-091; CallCredentials removed per ADR-091 Am. 2026-07-17
|
||||
alknet-call)
|
||||
RemoteIdentity from core per ADR-091)
|
||||
|
||||
alknet-hub (multi-transport endpoint)
|
||||
├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
|
||||
@@ -706,7 +670,7 @@ alknet-hub (multi-transport endpoint)
|
||||
├── alknet-channels-call (ChannelClient)
|
||||
├── alknet-call (CallAdapter, Dispatcher)
|
||||
├── alknet-http (HttpAdapter)
|
||||
├── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
|
||||
└── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
|
||||
```
|
||||
|
||||
`alknet-tls` depends on `alknet-core` only. No handler crate depends on
|
||||
@@ -725,7 +689,7 @@ All design decisions are documented as ADRs in
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [082](../../decisions/082-alknet-tls-extraction.md) | alknet-tls crate extraction | Extract TLS setup from alknet-core/endpoint.rs; `TlsServerConfig` shareable across quinn + TCP+TLS + iroh; one ACME state machine |
|
||||
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard moves to shared `dispatch` |
|
||||
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard is in shared `dispatch` |
|
||||
| [084](../../decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs crypto provider | `rustls::crypto::aws_lc_rs::default_provider()` on all server + client config paths; matches iroh; FIPS-capable; do not switch to `ring` or process-default without a new ADR |
|
||||
| [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); split ALPN lists per endpoint type (resolves OQ-62); entry-point vs. endpoint ALPN distinction |
|
||||
| [087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` not blocked on dial seam | `alknet-tls` provides `TlsClientConfig` (client-side); not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case |
|
||||
@@ -773,38 +737,33 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- **OQ-64** (resolved): `alknet-tls` provides `TlsClientConfig`
|
||||
(ADR-087). Not blocked on the dial-seam extraction — the TLS
|
||||
config is a prerequisite for the dial, not a consequence of it.
|
||||
Centralizes ADR-034 verifier selection + ADR-084 provider; the
|
||||
hub-as-client requirement makes it a prerequisite for the first hub
|
||||
deployment. The dial seam is now extracted as `alknet-client`
|
||||
(ADR-089, OQ-55 resolved); `TlsClientConfig` is consumed by
|
||||
`AlknetClient`'s QUIC and TCP+TLS dials.
|
||||
Centralizes ADR-034 verifier selection + ADR-084 provider. The dial
|
||||
seam is `alknet-client` (ADR-089, OQ-55 resolved); `TlsClientConfig`
|
||||
is consumed by `AlknetClient`'s QUIC and TCP+TLS dials.
|
||||
|
||||
- **OQ-55** (resolved by ADR-089): `AlknetClient::dial()` — the
|
||||
transport-polymorphic dial seam. Extracted as a new crate
|
||||
`alknet-client` with three dial methods (`dial_quic` /
|
||||
`dial_tcp_tls` / `dial_iroh`). `TlsClientConfig` (OQ-64, resolved)
|
||||
is the prerequisite the dial consumes. See
|
||||
[`crates/client/README.md`](../client/README.md) and
|
||||
transport-polymorphic dial seam. `alknet-client` has three dial
|
||||
methods (`dial_quic` / `dial_tcp_tls` / `dial_iroh`).
|
||||
`TlsClientConfig` (OQ-64, resolved) is the prerequisite the dial
|
||||
consumes. See [`crates/client/README.md`](../client/README.md) and
|
||||
[ADR-089](../../decisions/089-alknetclient-native-dial-seam.md).
|
||||
|
||||
### Next session — client shape
|
||||
### Client shape (in `alknet-client`)
|
||||
|
||||
The client is now specced. [`crates/client/README.md`](../client/README.md)
|
||||
defines `AlknetClient` — the native client dial seam (ADR-089, resolves
|
||||
OQ-55). There are exactly two clients in the alknet client surface as
|
||||
far as `TlsClientConfig` and `AlknetClient` are concerned: **call**
|
||||
[`crates/client/README.md`](../client/README.md) defines `AlknetClient`
|
||||
— the native client dial seam (ADR-089, resolves OQ-55). There are
|
||||
exactly two clients in the alknet client surface as far as
|
||||
`TlsClientConfig` and `AlknetClient` are concerned: **call**
|
||||
(`CallClient`) and **channels** (`ChannelClient`, a proxy over many
|
||||
ALPNs via channel 0). Both consume `TlsClientConfig` via the same three
|
||||
accessors (`for_quinn`, `for_tcp_tls`, `rustls_config`); iroh is the
|
||||
exception (shares the key, not the config). `AlknetClient` is the dial
|
||||
that feeds them — it produces a `Connection` and the protocol
|
||||
take-overs (`spawn_dispatch`, `from_connection`) consume it. The
|
||||
per-protocol QUIC convenience constructors (`CallClient::connect` /
|
||||
`ChannelClient::connect_quic`) are **removed** per ADR-089 §5 — the
|
||||
dial is centralized in `AlknetClient`, and the protocol crates shed
|
||||
their TLS/transport deps. The `alknet/register` ALPN (native
|
||||
registration entry point, parallel to HTTP registration in OQ-58) is
|
||||
named by ADR-089; its wire protocol is deferred (OQ-66).
|
||||
ALPNs via channel 0). Both consume `TlsClientConfig` through the dial
|
||||
(`for_quinn` for QUIC, `into_rustls_config` wrapped in a `TlsConnector`
|
||||
for TCP+TLS); iroh is the exception (shares the key, not the config).
|
||||
`AlknetClient` is the dial that feeds them — it produces a `Connection`
|
||||
and the protocol take-overs (`spawn_dispatch`, `from_connection`)
|
||||
consume it. The dial is centralized in `AlknetClient` (ADR-089); the
|
||||
protocol crates have no TLS/transport deps. The `alknet/register` ALPN
|
||||
(named by ADR-089; wire protocol deferred, OQ-66) is the native
|
||||
registration entry point, parallel to HTTP registration in OQ-58.
|
||||
|
||||
## References
|
||||
|
||||
@@ -829,15 +788,14 @@ named by ADR-089; its wire protocol is deferred (OQ-66).
|
||||
(the endpoint spec; TLS config is built by `alknet-tls`, not the
|
||||
endpoint — per ADR-083)
|
||||
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
|
||||
- `crates/alknet-core/src/endpoint.rs` — the server-side code being
|
||||
extracted (`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`,
|
||||
`Ed25519SigningKey`, `AcceptAnyCertVerifier`, `generate_self_signed_cert`,
|
||||
`load_cert_chain`, `load_private_key`)
|
||||
- `crates/alknet-call/src/client/call_client.rs` — the client-side code
|
||||
being extracted (`build_quinn_client_config`, `build_client_auth`,
|
||||
`select_server_verifier`, `load_platform_root_cert_store`,
|
||||
- `crates/alknet-tls/src/server.rs` — `TlsServerConfig`,
|
||||
`RawKeyCertResolver`, `AcceptAnyCertVerifier`,
|
||||
`generate_self_signed_cert`, `build_rustls_server_config`
|
||||
- `crates/alknet-tls/src/client.rs` — `TlsClientConfig`,
|
||||
`FingerprintPinVerifier`, `RawKeyClientCertResolver`,
|
||||
`NoClientCertResolver`, `Ed25519SigningKey` (duplicate),
|
||||
`load_cert_chain`/`load_private_key` (duplicates))
|
||||
`NoClientCertResolver`, `select_server_verifier`, `build_client_auth`,
|
||||
`load_platform_root_cert_store`
|
||||
- `crates/alknet-tls/src/pem.rs` — `load_cert_chain`, `load_private_key`
|
||||
- `crates/alknet-tls/src/signing.rs` — `Ed25519SigningKey`
|
||||
- `crates/alknet-core/src/fingerprint.rs` — fingerprint extraction
|
||||
(shared by server endpoint and client verifier)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-07
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# alknet-tty — TtyAdapter and Session Lifecycle
|
||||
@@ -100,14 +100,18 @@ A `alknet/tty` session on one bidi stream proceeds in three phases:
|
||||
`Some`, a concurrent stderr pump emits stderr chunks (stream_type 2).
|
||||
On backend stdout EOF, emit a zero-length stdout sentinel.
|
||||
- **B. client → backend**: client chunks → backend. stdin chunks
|
||||
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control
|
||||
chunks (stream_type 3) → `ControlMessage` dispatch: `Resize` →
|
||||
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` →
|
||||
close stdin. `Exit` from the client is ignored (server→client only).
|
||||
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`).
|
||||
Client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
|
||||
`ControlMessage` dispatch: `Resize` → `TtyControl::resize`, `Signal`
|
||||
→ `TtyControl::signal`, `Eof` → close stdin. `Exit` on
|
||||
`STREAM_CTRL_IN` is a protocol violation (it's server→client only)
|
||||
and is ignored. `STREAM_CTRL_OUT` (stream_type 4) from the client is
|
||||
a protocol violation (it's the server→client half) and is ignored.
|
||||
On client read-half close or a zero-length stdin chunk, signal EOF
|
||||
to the backend's stdin.
|
||||
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
|
||||
enqueue `{"type":"exit","code":N}` as a control chunk (stream_type 3).
|
||||
enqueue `{"type":"exit","code":N}` as a server→client control
|
||||
chunk (`STREAM_CTRL_OUT`, stream_type 4).
|
||||
|
||||
A drainer task writes chunks to the client in arrival order. After the
|
||||
exit chunk is written (task C resolves and the exit chunk drains),
|
||||
@@ -118,6 +122,36 @@ hardcoded the local PTY backend; the adapter dispatches to any
|
||||
`TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the
|
||||
reference implementation of the three-pump driver.
|
||||
|
||||
### Bidirectional Control Channel (Phase 7)
|
||||
|
||||
The control channel is split into two halves so it is genuinely
|
||||
bidirectional on the wire:
|
||||
|
||||
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`,
|
||||
`Eof`).
|
||||
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`).
|
||||
|
||||
The adapter enforces the direction:
|
||||
|
||||
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
|
||||
(server→client message on the client→server half) — the adapter
|
||||
ignores it (the previous single `STREAM_CONTROL = 3` could not
|
||||
distinguish the two directions, so `Exit` from the client was always
|
||||
ignored; the split makes the rejection explicit).
|
||||
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
|
||||
violation (client→server message on the server→client half) — the
|
||||
adapter ignores it (the server never dispatches control messages it
|
||||
receives on the server→client half).
|
||||
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
|
||||
protocol violation (the client should not write on the server→client
|
||||
half) — the adapter ignores them.
|
||||
|
||||
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
|
||||
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
|
||||
the two halves can route exit vs. control without parsing the JSON
|
||||
`type` tag first. See `docs/research/alknet-crate-extraction/findings.md`
|
||||
Phase 7 and `tty-wire.md` §"Control Channel".
|
||||
|
||||
### Negotiation Errors
|
||||
|
||||
If the server cannot allocate the session, it sends a JSON error response
|
||||
@@ -146,17 +180,18 @@ The disambiguation is by the first byte: a JSON error frame's 4-byte
|
||||
big-endian length prefix always starts with `0x00` (error frames MUST
|
||||
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
|
||||
a wire-format invariant, not an assumption), while a raw chunk's first
|
||||
byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0` (stdin
|
||||
from server) is invalid — the server never sends stdin chunks — so the
|
||||
client distinguishes: read the first byte; if it is `0x00`, interpret
|
||||
the next 4 bytes as a big-endian length prefix and read that many bytes
|
||||
as a JSON error frame; otherwise interpret it as a `stream_type` byte
|
||||
and continue reading the raw chunk header. This is a one-way-door
|
||||
wire-format invariant (ADR-052): error frames use the negotiation
|
||||
framing (length prefix) and MUST be under 16 MiB; success uses the raw
|
||||
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
|
||||
`0x00`-as-invalid-stream_type disambiguation is what makes the two
|
||||
distinguishable on the wire.
|
||||
byte is a `stream_type`. The server never sends `0` (stdin —
|
||||
client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so
|
||||
the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
|
||||
`0x00` is unambiguous. The client distinguishes: read the first byte; if
|
||||
it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
|
||||
and read that many bytes as a JSON error frame; otherwise interpret it
|
||||
as a `stream_type` byte and continue reading the raw chunk header. This
|
||||
is a one-way-door wire-format invariant (ADR-052): error frames use the
|
||||
negotiation framing (length prefix) and MUST be under 16 MiB; success
|
||||
uses the raw chunk framing (stream_type byte first); the
|
||||
`0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation
|
||||
is what makes the two distinguishable on the wire.
|
||||
|
||||
### Exit-Chunk Ordering (ADR-055)
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-07
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# alknet-tty — Wire Format
|
||||
|
||||
The wire protocol for `alknet/tty`: the negotiation frame (JSON
|
||||
carriage), the raw chunk codec, the control channel, and the sentinels.
|
||||
The two-carriage model is decided in
|
||||
carriage), the raw chunk codec, the control channel (split into
|
||||
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves — Phase 7), and the
|
||||
sentinels. The two-carriage model is decided in
|
||||
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md);
|
||||
this document specifies what an implementer builds.
|
||||
|
||||
@@ -164,19 +165,34 @@ no `call.responded`/`call.completed` — this is not the call protocol.
|
||||
|
||||
- **`stream_type`** (1 byte) — the channel:
|
||||
|
||||
| stream_type | channel | direction | payload |
|
||||
|---|---|---|---|
|
||||
| 0 | data-in (stdin) | client→server | raw bytes |
|
||||
| 1 | data-out (stdout) | server→client | raw bytes |
|
||||
| 2 | data-err (stderr) | server→client | raw bytes |
|
||||
| 3 | control | bidirectional | JSON control message |
|
||||
| stream_type | channel | direction | payload |
|
||||
|-------------|-------------|----------------|---------------------|
|
||||
| 0 | data-in (stdin) | client→server | raw bytes |
|
||||
| 1 | data-out (stdout) | server→client | raw bytes |
|
||||
| 2 | data-err (stderr) | server→client | raw bytes |
|
||||
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit`) |
|
||||
|
||||
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
|
||||
no extension escape hatch in the byte — a 5th channel is a wire-format
|
||||
`stream_type > 4` is a protocol error (`InvalidStreamType`). There is
|
||||
no extension escape hatch in the byte — a 6th channel is a wire-format
|
||||
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
|
||||
negotiated addition to this format. See ADR-052 §"Fixed channel set,
|
||||
not extensible."
|
||||
|
||||
**Bidirectional control channel (Phase 7).** The control channel is
|
||||
split into two halves so it is genuinely bidirectional on the wire:
|
||||
`STREAM_CTRL_IN = 3` carries client→server control (`Resize`,
|
||||
`Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries server→client control
|
||||
(`Exit`). The previous single `STREAM_CONTROL = 3` was documented as
|
||||
"bidirectional" but the adapter ignored `Exit` from the client
|
||||
because the two directions were indistinguishable on the same
|
||||
stream_type — see `docs/research/alknet-crate-extraction/findings.md`
|
||||
Phase 7. The split makes the bidirectionality explicit: each
|
||||
direction has its own stream_type, and the adapter enforces the
|
||||
direction (an `Exit` arriving on `STREAM_CTRL_IN` is a protocol
|
||||
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
|
||||
likewise a protocol violation and is ignored).
|
||||
|
||||
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max
|
||||
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
|
||||
is a protocol error (`ChunkTooLarge`).
|
||||
@@ -208,9 +224,14 @@ Zero-length data chunks are sentinels:
|
||||
Control chunks are never zero-length (the JSON payload is at least
|
||||
`{}`).
|
||||
|
||||
### Control Channel (stream_type 3)
|
||||
### Control Channel
|
||||
|
||||
Control chunks carry a JSON payload tagged by `type`. The schema is the
|
||||
The control channel is split into two halves (Phase 7):
|
||||
|
||||
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control.
|
||||
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control.
|
||||
|
||||
Each half carries JSON payloads tagged by `type`. The schema is the
|
||||
POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
|
||||
|
||||
```rust
|
||||
@@ -231,12 +252,22 @@ pub enum ControlMessage {
|
||||
}
|
||||
```
|
||||
|
||||
| Direction | Message | Shape | Maps to |
|
||||
|---|---|---|---|
|
||||
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
|
||||
| client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
|
||||
| client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
|
||||
| server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
|
||||
| stream_type | direction | Message | Shape | Maps to |
|
||||
|-------------|----------------|---------|-------|---------|
|
||||
| 3 (ctrl_in) | client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
|
||||
| 3 (ctrl_in) | client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
|
||||
| 3 (ctrl_in) | client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
|
||||
| 4 (ctrl_out) | server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
|
||||
|
||||
The adapter enforces the direction: an `Exit` arriving on
|
||||
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
|
||||
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
|
||||
protocol violation (the adapter ignores it). The split makes the
|
||||
control channel genuinely bidirectional on the wire — the previous
|
||||
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
|
||||
adapter had to ignore `Exit` from the client because the two directions
|
||||
were indistinguishable on the same stream_type. See
|
||||
`docs/research/alknet-crate-extraction/findings.md` Phase 7.
|
||||
|
||||
**Signal names.** `name` is an uppercase string. The supported set (per
|
||||
the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
|
||||
@@ -261,12 +292,12 @@ type is not.
|
||||
|
||||
Two signals both close the client's stdin:
|
||||
|
||||
1. **`{"type":"eof"}` control chunk** (stream_type 3) — explicit,
|
||||
recommended. Tells the server to close the backend's stdin
|
||||
(`ChildStdin::drop` / PTY writer close). The client may still want to
|
||||
receive remaining stdout + the exit code, so the server does not tear
|
||||
down the session on eof — it just closes stdin and keeps pumping
|
||||
output.
|
||||
1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
|
||||
— explicit, recommended. Tells the server to close the backend's
|
||||
stdin (`ChildStdin::drop` / PTY writer close). The client may still
|
||||
want to receive remaining stdout + the exit code, so the server does
|
||||
not tear down the session on eof — it just closes stdin and keeps
|
||||
pumping output.
|
||||
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
|
||||
POC's sentinel. Accepted for compatibility with that pattern.
|
||||
|
||||
@@ -288,17 +319,26 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
## Constraints
|
||||
|
||||
- **The wire format is one-way (ADR-052).** The 5-byte header, the fixed
|
||||
stream_type set (0-3), and the two-carriage sequence are bytes clients
|
||||
and servers parse. A 5th channel type requires a new ALPN
|
||||
stream_type set (0-4), and the two-carriage sequence are bytes clients
|
||||
and servers parse. A 6th channel type requires a new ALPN
|
||||
(`alknet/tty/v2` per ADR-006), not a negotiated addition.
|
||||
- **The control channel is split into two halves (Phase 7).**
|
||||
`STREAM_CTRL_IN = 3` is client→server (`Resize`, `Signal`, `Eof`);
|
||||
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
|
||||
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
|
||||
`STREAM_CTRL_OUT` is ignored. The split is what makes the control
|
||||
channel genuinely bidirectional on the wire — the previous single
|
||||
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
|
||||
had to ignore `Exit` from the client because the two directions were
|
||||
indistinguishable on the same stream_type.
|
||||
- **No windowing.** The chunk format has no flow-control window; QUIC's
|
||||
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
|
||||
the backpressure chain is complete by construction — QUIC flow control
|
||||
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY
|
||||
buffer → process `write()` blocks; no unbounded buffer breaks the
|
||||
chain). The reversal path, if ever needed, is an additive
|
||||
`ControlMessage` variant on stream_type 3, not a wire-format header
|
||||
change.
|
||||
`ControlMessage` variant on `STREAM_CTRL_IN`/`STREAM_CTRL_OUT`, not a
|
||||
wire-format header change.
|
||||
- **No negotiation round-trip.** The client writes the negotiation frame
|
||||
and starts sending chunks; the server reads the frame and starts
|
||||
pumping. There is no "the server acknowledges the negotiation before
|
||||
@@ -312,18 +352,21 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
without entering raw mode. The error response MUST be under 16 MiB
|
||||
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
|
||||
is `0x00` — this is what makes the framing-disambiguation trick
|
||||
(first byte `0x00` = error frame, first byte `1`/`2`/`3` = raw chunk)
|
||||
sound; it is a wire-format invariant, not an empirical observation.
|
||||
See [tty-adapter.md](tty-adapter.md) §"Negotiation errors".
|
||||
(first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
|
||||
the server never sends `0` (stdin, client→server) or `3`
|
||||
(`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
|
||||
is a wire-format invariant, not an empirical observation. See
|
||||
[tty-adapter.md](tty-adapter.md) §"Negotiation errors".
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
|
||||
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON |
|
||||
| Bidirectional control channel split | Phase 7 (this doc, amended) | `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) replace the single `STREAM_CONTROL = 3`; the adapter enforces the direction |
|
||||
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
|
||||
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant |
|
||||
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk or zero-length stdin chunk; `eof` recommended |
|
||||
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT` (stream_type 4); "exit chunk is last" invariant |
|
||||
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk (`STREAM_CTRL_IN`) or zero-length stdin chunk; `eof` recommended |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef
|
||||
|
||||
The binary struct engine: a small Rust crate that takes a JSON Schema
|
||||
with `TypeDef:*` custom keywords and produces an offset map, read/write
|
||||
functions, and validation — all driven by the schema. The schema is the
|
||||
format definition; the engine is generic.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, "schema is the format" principle, dependencies, consumers, scope boundaries |
|
||||
| [schema-layer.md](schema-layer.md) | draft | The 19 `TypeDef:*` kinds, jsonschema custom keyword integration, TypeBox interop, schema annotations |
|
||||
| [layout-engine.md](layout-engine.md) | draft | Offset computation, the two layout modes (packed sequential vs aligned static), alignment, endianness, variable-length handling |
|
||||
| [data-access.md](data-access.md) | draft | Read/write functions, TUnion dispatch, field paths, zero-copy access, length-prefix reading |
|
||||
| [validation.md](validation.md) | draft | Custom keyword validators for all 19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time validation, `TypedefEngine` |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Purpose, Scope, and the jsonschema Engine | What the crate is/isn't; why jsonschema not a custom engine; "schema is the format" principle; scope boundaries |
|
||||
| [096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Two Layout Modes — Packed Sequential vs Aligned Static | The most important architectural finding; when to use each mode; `LayoutBuilder`/`SequentialReader` vs `OffsetMap` |
|
||||
| [097](../../decisions/097-schema-annotations.md) | Schema Annotations — Endianness, Alignment, Encoding, TUnion Discriminators | Concrete JSON shapes for all schema-level annotations |
|
||||
| [098](../../decisions/098-error-handling-validation-strategy.md) | Error Handling and Validation Strategy | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors |
|
||||
| [099](../../decisions/099-int64-uint64-first-class-kinds.md) | Int64/Uint64 as First-Class Kinds | 64-bit integers (SFTP offsets, metatensor data_offsets); JSON precision caveat |
|
||||
| [100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Reject Non-Final Inline Length-Prefixed Variable Fields in Aligned Mode | Prevents silent data corruption (inline variable data clobbering subsequent fields) |
|
||||
| [101](../../decisions/101-packed-mode-read-factory.md) | Packed-Mode Read API — Engine as SequentialReader Factory | `engine.sequential_reader()` returns an owned reader, not a reference |
|
||||
| [102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Reject TUnion in Aligned Mode for v1 | Unions are the protocol pattern; aligned-mode union semantics were broken |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-069 | Arrays of variable-length-element structs | deferred(scope) | Requires lazy walking logic; blocked on a concrete consumer that needs it |
|
||||
| OQ-070 | `no_std` + `alloc` support | deferred(scope) | Target `std` for v1; blocked on an embedded use case |
|
||||
| OQ-071 | Builder API for schema construction | deferred(scope) | Schemas are authored in TypeBox or hand-written JSON for v1; blocked on a concrete need |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **The schema is the format.** A JSON Schema with `TypeDef:*` custom
|
||||
keywords is both the validation spec and the layout spec. No separate
|
||||
format definition, no separate parser, no separate validator. One
|
||||
schema, three uses: validate, compute offsets, access data. See
|
||||
[overview.md](overview.md) and [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
|
||||
|
||||
2. **jsonschema is the validation engine, not a custom engine.** The
|
||||
`jsonschema` crate (v0.46.5, Draft 2020-12) handles validation with
|
||||
custom keyword support. The novel code is the offset computation, not
|
||||
the validation. This eliminates ~14,000 lines of hand-rolled schema
|
||||
engines (typebox-rs, alktype). See [schema-layer.md](schema-layer.md)
|
||||
and [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
|
||||
|
||||
3. **Two layout modes for two use cases.** Packed sequential
|
||||
(`LayoutBuilder`/`SequentialReader`) for protocol wire formats (SFTP,
|
||||
channels, TTY). Aligned static (`OffsetMap`) for mmap-friendly formats
|
||||
(metatensor). The consumer selects the mode; the schema is the same.
|
||||
See [layout-engine.md](layout-engine.md) and
|
||||
[ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md).
|
||||
|
||||
4. **Variable-length types default to inline length-prefixing.**
|
||||
`[length: u32][data]` is the universal pattern used by channels, SFTP,
|
||||
TTY, and most binary protocols. Offset indirection (the metatensor
|
||||
blob tensor pattern) is opt-in via the `encoding` annotation. See
|
||||
[layout-engine.md](layout-engine.md) and
|
||||
[ADR-097](../../decisions/097-schema-annotations.md).
|
||||
|
||||
5. **TUnion supports both byte-offset and field-name discriminators.**
|
||||
Byte-offset for protocol dispatch (SFTP type bytes, call protocol
|
||||
event types). Field-name for the typedef.ts string pattern. See
|
||||
[data-access.md](data-access.md) and
|
||||
[ADR-097](../../decisions/097-schema-annotations.md).
|
||||
|
||||
6. **Endianness is per-schema, default little-endian.** The engine reads
|
||||
the `"endian"` annotation and byte-swaps accordingly. SFTP consumers
|
||||
specify `"endian": "big"`. See [layout-engine.md](layout-engine.md)
|
||||
and [ADR-097](../../decisions/097-schema-annotations.md).
|
||||
|
||||
7. **Validation is opt-in, built once at load time.** The jsonschema
|
||||
validator is compiled once at schema load time. Access-time validation
|
||||
is a fast `is_valid()` check. High-throughput paths can skip
|
||||
validation; security-sensitive paths can validate every frame. See
|
||||
[validation.md](validation.md) and
|
||||
[ADR-098](../../decisions/098-error-handling-validation-strategy.md).
|
||||
|
||||
8. **Not a serialization framework.** The typedef engine is not a
|
||||
general-purpose serde replacement. It operates on raw byte buffers at
|
||||
computed offsets — no intermediate `Value` tree, no reflection, no
|
||||
dynamic dispatch per field. For JSON data, use serde. For binary data
|
||||
with a known schema, use typedef. See [overview.md](overview.md) and
|
||||
[ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-typedef/findings.md` — POC results (26 tests
|
||||
passing, two layout modes, TUnion dispatch, endianness)
|
||||
- `docs/research/call-channels-unification/findings.md` §"alknet-typedef:
|
||||
JSON Schema as the binary struct engine" — the origin of this research
|
||||
thread
|
||||
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
|
||||
schema kinds (619 lines)
|
||||
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
|
||||
- `/workspace/alknet-typedef-poc/` — the POC code (disposable)
|
||||
- `/workspace/@alkimiadev/typebox-rs/` — prior attempt, replaced by typedef
|
||||
- `/workspace/@alkimiadev/alktype/` — prior attempt, replaced by typedef
|
||||
@@ -0,0 +1,385 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef — Data Access
|
||||
|
||||
The data access layer: read/write functions, TUnion dispatch, field paths,
|
||||
zero-copy access for fixed-size types, and length-prefix reading for
|
||||
variable-length types. This is the consumer-facing API — given a compiled
|
||||
`TypedefEngine` and a byte buffer, read and write fields at
|
||||
schema-computed offsets.
|
||||
|
||||
This document covers two layers:
|
||||
|
||||
- **Primitive read/write functions** in the `data_access` module —
|
||||
typed reads/writes at a caller-provided offset. These are the building
|
||||
blocks used by the layout types (`OffsetMap`, `LayoutBuilder`,
|
||||
`SequentialReader`) and the `TypedefEngine`. Each operates on a raw
|
||||
byte buffer at a known offset and returns a `TypedefError::Access`
|
||||
carrying the field path on bounds or encoding failures.
|
||||
- **The `FieldValue` enum and the higher-level APIs** —
|
||||
`TypedefEngine::read_field`/`write_field` (aligned mode) and
|
||||
`SequentialReader::read_next`/`read_field` (packed mode) — which look
|
||||
up a field's offset via the layout and dispatch to the primitive
|
||||
functions, returning a unified `FieldValue<'a>`.
|
||||
|
||||
## The `FieldValue` enum
|
||||
|
||||
The higher-level read APIs return a single unified type — `FieldValue<'a>`
|
||||
— so one method can read any field kind without the caller dispatching on
|
||||
schema kind first. The variant carries the typed value; the lifetime
|
||||
borrows from the input buffer for variable-length kinds (zero-copy).
|
||||
|
||||
```rust
|
||||
pub enum FieldValue<'a> {
|
||||
I8(i8), I16(i16), I32(i32), I64(i64),
|
||||
U8(u8), U16(u16), U32(u32), U64(u64),
|
||||
F32(f32), F64(f64),
|
||||
Bool(bool),
|
||||
Enum(u32), // u32 index into the schema's "enum" array
|
||||
String(&'a str), // borrows from the buffer
|
||||
Bytes(&'a [u8]), // borrows from the buffer
|
||||
Struct { start: usize, end: usize }, // consumer recurses with a fresh reader
|
||||
Union { discriminator: String, variant_start: usize },
|
||||
Array { count: u32, element_start: usize, element_stride: usize },
|
||||
}
|
||||
```
|
||||
|
||||
For composite kinds (`Struct`, `Union`, `Array`), `FieldValue` returns a
|
||||
layout descriptor, not the decoded contents — the consumer recurses with
|
||||
a fresh `SequentialReader` (or a sub-range read) scoped to the reported
|
||||
byte range. `Array`'s `element_stride` is `0` for variable-length element
|
||||
types, signalling the consumer must walk each element sequentially.
|
||||
|
||||
## Read/Write Model
|
||||
|
||||
The typedef engine operates on raw byte buffers (`&[u8]` for reading,
|
||||
`&mut [u8]` for writing). There is no intermediate `Value` tree, no
|
||||
reflection, no dynamic dispatch per field. The engine uses the offset map
|
||||
(or `LayoutBuilder`/`SequentialReader`) to locate fields, then performs
|
||||
typed access at the computed positions.
|
||||
|
||||
### Higher-level read/write
|
||||
|
||||
The `TypedefEngine` and `SequentialReader` provide the primary
|
||||
consumer-facing read/write APIs. They look up a field's offset via the
|
||||
layout and dispatch to the primitive `data_access` functions, returning
|
||||
`FieldValue` (read) or accepting `&FieldValue` (write).
|
||||
|
||||
```rust
|
||||
impl TypedefEngine {
|
||||
// Aligned mode: looks up the field's ByteRange in the OffsetMap,
|
||||
// dispatches to the right data_access function by TypeDefKind.
|
||||
// Returns TypedefError::Access if compiled in packed mode
|
||||
// (use sequential_reader() for packed mode).
|
||||
pub fn read_field<'a>(&self, buffer: &'a [u8], field_path: &str)
|
||||
-> Result<FieldValue<'a>, TypedefError>;
|
||||
pub fn write_field(&self, buffer: &mut [u8], field_path: &str,
|
||||
value: &FieldValue<'_>) -> Result<(), TypedefError>;
|
||||
|
||||
// Packed mode: returns an owned fresh SequentialReader (ADR-101).
|
||||
// Each call returns a new reader with the cursor at position 0.
|
||||
// The consumer owns the reader and drives read_next/read_field/reset.
|
||||
pub fn sequential_reader(&self) -> Option<SequentialReader>;
|
||||
}
|
||||
|
||||
impl SequentialReader {
|
||||
// Packed mode: walks the buffer field-by-field, reading length
|
||||
// prefixes to find each field's position. read_field walks all
|
||||
// preceding fields to reach the target.
|
||||
pub fn read_next<'a>(&mut self, buffer: &'a [u8])
|
||||
-> Result<Option<(String, FieldValue<'a>)>, TypedefError>;
|
||||
pub fn read_field<'a>(&mut self, buffer: &'a [u8], field_path: &str)
|
||||
-> Result<FieldValue<'a>, TypedefError>;
|
||||
pub fn reset(&mut self);
|
||||
pub fn position(&self) -> usize;
|
||||
pub fn endian(&self) -> Endian;
|
||||
}
|
||||
```
|
||||
|
||||
`read_field`/`write_field` on `TypedefEngine` work for the fixed-size
|
||||
primitive kinds and the length-prefixed `String`/`Bytes`/`Timestamp`
|
||||
fields. Composite kinds (`Struct`, `Union`, `Array`, `Record`) return a
|
||||
`FieldValue` carrying a layout descriptor (byte range, variant start,
|
||||
or array stride) for the consumer to recurse on — see §"FieldValue" above.
|
||||
|
||||
For writing in packed mode, the consumer uses `LayoutBuilder::build` to
|
||||
compute positions, then calls the primitive `data_access::write_*`
|
||||
functions at the computed offsets. There is no packed-mode
|
||||
`engine.write_field` — the layout depends on the actual data sizes,
|
||||
which the builder consumes at `build` time.
|
||||
|
||||
### Primitive read/write functions
|
||||
|
||||
The `data_access` module exposes typed read/write functions for each
|
||||
primitive kind. Each takes `field_path: &str` for error attribution
|
||||
(produces a `TypedefError::Access` carrying the path on bounds or
|
||||
encoding failures) and, for multi-byte types, an `Endian` parameter.
|
||||
|
||||
### Fixed-size types
|
||||
|
||||
Fixed-size types (`TFloat32`, `TInt32`, `TUint8`, `TEnum`, etc.) are
|
||||
accessed via zero-copy reads of N bytes at the offset:
|
||||
|
||||
```rust
|
||||
// Read a u32 at a known offset, applying endianness. Bounds-checked.
|
||||
fn read_u32(buffer: &[u8], offset: usize, field_path: &str, endian: Endian)
|
||||
-> Result<u32, TypedefError> {
|
||||
let bytes: [u8; 4] = read_array(buffer, offset, field_path)?;
|
||||
Ok(match endian {
|
||||
Endian::Little => u32::from_le_bytes(bytes),
|
||||
Endian::Big => u32::from_be_bytes(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
// Write a u32 at a known offset, applying endianness. Bounds-checked.
|
||||
fn write_u32(buffer: &mut [u8], offset: usize, value: u32,
|
||||
field_path: &str, endian: Endian) -> Result<(), TypedefError> {
|
||||
let bytes = match endian {
|
||||
Endian::Little => value.to_le_bytes(),
|
||||
Endian::Big => value.to_be_bytes(),
|
||||
};
|
||||
write_array(buffer, offset, bytes, field_path)
|
||||
}
|
||||
```
|
||||
|
||||
The engine applies endianness at access time based on the schema's
|
||||
`"endian"` annotation (ADR-097). The offset computation is
|
||||
endian-agnostic. The `read_array`/`write_array` helpers perform the
|
||||
bounds check and produce `TypedefError::Access` with the field path on
|
||||
failure.
|
||||
|
||||
### TEnum access
|
||||
|
||||
`TEnum` is a fixed-size type (4 bytes, `u32` index). Read/write delegates
|
||||
to the `u32` primitives, applying the schema's endianness:
|
||||
|
||||
```rust
|
||||
pub fn read_enum(buffer: &[u8], offset: usize, field_path: &str, endian: Endian)
|
||||
-> Result<u32, TypedefError> {
|
||||
read_u32(buffer, offset, field_path, endian)
|
||||
}
|
||||
```
|
||||
|
||||
The consumer maps the `u32` index back to the enum's string values using
|
||||
the schema's `"enum"` array (index 0 → first value, index 1 → second
|
||||
value, etc.). The engine does not perform this mapping — it operates on
|
||||
the raw `u32` index. The jsonschema validator checks that the index
|
||||
corresponds to a valid enum value at the JSON level.
|
||||
|
||||
### Variable-length types (inline length-prefixing)
|
||||
|
||||
For variable-length types with inline length-prefixing (the default),
|
||||
the `data_access` module provides `read_string`/`write_string`/
|
||||
`read_bytes`/`write_bytes`. Each takes `field_path: &str` for error
|
||||
attribution and `endian` for the length prefix:
|
||||
|
||||
```rust
|
||||
// Read a length-prefixed string, borrowing from the buffer.
|
||||
fn read_string<'a>(buffer: &'a [u8], offset: usize,
|
||||
field_path: &str, endian: Endian) -> Result<&'a str, TypedefError>;
|
||||
|
||||
// Write a length-prefixed string. Returns total bytes written (4 + data.len()).
|
||||
fn write_string(buffer: &mut [u8], offset: usize, value: &str,
|
||||
field_path: &str, endian: Endian) -> Result<usize, TypedefError>;
|
||||
|
||||
// read_bytes / write_bytes have the same shape — raw bytes, no UTF-8 check.
|
||||
```
|
||||
|
||||
The engine reads the 4-byte length prefix at the field's offset, then
|
||||
slices the data that follows. For writing, the engine writes the length
|
||||
prefix + data. `read_string` validates UTF-8 and returns a `&str`
|
||||
borrowing from the input buffer (zero-copy); `read_bytes` returns a
|
||||
`&[u8]` slice with no encoding check.
|
||||
|
||||
In packed sequential mode, the `SequentialReader` uses the length prefix
|
||||
to determine the position of the next field. In aligned static mode, the
|
||||
`OffsetMap` records the position of the length prefix; the variable data
|
||||
is accessed separately.
|
||||
|
||||
### Variable-length types (offset indirection)
|
||||
|
||||
For variable-length types with offset indirection (opt-in), the
|
||||
`data_access` module provides `read_string_indirect`/`read_bytes_indirect`.
|
||||
The 8-byte struct at `buffer[offset..offset+8]` is
|
||||
`{ data_offset: u32, data_length: u32 }` (endian-aware); the actual
|
||||
bytes live in a separate `data_region`:
|
||||
|
||||
```rust
|
||||
fn read_string_indirect<'a>(buffer: &'a [u8], offset: usize,
|
||||
data_region: &'a [u8], field_path: &str,
|
||||
endian: Endian) -> Result<&'a str, TypedefError>;
|
||||
fn read_bytes_indirect<'a>(buffer: &'a [u8], offset: usize,
|
||||
data_region: &'a [u8], field_path: &str,
|
||||
endian: Endian) -> Result<&'a [u8], TypedefError>;
|
||||
```
|
||||
|
||||
The field is a struct `{offset: u32, length: u32}` at a known position
|
||||
in the `OffsetMap`. The consumer provides the data region separately; the
|
||||
engine reads the offset and length, then slices the data region.
|
||||
|
||||
## TUnion Dispatch
|
||||
|
||||
The `tunion` module provides TUnion discriminator dispatch — reading the
|
||||
discriminator value from a byte buffer, looking up the variant schema in
|
||||
the union's `mapping`, and reporting the offset where the variant struct
|
||||
begins. All reads go through the `data_access` primitives so bounds checks
|
||||
and endianness handling are uniform with the rest of the engine.
|
||||
|
||||
The result of dispatch is a `UnionDispatch` struct:
|
||||
|
||||
```rust
|
||||
pub struct UnionDispatch {
|
||||
pub key: String, // mapping key (stringified disc value)
|
||||
pub variant_offset: usize, // byte offset where the variant struct starts
|
||||
pub discriminator_size: usize, // discriminator's byte size
|
||||
}
|
||||
```
|
||||
|
||||
After dispatch, the consumer calls `tunion::resolve_variant(union_schema, &dispatch.key)`
|
||||
to get the variant schema, then reads the variant's fields at
|
||||
`dispatch.variant_offset` using the normal `data_access` functions (or a
|
||||
fresh `SequentialReader` scoped to the variant).
|
||||
|
||||
### Byte-offset discriminator
|
||||
|
||||
```rust
|
||||
/// Read the discriminator value from a byte-offset TUnion. The discriminator
|
||||
/// is a fixed-size integer (TypeDef:Uint8/Uint16/Uint32) at a known byte
|
||||
/// offset. Returns the mapping key (stringified integer) and the variant
|
||||
/// struct offset.
|
||||
pub fn read_byte_discriminator(
|
||||
buffer: &[u8],
|
||||
union_schema: &Value,
|
||||
endian: Endian,
|
||||
) -> Result<UnionDispatch, TypedefError>;
|
||||
```
|
||||
|
||||
This is the SFTP `Packet` enum pattern — byte 0 is the type byte, bytes
|
||||
1..N are the variant struct. The call protocol's 5 event types
|
||||
(`call.requested` → 0x01, etc.) use the same pattern. The variant struct
|
||||
starts at `offset + discriminator_size`.
|
||||
|
||||
### Field-name discriminator
|
||||
|
||||
```rust
|
||||
/// Read the discriminator value from a field-name TUnion. The
|
||||
/// discriminator is a named field within the struct — the consumer
|
||||
/// provides the field's computed offset (from the OffsetMap or
|
||||
/// LayoutBuilder). Supports TypeDef:String, Uint8, and Enum discriminator
|
||||
/// fields.
|
||||
pub fn read_field_discriminator(
|
||||
buffer: &[u8],
|
||||
union_schema: &Value,
|
||||
disc_field_offset: usize,
|
||||
endian: Endian,
|
||||
) -> Result<UnionDispatch, TypedefError>;
|
||||
```
|
||||
|
||||
The discriminator is a named field within the struct. Its offset is
|
||||
computed like any other field (the consumer passes it in as
|
||||
`disc_field_offset`). The mapping keys are string values. After reading
|
||||
the discriminator, the consumer looks up the variant schema and reads
|
||||
the variant's fields starting at the end of the discriminator field.
|
||||
|
||||
### Variant resolution
|
||||
|
||||
```rust
|
||||
/// Look up a variant schema from the union's mapping. Inline schemas
|
||||
/// are returned directly. $ref pointers of the form "#/$defs/<name>"
|
||||
/// are resolved against the union schema's own $defs block.
|
||||
pub fn resolve_variant<'a>(union_schema: &'a Value, key: &str)
|
||||
-> Result<&'a Value, TypedefError>;
|
||||
|
||||
/// Get the discriminator's byte size (1/2/4 for Uint8/16/32) for a
|
||||
/// byte-offset TUnion. Field-name discriminators have no fixed size
|
||||
/// and produce a TypedefError::Schema.
|
||||
pub fn discriminator_size(union_schema: &Value) -> Result<usize, TypedefError>;
|
||||
```
|
||||
|
||||
### TUnion in the layout engines
|
||||
|
||||
The `LayoutBuilder` and `SequentialReader` also handle TUnion fields
|
||||
inline during traversal (the consumer does not need to call the `tunion`
|
||||
functions for a union field reached during a sequential walk). For
|
||||
`LayoutBuilder`, the consumer supplies the discriminator value (byte-offset)
|
||||
or variant index (field-name) in `var_sizes` under the synthetic key
|
||||
`"<union_path>.__discriminator"` or `"<union_path>.__variant"`. For
|
||||
`SequentialReader`, a union field yields
|
||||
`FieldValue::Union { discriminator, variant_start }`. The standalone
|
||||
`tunion` functions are for dispatch outside the layout walk — e.g., a
|
||||
consumer that receives a bare union buffer and needs to identify the
|
||||
variant before recursing.
|
||||
|
||||
## Field Paths
|
||||
|
||||
Fields are addressed by dotted paths: `"header.version"`, `"payload.data"`.
|
||||
Both `OffsetMap` and `PackedLayout` store fully-qualified paths (nested
|
||||
struct fields appear under their parent's path prefix). The higher-level
|
||||
APIs (`TypedefEngine::read_field`/`write_field`, `SequentialReader::read_field`)
|
||||
accept a field path, look up the byte range/position in the layout, and
|
||||
dispatch to the primitive `data_access` function for the field's kind.
|
||||
|
||||
For aligned-mode access, `TypedefEngine::read_field(&buffer, "header.version")`
|
||||
returns `FieldValue` — it looks up the `ByteRange` in the `OffsetMap`, finds
|
||||
the field's `TypeDef:*` kind in the schema, and calls the matching
|
||||
`data_access::read_*` function. `write_field` is the mirror. Composite
|
||||
kinds (`Struct`, `Union`, `Array`, `Record`) return a `FieldValue`
|
||||
carrying a layout descriptor; the consumer recurses with a fresh reader
|
||||
or sub-range read.
|
||||
|
||||
For packed-mode access, `SequentialReader::read_field(&buffer, "c")` walks
|
||||
all preceding fields to reach the target (sequential access is inherent
|
||||
to packed layouts). `read_next` walks fields in declaration order.
|
||||
|
||||
Nested structs produce nested field paths. The offset computation
|
||||
propagates the field path prefix during recursion, so the `OffsetMap`
|
||||
and `PackedLayout` contain entries like `"header.version"` and
|
||||
`"header.magic"`.
|
||||
|
||||
## Zero-Copy Access
|
||||
|
||||
For fixed-size types, the engine provides zero-copy access — the consumer
|
||||
gets a reference to the bytes in the buffer, not a copy. This is
|
||||
important for performance-sensitive paths (metatensor tensor access,
|
||||
high-throughput protocol parsing).
|
||||
|
||||
For variable-length types with inline length-prefixing, the engine
|
||||
returns a slice of the buffer — the string or byte array data is not
|
||||
copied. The consumer gets a `&str` or `&[u8]` that borrows from the
|
||||
input buffer.
|
||||
|
||||
For offset-indirect types, the consumer provides the data region; the
|
||||
engine returns a slice of that region.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Read/write errors carry the field path for debugging. See
|
||||
[ADR-098](../../decisions/098-error-handling-validation-strategy.md) and
|
||||
[validation.md](validation.md) for the full error model.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Determines whether offsets are fixed (OffsetMap) or sequential (SequentialReader) |
|
||||
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness, encoding, and TUnion discriminator shapes that control data access |
|
||||
| Error handling | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | Field-path-carrying errors for read/write operations |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs
|
||||
— affects the sequential walking logic for array access.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-typedef/findings.md` §"POC Results" — POC 1
|
||||
(read/write round-trip) and POC 2 (SFTP byte-identical round-trip)
|
||||
- [layout-engine.md](layout-engine.md) — offset computation that produces
|
||||
the positions this layer reads/writes at
|
||||
- [validation.md](validation.md) — validation that runs on the same
|
||||
buffers
|
||||
@@ -0,0 +1,360 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef — Layout Engine
|
||||
|
||||
The layout engine: offset computation, the two layout modes (packed
|
||||
sequential vs aligned static), alignment, endianness, and variable-length
|
||||
field handling. This is the novel code — the recursive walk of the schema
|
||||
JSON that computes byte positions for each field.
|
||||
|
||||
## The Two Layout Modes
|
||||
|
||||
The POCs surfaced that protocols and mmap-friendly formats need different
|
||||
layout strategies. This is the most important architectural finding —
|
||||
decided in [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md).
|
||||
|
||||
### Mode 1: Packed sequential (protocol wire formats)
|
||||
|
||||
Fields are packed with no alignment padding. Variable-length fields shift
|
||||
all subsequent fields. Used by SFTP, channels, TTY, and most binary
|
||||
protocols.
|
||||
|
||||
**Components:**
|
||||
|
||||
- **`LayoutBuilder`** — constructed via `LayoutBuilder::new(schema)` (requires `TypeDef:Struct` at the top level), then `builder.build(&var_sizes) -> Result<PackedLayout, TypedefError>` where `var_sizes: &HashMap<String, usize>` maps variable-length field paths (and TUnion discriminator/variant keys) to their actual byte sizes. Used at write time when the consumer knows the data sizes upfront. The builder computes positions only; the consumer writes data via the [`data_access`](data-access.md) functions at the computed positions.
|
||||
- **`SequentialReader`** — constructed via `SequentialReader::new(schema)`, then driven by `reader.read_next(&buffer) -> Result<Option<(String, FieldValue)>, TypedefError>` until `Ok(None)`, or `reader.read_field(&buffer, path)` to seek a single field (which walks all preceding fields to reach the target). `reader.reset()` rewinds to the start. Used at read time when the consumer is parsing an incoming frame.
|
||||
|
||||
**How it works:**
|
||||
|
||||
For a struct with fields `[u8, u32, string]` where the string is 10 bytes:
|
||||
|
||||
```
|
||||
LayoutBuilder::build(var_sizes: {"payload": 10}):
|
||||
field[0] u8: offset 0, size 1
|
||||
field[1] u32: offset 1, size 4
|
||||
field[2] string: offset 5, size 4 (length prefix) + 10 (data)
|
||||
total: 19
|
||||
|
||||
SequentialReader::read_next (read):
|
||||
read u8 at offset 0
|
||||
read u32 at offset 1
|
||||
read u32 length prefix at offset 5 → data_len
|
||||
read string data at offset 9, length data_len
|
||||
next field at offset 9 + data_len
|
||||
```
|
||||
|
||||
There is no alignment padding. The `u32` at offset 1 is unaligned — this
|
||||
is correct for protocol wire formats, which pack fields tightly.
|
||||
|
||||
**Variable-length fields in packed mode:**
|
||||
|
||||
The `LayoutBuilder` takes actual data sizes for variable-length fields
|
||||
to compute correct positions for subsequent fields. The consumer must
|
||||
know the data sizes before writing — this is inherent to packed layouts.
|
||||
|
||||
The `SequentialReader` reads each field's length prefix to determine the
|
||||
data extent and the position of the next field. The reader walks the
|
||||
buffer sequentially; it cannot jump to field N without reading fields
|
||||
0..N-1 first.
|
||||
|
||||
### Mode 2: Aligned static (mmap-friendly formats)
|
||||
|
||||
Fields have fixed positions with natural alignment padding.
|
||||
Variable-length fields get a 4-byte length prefix at a known offset; the
|
||||
variable data is not included in the static layout. Used by metatensor
|
||||
and safetensors.
|
||||
|
||||
**Component:**
|
||||
|
||||
- **`OffsetMap`** — constructed via `OffsetMap::compute(schema) -> Result<Self, TypedefError>` (requires `TypeDef:Struct` at the top level). Walks the schema once, computes fixed byte positions for each field based on type sizes and alignment. The output is a flat table of `(field_path, byte_range)` pairs (see [Public Types](#public-types)). Used for both read and write at known offsets.
|
||||
|
||||
**How it works:**
|
||||
|
||||
For a struct with fields `[u8, u32, f32]` and natural alignment:
|
||||
|
||||
```
|
||||
OffsetMap:
|
||||
field[0] u8: offset 0, size 1
|
||||
field[1] u32: offset 4, size 4 (3 bytes padding after u8)
|
||||
field[2] f32: offset 8, size 4
|
||||
total: 12 (struct aligned to 4)
|
||||
```
|
||||
|
||||
The `u32` is aligned to offset 4 (its natural alignment). The consumer
|
||||
can read `field[1]` at offset 4 without reading `field[0]` first — random
|
||||
access by field path.
|
||||
|
||||
**Variable-length fields in aligned mode:**
|
||||
|
||||
Variable-length fields get a 4-byte length prefix at a known offset. The
|
||||
variable data lives outside the static layout — either immediately after
|
||||
the fixed fields (inline length-prefixing) or in a separate data region
|
||||
(offset indirection). The `OffsetMap` records the position of the length
|
||||
prefix (or the `{offset, length}` pair for offset-indirect fields).
|
||||
|
||||
For inline length-prefixing, the variable data follows the fixed fields
|
||||
but is not included in the `OffsetMap`'s field ranges. The consumer reads
|
||||
the length prefix from the `OffsetMap`'s known offset, then slices the
|
||||
data region.
|
||||
|
||||
For offset indirection, the field is a struct `{offset: u32, length: u32}`
|
||||
at a known position in the `OffsetMap`. The consumer reads the offset and
|
||||
length, then slices the separate data region.
|
||||
|
||||
### Inline length-prefixing in aligned mode — non-final field restriction
|
||||
|
||||
Inline length-prefixed variable fields in aligned mode are only allowed
|
||||
as the **last field** in their struct. A non-final inline
|
||||
length-prefixed variable field is rejected at `OffsetMap::compute` time
|
||||
with a `TypedefError::Offset` — the `OffsetMap` reserves only 4 bytes
|
||||
(the length prefix), but `data_access::write_string` writes prefix +
|
||||
data inline, which would clobber subsequent fields. Non-final variable
|
||||
fields must use `maxLength` (fixed-size reservation) or
|
||||
`"encoding": "offset-indirect"`. See
|
||||
[ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md).
|
||||
|
||||
## Offset Computation Algorithm
|
||||
|
||||
The offset computation is a recursive walk of the schema JSON. The
|
||||
algorithm is the same for both modes; the difference is whether alignment
|
||||
padding is inserted between fields.
|
||||
|
||||
### Fixed-size types
|
||||
|
||||
For each fixed-size type, the algorithm:
|
||||
1. Determines the type's byte size from the `TypeDef:*` kind.
|
||||
2. In aligned mode: inserts padding to satisfy the type's alignment
|
||||
(or the field's `align` annotation, or the struct's `align` default).
|
||||
3. Records the field's `(start, end)` range.
|
||||
4. Advances the current offset by the type's size.
|
||||
|
||||
### Composite types
|
||||
|
||||
**`TStruct`:** Recurse into the struct's `properties`. The inner fields
|
||||
are computed relative to the struct's start offset. The struct's total
|
||||
size is the sum of its fields' sizes (plus alignment padding in aligned
|
||||
mode). The struct itself may have an `align` annotation that rounds up
|
||||
its total size.
|
||||
|
||||
**`TUnion`:** TUnion is supported in packed sequential mode only. In
|
||||
aligned static mode, `OffsetMap::compute` rejects `TUnion` fields with
|
||||
`TypedefError::Offset` — see
|
||||
[ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md). Unions
|
||||
are the protocol dispatch pattern (SFTP type bytes, call protocol event
|
||||
types); mmap-friendly formats use structs and arrays, not tagged unions.
|
||||
|
||||
In packed sequential mode, the discriminator occupies
|
||||
`offset..offset + discriminator_size` bytes. For byte-offset
|
||||
discriminators, the variant struct starts at `offset + discriminator_size`.
|
||||
For field-name discriminators, the discriminator is just another field —
|
||||
its offset is computed like any other field, and the variant struct
|
||||
follows at the end of the discriminator field.
|
||||
|
||||
Variant sizes depend on the actual sizes of variable-length fields within
|
||||
each variant, which aren't known at schema time. The `LayoutBuilder`
|
||||
takes the actual variant discriminator value and data sizes at write time,
|
||||
computes the size of the selected variant, and uses that for the union's
|
||||
total size. The `SequentialReader` reads the discriminator first, looks
|
||||
up the variant schema, then reads the variant struct sequentially — it
|
||||
doesn't need to know the union's total size upfront.
|
||||
|
||||
**`TArray` of fixed-size elements:** Element stride = element size (plus
|
||||
alignment padding in aligned mode). Element `i` starts at
|
||||
`array_offset + i × stride`. The array's total size is `count × stride`.
|
||||
|
||||
**`TArray` of variable-length-element structs:** Deferred for v1
|
||||
(OQ-069).
|
||||
|
||||
### Variable-length types
|
||||
|
||||
The typedef engine supports three strategies for variable-length types
|
||||
(see [schema-layer.md](schema-layer.md) §Variable-length types and
|
||||
[ADR-097](../../decisions/097-schema-annotations.md) §3 for the full
|
||||
annotation shapes).
|
||||
|
||||
**Strategy 1: Inline length-prefixing (default).**
|
||||
1. Records the position of the 4-byte length prefix.
|
||||
2. In aligned mode: the length prefix is aligned; the variable data is
|
||||
not included in the static layout.
|
||||
3. In packed mode: the `LayoutBuilder` takes the actual data size to
|
||||
compute the length prefix value and the position of subsequent fields.
|
||||
The `SequentialReader` reads the length prefix to determine the data
|
||||
extent and the position of the next field.
|
||||
|
||||
**Strategy 2: Fixed-size reservation (`maxLength`).**
|
||||
1. In aligned static mode: reserves `maxLength` bytes at a fixed offset.
|
||||
Data shorter than `maxLength` is zero-padded. Subsequent fields have
|
||||
known, unchanging offsets — the field is fixed-size from the layout
|
||||
perspective. This is the database `VARCHAR(N)` pattern.
|
||||
2. In packed sequential mode: `maxLength` is a validation constraint
|
||||
only. The engine uses strategy 1 (inline length-prefixing) because
|
||||
protocols don't benefit from fixed-size reservation.
|
||||
|
||||
**Strategy 3: Offset indirection (`"encoding": "offset-indirect"`).**
|
||||
1. The field is a struct `{offset: u32, length: u32}`.
|
||||
2. The `OffsetMap` records the position of this struct.
|
||||
3. The consumer provides the data region separately. This is the
|
||||
metatensor blob tensor pattern — the index struct lives in one region,
|
||||
the blob data lives in another.
|
||||
|
||||
### Nested structs and field paths
|
||||
|
||||
Nested structs produce dotted field paths: `header.version`,
|
||||
`header.magic`. The offset computation propagates the field path prefix
|
||||
during recursion. Both `OffsetMap` and `PackedLayout` store fully-qualified
|
||||
paths; the `iter()` method of each yields fields in schema `properties`
|
||||
order, with nested struct fields appearing inline under their parent's
|
||||
path prefix.
|
||||
|
||||
### Endianness
|
||||
|
||||
Endianness is per-schema (ADR-097). The offset computation is
|
||||
endian-agnostic — it computes byte positions, not byte values. The
|
||||
read/write functions apply endianness when converting between bytes and
|
||||
typed values. The engine reads the `"endian"` annotation from the schema
|
||||
and byte-swaps accordingly. All fixed-size types — including `TEnum`
|
||||
(u32 index) — follow the schema's endianness.
|
||||
|
||||
## Mode Selection
|
||||
|
||||
The consumer selects the mode at engine construction time via the
|
||||
`LayoutMode` enum, passed to `TypedefEngine::compile`:
|
||||
|
||||
```rust
|
||||
pub enum LayoutMode {
|
||||
/// Packed sequential — for protocol wire formats (SFTP, channels, TTY).
|
||||
Packed,
|
||||
/// Aligned static — for mmap-friendly formats (metatensor, safetensors).
|
||||
Aligned,
|
||||
}
|
||||
```
|
||||
|
||||
The choice is determined by the use case, not by the schema:
|
||||
|
||||
- **Protocol consumer** (SFTP, binary call frames, TTY negotiation):
|
||||
`LayoutMode::Packed` → uses `LayoutBuilder` for writing and
|
||||
`SequentialReader` for reading.
|
||||
- **mmap consumer** (metatensor): `LayoutMode::Aligned` → uses `OffsetMap`
|
||||
for both reading and writing at known offsets.
|
||||
|
||||
The same schema can be used in either mode. A schema describing an SFTP
|
||||
packet can be consumed by a `SequentialReader` (for parsing incoming
|
||||
frames) and a `LayoutBuilder` (for constructing outgoing frames). A schema
|
||||
describing a metatensor layout can be consumed by an `OffsetMap` (for
|
||||
mmap access).
|
||||
|
||||
`TypedefEngine` exposes mode-appropriate accessors: `engine.offset_map()`
|
||||
returns `Some(&OffsetMap)` in aligned mode and `None` in packed mode;
|
||||
`engine.layout_builder()` returns `Some(&LayoutBuilder)` in packed mode
|
||||
and `None` in aligned mode. `engine.sequential_reader()` returns
|
||||
`Option<SequentialReader>` (an owned fresh reader, not a reference — the
|
||||
reader has mutable cursor state that the consumer owns; see
|
||||
[ADR-101](../../decisions/101-packed-mode-read-factory.md)) in packed
|
||||
mode and `None` in aligned mode. See [validation.md](validation.md)
|
||||
§"The TypedefEngine struct" for the engine API.
|
||||
|
||||
## Public Types
|
||||
|
||||
The layout engine produces three public types, one per layout component.
|
||||
All are re-exported from the crate root.
|
||||
|
||||
### `ByteRange` (aligned mode)
|
||||
|
||||
```rust
|
||||
pub struct ByteRange {
|
||||
pub start: usize, // inclusive
|
||||
pub end: usize, // exclusive
|
||||
}
|
||||
```
|
||||
|
||||
A half-open byte range produced by `OffsetMap::compute` for each field.
|
||||
`end - start` is the field's byte size in the static layout (for
|
||||
variable-length fields: the length prefix, the `{offset, length}` pair,
|
||||
or the `maxLength` reservation — not the variable data). `ByteRange`
|
||||
provides `len()` and `is_empty()`.
|
||||
|
||||
### `FieldPosition` (packed mode)
|
||||
|
||||
```rust
|
||||
pub struct FieldPosition {
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
pub kind: TypeDefKind,
|
||||
}
|
||||
```
|
||||
|
||||
A field's computed position in a packed layout, produced by
|
||||
`LayoutBuilder::build`. For variable-length fields, `size` is `4` (the
|
||||
length prefix); for fixed-size fields, `size` is the type's byte size.
|
||||
`kind` records the field's `TypeDef:*` kind so the consumer can dispatch
|
||||
to the correct `data_access` read/write function.
|
||||
|
||||
### `PackedLayout` (packed mode)
|
||||
|
||||
The result of `LayoutBuilder::build`: a map of `field_path → FieldPosition`
|
||||
plus the total buffer size needed.
|
||||
|
||||
```rust
|
||||
impl PackedLayout {
|
||||
pub fn get(&self, field_path: &str) -> Option<&FieldPosition>;
|
||||
pub fn total_size(&self) -> usize;
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(String, FieldPosition)>;
|
||||
}
|
||||
```
|
||||
|
||||
`get` looks up a field by dotted path. For TUnion byte-offset
|
||||
discriminators, the discriminator is recorded under the synthetic path
|
||||
`"<union_path>.__discriminator"`. `iter` yields fields in layout order
|
||||
(schema `properties` order, with nested struct fields appearing inline
|
||||
under their parent's path prefix).
|
||||
|
||||
### `OffsetMap` (aligned mode)
|
||||
|
||||
A flat table of `(field_path, byte_range)` pairs computed from a schema.
|
||||
|
||||
```rust
|
||||
impl OffsetMap {
|
||||
pub fn compute(schema: &Value) -> Result<Self, TypedefError>;
|
||||
pub fn get(&self, field_path: &str) -> Option<&ByteRange>;
|
||||
pub fn total_size(&self) -> usize;
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(String, ByteRange)>;
|
||||
}
|
||||
```
|
||||
|
||||
`compute` requires a `TypeDef:Struct` at the top level. `total_size`
|
||||
includes trailing alignment padding. `iter` yields fields in insertion
|
||||
order (schema `properties` order, nested struct fields appearing inline).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Packed sequential for protocols; aligned static for mmap formats |
|
||||
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness, alignment, encoding annotations that control layout behavior |
|
||||
| Non-final inline variable fields | [ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Rejected in aligned mode (would clobber subsequent fields); use `maxLength` or `offset-indirect` |
|
||||
| Packed-mode read factory | [ADR-101](../../decisions/101-packed-mode-read-factory.md) | `engine.sequential_reader()` returns an owned fresh reader, not a reference |
|
||||
| TUnion in aligned mode | [ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Rejected for v1 (broken semantics; no current consumer needs it) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs
|
||||
— requires lazy walking logic; blocked on a concrete consumer that
|
||||
needs it.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-typedef/findings.md` §"POC Results" — POC 1
|
||||
(aligned OffsetMap) and POC 2 (packed LayoutBuilder/SequentialReader)
|
||||
- [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) —
|
||||
the two layout modes decision
|
||||
- [ADR-097](../../decisions/097-schema-annotations.md) — schema
|
||||
annotations
|
||||
- [schema-layer.md](schema-layer.md) — the 17 TypeDef kinds and their
|
||||
byte sizes
|
||||
- [data-access.md](data-access.md) — read/write functions that use the
|
||||
computed offsets
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef — Overview
|
||||
|
||||
The binary struct engine: a small Rust crate that takes a JSON Schema
|
||||
with `TypeDef:*` custom keywords and produces an offset map, read/write
|
||||
functions, and validation — all driven by the schema. The schema is the
|
||||
format definition; the engine is generic.
|
||||
|
||||
This document covers the crate's purpose, the "schema is the format"
|
||||
principle, its dependency edges, consumers, and scope boundaries.
|
||||
Component details are in the sibling documents.
|
||||
|
||||
## What
|
||||
|
||||
`alknet-typedef` is a library crate that consumes JSON Schemas annotated
|
||||
with `TypeDef:*` custom keywords (the same kinds defined in TypeBox's
|
||||
`typedef.ts`, plus `TypeDef:Bytes`, `TypeDef:Int64`, and `TypeDef:Uint64`
|
||||
as alknet-typedef additions) and produces three capabilities:
|
||||
|
||||
1. **An offset map** — walks the schema, computes byte offsets for each
|
||||
field based on type sizes, field order, and alignment.
|
||||
2. **Read/write functions** — given a `&[u8]` buffer and a field path,
|
||||
read the field's bytes at its offset (zero-copy for fixed-size types).
|
||||
Given a `&mut [u8]` buffer, write a value at its offset.
|
||||
3. **Validation** — via `jsonschema` custom keywords, validates that a
|
||||
buffer's bytes match the schema's type constraints.
|
||||
|
||||
The heavy lifting is done by the `jsonschema` crate (validation) and
|
||||
`serde_json` (schema parsing). The novel code is the offset computation
|
||||
— a recursive walk of the schema JSON that computes byte positions for
|
||||
each field. The custom keyword implementations are small (a few lines
|
||||
each, generated from shared macros — see [validation.md](validation.md)).
|
||||
|
||||
The crate replaces two prior attempts that built their own jsonschema
|
||||
engines — typebox-rs (~8,400 lines) and alktype (~5,600 lines) — with
|
||||
`jsonschema` + an offset map + small custom keyword implementations. See
|
||||
[ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
|
||||
|
||||
## Why
|
||||
|
||||
The crate's purpose is to be the binary struct engine for every alknet
|
||||
component that reads or writes binary data at computed offsets. Instead
|
||||
of per-protocol serde structs (russh-sftp's 29 packet types), per-handler
|
||||
wire format code (TTY's 5-byte format parser), or per-format offset
|
||||
computation (metatensor's tensor access), all of these become instances
|
||||
of the same engine with different schemas.
|
||||
|
||||
The guiding insight:
|
||||
|
||||
> **The schema is the format.** A JSON Schema with `TypeDef:Float32`,
|
||||
> `TypeDef:Struct`, `TypeDef:Union` etc. is both the validation spec and
|
||||
> the layout spec. No separate format definition, no separate parser, no
|
||||
> separate validator. One schema, three uses: validate, compute offsets,
|
||||
> access data.
|
||||
|
||||
This is the convergence of three threads identified in the
|
||||
call-channels-unification research: the `typedef.ts` schema kinds from
|
||||
TypeBox, the russh-sftp protocol packets, and the metatensor format. The
|
||||
common pattern: a JSON Schema describes the shape of binary data, and
|
||||
the binary data is the struct's bytes at computed offsets.
|
||||
|
||||
The crate was bumped up in the timeline when the call-channels-unification
|
||||
research surfaced that channels, TTY, and the binary call protocol are
|
||||
all variations on the same wire-format family — `[discriminant][length][payload]`.
|
||||
The typedef engine makes the "channels is call with a binary data plane"
|
||||
unification concrete: the binary data plane's wire format is the call
|
||||
protocol's own schema system, just binary-encoded. The `channel_open`
|
||||
marker says "use binary framing"; the typedef engine says "here's how to
|
||||
read/write the binary payload."
|
||||
|
||||
## The "Schema Is the Format" Principle
|
||||
|
||||
A JSON Schema with `TypeDef:*` custom keywords serves three roles
|
||||
simultaneously:
|
||||
|
||||
| Role | Mechanism | When |
|
||||
|------|-----------|------|
|
||||
| **Validation spec** | `jsonschema` custom keywords | Load time (build validator), access time (validate buffer) |
|
||||
| **Layout spec** | Offset computation from type sizes + field order | Load time (build offset map) |
|
||||
| **Data access** | Read/write at computed offsets | Access time (read field, write field) |
|
||||
|
||||
No separate format definition, no separate parser, no separate validator.
|
||||
The schema is the single source of truth for the binary format. Adding a
|
||||
new field to a protocol is adding a property to the schema JSON — the
|
||||
engine computes the new offsets automatically.
|
||||
|
||||
This is the same principle as `#[repr(C)]` struct field access, but at
|
||||
runtime from a portable JSON Schema instead of at compile-time from
|
||||
language-specific annotations. The schema is the ABI contract.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alknet-typedef
|
||||
├── jsonschema (v0.46.5, Draft 2020-12) — validation engine, custom keyword support
|
||||
├── serde_json (with preserve_order) — schema parsing; field order is load-bearing
|
||||
└── (no tokio, no platform deps) — WASM-clean by construction
|
||||
```
|
||||
|
||||
`alknet-typedef` is dependency-light: `jsonschema` + `serde_json` only.
|
||||
No tokio, no platform deps. Compiles to `wasm32-unknown-unknown` for
|
||||
browser use. The `jsonschema` crate is already in the workspace at
|
||||
`/workspace/jsonschema/` but not yet used by any alknet crate — typedef
|
||||
is the first consumer.
|
||||
|
||||
`serde_json` requires the `preserve_order` feature because field order
|
||||
is load-bearing for binary layouts. The order of properties in the
|
||||
schema JSON determines the order of fields in the binary struct.
|
||||
|
||||
## Consumers
|
||||
|
||||
| Consumer | Schema describes | Engine provides |
|
||||
|----------|-----------------|-----------------|
|
||||
| russh-sftp | 29 packet structs + Packet union (byte discriminator) | Read/write SFTP frames from bytes |
|
||||
| metatensor | Model layout (ConvNet struct, tensor refs) | Offset map for mmap'd tensor access |
|
||||
| binary call frames | `call.requested` / `call.responded` / etc. structs | Read/write binary call frames |
|
||||
| TTY negotiation | `NegotiateRequest` / `NegotiateResponse` structs | Read/write TTY control frames |
|
||||
| channels wire | `ChunkHeader { channel_id, length }` | Already trivial (8 bytes, no schema needed) |
|
||||
|
||||
The russh-sftp case is the most instructive and the highest-value POC
|
||||
target. The `Packet` enum's `TryFrom<&mut Bytes>` impl is a hand-written
|
||||
dispatch on a type byte followed by serde deserialization. Under typedef,
|
||||
the dispatch is `TUnion` with a byte-offset discriminator — the schema
|
||||
says "byte 0 is the discriminator, bytes 1..N are the variant struct."
|
||||
The engine reads the discriminator, looks up the variant schema, computes
|
||||
offsets, reads fields. Same result, no per-packet-type code.
|
||||
|
||||
## Scope Boundaries (What This Is Not)
|
||||
|
||||
These boundaries are decided in [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md).
|
||||
|
||||
- **Not metatensor.** typedef is the binary struct *engine*. Metatensor
|
||||
is a *format* (8-byte header + JSON header + binary data) that uses the
|
||||
typedef engine for its offset computation and tensor access.
|
||||
- **Not a Value system.** TypeBox's `Value.Diff`, `Value.Migrate`,
|
||||
`Value.Convert` — schema evolution — is out of scope for v1. The engine
|
||||
should not do anything that explicitly blocks adding a Value system
|
||||
later.
|
||||
- **Not a code generator.** typebox-rs's `codegen/` module is a separate
|
||||
concern. The typedef engine consumes schemas; it does not generate them.
|
||||
- **Not a schema builder.** The typedef engine does not provide a fluent
|
||||
API for constructing schemas. Schemas are plain JSON — authored in
|
||||
TypeBox, generated by ujsx components, or hand-written. A builder API
|
||||
is deferred (OQ-071).
|
||||
- **Not a serialization framework.** The typedef engine is not a
|
||||
general-purpose serde replacement. It operates on raw byte buffers at
|
||||
computed offsets — no intermediate `Value` tree, no reflection, no
|
||||
dynamic dispatch per field. For JSON data, use serde. For binary data
|
||||
with a known schema, use typedef.
|
||||
|
||||
## Architecture (component pointers)
|
||||
|
||||
- **[schema-layer.md](schema-layer.md)** — the 19 `TypeDef:*` kinds,
|
||||
jsonschema custom keyword integration, TypeBox interop, schema
|
||||
annotations (endianness, alignment, encoding, TUnion discriminators).
|
||||
- **[layout-engine.md](layout-engine.md)** — offset computation, the two
|
||||
layout modes (packed sequential vs aligned static), alignment,
|
||||
endianness, variable-length field handling.
|
||||
- **[data-access.md](data-access.md)** — read/write functions, TUnion
|
||||
dispatch, field paths, zero-copy access for fixed-size types,
|
||||
length-prefix reading for variable-length types.
|
||||
- **[validation.md](validation.md)** — custom keyword validators for all
|
||||
19 `TypeDef:*` kinds, `TypedefError`, load-time vs access-time
|
||||
validation, `TypedefEngine` as the compiled form of a schema.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Purpose, scope, and the jsonschema engine | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | What the crate is/isn't; why jsonschema not a custom engine; "schema is the format" principle; scope boundaries |
|
||||
| Two layout modes | [ADR-096](../../decisions/096-two-layout-modes-packed-vs-aligned.md) | Packed sequential (`LayoutBuilder`/`SequentialReader`) for protocols; aligned static (`OffsetMap`) for mmap formats |
|
||||
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Endianness (schema-level, default LE), alignment (struct + field-level), encoding (length-prefixed vs offset-indirect), TUnion discriminators (byte-offset vs field-name) |
|
||||
| Error handling and validation | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors; jsonschema `ValidationError` wrapping |
|
||||
| Int64/Uint64 kinds | [ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md) | 64-bit integers as first-class kinds (SFTP offsets, metatensor data_offsets) |
|
||||
| Non-final inline variable fields | [ADR-100](../../decisions/100-reject-non-final-inline-length-prefixed-in-aligned-mode.md) | Rejected in aligned mode (would clobber subsequent fields) |
|
||||
| Packed-mode read factory | [ADR-101](../../decisions/101-packed-mode-read-factory.md) | `engine.sequential_reader()` returns an owned fresh reader |
|
||||
| TUnion in aligned mode | [ADR-102](../../decisions/102-reject-tunion-in-aligned-mode.md) | Rejected for v1 (broken semantics; no current consumer needs it) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-069** (deferred(scope)): Arrays of variable-length-element structs.
|
||||
- **OQ-070** (deferred(scope)): `no_std` + `alloc` support.
|
||||
- **OQ-071** (deferred(scope)): Builder API for schema construction.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-typedef/findings.md` — POC results (26 tests
|
||||
passing, two layout modes, TUnion dispatch, endianness)
|
||||
- `docs/research/call-channels-unification/findings.md` §"alknet-typedef:
|
||||
JSON Schema as the binary struct engine" — the origin of this research
|
||||
thread
|
||||
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
|
||||
schema kinds (619 lines)
|
||||
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
|
||||
- `/workspace/alknet-typedef-poc/` — the POC code (disposable)
|
||||
- `/workspace/@alkimiadev/typebox-rs/` — prior attempt, replaced by typedef
|
||||
- `/workspace/@alkimiadev/alktype/` — prior attempt, replaced by typedef
|
||||
@@ -0,0 +1,506 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef — Schema Layer
|
||||
|
||||
The schema layer: the 19 `TypeDef:*` custom type kinds, their mapping to
|
||||
Rust types and byte sizes, the `jsonschema` custom keyword integration,
|
||||
TypeBox interop, and the concrete JSON shapes for schema-level
|
||||
annotations.
|
||||
|
||||
## The 19 TypeDef Kinds
|
||||
|
||||
These are the custom schema kinds defined in TypeBox's `typedef.ts`
|
||||
(`/workspace/@alkdev/typebox/example/typedef/typedef.ts`, 619 lines) and
|
||||
ported to Rust via `jsonschema` custom keywords. Each kind carries binary
|
||||
layout semantics — a known byte size (for fixed-size types) or a known
|
||||
encoding strategy (for variable-length types).
|
||||
|
||||
| Kind | TypeBox key | Rust type | Size | Category |
|
||||
|------|-------------|-----------|------|----------|
|
||||
| `TFloat32` | `TypeDef:Float32` | `f32` | 4 | fixed |
|
||||
| `TFloat64` | `TypeDef:Float64` | `f64` | 8 | fixed |
|
||||
| `TInt8` | `TypeDef:Int8` | `i8` | 1 | fixed |
|
||||
| `TInt16` | `TypeDef:Int16` | `i16` | 2 | fixed |
|
||||
| `TInt32` | `TypeDef:Int32` | `i32` | 4 | fixed |
|
||||
| `TInt64` | `TypeDef:Int64` | `i64` | 8 | fixed |
|
||||
| `TUint8` | `TypeDef:Uint8` | `u8` | 1 | fixed |
|
||||
| `TUint16` | `TypeDef:Uint16` | `u16` | 2 | fixed |
|
||||
| `TUint32` | `TypeDef:Uint32` | `u32` | 4 | fixed |
|
||||
| `TUint64` | `TypeDef:Uint64` | `u64` | 8 | fixed |
|
||||
| `TBoolean` | `TypeDef:Boolean` | `bool` (0x00=false, 0x01=true) | 1 | fixed |
|
||||
| `TString` | `TypeDef:String` | length-prefixed UTF-8 | variable | variable |
|
||||
| `TBytes` | `TypeDef:Bytes` | length-prefixed raw bytes | variable | variable |
|
||||
| `TStruct` | `TypeDef:Struct` | record of fields | sum of field sizes | composite |
|
||||
| `TUnion` | `TypeDef:Union` | tagged union | discriminator + variant | composite |
|
||||
| `TArray` | `TypeDef:Array` | repeated element | count × element size | composite |
|
||||
| `TEnum` | `TypeDef:Enum` | u32 index into enum values | 4 (fixed) | fixed |
|
||||
| `TRecord` | `TypeDef:Record` | count-prefixed sequence of (key, value) pairs | variable | variable |
|
||||
| `TTimestamp` | `TypeDef:Timestamp` | length-prefixed RFC 3339 string | variable | variable |
|
||||
|
||||
`TypeDef:Int64` and `TypeDef:Uint64` are alknet-typedef additions —
|
||||
TypeBox's `typedef.ts` tops out at 32-bit integers. They are required by
|
||||
the primary POC targets: SFTP `Read`/`Write` packets have `offset: u64`,
|
||||
and metatensor `data_offsets` are `u64`. See
|
||||
[ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md).
|
||||
|
||||
### The `TypeDefKind` enum
|
||||
|
||||
The engine represents the 19 kinds as a Rust enum — `TypeDefKind` — with
|
||||
one variant per kind (`TypeDefKind::Float32`, `TypeDefKind::Struct`, etc.).
|
||||
The enum provides compile-time exhaustiveness checking and integer
|
||||
discriminant dispatch (a jump table) instead of string comparison at
|
||||
every field access. It is `pub` and re-exported from the crate root.
|
||||
|
||||
```rust
|
||||
pub enum TypeDefKind {
|
||||
Int8, Int16, Int32, Int64,
|
||||
Uint8, Uint16, Uint32, Uint64,
|
||||
Float32, Float64,
|
||||
Boolean, Enum,
|
||||
String, Bytes, Timestamp,
|
||||
Struct, Union, Array, Record,
|
||||
}
|
||||
```
|
||||
|
||||
The enum carries the kind's binary-layout metadata as inherent methods:
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|--------|---------|-------|
|
||||
| `as_str(self)` | `&'static str` | The JSON Schema keyword, e.g. `"TypeDef:Uint8"` |
|
||||
| `type_size(self)` | `Option<usize>` | `Some(N)` for fixed-size kinds; `None` for variable/composite |
|
||||
| `natural_alignment(self)` | `usize` | 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/enum, 8 for u64/i64/f64, 4 for variable-length (the u32 length prefix), 1 for struct/union/array |
|
||||
| `is_fixed_size(self)` | `bool` | True for the 12 fixed-size primitive kinds |
|
||||
| `is_composite(self)` | `bool` | True for Struct, Union, Array, Record |
|
||||
| `is_variable_length(self)` | `bool` | True for String, Bytes, Timestamp, Record |
|
||||
| `needs_endian(self)` | `bool` | True for kinds whose read/write takes an `Endian` parameter |
|
||||
|
||||
`TypeDefKind` implements `Display` (renders the keyword string) and
|
||||
`FromStr` (parses the keyword string back into the variant, returning
|
||||
`TypedefError::Schema` for unknown kinds). The layout engines and the
|
||||
validator dispatch on the enum, not on strings.
|
||||
|
||||
### Fixed-size types
|
||||
|
||||
`TFloat32`, `TFloat64`, `TInt8`, `TInt16`, `TInt32`, `TUint8`, `TUint16`,
|
||||
`TUint32`, `TBoolean`, and `TEnum` have known byte sizes. The offset
|
||||
computation uses these sizes directly. Read/write is zero-copy pointer
|
||||
cast for these types.
|
||||
|
||||
**`TBoolean` byte representation:** `0x00` = false, `0x01` = true. Other
|
||||
values are invalid and produce a `TypedefError::Access` on read.
|
||||
|
||||
**`TEnum` binary representation:** A `u32` index into the enum's declared
|
||||
values, in declaration order. The first declared value is index 0, the
|
||||
second is index 1, etc. The enum's values are declared via the standard
|
||||
JSON Schema `"enum"` keyword (e.g., `"enum": ["read", "write", "execute"]`).
|
||||
The `TypeDef:Enum` custom keyword signals that the type is an enum for
|
||||
layout purposes; the built-in `enum` keyword provides the value list.
|
||||
|
||||
**Design note:** TypeBox's `TEnum` is a string enum (variable-length). The
|
||||
typedef engine uses a `u32` index instead — a deliberate deviation from
|
||||
TypeBox fidelity in favor of binary efficiency. Most enums have a small
|
||||
number of variants (e.g., the call protocol's 5 event types); a `u32`
|
||||
index is compact, fixed-size, and sufficient for any realistic enum. The
|
||||
JSON representation (for validation) remains a string; the binary
|
||||
representation is the `u32` index.
|
||||
The `u32` index follows the schema's endianness annotation (ADR-097), like
|
||||
all other fixed-size types. In little-endian mode the index is
|
||||
`u32::from_le_bytes`; in big-endian mode it is `u32::from_be_bytes`.
|
||||
|
||||
### Variable-length types
|
||||
|
||||
`TString`, `TBytes`, `TRecord`, and `TTimestamp` have variable byte sizes.
|
||||
The typedef engine supports three strategies for handling variable-length
|
||||
types in binary layouts, selected by the `encoding` annotation and the
|
||||
standard JSON Schema `maxLength` keyword:
|
||||
|
||||
| Strategy | Encoding annotation | Layout behavior | Use case |
|
||||
|----------|-------------------|-----------------|----------|
|
||||
| **Inline length-prefixed** | `"length-prefixed"` (default) | `[length: u32][data]`; shifts subsequent fields in packed mode | Protocol wire formats (SFTP, channels, TTY) |
|
||||
| **Fixed-size reservation** | (none — uses `maxLength`) | `[data: maxLength bytes]`, zero-padded; fixed offset in aligned mode | mmap-friendly formats where max size is known (database `VARCHAR(N)` pattern) |
|
||||
| **Offset indirection** | `"offset-indirect"` | `{offset: u32, length: u32}` pointing into a separate data region | Blob tensors, metatensor variable-length data (the blob tensor pattern) |
|
||||
|
||||
**Strategy 1: Inline length-prefixing (default).** The field's fixed
|
||||
portion is a 4-byte length prefix at a computed offset. The variable data
|
||||
follows immediately after. In packed sequential mode, the length prefix
|
||||
determines the position of subsequent fields. In aligned static mode, the
|
||||
length prefix is at a known offset; the variable data is not included in
|
||||
the static layout. This is the universal pattern used by channels, SFTP,
|
||||
TTY, and most binary protocols.
|
||||
|
||||
**Strategy 2: Fixed-size reservation.** When a variable-length field
|
||||
declares `maxLength` (a standard JSON Schema keyword), the engine reserves
|
||||
`maxLength` bytes at a fixed offset in aligned static mode. Data shorter
|
||||
than `maxLength` is zero-padded; data longer than `maxLength` is a
|
||||
validation error. This makes the field fixed-size from the layout
|
||||
perspective — subsequent fields have known, unchanging offsets. This is
|
||||
the database `VARCHAR(N)` pattern and the metatensor struct-tensor
|
||||
pattern for fields with known maximum sizes.
|
||||
|
||||
In packed sequential mode, `maxLength` is a validation constraint only —
|
||||
the engine still uses inline length-prefixing (strategy 1) because
|
||||
protocols don't benefit from fixed-size reservation.
|
||||
|
||||
**Strategy 3: Offset indirection.** The field is a struct
|
||||
`{offset: u32, length: u32}` at a known position. The consumer provides
|
||||
the data region separately; the engine reads the offset and length, then
|
||||
slices the data region. This is the metatensor blob tensor pattern — the
|
||||
index struct lives in one region, the blob data lives in another. Enables
|
||||
mmap-friendly random access to variable-length data without parsing
|
||||
length prefixes and without reserving worst-case space.
|
||||
|
||||
**Default strategy selection:**
|
||||
- In packed sequential mode: always strategy 1 (inline length-prefixing).
|
||||
`maxLength` is a validation constraint only.
|
||||
- In aligned static mode: strategy 2 (fixed-size reservation) if
|
||||
`maxLength` is declared; strategy 3 (offset indirection) if
|
||||
`"encoding": "offset-indirect"` is declared; strategy 1 (inline
|
||||
length-prefixing) otherwise.
|
||||
|
||||
**Length prefix endianness:** The 4-byte length prefix (strategies 1 and 3)
|
||||
respects the schema's `"endian"` annotation (ADR-097). In little-endian
|
||||
mode, the length is `u32::from_le_bytes`. In big-endian mode, the length
|
||||
is `u32::from_be_bytes`. This ensures SFTP consumers (big-endian) have
|
||||
consistent byte order for both field values and length prefixes.
|
||||
|
||||
**`TBytes`:** Raw bytes — no UTF-8 constraint. The payload is `&[u8]`.
|
||||
Otherwise identical to `TString` in layout (same three strategies).
|
||||
|
||||
**Design note:** `TypeDef:Bytes` is an alknet-typedef addition — it does
|
||||
not exist in TypeBox's `typedef.ts` (which defines 16 kinds). It is
|
||||
included because raw byte arrays are a common binary protocol primitive
|
||||
(SFTP data payloads, channels payloads, tensor data) and are semantically
|
||||
distinct from UTF-8 strings. In the binary representation, TBytes is raw
|
||||
bytes with no encoding (not base64, not hex). In the JSON representation
|
||||
(for validation), TBytes is a string (JSON has no native byte type).
|
||||
|
||||
**`TRecord`:** A string-keyed map. The value type is declared via the
|
||||
schema's `"values"` property (e.g., `"values": { "TypeDef:Float32": true }`).
|
||||
Binary layout is a count-prefixed sequence of `(key, value)` pairs:
|
||||
`[count: u32][key_len: u32][key_bytes][value]...` repeated `count` times.
|
||||
The count is the number of entries. Each key is a length-prefixed UTF-8
|
||||
string. Each value is encoded according to its declared `TypeDef:*` kind
|
||||
— a `Record<Uint32>` value is 4 raw bytes; a `Record<String>` value is
|
||||
itself a length-prefixed string; a `Record<Struct>` value is the struct's
|
||||
fields laid out inline. There is **no separate `value_len` prefix** —
|
||||
the value's size is determined by its kind (fixed-size kinds have a
|
||||
known size; variable-length kinds carry their own length prefix). The
|
||||
count and key-length prefixes respect the schema's endianness. In
|
||||
aligned static mode with `maxLength`, the entire record is reserved at
|
||||
`maxLength` bytes (zero-padded).
|
||||
|
||||
**`TTimestamp`:** An RFC 3339 timestamp string (the internet profile of
|
||||
ISO 8601). Stored as a length-prefixed UTF-8 string (strategy 1) or
|
||||
fixed-size reservation (strategy 2 with `maxLength`). The data-access
|
||||
layer treats timestamps as opaque length-prefixed strings — it does not
|
||||
parse or validate the timestamp format. The jsonschema custom keyword
|
||||
validator checks RFC 3339 conformance at the JSON level (see
|
||||
[validation.md](validation.md)).
|
||||
|
||||
`TArray` is variable-length when the element type is variable-length or
|
||||
when the count is not known at schema time. For fixed-size element arrays
|
||||
with a known count, the size is `element_size × count`.
|
||||
|
||||
**`TArray` count declaration:** The array count is declared via the
|
||||
standard JSON Schema `"minItems"` and `"maxItems"` keywords. When
|
||||
`minItems == maxItems`, the array has a fixed count known at schema time.
|
||||
When they differ or are absent, the count is variable and the array uses
|
||||
a length-prefixed encoding: `[count: u32][element_0]...[element_N]`.
|
||||
The count prefix respects the schema's endianness.
|
||||
|
||||
### Composite types
|
||||
|
||||
`TStruct` and `TUnion` are composite — their size is the sum of their
|
||||
fields' sizes (plus alignment padding in aligned static mode). The offset
|
||||
computation recurses into their properties.
|
||||
|
||||
## Schema-Layer Public API
|
||||
|
||||
The `schema` module exposes the foundational types and functions every
|
||||
other module depends on. These are re-exported from the crate root.
|
||||
|
||||
### `get_typedef_kind` vs `get_typedef_kind_loose`
|
||||
|
||||
The engine recognizes a `TypeDef:*` kind on a schema node two ways,
|
||||
because the keyword value may be either a boolean (`true`) or an
|
||||
annotation object (`{ "encoding": "..." }`):
|
||||
|
||||
| Function | Recognizes | Returns |
|
||||
|----------|------------|---------|
|
||||
| `get_typedef_kind(node) -> Option<&str>` | Boolean form only (`{ "TypeDef:String": true }`) | The keyword string, e.g. `"TypeDef:String"` |
|
||||
| `get_typedef_kind_loose(node) -> Option<&str>` | Boolean form **and** object form | The keyword string |
|
||||
| `get_typedef_kind_enum(node) -> Option<TypeDefKind>` | Boolean form only | The parsed enum variant |
|
||||
| `get_typedef_kind_loose_enum(node) -> Option<TypeDefKind>` | Boolean form **and** object form | The parsed enum variant |
|
||||
|
||||
The boolean-form-only functions are used by the validator factories
|
||||
(which reject the object form as a schema error) and the top-level
|
||||
kind-check in `OffsetMap::compute` / `LayoutBuilder::new` / `SequentialReader::new`
|
||||
(which require `TypeDef:Struct` at the root). The "loose" variants are
|
||||
used by the layout engines during field traversal, so that a variable-
|
||||
length field with an `encoding` annotation (`{ "TypeDef:String":
|
||||
{ "encoding": "offset-indirect" } }`) is still recognized as a `String`.
|
||||
|
||||
### Annotation parsers
|
||||
|
||||
Each schema-level annotation has a dedicated parser that reads it from a
|
||||
`serde_json::Value` node and returns a sensible default when absent:
|
||||
|
||||
| Function | Annotation | Default |
|
||||
|----------|------------|---------|
|
||||
| `parse_endian(node) -> Endian` | `"endian"` | `Endian::Little` |
|
||||
| `parse_align(node) -> Option<usize>` | `"align"` | `None` |
|
||||
| `parse_max_length(node) -> Option<usize>` | `"maxLength"` | `None` |
|
||||
| `parse_encoding(keyword_value) -> VariableEncoding` | `"encoding"` (within the keyword's value object) | `VariableEncoding::LengthPrefixed` |
|
||||
| `parse_discriminator(node) -> Result<DiscriminatorKind, TypedefError>` | `"discriminator"` | (required — returns `TypedefError::Schema` if absent) |
|
||||
|
||||
### Public enums
|
||||
|
||||
```rust
|
||||
pub enum Endian { Little, Big }
|
||||
pub enum VariableEncoding { LengthPrefixed, OffsetIndirect }
|
||||
pub enum DiscriminatorKind {
|
||||
Byte { offset: usize, disc_type: TypeDefKind },
|
||||
Field { name: String },
|
||||
}
|
||||
```
|
||||
|
||||
`DiscriminatorKind::Byte` carries the byte position (`offset`) and the
|
||||
discriminator's `TypeDef:*` kind (`disc_type`, restricted to `Uint8`/
|
||||
`Uint16`/`Uint32`). `DiscriminatorKind::Field` carries the discriminator
|
||||
field's name. See [data-access.md](data-access.md) §"TUnion Dispatch" for
|
||||
how these drive dispatch.
|
||||
|
||||
### `$ref` resolution and normalization
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `normalize_refs(schema: &mut Value)` | Walks the schema; rewrites every `"$ref"` whose value is a bare name (no `#` prefix) to `"#/$defs/<name>"`. Idempotent. Runs once at `TypedefEngine::compile` time. |
|
||||
| `resolve_ref(root, ref_path) -> Option<&Value>` | Resolves a JSON Pointer `$ref` (e.g. `"#/$defs/Read"`) against the root schema. |
|
||||
| `resolve_ref_or_inline(node, root) -> Option<&Value>` | If `node` has a `"$ref"`, resolves it against `root`; otherwise returns `node` itself (it's an inline schema). |
|
||||
|
||||
`normalize_refs` bridges TypeBox's bare-name ref output and `jsonschema`'s
|
||||
JSON Pointer requirement. The layout engines call `resolve_ref_or_inline`
|
||||
on every `$ref`-bearing node they encounter during traversal.
|
||||
|
||||
## jsonschema Custom Keyword Integration
|
||||
|
||||
The `jsonschema` crate (v0.46.5, Draft 2020-12) supports custom keywords
|
||||
via the `with_keyword` API. Each `TypeDef:*` kind is registered as a
|
||||
custom keyword:
|
||||
|
||||
```rust
|
||||
let validator = jsonschema::options()
|
||||
.with_keyword("TypeDef:Float32", factory)
|
||||
.with_keyword("TypeDef:Int32", factory)
|
||||
.with_keyword("TypeDef:Struct", factory)
|
||||
// ... all 17 kinds
|
||||
.build(&schema)?;
|
||||
```
|
||||
|
||||
The factory closure receives the parent schema object, the keyword's
|
||||
value, and the schema path — enabling cross-keyword awareness. The
|
||||
`TypeDef:Struct` validator, for example, inspects the parent's
|
||||
`properties` to validate each field against its declared `TypeDef:*` kind.
|
||||
|
||||
Each custom keyword implementation is ~10 lines. The `jsonschema` crate
|
||||
handles all structural validation (object properties, required fields,
|
||||
array items, enum values) — the custom keywords only need to validate
|
||||
the leaf type constraints. See [validation.md](validation.md) for the
|
||||
validator implementations.
|
||||
|
||||
This is the same pattern as TypeBox's `TypeRegistry.Set` on the JS side.
|
||||
Same semantics, different language, same JSON Schema wire format. A
|
||||
TypeBox schema serialized to JSON feeds into the typedef engine after a
|
||||
single pre-processing step: normalizing `$ref` values (see below).
|
||||
|
||||
## TypeBox Interop
|
||||
|
||||
TypeBox modules render to standard JSON Schema under `$defs`. A TypeBox
|
||||
schema like:
|
||||
|
||||
```typescript
|
||||
const TensorRef = Type.Object({
|
||||
dtype: Type.Union([Type.Literal("F32"), Type.Literal("I16")]),
|
||||
shape: Type.Array(Type.Number()),
|
||||
data_offsets: Type.Tuple([Type.Number(), Type.Number()])
|
||||
});
|
||||
```
|
||||
|
||||
serialized to JSON is a standard JSON Schema with `type: "object"`,
|
||||
`properties`, and `required`. That JSON feeds into the typedef engine
|
||||
after `$ref` normalization. The `TypeDef:*` custom keywords are added by
|
||||
TypeBox's `TypeRegistry.Set` — they appear in the serialized JSON as
|
||||
additional properties on the schema object.
|
||||
|
||||
### `$ref` normalization
|
||||
|
||||
TypeBox generates bare-name `$ref` values (e.g., `"$ref": "Read"`),
|
||||
referencing sibling definitions within the same `$defs` block. The
|
||||
`jsonschema` crate requires full JSON Pointer paths (e.g.,
|
||||
`"$ref": "#/$defs/Read"`). The typedef engine normalizes TypeBox-style
|
||||
refs at schema load time via [`normalize_refs`](#ref-resolution-and-normalization)
|
||||
— a ~20-line recursive walk that rewrites every bare-name `"$ref"` to
|
||||
`"#/$defs/<name>"`. The normalization is idempotent — full JSON Pointer
|
||||
refs pass through unchanged. It runs once at `TypedefEngine::compile`
|
||||
time, before the schema is passed to `jsonschema` or the offset
|
||||
computation.
|
||||
|
||||
**Verification:** The jsonschema crate (v0.46.5) rejects bare-name refs
|
||||
with `Resource 'Read' is not present in a registry`. Full JSON Pointer
|
||||
refs (`#/$defs/Read`) resolve correctly. The normalization step bridges
|
||||
the gap between TypeBox's output and jsonschema's input.
|
||||
|
||||
The typedef engine does not depend on TypeBox or any JS toolchain. It
|
||||
consumes JSON — whether that JSON was authored in TypeBox, generated by
|
||||
a ujsx component, or hand-written. The schema is the interface.
|
||||
|
||||
## Schema Annotations
|
||||
|
||||
Schema-level annotations control binary layout behavior. These are
|
||||
decided in [ADR-097](../../decisions/097-schema-annotations.md).
|
||||
|
||||
### Endianness
|
||||
|
||||
Schema-level annotation with a default of little-endian:
|
||||
|
||||
```json
|
||||
{ "TypeDef:Struct": true, "endian": "big", "properties": { ... } }
|
||||
```
|
||||
|
||||
- `"endian": "little"` (default) — read/write in little-endian byte order.
|
||||
- `"endian": "big"` — read/write in big-endian byte order.
|
||||
- Applies to the entire schema and all nested types.
|
||||
|
||||
### Alignment
|
||||
|
||||
Both struct-level and field-level, with field-level overriding:
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Struct": true,
|
||||
"align": 256,
|
||||
"properties": {
|
||||
"weight": { "TypeDef:Float32": true, "align": 16 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Struct-level `"align"` sets the default for all fields.
|
||||
- Field-level `"align"` overrides the struct default.
|
||||
- Default alignment: 1 for u8/i8/bool, 2 for u16/i16, 4 for u32/i32/f32/
|
||||
enum, 8 for u64/i64/f64, 4 for variable-length (the u32 length prefix),
|
||||
1 for struct/union/array.
|
||||
- Only meaningful in aligned static mode (ADR-096). Ignored in packed
|
||||
sequential mode.
|
||||
|
||||
### Variable-length encoding
|
||||
|
||||
The typedef engine supports three strategies for variable-length types
|
||||
(see §Variable-length types above for full details). The strategy is
|
||||
selected by the `encoding` annotation and the standard JSON Schema
|
||||
`maxLength` keyword:
|
||||
|
||||
```json
|
||||
// Strategy 1: Inline length-prefixing (default, shorthand)
|
||||
{ "TypeDef:String": true }
|
||||
|
||||
// Strategy 1: Explicit inline length-prefixing
|
||||
{ "TypeDef:String": { "encoding": "length-prefixed" } }
|
||||
|
||||
// Strategy 2: Fixed-size reservation (uses standard maxLength)
|
||||
{ "TypeDef:String": true, "maxLength": 256 }
|
||||
|
||||
// Strategy 3: Offset indirection (opt-in)
|
||||
{ "TypeDef:String": { "encoding": "offset-indirect" } }
|
||||
```
|
||||
|
||||
- `"encoding": "length-prefixed"` (default) — 4-byte length prefix at
|
||||
computed offset, variable data follows immediately. Used by protocol
|
||||
wire formats.
|
||||
- `maxLength` (standard JSON Schema keyword) — in aligned static mode,
|
||||
reserves `maxLength` bytes at a fixed offset (zero-padded). Makes the
|
||||
field fixed-size from the layout perspective. In packed sequential
|
||||
mode, `maxLength` is a validation constraint only.
|
||||
- `"encoding": "offset-indirect"` — field is a struct
|
||||
`{offset: u32, length: u32}` pointing into a separate data region.
|
||||
The consumer provides the data region separately. Used by metatensor
|
||||
blob tensors.
|
||||
- Applies to all variable-length types: `TypeDef:String`, `TypeDef:Bytes`,
|
||||
`TypeDef:Array`, `TypeDef:Record`, `TypeDef:Timestamp`.
|
||||
|
||||
### TUnion discriminators
|
||||
|
||||
Two discriminator kinds: byte-offset (protocol dispatch) and field-name
|
||||
(typedef.ts pattern).
|
||||
|
||||
**Byte-offset discriminator** (SFTP type bytes, call protocol event types):
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "byte",
|
||||
"offset": 0,
|
||||
"type": "TypeDef:Uint8"
|
||||
},
|
||||
"mapping": {
|
||||
"5": { "$ref": "#/$defs/Read" },
|
||||
"6": { "$ref": "#/$defs/Write" },
|
||||
"101": { "$ref": "#/$defs/Status" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `"offset"` — byte position of the discriminator.
|
||||
- `"type"` — the `TypeDef:*` kind of the discriminator (typically
|
||||
`TypeDef:Uint8`).
|
||||
- Mapping keys are stringified integers. The variant struct starts at
|
||||
`offset + discriminator_size`.
|
||||
|
||||
**Field-name discriminator** (typedef.ts pattern):
|
||||
|
||||
```json
|
||||
{
|
||||
"TypeDef:Union": true,
|
||||
"discriminator": {
|
||||
"kind": "field",
|
||||
"name": "type"
|
||||
},
|
||||
"mapping": {
|
||||
"read": { "$ref": "#/$defs/Read" },
|
||||
"write": { "$ref": "#/$defs/Write" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `"name"` — the field name holding the discriminator value.
|
||||
- Mapping keys are string values matching the discriminator field's value.
|
||||
- The discriminator field is just another field in the struct.
|
||||
|
||||
Mapping values may be either inline schemas or `$ref` pointers. Both work.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Schema annotations | [ADR-097](../../decisions/097-schema-annotations.md) | Concrete JSON shapes for endianness, alignment, encoding, and TUnion discriminators |
|
||||
| Int64/Uint64 kinds | [ADR-099](../../decisions/099-int64-uint64-first-class-kinds.md) | 64-bit integers as first-class kinds (required by SFTP offsets and metatensor data_offsets) |
|
||||
| Purpose and scope | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Why jsonschema not a custom engine; "schema is the format" principle |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-071** (deferred(scope)): Builder API for schema construction.
|
||||
|
||||
## References
|
||||
|
||||
- `/workspace/@alkdev/typebox/example/typedef/typedef.ts` — the TypeBox
|
||||
schema kinds (619 lines)
|
||||
- `/workspace/jsonschema/` — the jsonschema crate (v0.46.5, Draft 2020-12)
|
||||
- [ADR-097](../../decisions/097-schema-annotations.md) — schema
|
||||
annotation shapes
|
||||
- [validation.md](validation.md) — custom keyword validator implementations
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-22
|
||||
---
|
||||
|
||||
# alknet-typedef — Validation
|
||||
|
||||
The validation layer: custom keyword validators for all 19 `TypeDef:*`
|
||||
kinds, the `TypedefError` enum, load-time vs access-time validation
|
||||
strategy, and the `TypedefEngine` as the compiled form of a schema.
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
Validation is delegated to the `jsonschema` crate (v0.46.5, Draft
|
||||
2020-12). The typedef engine does not implement its own validation —
|
||||
it registers custom keyword validators for each `TypeDef:*` kind and
|
||||
lets `jsonschema` handle the structural validation (object properties,
|
||||
required fields, array items, enum values).
|
||||
|
||||
The strategy is decided in [ADR-098](../../decisions/098-error-handling-validation-strategy.md):
|
||||
|
||||
1. **Load time:** Parse the schema JSON, build the layout engine, build the
|
||||
jsonschema validator. This is the `TypedefEngine::compile(schema)` constructor.
|
||||
2. **Access time:** Use the compiled engine for repeated read/write
|
||||
operations. Validation is opt-in per operation.
|
||||
|
||||
### What validation validates
|
||||
|
||||
The jsonschema validator operates on `serde_json::Value` instances — it
|
||||
validates JSON representations of data, not raw byte buffers. This is
|
||||
the correct separation of concerns:
|
||||
|
||||
- **JSON validation** (jsonschema): validates that a JSON document
|
||||
conforms to the schema. Used for validating hand-written schemas,
|
||||
TypeBox output, JSON payloads, or the JSON representation of a binary
|
||||
struct after deserialization.
|
||||
- **Binary access validation** (data access layer): the read/write
|
||||
functions perform type-level validation at access time — range checks
|
||||
for integers, UTF-8 validity for strings, buffer bounds checking.
|
||||
These return `TypedefError::Access` with field paths.
|
||||
|
||||
The "schema is the format" principle means the same schema describes
|
||||
both the JSON shape and the binary layout. The jsonschema validator
|
||||
checks the JSON shape; the data access layer checks the binary layout.
|
||||
A consumer that wants to validate a binary buffer end-to-end reads the
|
||||
buffer into a `Value` tree via the data access layer, then validates
|
||||
that `Value` against the jsonschema validator. This is a two-step
|
||||
process, not a single `validate(buffer)` call.
|
||||
|
||||
### The `TypedefEngine` struct
|
||||
|
||||
The `TypedefEngine` is the compiled form of a schema. It supports both
|
||||
layout modes (ADR-096) via an internal `Layout` enum:
|
||||
|
||||
```rust
|
||||
pub struct TypedefEngine {
|
||||
layout: Layout, // packed or aligned (private enum)
|
||||
validator: jsonschema::Validator, // compiled once at load time
|
||||
endian: Endian, // parsed from the schema's "endian" annotation
|
||||
schema: Value, // the normalized schema (refs resolved)
|
||||
}
|
||||
|
||||
// Private — the consumer selects via LayoutMode at compile time.
|
||||
enum Layout {
|
||||
Packed { builder: LayoutBuilder },
|
||||
Aligned { offset_map: OffsetMap },
|
||||
}
|
||||
```
|
||||
|
||||
The consumer selects the mode at construction time via `LayoutMode`
|
||||
(see [layout-engine.md](layout-engine.md) §"Mode Selection"). The `Layout`
|
||||
enum is private — the engine exposes mode-appropriate accessors instead:
|
||||
|
||||
```rust
|
||||
impl TypedefEngine {
|
||||
pub fn compile(schema: &mut Value, mode: LayoutMode) -> Result<Self, TypedefError>;
|
||||
pub fn mode(&self) -> LayoutMode;
|
||||
pub fn endian(&self) -> Endian;
|
||||
pub fn offset_map(&self) -> Option<&OffsetMap>; // Some in aligned mode
|
||||
pub fn layout_builder(&self) -> Option<&LayoutBuilder>; // Some in packed mode
|
||||
pub fn sequential_reader(&self) -> Option<SequentialReader>; // owned fresh reader (ADR-101)
|
||||
}
|
||||
```
|
||||
|
||||
`compile` takes `&mut Value` because it normalizes `$ref` values in place
|
||||
(via [`normalize_refs`](schema-layer.md#ref-resolution-and-normalization))
|
||||
before computing the layout and building the validator. The `schema`
|
||||
field retains the normalized schema for `read_field`'s kind lookup and
|
||||
for `sequential_reader()`'s factory construction. The validator is
|
||||
mode-agnostic (it operates on `Value`, not raw bytes).
|
||||
|
||||
The `Layout::Packed` variant stores only the `LayoutBuilder` (write-side).
|
||||
The `SequentialReader` (read-side) is not stored — it has mutable cursor
|
||||
state that the consumer owns, so `sequential_reader()` constructs a fresh
|
||||
reader on each call (ADR-101).
|
||||
|
||||
The `read_field`/`write_field` methods on `TypedefEngine` are the
|
||||
aligned-mode data-access API — see [data-access.md](data-access.md)
|
||||
§"Higher-level read/write".
|
||||
|
||||
## Custom Keyword Validators
|
||||
|
||||
Each `TypeDef:*` kind gets a `Keyword` implementation registered via
|
||||
`jsonschema::options().with_keyword(...)`. The validators check leaf
|
||||
type constraints; `jsonschema` handles all structural validation.
|
||||
|
||||
### Numeric type validators
|
||||
|
||||
**`TypeDef:Float32` / `TypeDef:Float64`:**
|
||||
- Value must be a finite number.
|
||||
- For `Float32`: value must be representable as `f32` (no precision loss
|
||||
beyond `f32`'s mantissa).
|
||||
|
||||
**`TypeDef:Int8` / `TypeDef:Int16` / `TypeDef:Int32`:**
|
||||
- Value must be an integer within the type's range.
|
||||
- Int8: -128..127, Int16: -32768..32767, Int32: -2147483648..2147483647.
|
||||
|
||||
**`TypeDef:Uint8` / `TypeDef:Uint16` / `TypeDef:Uint32`:**
|
||||
- Value must be a non-negative integer within the type's range.
|
||||
- Uint8: 0..255, Uint16: 0..65535, Uint32: 0..4294967295.
|
||||
|
||||
### String and binary validators
|
||||
|
||||
**`TypeDef:String`:**
|
||||
- Value must be a valid UTF-8 string.
|
||||
- If `maxLength` is specified in the schema, the string's byte length
|
||||
must not exceed it.
|
||||
|
||||
**`TypeDef:Bytes`:**
|
||||
- Value must be a string (JSON represents binary data as a string — JSON
|
||||
has no native byte type).
|
||||
- If `maxLength` is specified, the byte length must not exceed it.
|
||||
- **Binary representation:** In the binary layout, `TBytes` is raw bytes
|
||||
with no encoding (not base64, not hex). The JSON representation (for
|
||||
validation) uses a string; the binary representation (for data access)
|
||||
uses `&[u8]` directly.
|
||||
|
||||
**`TypeDef:Enum`:**
|
||||
- The `TypeDef:Enum` custom keyword signals that the type is an enum for
|
||||
*layout* purposes (the engine needs to know it's a fixed-size u32 index,
|
||||
not a variable-length string). The built-in `enum` keyword provides the
|
||||
value list and handles value-membership validation. The custom keyword
|
||||
validator is a no-op beyond the built-in check — it exists solely for
|
||||
the layout engine to recognize the type.
|
||||
|
||||
**`TypeDef:Timestamp`:**
|
||||
- Value must be a valid RFC 3339 timestamp string (the internet profile
|
||||
of ISO 8601, e.g., `"2026-07-20T15:30:00Z"`).
|
||||
|
||||
### Composite type validators
|
||||
|
||||
**`TypeDef:Struct`:**
|
||||
- Value must be an object.
|
||||
- Each property must match its declared `TypeDef:*` kind.
|
||||
- Required fields must be present.
|
||||
- The `jsonschema` crate's built-in `properties` and `required` keywords
|
||||
handle the structural checks — the custom keyword only needs to
|
||||
validate that each field's value matches its `TypeDef:*` kind.
|
||||
|
||||
**`TypeDef:Union`:**
|
||||
- The discriminator value must be one of the mapping keys.
|
||||
- The variant struct must match the declared schema for that discriminator
|
||||
value.
|
||||
|
||||
**`TypeDef:Array`:**
|
||||
- Value must be an array.
|
||||
- Each element must match the array's declared element type.
|
||||
- If `minItems`/`maxItems` is specified, the array length must be within
|
||||
bounds.
|
||||
|
||||
### Other validators
|
||||
|
||||
**`TypeDef:Boolean`:**
|
||||
- Value must be `true` or `false`.
|
||||
|
||||
**`TypeDef:Record`:**
|
||||
- Value must be an object.
|
||||
- All values must match the record's declared value type (specified via
|
||||
the `"values"` property in the schema, e.g.,
|
||||
`"values": { "TypeDef:Float32": true }`).
|
||||
|
||||
### Validator implementation pattern
|
||||
|
||||
Each custom keyword implementation is ~10 lines. Example for
|
||||
`TypeDef:Float32`:
|
||||
|
||||
```rust
|
||||
struct Float32Validator;
|
||||
|
||||
impl Keyword for Float32Validator {
|
||||
fn validate<'i>(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
|
||||
match instance {
|
||||
Value::Number(n) if n.as_f64().map_or(false, |f| f.is_finite()) => Ok(()),
|
||||
_ => Err(ValidationError::custom("expected finite f32-compatible number")),
|
||||
}
|
||||
}
|
||||
fn is_valid(&self, instance: &Value) -> bool {
|
||||
instance.as_f64().map_or(false, |f| f.is_finite())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Registration:
|
||||
|
||||
```rust
|
||||
let validator = jsonschema::options()
|
||||
.with_keyword("TypeDef:Float32", |parent, value, path| {
|
||||
Ok(Box::new(Float32Validator))
|
||||
})
|
||||
.build(&schema)?;
|
||||
```
|
||||
|
||||
The factory closure receives the parent schema object, the keyword's
|
||||
value, and the schema path. This enables cross-keyword awareness — for
|
||||
example, a `TypeDef:Struct` validator can inspect the parent's
|
||||
`properties` to validate each field against its declared `TypeDef:*` kind.
|
||||
|
||||
## TypedefError
|
||||
|
||||
A single `TypedefError` enum covers all error conditions across the
|
||||
engine's three phases (schema parsing, offset computation, read/write)
|
||||
plus validation. Decided in [ADR-098](../../decisions/098-error-handling-validation-strategy.md).
|
||||
|
||||
```rust
|
||||
pub enum TypedefError {
|
||||
/// Schema parsing errors (invalid JSON, missing keywords, unknown TypeDef kinds).
|
||||
Schema(String),
|
||||
/// Offset computation errors (field not found, unsupported type).
|
||||
Offset { field_path: String, reason: String },
|
||||
/// Read/write errors (buffer too short, invalid UTF-8, value out of range).
|
||||
Access { field_path: String, reason: String },
|
||||
/// Validation errors (delegated to jsonschema).
|
||||
Validation(ValidationError<'static>),
|
||||
}
|
||||
```
|
||||
|
||||
- **`Schema`** — for errors during `TypedefEngine::compile()`. Invalid
|
||||
JSON, missing required keywords, unknown `TypeDef:*` kinds.
|
||||
- **`Offset`** — for errors during offset computation. Field not found
|
||||
in the schema, type not supported for offset computation, recursive
|
||||
depth exceeded. Carries the field path.
|
||||
- **`Access`** — for errors during read/write. Buffer too short, invalid
|
||||
UTF-8 in a string field, value out of range for the target type.
|
||||
Carries the field path.
|
||||
- **`Validation`** — wraps `jsonschema`'s `ValidationError`. The
|
||||
`'static` lifetime is correct — the validator owns its schema reference
|
||||
and lives for the lifetime of the `TypedefEngine`.
|
||||
|
||||
### Field-path-carrying errors
|
||||
|
||||
Read/write and offset errors include the field path for debugging:
|
||||
|
||||
```rust
|
||||
Err(TypedefError::Access {
|
||||
field_path: "header.version".to_string(),
|
||||
reason: "buffer too short: need 4 bytes at offset 12, have 2".to_string(),
|
||||
})
|
||||
```
|
||||
|
||||
This makes debugging binary format issues tractable — the error tells
|
||||
you exactly which field failed and why.
|
||||
|
||||
## Validation Timing
|
||||
|
||||
### Load time: `TypedefEngine::compile()`
|
||||
|
||||
The expensive work happens once at schema load time:
|
||||
1. Normalize `$ref` values in the schema (`normalize_refs`).
|
||||
2. Parse the schema's `"endian"` annotation.
|
||||
3. Compute the layout (`LayoutBuilder`/`SequentialReader` for packed, `OffsetMap` for aligned).
|
||||
4. Build the jsonschema validator (`jsonschema::options().with_keyword(...).build(&schema)?`).
|
||||
|
||||
The result is a `TypedefEngine` that can be used for repeated operations.
|
||||
|
||||
### Access time: `engine.validate_json(&Value)` / `engine.is_valid_json(&Value)`
|
||||
|
||||
Validation is opt-in per operation. The consumer calls
|
||||
`engine.validate_json(instance)` when validation is desired, or
|
||||
`engine.is_valid_json(instance)` for a boolean check. The jsonschema
|
||||
validator is already compiled — these are fast checks against the
|
||||
compiled validator.
|
||||
|
||||
```rust
|
||||
pub fn validate_json(&self, instance: &Value) -> Result<(), TypedefError>;
|
||||
pub fn is_valid_json(&self, instance: &Value) -> bool;
|
||||
```
|
||||
|
||||
The argument is a `serde_json::Value` (the JSON representation of the
|
||||
data), not a raw byte buffer — see §"What validation validates" above.
|
||||
To validate a binary buffer end-to-end, the consumer reads it into a
|
||||
`Value` tree via the data access layer, then validates that `Value`.
|
||||
|
||||
High-throughput paths can skip validation. Security-sensitive paths
|
||||
(parsing incoming frames from untrusted peers) can validate every frame.
|
||||
The choice is the consumer's.
|
||||
|
||||
## Relationship to Read/Write
|
||||
|
||||
Validation and data access are independent operations on the same data.
|
||||
The consumer can:
|
||||
|
||||
1. Validate the JSON representation of a buffer to ensure it conforms to
|
||||
the schema.
|
||||
2. Read fields from the binary buffer at computed offsets.
|
||||
3. Both — validate the JSON representation first, then read the binary
|
||||
buffer (defense in depth).
|
||||
|
||||
The engine does not couple validation and access. A consumer that trusts
|
||||
its data source can skip validation and go straight to read/write. A
|
||||
consumer that parses untrusted input can validate the JSON
|
||||
representation first, then access the binary buffer.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Error handling and validation | [ADR-098](../../decisions/098-error-handling-validation-strategy.md) | `TypedefError` enum; load-time build, access-time check; field-path-carrying errors; jsonschema `ValidationError` wrapping |
|
||||
| Purpose and scope | [ADR-095](../../decisions/095-alknet-typedef-purpose-scope-jsonschema-engine.md) | Why jsonschema not a custom engine |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None specific to validation. The three typedef OQs (OQ-069, OQ-070,
|
||||
OQ-071) are about layout, platform support, and schema construction —
|
||||
not validation.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-typedef/findings.md` §"Validation" — the POC's
|
||||
custom keyword validators for all 17 kinds
|
||||
- [ADR-098](../../decisions/098-error-handling-validation-strategy.md) —
|
||||
error handling and validation strategy
|
||||
- [schema-layer.md](schema-layer.md) — the 17 TypeDef kinds that the
|
||||
validators check
|
||||
- [data-access.md](data-access.md) — read/write functions that operate
|
||||
on the same buffers
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-07-18 — Phase 7: the control channel is split
|
||||
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
|
||||
(server→client) halves; see §"Control channel split" below)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -123,12 +125,15 @@ The bidi stream has two phases:
|
||||
| 0 | data-in (stdin) | client→server | raw bytes |
|
||||
| 1 | data-out (stdout) | server→client | raw bytes |
|
||||
| 2 | data-err (stderr) | server→client | raw bytes |
|
||||
| 3 | control | bidirectional | JSON control message |
|
||||
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
|
||||
|
||||
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
|
||||
no extension escape hatch in the byte — a 5th channel is a wire-format
|
||||
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
|
||||
negotiated addition to this format.
|
||||
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
|
||||
amendment, §4a: the original `3 = control (bidirectional)` is split
|
||||
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
|
||||
`> 3`.) There is no extension escape hatch in the byte — a 6th
|
||||
channel is a wire-format change requiring a new ALPN (`alknet/tty/v2`
|
||||
per ADR-006), not a negotiated addition to this format.
|
||||
|
||||
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
|
||||
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
|
||||
@@ -176,6 +181,53 @@ tearing down the session. This is a two-way-door extension point within
|
||||
the one-way-door wire format — adding a control message type is
|
||||
additive; changing the chunk header is not.
|
||||
|
||||
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
|
||||
|
||||
The single `stream_type 3 = control (bidirectional)` in §3 and §4 above
|
||||
is **split into two halves** so the control channel is genuinely
|
||||
bidirectional on the wire:
|
||||
|
||||
| stream_type | channel | direction | payload |
|
||||
|-------------|-------------|----------------|------------------------------------------------------|
|
||||
| 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
|
||||
| 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) |
|
||||
|
||||
The `stream_type > 3` protocol-error bound becomes `stream_type > 4`.
|
||||
The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
|
||||
|
||||
**Why the split.** The original §4 documented stream_type 3 as
|
||||
"bidirectional" and listed the four control messages with their
|
||||
directions. But the adapter had no way to distinguish the two
|
||||
directions on the same stream_type — `Exit` from the client was always
|
||||
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
|
||||
logged "ignoring Exit control from client (server→client only)"). The
|
||||
spec said "bidirectional"; the code was half-duplex. The split makes
|
||||
the bidirectionality literal: each direction has its own stream_type,
|
||||
the adapter enforces the direction (an `Exit` arriving on
|
||||
`STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on
|
||||
`STREAM_CTRL_OUT` is a protocol violation), and a client can route
|
||||
exit vs. control without parsing the JSON `type` tag first.
|
||||
|
||||
**Door type.** One-way, same as the original §3 / §4. The stream_type
|
||||
set is bytes clients and servers parse. A client written against the
|
||||
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
|
||||
`InvalidStreamType (> 3)` and tear down the session — the split is a
|
||||
wire-format change, not an additive extension. The reversal path is
|
||||
the same as the original: a new ALPN (`alknet/tty/v2`), which coexists
|
||||
rather than replaces. The trade is one new stream_type byte now vs.
|
||||
the half-duplex-in-disguise flaw forever.
|
||||
|
||||
**What changes in the spec.** §3's stream_type table gains a 5th row
|
||||
(`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's
|
||||
direction table is unchanged in content (the four messages keep their
|
||||
directions and shapes) but the direction is now encoded in the
|
||||
stream_type, not just in the adapter's behavior. ADR-055's "exit chunk
|
||||
is last" invariant is unchanged — the exit chunk still rides the
|
||||
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
|
||||
the old single `STREAM_CONTROL` (stream_type 3). See
|
||||
`docs/research/alknet-crate-extraction/findings.md` Phase 7 for the
|
||||
full migration notes.
|
||||
|
||||
### 5. Negotiation errors use the JSON framing, not the raw chunk format
|
||||
|
||||
If the server cannot allocate the session (unknown backend, PTY
|
||||
|
||||
@@ -280,7 +280,11 @@ the args are optional for their transport.
|
||||
|
||||
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
|
||||
- ADR-007: BiStream type definition (amended by ADR-065; this ADR does not
|
||||
touch `BiStream`)
|
||||
touch `BiStream`; amended by ADR-092 — `BiStream` is the concrete handler
|
||||
leaf, not a bare trait)
|
||||
- ADR-092: `BiStream` as the handler leaf (amends this ADR's `accept_bi`
|
||||
return type — `(SendStream, RecvStream)` → `BiStream`; the trait shape
|
||||
and the `from_source` extension point are preserved)
|
||||
- ADR-009: One-way door decision framework (why `ProtocolHandler` is not
|
||||
changed — this ADR is additive to `Connection`, not a trait revision)
|
||||
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
# ADR-071: alknet-channels Wire Format — 9-Byte Chunk Header
|
||||
# ADR-071: alknet-channels Wire Format — 8-Byte Chunk Header
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (revised 2026-07-12: substrate simplification + stream_type
|
||||
decomposition)
|
||||
decomposition; **amended 2026-07-18 by ADR-093: wire format is 8 bytes,
|
||||
not 9; `stream_type` removed from the channels header — see "Amendment
|
||||
(ADR-093, 2026-07-18)" below**)
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
The 9-byte chunk header is **amended to 8 bytes**:
|
||||
`[channel_id:u32 BE][length:u32 BE][payload]`. The `stream_type` byte is
|
||||
**removed** from the channels header — the channels layer has no
|
||||
`stream_type` concept, not in its header, not in its code, not in its
|
||||
mental model. The handler owns its sub-stream multiplexing on the
|
||||
`BiStream` the channels layer gives it (per ADR-093, the channels-layer
|
||||
consequence of ADR-092's `BiStream` handler leaf). What was the channels
|
||||
header's `stream_type` byte is now the first byte of the payload, owned
|
||||
by the handler's framing (TTY's 5-byte format, call's length-prefixed
|
||||
JSON, tunnel's raw bytes, SSH's channel protocol).
|
||||
|
||||
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
|
||||
groups) is **removed from the channels layer**. The stream_type concept
|
||||
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
|
||||
channels layer carries transparently in its payload. The total header
|
||||
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
|
||||
9; the two length fields are close but not identical
|
||||
(`ch_len = tty_len + 5`). This is the documented cost of clean
|
||||
separation of concerns — see ADR-093 §"Consequences" for the full
|
||||
cost/benefit.
|
||||
|
||||
The body below describes the **current** (9-byte) shape; the amendment
|
||||
above is the operative decision. The 9-byte description is kept as the
|
||||
historical context for the amendment. See ADR-093 for the resolution
|
||||
rationale and the cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -239,17 +269,29 @@ tokio-dependent shell.
|
||||
## Door type
|
||||
|
||||
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
|
||||
length:u32`) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
|
||||
length:u32`, 9 bytes) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
|
||||
control, `% 3` formula) are wire-format commitments. Changing them after
|
||||
deployments exist requires a version migration.
|
||||
|
||||
**Amended by ADR-093 (2026-07-18):** the header layout is now
|
||||
`channel_id:u32 + length:u32` (8 bytes); the `stream_type` byte and its
|
||||
decomposition are removed from the channels layer. The one-way door is
|
||||
re-cast (the channels crate is not yet implemented, so this is the right
|
||||
time to cast it). See ADR-093 for the amended door-type discussion.
|
||||
|
||||
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
|
||||
within the one-way format.
|
||||
|
||||
## References
|
||||
|
||||
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
|
||||
wire format is 8 bytes, not 9; `stream_type` removed from the channels
|
||||
header; the stream_type decomposition is removed from the channels
|
||||
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||||
amended by ADR-077 — scoped to direct TTY)
|
||||
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-093 —
|
||||
TTY always uses its 5-byte format, carried transparently in the
|
||||
channels payload**)
|
||||
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection)
|
||||
- ADR-070: `BidiStreamSource` trait (the extension point the channels
|
||||
connection implements; its docstring already anticipated per-channel
|
||||
|
||||
@@ -2,7 +2,27 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-07-18 by ADR-093 — channel 0's `stream_types`
|
||||
field is removed; the channels layer has no `stream_type` concept; the
|
||||
call protocol's `EventEnvelope` framing is the channels payload, carried
|
||||
transparently — see "Amendment (ADR-093, 2026-07-18)" below)
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
Channel 0's `stream_types` field (the `[0, 1]` active set) is **removed**.
|
||||
The channels layer has no `stream_type` concept (ADR-093) — it carries the
|
||||
call protocol's `EventEnvelope` framing (ADR-064) transparently in the
|
||||
8-byte header's payload. The call protocol's bidirectionality (client
|
||||
writes requests, server writes responses) is a call-protocol concern,
|
||||
not a channels-layer concern; the channels layer routes by `channel_id`
|
||||
only and yields a `BiStream` to the `CallAdapter`. The `CallAdapter`'s
|
||||
`accept_bi()` returns one `BiStream` (per ADR-092); the call protocol
|
||||
reads/writes `EventEnvelope` frames on it, exactly as on a top-level
|
||||
`alknet/call` connection.
|
||||
|
||||
The body below describes the **original** (with `stream_types`) shape;
|
||||
the amendment above is the operative decision. See ADR-093 for the
|
||||
resolution rationale and the cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -121,7 +141,11 @@ unused; assigning them is additive).
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format (the 9-byte chunk header channel 0 uses)
|
||||
- ADR-071: channels wire format (the 8-byte chunk header channel 0 uses,
|
||||
as amended by ADR-093)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR —
|
||||
channel 0's `stream_types` field removed; the call protocol's framing
|
||||
is the channels payload, carried transparently)
|
||||
- ADR-073: channel lifecycle operations (registered on channel 0's
|
||||
`OperationRegistry`)
|
||||
- ADR-064: irpc never integrated — hand-rolled EventEnvelope framing (the
|
||||
|
||||
@@ -2,7 +2,26 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-07-18 by ADR-093 — `stream_types` field removed
|
||||
from `channel/open`; `stream_type` field removed from `channel/control`;
|
||||
`channel:stream_type_unavailable` error code removed; the channels layer
|
||||
has no `stream_type` concept — see "Amendment (ADR-093, 2026-07-18)"
|
||||
below)
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
The `stream_types` field is **removed** from `channel/open`'s input and
|
||||
output. The `stream_type` field is **removed** from `channel/control`'s
|
||||
input. The `channel:stream_type_unavailable` error code is **removed**.
|
||||
The channels layer has no `stream_type` concept (ADR-093) — the handler
|
||||
owns its sub-stream multiplexing on the `BiStream` it receives. The
|
||||
handler's sub-stream set is implicit in its ALPN's wire format (e.g.,
|
||||
TTY's 5-byte format declares its own `stream_type` set internally; the
|
||||
channels layer carries the bytes transparently).
|
||||
|
||||
The body below describes the **original** (with `stream_types`) shape;
|
||||
the amendment above is the operative decision. See ADR-093 for the
|
||||
resolution rationale and the cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -292,7 +311,11 @@ the underlying one-way commitment.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format
|
||||
- ADR-071: channels wire format (amended by ADR-093 — 8-byte header, no
|
||||
`stream_type`)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR —
|
||||
`stream_types` field removed from `channel/open`; `stream_type` field
|
||||
removed from `channel/control`; handler owns sub-stream multiplexing)
|
||||
- ADR-072: channel 0 is pre-negotiated `alknet/call`
|
||||
- ADR-049: StreamingHandler for subscriptions (the machinery
|
||||
`channel/resources/subscribe` uses — implemented and tested)
|
||||
|
||||
@@ -2,7 +2,29 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (**amended 2026-07-18 by ADR-093: `into_sub_streams()` removed;
|
||||
`accept_bi` is the only accessor, yields one `BiStream` per channel —
|
||||
see "Amendment (ADR-093, 2026-07-18)" below**)
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
`into_sub_streams()`, `ChannelSubStreams`, and `SubStreamHandle` are
|
||||
**removed**. The channels layer exposes one accessor: `accept_bi()`,
|
||||
which yields one `BiStream` per channel (per ADR-092, already landed).
|
||||
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
||||
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
||||
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
|
||||
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
||||
handlers) collapses to one accessor.
|
||||
|
||||
The body below describes the **original** (two-accessor) shape; the
|
||||
amendment above is the operative decision. The two-accessor description
|
||||
is kept as the historical context for the amendment. See ADR-093 for
|
||||
the resolution rationale (the channels layer has no `stream_type`
|
||||
concept; the handler owns its sub-stream multiplexing) and the
|
||||
cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -194,6 +216,11 @@ rewrite of those handlers' integration code. The trait impl is in the
|
||||
channels crate (not core), so the one-way door is the channels crate's API,
|
||||
not a core type.
|
||||
|
||||
**Amended by ADR-093 (2026-07-18):** `into_sub_streams()` is removed;
|
||||
`accept_bi` is the only accessor. The one-way door is re-cast (the
|
||||
channels crate is not yet implemented, so this is the right time). See
|
||||
ADR-093 for the amended door-type discussion.
|
||||
|
||||
The choice of `into_sub_streams()` returning `Vec<(u8, SendStream,
|
||||
RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation
|
||||
detail — the return type can change without breaking the contract as long
|
||||
@@ -201,6 +228,12 @@ as the handler crate's destructure code updates.
|
||||
|
||||
## References
|
||||
|
||||
- **ADR-093**: channels pure channel multiplexing (amends this ADR —
|
||||
`into_sub_streams()` removed; `accept_bi` is the only accessor, yields
|
||||
one `BiStream` per channel; the handler owns its sub-stream
|
||||
multiplexing)
|
||||
- ADR-092: `BiStream` as the handler leaf (the transport-leaf decision
|
||||
this ADR's amendment builds on — `accept_bi` returns `BiStream`)
|
||||
- ADR-070: BidiStreamSource trait (the extension point this implements)
|
||||
- ADR-065: Connection::from_stream (the yield-once path this generalizes for
|
||||
channels)
|
||||
|
||||
@@ -2,7 +2,26 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-07-18 by ADR-093 — demux reads 8-byte headers, not
|
||||
9-byte; one reassembly buffer per `channel_id` (not per
|
||||
`(channel_id, stream_type)`); `ChannelState.stream_types` removed; the
|
||||
channels layer has no `stream_type` concept — see "Amendment (ADR-093,
|
||||
2026-07-18)" below)
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
The demux loop reads **8-byte headers** (not 9-byte). `ChannelState` has
|
||||
**one reassembly buffer per `channel_id`** (not per
|
||||
`(channel_id, stream_type)`), yielding a `BiStream` to the handler. The
|
||||
`stream_types: Vec<u8>` field on `ChannelState` is **removed**. The
|
||||
`ChannelManager` has no `stream_type` concept — it routes by `channel_id`
|
||||
only, and the handler owns its sub-stream multiplexing on the `BiStream`
|
||||
it receives (per ADR-093, the channels-layer consequence of ADR-092's
|
||||
`BiStream` handler leaf).
|
||||
|
||||
The body below describes the **original** (9-byte, per-stream_type) shape;
|
||||
the amendment above is the operative decision. See ADR-093 for the
|
||||
resolution rationale and the cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -224,11 +243,14 @@ contract.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format (the chunks the demux reads)
|
||||
- ADR-071: channels wire format (the chunks the demux reads, as amended
|
||||
by ADR-093 — 8-byte header)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR — 8-byte
|
||||
header, one reassembly buffer per channel, no `stream_type` concept)
|
||||
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- ADR-074: ChannelBidiStreamSource (the per-channel source the manager
|
||||
constructs)
|
||||
constructs, as amended by ADR-093 — `accept_bi` yields a `BiStream`)
|
||||
- ADR-076: backpressure, channel limits, ID reuse (the `buffer_cap` /
|
||||
`max_channels` / reuse invariants)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#6
|
||||
|
||||
@@ -2,7 +2,73 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
|
||||
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
|
||||
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
|
||||
2026-07-18)" below; **amended 2026-07-19 by ADR-094 — the per-connection
|
||||
`max_channels = 256` is reframed as a per-connection memory bound, not a
|
||||
DoS defense; the per-identity DoS defense lives in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — see "Amendment (ADR-094, 2026-07-19)" below**)
|
||||
|
||||
## Amendment (ADR-094, 2026-07-19)
|
||||
|
||||
The per-connection `max_channels = 256` cap is **reframed as a
|
||||
per-connection memory bound**, not a DoS defense. A single peer can
|
||||
open an unbounded number of transport connections, so a per-connection
|
||||
cap is not a per-peer DoS defense — it is a bound on one connection's
|
||||
reassembly-buffer cost. The per-identity DoS defense (256 per
|
||||
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
|
||||
is documented in [ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
What changes in this ADR:
|
||||
|
||||
1. **§"Maximum channels per connection: 256 default"** — the cap stays
|
||||
at 256, but its role is reframed. It is a per-connection memory
|
||||
bound (limits one connection's reassembly-buffer cost regardless of
|
||||
policy), not the DoS defense against an authenticated peer. The
|
||||
per-identity DoS defense is the `ChannelLifecyclePolicy`
|
||||
consultation in the `channel/open` handler (ADR-094).
|
||||
2. **§"DoS defense summary"** — the table is **removed**. It framed
|
||||
the per-connection cap as the DoS defense, which it is not. ADR-094
|
||||
§2 contains the corrected per-identity DoS defense summary.
|
||||
3. **The "per-connection, not per-peer — a peer can open more channels
|
||||
on a second connection" line** — this was the channels layer
|
||||
confessing a hole and hoping the layer above it would fill it. The
|
||||
line is **corrected** to state that the per-connection cap is a
|
||||
memory bound, and that the per-identity cap is the DoS defense
|
||||
(ADR-094). A peer that opens a second connection gets a second
|
||||
per-connection memory bound; it does **not** get a second
|
||||
per-identity quota — the `ChannelLifecyclePolicy` is shared across
|
||||
connections.
|
||||
|
||||
What stays:
|
||||
|
||||
- The 256 default and the `max_channels` field on `ChannelManager`
|
||||
(still returns `channel:too_many_channels` when hit — the
|
||||
per-identity policy returns the same error code, so an over-cap
|
||||
peer sees the same error either way).
|
||||
- The bounded-buffer backpressure decision (DP-5) — unchanged.
|
||||
- The channel-ID reuse decision (monotonic `next_id` with
|
||||
wrap-around) — unchanged.
|
||||
- The drain-before-reuse invariant — unchanged, and the
|
||||
`channel/close` handler now also calls
|
||||
`ChannelLifecyclePolicy::on_close` at this point (ADR-094 §3).
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
The bounded-buffer backpressure is per-`channel_id` (not per
|
||||
`(channel_id, stream_type)`). The channels layer has one reassembly
|
||||
buffer per channel (yielding a `BiStream`), not one per
|
||||
`(channel_id, stream_type)`. The 1 MiB default and the 256-channel cap are
|
||||
unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a
|
||||
TTY channel with 5 active stream_types under the per-stream_type model).
|
||||
This is a net improvement (lower memory ceiling per channel), not a
|
||||
regression. The bounded-buffer *approach* is unchanged; only the
|
||||
buffer granularity changes (per-channel, not per-stream_type).
|
||||
|
||||
The body below describes the **original** (per-stream_type) shape; the
|
||||
amendment above is the operative decision. See ADR-093 for the resolution
|
||||
rationale.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -69,36 +135,47 @@ default `max_channels` of 256, the `u32` space is effectively unlimited
|
||||
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
|
||||
satisfied by the wrap-around, not by a free-list.**
|
||||
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06)
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
|
||||
|
||||
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
|
||||
The practical limit is memory (reassembly buffers per channel) and the
|
||||
transport's flow control.
|
||||
|
||||
**Default per-connection channel limit: 256** (`max_channels` field on
|
||||
`ChannelManager`, configurable). This is the DoS defense (OQ-CH-06): an
|
||||
authenticated peer that opens many channels and never reads from them is
|
||||
bounded by `max_channels × buffer_cap` = 256 × 1 MiB = 256 MiB worst case.
|
||||
Bounded buffers (DP-5) limit the damage per channel; the connection cap
|
||||
limits the number of channels. Defense in depth.
|
||||
`ChannelManager`, configurable). This is a **per-connection memory
|
||||
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
|
||||
= 256 MiB worst case per connection) regardless of policy. It composes
|
||||
with the per-identity DoS defense (ADR-094) but is not itself a DoS
|
||||
defense — a peer can open an unbounded number of transport connections,
|
||||
so a per-connection cap cannot bound a peer's total channels. The
|
||||
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
|
||||
via `ChannelLifecyclePolicy`) is documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
Exceeding the limit returns `channel:too_many_channels` (ADR-073 error
|
||||
codes). The limit is per-connection, not per-peer — a peer can open more
|
||||
channels on a second connection.
|
||||
Exceeding the per-connection limit returns `channel:too_many_channels`
|
||||
(ADR-073 error codes) — the same error code the per-identity policy
|
||||
returns when the per-identity cap is hit. An over-cap peer sees the
|
||||
same error either way; which cap fired first is an implementation
|
||||
detail. The limit is per-connection as a memory bound; the per-identity
|
||||
cap (ADR-094) is what bounds a peer's total channels across all its
|
||||
connections.
|
||||
|
||||
### DoS defense summary (OQ-CH-06)
|
||||
|
||||
| Layer | Mechanism | Default |
|
||||
|-------|-----------|---------|
|
||||
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per `(channel_id, stream_type)` |
|
||||
| Per-connection | Channel count cap | 256 channels |
|
||||
| Per-peer | Auth (`AccessControl::check` on `channel/open`) | Assembly-layer policy |
|
||||
The DoS defense against an authenticated peer opening many channels is
|
||||
the **per-identity cap** enforced in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md). A per-connection cap
|
||||
cannot be the DoS defense because a peer can open an unbounded number
|
||||
of transport connections; the unit that must be bounded is the
|
||||
identity, not the connection.
|
||||
|
||||
An authenticated peer that opens 256 channels and never reads from them
|
||||
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
|
||||
The assembly layer's `AccessControl` policy can further restrict
|
||||
`channel/open` (e.g., `required_scopes: ["channel:open:alknet/tty"]`) to
|
||||
limit who can open channels at all.
|
||||
The per-connection `max_channels = 256` (this ADR) is a **memory
|
||||
bound** that limits one connection's reassembly-buffer cost. It
|
||||
composes with the per-identity cap as defense-in-depth (the
|
||||
`NoCap` policy path still has the per-connection memory bound), but
|
||||
it is not the security boundary. See ADR-094 §2 for the corrected
|
||||
DoS defense summary.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -106,18 +183,21 @@ limit who can open channels at all.
|
||||
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
|
||||
deadlock, no cross-channel blocking). The decision is made, not hedged.
|
||||
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
|
||||
worst-case memory per connection — a clear DoS ceiling, not an open-ended
|
||||
one.
|
||||
worst-case memory per connection — a clear per-connection memory
|
||||
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
|
||||
`PeerId` across all the peer's connections) is documented in ADR-094.
|
||||
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
|
||||
complexity while still satisfying ID reuse (on wrap, after ~16.7M
|
||||
channels).
|
||||
|
||||
**Negative:**
|
||||
- The 256-channel default may be too low for a hub with many concurrent
|
||||
browser sessions each opening multiple channels. The cap is configurable
|
||||
per `ChannelManager`; the hub assembly layer may set it higher for
|
||||
deployments with many concurrent sessions. This is a deployment-time
|
||||
decision, not an architecture decision.
|
||||
- The 256-channel per-connection cap may be too low for a hub with many
|
||||
concurrent browser sessions each opening multiple channels. The cap
|
||||
is configurable per `ChannelManager`; the hub deployment may set it
|
||||
higher for deployments with many concurrent sessions. This is a
|
||||
deployment-time decision, not an architecture decision. (The
|
||||
per-identity cap in ADR-094 is the DoS-relevant bound; the
|
||||
per-connection cap is a memory backstop.)
|
||||
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
||||
bounds the memory cost. A slow consumer still stalls its own channel's
|
||||
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
||||
@@ -134,9 +214,23 @@ doesn't change the wire format, so even that reversal is feasible.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format (the chunks the buffers hold)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
|
||||
- ADR-071: channels wire format (the chunks the buffers hold, as amended
|
||||
by ADR-093)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR —
|
||||
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
|
||||
- ADR-094: per-identity channel cap as DoS defense (amends this ADR —
|
||||
the per-connection `max_channels = 256` is reframed as a per-connection
|
||||
memory bound, not a DoS defense; the per-identity DoS defense lives in
|
||||
`channels-call` via `ChannelLifecyclePolicy`)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels`
|
||||
error; the `channel/open` and `channel/close` handlers that gain the
|
||||
`ChannelLifecyclePolicy` consultation)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
|
||||
fields; the auth-blindness that forces the per-identity cap into
|
||||
`channels-call`, not `channels-core`)
|
||||
- ADR-032: forwarded-for identity (why the spoke caps the hub, not the
|
||||
browser — `forwarded_for` is metadata, not authority, for the cap as
|
||||
for `AccessControl::check`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
|
||||
validation), §POC Target 3 (1 MiB tunnel test)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user