diff --git a/Makefile b/Makefile index 7f514a8f7..ff514a278 100644 --- a/Makefile +++ b/Makefile @@ -181,7 +181,7 @@ check-rust-ios: @$(REQUIRE_CARGO) @$(REQUIRE_RUSTUP) @rustup target list --installed 2>/dev/null | grep -qx "$(IOS_TARGET)" || { echo "Rust target $(IOS_TARGET) is required for the iOS gate; run rustup target add $(IOS_TARGET)" >&2; exit 1; } - cargo check --manifest-path $(RUST_MANIFEST) --workspace --exclude solstone-core --exclude solstone-core-indexer-store --exclude solstone-core-speakers-analyze --exclude solstone-core-speakers-onnx --lib --target $(IOS_TARGET) --locked + cargo check --manifest-path $(RUST_MANIFEST) --workspace --exclude solstone-core --exclude solstone-core-indexer-store --exclude solstone-core-sol-link --exclude solstone-core-speakers-analyze --exclude solstone-core-speakers-onnx --lib --target $(IOS_TARGET) --locked check-rust-deny: @$(REQUIRE_CARGO) diff --git a/core/Cargo.lock b/core/Cargo.lock index 587bfc338..f77ac8de3 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -11,6 +11,45 @@ dependencies = [ "libc", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -29,6 +68,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -84,6 +132,73 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -120,6 +235,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + [[package]] name = "futures-task" version = "0.3.33" @@ -133,11 +254,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", + "futures-sink", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "glob" version = "0.3.4" @@ -171,6 +326,24 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -238,6 +411,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -283,6 +462,23 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "ndarray" version = "0.17.2" @@ -298,6 +494,26 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -307,6 +523,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -325,6 +547,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -349,6 +580,16 @@ version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -400,6 +641,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -429,6 +685,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rapidfuzz" version = "0.5.0" @@ -441,6 +732,34 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -448,7 +767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -466,6 +785,50 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -479,6 +842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -515,6 +879,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -539,6 +925,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "solstone-core" version = "1.0.17" @@ -604,6 +1000,7 @@ dependencies = [ "chrono", "serde_json", "solstone-core-journal", + "spl-core", "ureq", ] @@ -615,6 +1012,19 @@ dependencies = [ "solstone-core-sol-client", ] +[[package]] +name = "solstone-core-sol-link" +version = "1.0.17" +dependencies = [ + "rcgen", + "rustls", + "serde_json", + "solstone-core-sol-client", + "spl-core", + "spl-transport", + "tokio", +] + [[package]] name = "solstone-core-speakers" version = "1.0.17" @@ -640,6 +1050,40 @@ dependencies = [ "solstone-core-speakers", ] +[[package]] +name = "spl-core" +version = "0.1.0" +source = "git+https://github.com/solpbc/spl-rust?tag=v0.1.0#6ccda09777fd0da0489d77b3098892492c90c819" +dependencies = [ + "base64", + "hkdf", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.19", +] + +[[package]] +name = "spl-transport" +version = "0.1.0" +source = "git+https://github.com/solpbc/spl-rust?tag=v0.1.0#6ccda09777fd0da0489d77b3098892492c90c819" +dependencies = [ + "base64", + "futures-util", + "rcgen", + "rustls", + "rustls-webpki", + "serde", + "serde_json", + "spl-core", + "thiserror 2.0.19", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tracing", + "webpki-roots", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -652,6 +1096,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -674,13 +1124,44 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -694,6 +1175,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -709,6 +1220,57 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -733,9 +1295,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -745,6 +1319,31 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -772,6 +1371,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.3.0" @@ -797,6 +1402,12 @@ dependencies = [ "log", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -809,6 +1420,27 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -854,6 +1486,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -913,6 +1554,88 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -922,6 +1645,65 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/core/Cargo.toml b/core/Cargo.toml index a8ac3db03..0b1296e9a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/solstone-core-sol", "crates/solstone-core-sol-client", "crates/solstone-core-sol-client-cli", + "crates/solstone-core-sol-link", "crates/solstone-core-speakers", "crates/solstone-core-speakers-analyze", "crates/solstone-core-speakers-onnx", @@ -31,6 +32,7 @@ solstone-core-journal = { path = "crates/solstone-core-journal" } solstone-core-sol = { path = "crates/solstone-core-sol" } solstone-core-sol-client = { path = "crates/solstone-core-sol-client" } solstone-core-sol-client-cli = { path = "crates/solstone-core-sol-client-cli" } +solstone-core-sol-link = { path = "crates/solstone-core-sol-link" } solstone-core-speakers = { path = "crates/solstone-core-speakers" } solstone-core-speakers-analyze = { path = "crates/solstone-core-speakers-analyze" } solstone-core-speakers-onnx = { path = "crates/solstone-core-speakers-onnx" } @@ -41,8 +43,13 @@ md5 = "0.8.1" ort = { version = "=2.0.0-rc.12", default-features = false } pulldown-cmark = { version = "0.13.4", default-features = false } rapidfuzz = "0.5.0" +rcgen = { version = "0.13", default-features = false, features = ["ring", "pem", "x509-parser"] } rusqlite = { version = "0.40.1", features = ["bundled"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } serde_json = { version = "1.0.150", features = ["preserve_order", "float_roundtrip"] } +spl-core = { git = "https://github.com/solpbc/spl-rust", tag = "v0.1.0" } +spl-transport = { git = "https://github.com/solpbc/spl-rust", tag = "v0.1.0" } +tokio = { version = "1", default-features = false } toml_edit = { version = "0.22.27", default-features = false, features = ["parse"] } unicode-normalization = "0.1.25" unidecode = "0.3.0" diff --git a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs index 586bb49cb..e8b17a761 100644 --- a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs +++ b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs @@ -44,6 +44,7 @@ fn main() -> Result<(), String> { let lookup_args = match surface { "sol-chat" => vec!["chat".to_string()], "sol-import" => vec!["import".to_string()], + "sol-link" => vec!["link".to_string(), "join".to_string()], "sol-notify" => vec!["notify".to_string()], _ => argv, }; diff --git a/core/crates/solstone-core-sol-client-cli/src/lib.rs b/core/crates/solstone-core-sol-client-cli/src/lib.rs index ce1b00353..bb6422397 100644 --- a/core/crates/solstone-core-sol-client-cli/src/lib.rs +++ b/core/crates/solstone-core-sol-client-cli/src/lib.rs @@ -3,12 +3,13 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; +use std::path::Path; use solstone_core_sol_client::aggregate; use solstone_core_sol_client::command::{CommandContext, CommandOutput}; use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, NotificationSink, + HttpTransport, LinkJoinPairingSeam, NotificationSink, }; pub mod help; @@ -33,6 +34,14 @@ pub struct DispatchSeams<'a> { pub notification_sink: Option<&'a dyn NotificationSink>, } +pub struct LinkDispatchSeams<'a> { + pub transport: &'a dyn HttpTransport, + pub clock: Option<&'a dyn Clock>, + pub files: Option<&'a dyn FileProvider>, + pub link_pairing: Option<&'a dyn LinkJoinPairingSeam>, + pub journal_root: Option<&'a Path>, +} + #[must_use] pub fn evaluate_args(args: &[OsString]) -> Outcome { match args { @@ -97,6 +106,8 @@ pub fn dispatch_sol_chat_with_seams( build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, notification_sink: None, + link_pairing: None, + journal_root: None, }) } @@ -124,6 +135,8 @@ pub fn dispatch_sol_import_with_seams( build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, notification_sink: None, + link_pairing: None, + journal_root: None, }) } @@ -151,6 +164,43 @@ pub fn dispatch_sol_notify_with_seams( build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, notification_sink: seams.notification_sink, + link_pairing: None, + journal_root: None, + }) +} + +#[must_use] +pub fn dispatch_sol_link_with_seams( + args: &[String], + env: &BTreeMap, + stdin: &str, + today: &str, + seams: LinkDispatchSeams<'_>, +) -> CommandOutput { + let Some((_, handler)) = + match_generated_surface_path("sol-link", &[String::from("link"), String::from("join")]) + else { + return CommandOutput::failure("Unsupported native sol command.\n", 64); + }; + let remaining = match args { + [command, subcommand, rest @ ..] if command == "link" && subcommand == "join" => rest, + [subcommand, rest @ ..] if subcommand == "join" => rest, + rest => rest, + }; + handler(CommandContext { + args: remaining, + env, + stdin, + today, + transport: seams.transport, + clock: seams.clock, + chat_events: None, + files: seams.files, + build_identity: None, + client_item_ids: None, + notification_sink: None, + link_pairing: seams.link_pairing, + journal_root: seams.journal_root, }) } @@ -222,6 +272,8 @@ pub fn dispatch_sol_call_with_seams( build_identity: seams.build_identity, client_item_ids: seams.client_item_ids, notification_sink: None, + link_pairing: None, + journal_root: None, }) } diff --git a/core/crates/solstone-core-sol-client-cli/tests/parity.rs b/core/crates/solstone-core-sol-client-cli/tests/parity.rs index 917265f7b..4c9ada840 100644 --- a/core/crates/solstone-core-sol-client-cli/tests/parity.rs +++ b/core/crates/solstone-core-sol-client-cli/tests/parity.rs @@ -9,7 +9,7 @@ use solstone_core_sol_client::error::ClientError; use solstone_core_sol_client::seam::{ ChatInput, ExpectedHttpCall, FakeBuildIdentityProvider, FakeClientItemIdProvider, FakeClock, FixtureFileProvider, RecordedHttpCall, RecordingNotificationSink, ScriptedChatEventSource, - ScriptedHttpTransport, + ScriptedHttpTransport, ScriptedLinkJoinPairingSeam, }; use solstone_core_sol_client::sse::iter_sse_events; use solstone_core_sol_client::transport::{ @@ -17,8 +17,8 @@ use solstone_core_sol_client::transport::{ TimeoutPolicy, UploadRequest, }; use solstone_core_sol_client_cli::{ - DispatchSeams, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, dispatch_sol_notify_with_seams, + DispatchSeams, LinkDispatchSeams, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, + dispatch_sol_import_with_seams, dispatch_sol_link_with_seams, dispatch_sol_notify_with_seams, }; const ACTIVITIES_VECTORS: &str = @@ -38,6 +38,7 @@ const HEALTH_COVERAGE_VECTORS: &str = const IMPORT_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/import.jsonl"); const LEDGER_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/ledger.jsonl"); const LINK_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/link.jsonl"); +const LINK_JOIN_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/link_join.jsonl"); const MOVED_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/moved.jsonl"); const NOTIFY_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/notify.jsonl"); const PROFILE_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/profile.jsonl"); @@ -68,6 +69,7 @@ fn native_matches_sol_call_parity_vectors() { .chain(load_vectors(IMPORT_VECTORS)) .chain(load_vectors(LEDGER_VECTORS)) .chain(load_vectors(LINK_VECTORS)) + .chain(load_vectors(LINK_JOIN_VECTORS)) .chain(load_vectors(MOVED_VECTORS)) .chain(load_vectors(NOTIFY_VECTORS)) .chain(load_vectors(PROFILE_VECTORS)) @@ -117,6 +119,7 @@ fn run_vector(vector: &Value) { } else { RecordingNotificationSink::new() }; + let link_pairing = ScriptedLinkJoinPairingSeam::new(vec![]); let output = if vector["surface"].as_str() == Some("sol-chat") { dispatch_sol_chat_with_seams( @@ -167,6 +170,20 @@ fn run_vector(vector: &Value) { notification_sink: Some(¬ification_sink), }, ) + } else if vector["surface"].as_str() == Some("sol-link") { + dispatch_sol_link_with_seams( + &argv, + &env, + stdin, + today, + LinkDispatchSeams { + transport: &transport, + clock: Some(&clock), + files: Some(&files), + link_pairing: Some(&link_pairing), + journal_root: Some(std::path::Path::new("/native-sol-parity-journal")), + }, + ) } else { dispatch_sol_call_with_seams( &argv, @@ -185,6 +202,7 @@ fn run_vector(vector: &Value) { ) }; transport.assert_done(); + link_pairing.assert_done(); let mut actual = json!({ "stdout": output.stdout, "stderr": output.stderr, diff --git a/core/crates/solstone-core-sol-client/Cargo.toml b/core/crates/solstone-core-sol-client/Cargo.toml index b4bba278e..6feaf9c87 100644 --- a/core/crates/solstone-core-sol-client/Cargo.toml +++ b/core/crates/solstone-core-sol-client/Cargo.toml @@ -10,6 +10,7 @@ publish = false chrono = { workspace = true } serde_json = { workspace = true } solstone-core-journal = { workspace = true } +spl-core = { workspace = true } ureq = { workspace = true } [lints] diff --git a/core/crates/solstone-core-sol-client/src/command.rs b/core/crates/solstone-core-sol-client/src/command.rs index 9a390cda7..b74a1cf2b 100644 --- a/core/crates/solstone-core-sol-client/src/command.rs +++ b/core/crates/solstone-core-sol-client/src/command.rs @@ -2,10 +2,11 @@ // Copyright (c) 2026 sol pbc use std::collections::BTreeMap; +use std::path::Path; use crate::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, NotificationSink, + HttpTransport, LinkJoinPairingSeam, NotificationSink, }; #[derive(Clone, Copy)] @@ -21,6 +22,8 @@ pub struct CommandContext<'a> { pub build_identity: Option<&'a dyn BuildIdentityProvider>, pub client_item_ids: Option<&'a dyn ClientItemIdProvider>, pub notification_sink: Option<&'a dyn NotificationSink>, + pub link_pairing: Option<&'a dyn LinkJoinPairingSeam>, + pub journal_root: Option<&'a Path>, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/core/crates/solstone-core-sol-client/src/generated/inventory.rs b/core/crates/solstone-core-sol-client/src/generated/inventory.rs index b3d18cef1..a3f3be0a4 100644 --- a/core/crates/solstone-core-sol-client/src/generated/inventory.rs +++ b/core/crates/solstone-core-sol-client/src/generated/inventory.rs @@ -35,6 +35,8 @@ mod solstone_apps_transcripts_native_command_rs; mod solstone_think_native_chat_command_rs; #[path = "../../../../../solstone/think/native/import/command.rs"] mod solstone_think_native_import_command_rs; +#[path = "../../../../../solstone/think/native/link/command.rs"] +mod solstone_think_native_link_command_rs; #[path = "../../../../../solstone/think/native/moved/command.rs"] mod solstone_think_native_moved_command_rs; #[path = "../../../../../solstone/think/native/notify/command.rs"] @@ -2049,6 +2051,20 @@ pub const ENTRIES: &[InventoryEntry] = &[ contract_operation_id: None, handler: "import_top_level", }, + InventoryEntry { + surface: "sol-link", + path: &["link", "join"], + kind: "top-level", + help: "join a solstone with a short code or pair link", + authority_path: "solstone/think/native/link/authority.toml", + params_json: "[{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"home\",\"nargs\":1,\"options\":[\"--home\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"code\",\"nargs\":1,\"options\":[\"--code\"],\"required\":true,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"as_role\",\"nargs\":1,\"options\":[\"--as\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"label\",\"nargs\":1,\"options\":[\"--label\"],\"required\":false,\"secondary\":[],\"type\":\"text\"}]", + entry_type: "top-level-link", + operation_id: "link.join", + method: None, + route: None, + contract_operation_id: None, + handler: "link_join", + }, InventoryEntry { surface: "sol-call", path: &["identity"], @@ -2405,6 +2421,7 @@ pub const HANDLERS: &[Handler] = &[ solstone_apps_transcripts_native_command_rs::stats, solstone_think_native_chat_command_rs::chat, solstone_think_native_import_command_rs::import_top_level, + solstone_think_native_link_command_rs::link_join, solstone_think_native_moved_command_rs::identity, solstone_think_native_moved_command_rs::navigate, solstone_think_native_notify_command_rs::notify, diff --git a/core/crates/solstone-core-sol-client/src/seam.rs b/core/crates/solstone-core-sol-client/src/seam.rs index 395a2fdfe..4d2b9f606 100644 --- a/core/crates/solstone-core-sol-client/src/seam.rs +++ b/core/crates/solstone-core-sol-client/src/seam.rs @@ -5,6 +5,7 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{Error, ErrorKind, Result as IoResult}; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::error::ClientError; @@ -66,6 +67,112 @@ pub trait NotificationSink { fn send_line(&self, line: &str) -> Result<(), NotificationSinkError>; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkJoinRelayErrorKind { + HomeOffline, + Unauthorized, + Unpaid, + UnknownInstance, + PairWindowClosed, + Overflow, + Abnormal, + UpgradeRejected, + Stalled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkJoinRelayControlEndpoint { + EnrollDevice, + TokenRefresh, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkJoinPairingErrorKind { + Io, + Tls, + Crypto, + Mux, + Http, + Json, + PairLink, + Pairing, + PairResponseMissingHomeAttestation, + Rejected { + status: u16, + }, + Relay(LinkJoinRelayErrorKind), + RelayControlRejected { + endpoint: LinkJoinRelayControlEndpoint, + status: u16, + }, + NoEndpoint, + NotPaired, + LocalOffset, + RuntimeUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkJoinPairingError { + pub kind: LinkJoinPairingErrorKind, +} + +impl LinkJoinPairingError { + #[must_use] + pub fn new(kind: LinkJoinPairingErrorKind) -> Self { + Self { kind } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkJoinPairTarget { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkJoinDirectRequest { + pub targets: Vec, + pub nonce_hex: String, + pub ca_fp_prefix: Vec, + pub device_label: String, + pub additional_fields: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkJoinRelayRequest { + pub relay_origin: String, + pub secret: Vec, + pub ca_fp_spki: Vec, + pub device_label: String, + pub additional_fields: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkJoinCredential { + pub client_key_pem: String, + pub client_cert_pem: String, + pub ca_chain_pem: Vec, + pub ca_fingerprint: String, + pub instance_id: String, + pub home_label: String, + pub home_attestation: Option, + pub local_endpoints: serde_json::Value, + pub relay_device_token: Option, + pub relay_device_token_expires_at: Option, +} + +pub trait LinkJoinPairingSeam: Send + Sync { + fn pair_direct( + &self, + request: LinkJoinDirectRequest, + ) -> Result; + + fn pair_relay( + &self, + request: LinkJoinRelayRequest, + ) -> Result; +} + pub trait FileProvider { fn read(&self, path: &Path) -> IoResult>; fn read_to_string(&self, path: &Path) -> std::io::Result; @@ -138,6 +245,55 @@ pub struct RecordingNotificationSink { fail: bool, } +#[derive(Debug, Clone, PartialEq)] +pub enum ExpectedLinkJoinPairingCall { + Direct { + expected: LinkJoinDirectRequest, + result: Result, + }, + Relay { + expected: LinkJoinRelayRequest, + result: Result, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedLinkJoinPairingCall { + Direct(LinkJoinDirectRequest), + Relay(LinkJoinRelayRequest), +} + +#[derive(Debug, Default)] +pub struct ScriptedLinkJoinPairingSeam { + calls: Mutex>, + recorded: Mutex>, +} + +impl ScriptedLinkJoinPairingSeam { + #[must_use] + pub fn new(calls: Vec) -> Self { + Self { + calls: Mutex::new(calls.into()), + recorded: Mutex::new(Vec::new()), + } + } + + pub fn assert_done(&self) { + assert!( + self.calls.lock().expect("scripted calls lock").is_empty(), + "scripted link pairing calls were not exhausted" + ); + } + + #[must_use] + pub fn recorded(&self) -> Vec { + self.recorded + .lock() + .expect("recorded link pairing lock") + .clone() + } +} + impl RecordingNotificationSink { #[must_use] pub fn new() -> Self { @@ -168,6 +324,42 @@ impl NotificationSink for RecordingNotificationSink { } } +impl LinkJoinPairingSeam for ScriptedLinkJoinPairingSeam { + fn pair_direct( + &self, + request: LinkJoinDirectRequest, + ) -> Result { + self.recorded + .lock() + .expect("recorded link pairing lock") + .push(RecordedLinkJoinPairingCall::Direct(request.clone())); + match self.calls.lock().expect("scripted calls lock").pop_front() { + Some(ExpectedLinkJoinPairingCall::Direct { expected, result }) => { + assert_eq!(request, expected); + result + } + other => panic!("expected direct link pairing call, got {other:?}"), + } + } + + fn pair_relay( + &self, + request: LinkJoinRelayRequest, + ) -> Result { + self.recorded + .lock() + .expect("recorded link pairing lock") + .push(RecordedLinkJoinPairingCall::Relay(request.clone())); + match self.calls.lock().expect("scripted calls lock").pop_front() { + Some(ExpectedLinkJoinPairingCall::Relay { expected, result }) => { + assert_eq!(request, expected); + result + } + other => panic!("expected relay link pairing call, got {other:?}"), + } + } +} + impl ScriptedChatEventSource { #[must_use] pub fn new(inputs: Vec) -> Self { diff --git a/core/crates/solstone-core-sol-link/Cargo.toml b/core/crates/solstone-core-sol-link/Cargo.toml new file mode 100644 index 000000000..17a27b111 --- /dev/null +++ b/core/crates/solstone-core-sol-link/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +[package] +name = "solstone-core-sol-link" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +rustls = { workspace = true } +serde_json = { workspace = true } +solstone-core-sol-client = { workspace = true } +spl-core = { workspace = true } +spl-transport = { workspace = true } +tokio = { workspace = true, features = ["rt", "net", "time", "io-util", "sync"] } + +[dev-dependencies] +rcgen = { workspace = true } + +[lints] +workspace = true diff --git a/core/crates/solstone-core-sol-link/src/direct_seam.rs b/core/crates/solstone-core-sol-link/src/direct_seam.rs new file mode 100644 index 000000000..3af30d07e --- /dev/null +++ b/core/crates/solstone-core-sol-link/src/direct_seam.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::sync::Arc; + +use rustls::ClientConfig; +use spl_core::pairlink::Endpoint; +use spl_transport::connection::request_once; +use spl_transport::pairing::{ + DirectPairPrepareFuture, DirectPairSendFuture, DirectPairingSeam, PreparedDirectPairConnection, +}; + +pub(crate) struct SplDirectPairingSeam; + +impl DirectPairingSeam for SplDirectPairingSeam { + fn prepare<'a>( + &'a self, + config: Arc, + endpoint: &'a Endpoint, + ) -> DirectPairPrepareFuture<'a> { + let host = endpoint.host.clone(); + let port = endpoint.port; + Box::pin(async move { + Ok( + Box::new(SplPreparedDirectPairConnection { config, host, port }) + as Box, + ) + }) + } +} + +struct SplPreparedDirectPairConnection { + config: Arc, + host: String, + port: u16, +} + +impl PreparedDirectPairConnection for SplPreparedDirectPairConnection { + fn send<'a>( + self: Box, + method: &'a str, + path: &'a str, + headers: &'a [(String, String)], + body: &'a [u8], + ) -> DirectPairSendFuture<'a> { + let Self { config, host, port } = *self; + Box::pin( + async move { request_once(config, &host, port, method, path, headers, body).await }, + ) + } +} diff --git a/core/crates/solstone-core-sol-link/src/lib.rs b/core/crates/solstone-core-sol-link/src/lib.rs new file mode 100644 index 000000000..e779c9f55 --- /dev/null +++ b/core/crates/solstone-core-sol-link/src/lib.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::future::Future; +use std::sync::Arc; + +use solstone_core_sol_client::seam::{ + LinkJoinCredential, LinkJoinDirectRequest, LinkJoinPairingError, LinkJoinPairingErrorKind, + LinkJoinPairingSeam, LinkJoinRelayControlEndpoint, LinkJoinRelayErrorKind, + LinkJoinRelayRequest, +}; +use spl_transport::credential::Credential; +use spl_transport::{RelayControlEndpoint, RelayError, TransportError, tls}; + +mod direct_seam; +mod pairing_entry; + +#[derive(Debug, Clone, Copy, Default)] +pub struct SplLinkJoinPairingSeam; + +impl LinkJoinPairingSeam for SplLinkJoinPairingSeam { + fn pair_direct( + &self, + request: LinkJoinDirectRequest, + ) -> Result { + pair_direct_with_spl_seam(request, Arc::new(direct_seam::SplDirectPairingSeam)) + } + + fn pair_relay( + &self, + request: LinkJoinRelayRequest, + ) -> Result { + let credential = block_on_transport(pairing_entry::relay(&request))?; + link_credential_from_spl(credential) + } +} + +fn pair_direct_with_spl_seam( + request: LinkJoinDirectRequest, + seam: Arc, +) -> Result { + let credential = block_on_transport(pairing_entry::direct(&request, seam))?; + link_credential_from_spl(credential) +} + +fn block_on_transport(future: F) -> Result +where + F: Future>, +{ + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| LinkJoinPairingError::new(LinkJoinPairingErrorKind::RuntimeUnavailable))?; + runtime.block_on(future).map_err(map_transport_error) +} + +fn link_credential_from_spl( + credential: Credential, +) -> Result { + let ca_fingerprint = ca_fingerprint(&credential.ca_chain_pem)?; + Ok(LinkJoinCredential { + client_key_pem: credential.client_key_pem, + client_cert_pem: credential.client_cert_pem, + ca_chain_pem: credential.ca_chain_pem, + ca_fingerprint, + instance_id: credential.instance_id, + home_label: credential.home_label, + home_attestation: credential.home_attestation, + local_endpoints: credential + .local_endpoints + .unwrap_or(serde_json::Value::Null), + relay_device_token: credential.device_token, + relay_device_token_expires_at: credential.device_token_expires_at, + }) +} + +fn ca_fingerprint(ca_chain_pem: &[String]) -> Result { + let chain_pem = ca_chain_pem + .iter() + .map(|cert| { + if cert.ends_with('\n') { + cert.clone() + } else { + format!("{cert}\n") + } + }) + .collect::(); + let certs = tls::parse_certs(&chain_pem).map_err(map_transport_error)?; + let Some(first) = certs.first() else { + return Err(LinkJoinPairingError::new(LinkJoinPairingErrorKind::Pairing)); + }; + Ok(format!( + "sha256:{}", + spl_core::ca::sha256_hex(first.as_ref()) + )) +} + +fn map_transport_error(error: TransportError) -> LinkJoinPairingError { + LinkJoinPairingError::new(match error { + TransportError::Io(error) => { + drop(error); + LinkJoinPairingErrorKind::Io + } + TransportError::Tls(message) => { + drop(message); + LinkJoinPairingErrorKind::Tls + } + TransportError::Crypto(message) => { + drop(message); + LinkJoinPairingErrorKind::Crypto + } + TransportError::Mux(error) => { + drop(error); + LinkJoinPairingErrorKind::Mux + } + TransportError::Http(error) => { + drop(error); + LinkJoinPairingErrorKind::Http + } + TransportError::Json(error) => { + drop(error); + LinkJoinPairingErrorKind::Json + } + TransportError::PairLink(message) => { + drop(message); + LinkJoinPairingErrorKind::PairLink + } + TransportError::Pairing(message) => { + if message == "relay response missing home attestation" { + LinkJoinPairingErrorKind::PairResponseMissingHomeAttestation + } else { + LinkJoinPairingErrorKind::Pairing + } + } + TransportError::Rejected { status, body } => { + drop(body); + LinkJoinPairingErrorKind::Rejected { status } + } + TransportError::Relay(error) => LinkJoinPairingErrorKind::Relay(map_relay_error(error)), + TransportError::RelayControlRejected { endpoint, status } => { + LinkJoinPairingErrorKind::RelayControlRejected { + endpoint: map_relay_control_endpoint(endpoint), + status, + } + } + TransportError::NoEndpoint => LinkJoinPairingErrorKind::NoEndpoint, + TransportError::NotPaired => LinkJoinPairingErrorKind::NotPaired, + TransportError::LocalOffset => LinkJoinPairingErrorKind::LocalOffset, + }) +} + +fn map_relay_error(error: RelayError) -> LinkJoinRelayErrorKind { + match error { + RelayError::HomeOffline => LinkJoinRelayErrorKind::HomeOffline, + RelayError::Unauthorized => LinkJoinRelayErrorKind::Unauthorized, + RelayError::Unpaid => LinkJoinRelayErrorKind::Unpaid, + RelayError::UnknownInstance => LinkJoinRelayErrorKind::UnknownInstance, + RelayError::PairWindowClosed => LinkJoinRelayErrorKind::PairWindowClosed, + RelayError::Overflow => LinkJoinRelayErrorKind::Overflow, + RelayError::Abnormal => LinkJoinRelayErrorKind::Abnormal, + RelayError::UpgradeRejected => LinkJoinRelayErrorKind::UpgradeRejected, + RelayError::Stalled => LinkJoinRelayErrorKind::Stalled, + } +} + +fn map_relay_control_endpoint(endpoint: RelayControlEndpoint) -> LinkJoinRelayControlEndpoint { + match endpoint { + RelayControlEndpoint::EnrollDevice => LinkJoinRelayControlEndpoint::EnrollDevice, + RelayControlEndpoint::TokenRefresh => LinkJoinRelayControlEndpoint::TokenRefresh, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use rcgen::{ + BasicConstraints, CertificateParams, CertificateSigningRequestParams, IsCa, KeyPair, + KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, + }; + use serde_json::json; + use solstone_core_sol_client::seam::{LinkJoinPairTarget, LinkJoinRelayRequest}; + use spl_core::PairRequest; + use spl_core::http::HttpResponse; + use spl_core::pairlink::Endpoint; + use spl_transport::pairing::{ + DirectPairPrepareFuture, DirectPairSendFuture, DirectPairingSeam, + PreparedDirectPairConnection, + }; + + use super::*; + + struct TestCa { + cert: rcgen::Certificate, + key: KeyPair, + } + + impl TestCa { + fn new() -> Self { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("test key"); + let mut params = CertificateParams::new(Vec::::new()).expect("test params"); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params.key_usages.push(KeyUsagePurpose::KeyCertSign); + Self { + cert: params.self_signed(&key).expect("test ca"), + key, + } + } + + fn fp_prefix(&self) -> Vec { + spl_core::ca::sha256(self.cert.der())[..16].to_vec() + } + } + + struct FakeDirectPairingSeam { + calls: Arc>>, + ca: Arc, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct FakeDirectCall { + host: String, + port: u16, + method: String, + path: String, + body: Vec, + } + + impl DirectPairingSeam for FakeDirectPairingSeam { + fn prepare<'a>( + &'a self, + _config: Arc, + endpoint: &'a Endpoint, + ) -> DirectPairPrepareFuture<'a> { + let host = endpoint.host.clone(); + let port = endpoint.port; + let calls = self.calls.clone(); + let ca = self.ca.clone(); + Box::pin(async move { + Ok(Box::new(FakePreparedDirectPairConnection { + host, + port, + calls, + ca, + }) as Box) + }) + } + } + + struct FakePreparedDirectPairConnection { + host: String, + port: u16, + calls: Arc>>, + ca: Arc, + } + + impl PreparedDirectPairConnection for FakePreparedDirectPairConnection { + fn send<'a>( + self: Box, + method: &'a str, + path: &'a str, + _headers: &'a [(String, String)], + body: &'a [u8], + ) -> DirectPairSendFuture<'a> { + let Self { + host, + port, + calls, + ca, + } = *self; + let method = method.to_string(); + let path = path.to_string(); + let body = body.to_vec(); + Box::pin(async move { + calls.lock().expect("calls lock").push(FakeDirectCall { + host, + port, + method, + path, + body: body.clone(), + }); + Ok(HttpResponse { + status: 200, + headers: Vec::new(), + body: serde_json::to_vec(&pair_response(&body, &ca)).expect("response json"), + }) + }) + } + } + + fn pair_response(request_body: &[u8], ca: &TestCa) -> spl_core::PairResponse { + let request: PairRequest = serde_json::from_slice(request_body).expect("pair request"); + let client_cert = CertificateSigningRequestParams::from_pem(&request.csr) + .expect("csr pem") + .signed_by(&ca.cert, &ca.key) + .expect("client cert"); + spl_core::PairResponse { + client_cert: client_cert.pem(), + ca_chain: vec![ca.cert.pem()], + instance_id: "receiver-instance".to_string(), + home_label: "Home".to_string(), + fingerprint: format!("sha256:{}", spl_core::ca::sha256_hex(client_cert.der())), + home_attestation: Some("header.payload.signature".to_string()), + local_endpoints: Some(json!([ + {"ip": "192.168.1.10", "port": 7657, "scope": "lan"} + ])), + } + } + + #[test] + fn direct_path_uses_fake_spl_direct_seam_without_sockets() { + let ca = Arc::new(TestCa::new()); + let seam = Arc::new(FakeDirectPairingSeam { + calls: Arc::new(Mutex::new(Vec::new())), + ca: ca.clone(), + }); + let request = LinkJoinDirectRequest { + targets: vec![LinkJoinPairTarget { + host: "10.0.0.42".to_string(), + port: 7657, + }], + nonce_hex: "00112233445566778899aabbccddeeff".to_string(), + ca_fp_prefix: ca.fp_prefix(), + device_label: "laptop".to_string(), + additional_fields: serde_json::Map::new(), + }; + + let credential = + pair_direct_with_spl_seam(request, seam.clone()).expect("direct credential"); + + assert_eq!(credential.instance_id, "receiver-instance"); + assert_eq!( + credential.home_attestation.as_deref(), + Some("header.payload.signature") + ); + assert_eq!( + credential.ca_fingerprint, + format!("sha256:{}", spl_core::ca::sha256_hex(ca.cert.der())) + ); + assert_eq!(credential.local_endpoints[0]["ip"], "192.168.1.10"); + let calls = seam.calls.lock().expect("calls lock"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].host, "10.0.0.42"); + assert_eq!(calls[0].port, 7657); + assert_eq!(calls[0].method, "POST"); + assert_eq!( + calls[0].path, + "/app/network/pair?token=00112233445566778899aabbccddeeff" + ); + let request_body: PairRequest = + serde_json::from_slice(&calls[0].body).expect("request body"); + assert_eq!(request_body.device_label, "laptop"); + assert!(request_body.csr.contains("BEGIN CERTIFICATE REQUEST")); + } + + #[test] + fn relay_secret_length_error_is_sanitized() { + let request = LinkJoinRelayRequest { + relay_origin: "https://link.solstone.app".to_string(), + secret: vec![1, 2, 3], + ca_fp_spki: vec![0; 16], + device_label: "laptop".to_string(), + additional_fields: serde_json::Map::new(), + }; + let error = block_on_transport(pairing_entry::relay(&request)).expect_err("relay error"); + assert_eq!(error.kind, LinkJoinPairingErrorKind::PairLink); + } +} diff --git a/core/crates/solstone-core-sol-link/src/pairing_entry.rs b/core/crates/solstone-core-sol-link/src/pairing_entry.rs new file mode 100644 index 000000000..078ab3022 --- /dev/null +++ b/core/crates/solstone-core-sol-link/src/pairing_entry.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +//! SPL pairing entry calls. No retry invariant: SPL owns the one-request commit +//! rule; this adapter calls each SPL entry once and adds no retry wrapper. + +use std::sync::Arc; + +use solstone_core_sol_client::seam::{LinkJoinDirectRequest, LinkJoinRelayRequest}; +use spl_core::pairlink::{Endpoint, RelayPairLink}; +use spl_transport::credential::Credential; +use spl_transport::pairing::{self, DirectPairingSeam}; +use spl_transport::{TransportError, relay_pairing}; + +pub(crate) async fn direct( + request: &LinkJoinDirectRequest, + seam: Arc, +) -> Result { + let endpoints = request + .targets + .iter() + .map(|target| Endpoint { + host: target.host.clone(), + port: target.port, + }) + .collect::>(); + pairing::pair_with_seam( + &endpoints, + &request.nonce_hex, + &request.ca_fp_prefix, + &request.device_label, + seam, + &request.additional_fields, + ) + .await +} + +pub(crate) async fn relay(request: &LinkJoinRelayRequest) -> Result { + let link = RelayPairLink { + s: secret_array(&request.secret)?, + ca_fp_spki: request.ca_fp_spki.clone(), + relay_origin: request.relay_origin.clone(), + }; + relay_pairing::pair_over_relay(&link, &request.device_label, &request.additional_fields).await +} + +fn secret_array(secret: &[u8]) -> Result<[u8; 8], TransportError> { + secret + .try_into() + .map_err(|_| TransportError::PairLink("relay secret length".to_string())) +} + +#[cfg(test)] +mod tests { + #[test] + fn spl_entry_module_stays_one_shot() { + let source = include_str!("pairing_entry.rs"); + assert_eq!(source.matches(concat!("pair", "_with_seam(")).count(), 1); + assert_eq!(source.matches(concat!("pair", "_over_relay(")).count(), 1); + assert!(!source.contains(concat!("fo", "r "))); + assert!(!source.contains(concat!("wh", "ile "))); + assert!(!source.contains(concat!("lo", "op "))); + } +} diff --git a/core/deny.toml b/core/deny.toml index 3b76ec267..807b2c9a6 100644 --- a/core/deny.toml +++ b/core/deny.toml @@ -10,6 +10,9 @@ allow = [ "Unicode-DFS-2016", "Unicode-3.0", "AGPL-3.0-only", + # via webpki-roots (the Mozilla CA bundle; permissive data license). Mirrors + # spl-rust's dependency-policy rationale for the same crate. + "CDLA-Permissive-2.0", ] [bans] @@ -32,3 +35,4 @@ targets = [ unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = ["https://github.com/solpbc/spl-rust"] diff --git a/core/fixtures/native-sol/link-join/nested_endpoints_peer.json b/core/fixtures/native-sol/link-join/nested_endpoints_peer.json new file mode 100644 index 000000000..502315db6 --- /dev/null +++ b/core/fixtures/native-sol/link-join/nested_endpoints_peer.json @@ -0,0 +1,24 @@ +{ + "label": "laptop", + "paired_at": "1970-01-01T00:00:00Z", + "instance_id": "receiver-instance", + "home_label": "Home", + "fingerprint": "sha256:abc", + "local_endpoints": [ + { + "ip": "10.0.0.2", + "port": 7657, + "scope": "lan", + "meta": { + "first": "one", + "second": [ + "two", + { + "third": "three" + } + ] + } + } + ], + "role": "" +} diff --git a/core/fixtures/native-sol/link-join/observer_ascii_peer.json b/core/fixtures/native-sol/link-join/observer_ascii_peer.json new file mode 100644 index 000000000..5bacf13da --- /dev/null +++ b/core/fixtures/native-sol/link-join/observer_ascii_peer.json @@ -0,0 +1,9 @@ +{ + "label": "laptop", + "paired_at": "1970-01-01T00:00:00Z", + "instance_id": "receiver-instance", + "home_label": "Home", + "fingerprint": "sha256:abc", + "local_endpoints": [], + "role": "" +} diff --git a/core/fixtures/native-sol/link-join/peer_non_ascii_peer.json b/core/fixtures/native-sol/link-join/peer_non_ascii_peer.json new file mode 100644 index 000000000..1cc0eec29 --- /dev/null +++ b/core/fixtures/native-sol/link-join/peer_non_ascii_peer.json @@ -0,0 +1,15 @@ +{ + "label": "caf\u00e9", + "paired_at": "1970-01-01T00:00:00Z", + "instance_id": "receiver-instance", + "home_label": "H\u00f4me", + "fingerprint": "sha256:abc", + "local_endpoints": [ + { + "endpoint": "r\u00e9seau-local", + "port": 7657, + "scope": "lan" + } + ], + "role": "peer" +} diff --git a/core/fixtures/native-sol/parity/link_join.jsonl b/core/fixtures/native-sol/parity/link_join.jsonl new file mode 100644 index 000000000..db1edc6d1 --- /dev/null +++ b/core/fixtures/native-sol/parity/link_join.jsonl @@ -0,0 +1,5 @@ +{"id":"link-join-help","surface":"sol-link","argv":["link","join","--help"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"usage: sol link join [-h] [--home HOME] --code CODE [--as AS_ROLE]\n [--label LABEL]\n\noptions:\n -h, --help show this help message and exit\n --home HOME Receiver base URL\n --code CODE pair-link URL\n --as AS_ROLE Optional tag to join as\n --label LABEL Local credentials label (defaults to this machine's hostname)\n","stderr":"","exit":0,"requests":[]},"normalizations":[]} +{"id":"link-join-invalid-role","surface":"sol-link","argv":["link","join","--code","https://go.solstone.app/p#BAD","--as","bad"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"invalid role; expected one of: phone, observer, peer\n","exit":2,"requests":[]},"normalizations":[]} +{"id":"link-join-invalid-label","surface":"sol-link","argv":["link","join","--code","https://go.solstone.app/p#BAD","--label","bad..name"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"--label must not contain '..'\n","exit":2,"requests":[]},"normalizations":[]} +{"id":"link-join-invalid-code-with-home","surface":"sol-link","argv":["link","join","--code","https://go.solstone.app/p#BAD","--home","https://home.local:7657"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"Malformed pair-link. Use the full https://go.solstone.app/p#... value from the pairing output.\n","exit":1,"requests":[]},"normalizations":[]} +{"id":"link-join-missing-code","surface":"sol-link","argv":["link","join"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"usage: sol link join [-h] [--home HOME] --code CODE [--as AS_ROLE]\n [--label LABEL]\nsol link join: error: the following arguments are required: --code\n","exit":2,"requests":[]},"normalizations":[]} diff --git a/docs/PORTING.md b/docs/PORTING.md index aa7bf2818..4c0b26b41 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -31,6 +31,17 @@ crate to the iOS gate. the analyzer transitively depends on the ONNX Runtime host native-runtime adapter, which is not mobile-ready subsystem logic. +`solstone-core-sol-link` is excluded permanently by product shape, not deferred +iOS debt. `sol link` is a desktop and linked-system surface; phones do not +consume it, and iOS/watchOS pairing lives in `spl-swift`, a separate package +with its own release rail. This matches the program's standing priority: +desktop-first is the product goal, mobile-runtime constraints are explicitly +not product requirements here, and the iOS canary is engineering insurance +rather than a product gate. The split is still useful to mobile consumers: +`spl-core` keeps pure pair-link parsing and CA logic iOS-eligible and +cross-checkable without a platform toolchain, while only `spl-transport` needs +the real host toolchain for `ring`'s C build. + ## Native Dependency Release Proof A Rust conversion that adds or bumps a dependency with C/C++ build steps or @@ -308,14 +319,16 @@ The detailed native atomicity design is in `docs/design/indexer-native-atomicity.md`. Native sol client design records: -`docs/design/native-sol-client/00-prep-findings.md`, -`docs/design/native-sol-client/01-oracle-repro.md`, -`docs/design/native-sol-client/02-design.md`, -`docs/design/native-sol-client/03-batch-prep.md`, -`docs/design/native-sol-client/04-batch-design.md`, -`docs/design/native-sol-client/05-raw-body-parity.md`, -`docs/design/native-sol-client/06-cutover-design.md`, and -`docs/design/native-sol-client/07-notify-contract-design.md`. + +- `docs/design/native-sol-client/00-prep-findings.md` +- `docs/design/native-sol-client/01-oracle-repro.md` +- `docs/design/native-sol-client/02-design.md` +- `docs/design/native-sol-client/03-batch-prep.md` +- `docs/design/native-sol-client/04-batch-design.md` +- `docs/design/native-sol-client/05-raw-body-parity.md` +- `docs/design/native-sol-client/06-cutover-design.md` +- `docs/design/native-sol-client/07-notify-contract-design.md` +- `docs/design/native-sol-client/08-link-join-design.md` ## Dual Paths And Shims diff --git a/docs/design/native-sol-client/08-link-join-design.md b/docs/design/native-sol-client/08-link-join-design.md new file mode 100644 index 000000000..68f6d12fd --- /dev/null +++ b/docs/design/native-sol-client/08-link-join-design.md @@ -0,0 +1,374 @@ +# Native Sol Link Join Design + +This records the design for porting top-level `sol link join` to native Rust. +Generated artifacts are regenerated during implementation; this record does not +change runtime behavior. + +## D0. Topology + +Decision: use Option A, a new host-only lib crate named +`solstone-core-sol-link`, and exclude it from `make check-rust-ios`. + +Rationale: native authority `command.rs` files are compiled into +`solstone-core-sol-client`, which is iOS-visible, so they cannot name +`spl-transport` types. A host-only lib crate is the existing, greppable +mechanism used by `solstone-core-indexer-store` and +`solstone-core-speakers-onnx` for non-iOS dependency islands, while keeping fmt, +clippy, and `check-rust-test` coverage. Putting the adapter in +`solstone-core/src/main.rs` would work mechanically, but it hides substantial +logic in the thin binary and contradicts the library-first native-port shape in +`docs/PORTING.md`. + +Rejected shapes: + +- Feature-gating `spl-transport` behind a non-default feature: invisible and + fragile under Cargo feature unification. +- `[workspace] exclude`: escapes workspace fmt, clippy, and test coverage. + +Behavior marker: `matches-python`; this is an implementation topology, not a +user-visible behavior change. + +## D1. Authority and Dispatch + +Authority: + +- File: `solstone/think/native/link/authority.toml` +- Source: `command.rs` +- `surface = "sol-link"` +- `path = ["link", "join"]` +- `kind = "top-level"` +- `operation_id = "link.join"` +- `entry_type = "top-level-link"` +- `handler = "link_join"` +- Params: + `home` option, text, optional, `options = ["--home"]`; + `code` option, text, required, `options = ["--code"]`; + `as_role` option, text, optional, `options = ["--as"]`; + `label` option, text, optional, `options = ["--label"]` + +`link.join` is the right operation id because this is a subcommand under a +top-level domain. Chat and notify use `.top_level` because each owns a +single-verb top-level command; `link` has multiple verbs, and `link_join` avoids +a generic generated handler name. + +Dispatch shape: + +- `core/crates/solstone-core-sol-client-cli/src/lib.rs` adds + `LinkDispatchSeams` and `dispatch_sol_link_with_seams`. +- This lode does not flip top-level `sol link` routing. `sol link join --help` + still reaches Python compatibility until a later cutover connects the native + dispatcher. +- The native command receives `journal_root` as an explicit dispatch parameter. + It must not resolve `SOLSTONE_JOURNAL` internally. + +## D2. Consumer-Side Seam + +All seam types live in +`core/crates/solstone-core-sol-client/src/seam.rs`. They must not name any +`spl_*` type. + +Trait: + +- `LinkJoinPairingSeam: Send + Sync` +- `pair_direct(&self, request: LinkJoinDirectRequest) -> + Result` +- `pair_relay(&self, request: LinkJoinRelayRequest) -> + Result` + +Owned request data: + +- `LinkJoinPairTarget { host: String, port: u16 }` +- `LinkJoinDirectRequest { targets: Vec, nonce_hex: String, + ca_fp_prefix: Vec, device_label: String, additional_fields: + serde_json::Map }` +- `LinkJoinRelayRequest { relay_origin: String, secret: Vec, ca_fp_spki: + Vec, device_label: String, additional_fields: + serde_json::Map }` + +Owned response data: + +- `LinkJoinCredential { client_key_pem: String, client_cert_pem: String, + ca_chain_pem: Vec, ca_fingerprint: String, instance_id: String, + home_label: String, home_attestation: Option, local_endpoints: + serde_json::Value, relay_device_token: Option, + relay_device_token_expires_at: Option }` + +The seam returns `ca_chain_pem: Vec`, not a pre-joined chain. The native +command owns the Python-compatible `join_chain` formatting because it is bundle +presentation, not transport. The seam also returns the precomputed +`ca_fingerprint` string because `spl_transport::tls::parse_certs` is the only +public PEM-to-DER path at SPL v0.1.0. + +This seam makes both paths fakeable in-process with zero sockets: native command +tests provide a recording/failing `LinkJoinPairingSeam`, and the real +`spl-transport` adapter exists only in the excluded host crate. + +Behavior marker: `matches-python`. + +## D3. SPL Adapter Boundaries + +The real adapter in `solstone-core-sol-link` owns all `spl-transport` imports +and the async runtime boundary. + +Direct pairing: + +- Parse the already validated plain direct request into SPL-compatible values. +- Call SPL's existing direct path. `spl_core::PAIR_PATH` is + `/app/network/pair`, and `spl_transport::pairing::pair_with_seam` sends + `format!("{PAIR_PATH}?token={nonce_hex}")`, byte-identical to Python's + `_direct_pair_path`. +- Keep `_framed_target` validation in the native command for `--home`: missing + host yields `Pair-link target missing host.`, and missing explicit port yields + `Pair-link target missing explicit port.`. The seam target only needs host and + port. + +Relay pairing: + +- Convert the plain relay request to `spl_core::pairlink::RelayPairLink`. +- Call `spl_transport::relay_pairing::pair_over_relay`. +- Return relay device-token fields only from this path. + +The adapter computes `ca_fingerprint` from the first PEM certificate in the +returned chain via `spl_transport::tls::parse_certs` and `spl_core::ca`. + +## D4. Behavior Decisions + +1. `topology` - `matches-python`. Option A is a build-boundary decision; CLI + behavior remains the Python contract. +2. `seam shape` - `matches-python`. Two plain-data methods make direct and + relay fakeable without sockets and keep SPL types outside iOS-visible code. +3. `--as peer` state - `expected-differs`. Native reads `link/state.json` + read-only and fails before pairing if it is missing, unreadable, or lacks a + valid `instance_id`. Error text names the creator command: + `Peer join requires an initialized link identity. Run 'sol call link pair' + on this journal first, then retry.` +4. Pair-link prefix guard - `matches-python`. Keep the + `https://go.solstone.app/p#` check above `spl_core::pairlink::parse`, so + bare fragments and alternate hosts fail with the Python-facing message. +5. `--home` override - `expected-differs`. Native requires host plus explicit + port before dialing, matching Python's validation, but ignores a base-path or + query prefix and uses SPL's fixed pair path. Python would honor the prefix; + it is meaningless for this raw pairing socket. +6. `local_endpoints` - mixed. Absent or JSON null becomes `[]` + (`matches-python`). A present array passes through verbatim with nested key + order preserved (`matches-python`). A present non-array fails before any + write (`expected-differs`; Python coerces to `[]`). A serialized value over + 16 KiB fails before any write (`expected-differs`; Python has no ceiling). +7. Existing path ordering - `expected-differs`. Python prechecks only the + direct-observer path. Native prechecks both observer paths because the label + determines their destination before dialing; both peer paths still check + after the response because the directory name is the receiver `instance_id`. + Post-burn failures use + `Credentials path already exists: {path}. The pairing code is now spent; generate a new one and rerun after removing it.` + Pre-dial observer failures keep Python's wording. +8. `peer.json` bytes - `matches-python`. Use injected UTC clock with + `%Y-%m-%dT%H:%M:%SZ`, exact key order `label`, `paired_at`, `instance_id`, + `home_label`, `fingerprint`, `local_endpoints`, `role`, two-space indent, + `": "` separators, trailing newline, and Python `ensure_ascii=True` + semantics. `serde_json` does not escape non-ASCII by default, so the command + needs an explicit Python-compatible JSON writer. Nested object order is + preserved through the workspace `serde_json` `preserve_order` feature. +9. `home_attestation` - `matches-python`. `home_attestation.jwt` is mandatory; + reject missing or empty `home_attestation` before writing any bundle file + with `Pair response missing home_attestation`. +10. Relay returned-certificate validation - `expected-differs`. This is a + security improvement: Python's relay path binds the returned private key but + never validates the returned client certificate, while + `spl_transport::relay_pairing::pair_over_relay` verifies the live peer leaf + against the pinned CA. +11. `home_label` - `expected-differs`. SPL's `PairResponse.home_label` is a + required string with no serde default, so native fails deserialization where + Python would coerce missing or non-string to `""`. Do not weaken SPL's + schema in the native command. +12. Missing `--code` - `matches-python`. The native argv parser must produce + exit 2 before pair-link parsing or seam access, with an argparse-shaped + required-argument error for `--code`. + +## D5. Bundle Writes + +The native command owns credential layout and byte formatting: + +- Observer path: `observer_bundle_dir(label)` equivalent under + `$XDG_CONFIG_HOME/solstone-observer/spl/