From a98c75090852e3137fd3f1da3497a73dcc49e4d0 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:10:49 -0500 Subject: [PATCH] holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline --- crates/holdings-contract/Cargo.lock | 1449 +++++++++++++++++++++++ crates/holdings-contract/Cargo.toml | 28 + crates/holdings-contract/src/lib.rs | 132 +++ crates/library/src/lib.rs | 8 + crates/persona-state/src/holdings.rs | 446 +++++++ crates/persona-state/src/lib.rs | 1 + src-tauri/assets/holdings_contract.wasm | Bin 0 -> 185389 bytes src-tauri/src/holdings.rs | 809 +++++++++++++ src-tauri/src/lib.rs | 9 + src-tauri/src/overlap.rs | 6 +- src-tauri/src/slsk_link.rs | 6 + src-tauri/src/spaces.rs | 69 +- 12 files changed, 2955 insertions(+), 8 deletions(-) create mode 100644 crates/holdings-contract/Cargo.lock create mode 100644 crates/holdings-contract/Cargo.toml create mode 100644 crates/holdings-contract/src/lib.rs create mode 100644 crates/persona-state/src/holdings.rs create mode 100644 src-tauri/assets/holdings_contract.wasm create mode 100644 src-tauri/src/holdings.rs diff --git a/crates/holdings-contract/Cargo.lock b/crates/holdings-contract/Cargo.lock new file mode 100644 index 0000000..4d1e681 --- /dev/null +++ b/crates/holdings-contract/Cargo.lock @@ -0,0 +1,1449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +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 = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "freenet-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26e5c6bee9ffa0e2683bc8b41a7bd9f1e57e7fd49ac4f6ae433e5a82e9006df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "freenet-stdlib" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62aeba7634d1ae5c2d02f78db33ee40442511858275be5be2099d24a13b2559" +dependencies = [ + "bincode", + "blake3", + "bs58", + "byteorder", + "bytes", + "chrono", + "flatbuffers", + "freenet-macros", + "futures", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-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 = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "persona-state" +version = "1.7.0-alpha" +dependencies = [ + "bincode", + "blake3", + "chacha20poly1305", + "ed25519-dalek", + "serde", + "x25519-dalek", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "soulstream-holdings-contract" +version = "0.1.0" +dependencies = [ + "bincode", + "blake3", + "freenet-stdlib", + "persona-state", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/holdings-contract/Cargo.toml b/crates/holdings-contract/Cargo.toml new file mode 100644 index 0000000..9bc9061 --- /dev/null +++ b/crates/holdings-contract/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soulstream-holdings-contract" +version = "0.1.0" +edition = "2021" +description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +freenet-stdlib = { version = "=0.8.5" } +persona-state = { path = "../persona-state" } +bincode = "1" +blake3 = "1" + +[features] +default = ["freenet-main-contract", "contract"] +freenet-main-contract = [] +contract = ["freenet-stdlib/contract"] +trace = ["freenet-stdlib/trace"] + +[profile.release] +strip = true +lto = true + +# Its own workspace: this crate targets wasm32 and must never join the +# app's host build. +[workspace] diff --git a/crates/holdings-contract/src/lib.rs b/crates/holdings-contract/src/lib.rs new file mode 100644 index 0000000..3f2d1b0 --- /dev/null +++ b/crates/holdings-contract/src/lib.rs @@ -0,0 +1,132 @@ +//! The SoulStream holdings contract. Single writer: one member's latest +//! signed album list in one Space, at the address (space id, member key). +//! Merge is the pointer contract's total order, (sequence, blake3) — +//! `persona_state::holdings::merge` decides, so every delivery order +//! converges, and a stale replay cannot bring a withdrawn list back. +//! +//! The body is opaque here: sealed to the room's epoch key in a private +//! Space, and the contract only ever checks the envelope and its signature. +//! +//! Build: `cargo build --release --target wasm32-unknown-unknown` in this +//! directory; copy `target/wasm32-unknown-unknown/release/ +//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm` +//! and re-pin the code hash in `src-tauri/src/holdings.rs`. + +use freenet_stdlib::prelude::*; +use persona_state::holdings as t; + +pub struct Contract; + +fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> { + t::params_parts(parameters.as_ref()) + .ok_or_else(|| ContractError::Other("params are not family+space+member".into())) +} + +fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec { + let (seq, hash) = t::rank(bytes, s); + let mut out = Vec::with_capacity(40); + out.extend_from_slice(&seq.to_le_bytes()); + out.extend_from_slice(&hash); + out +} + +#[contract] +impl ContractInterface for Contract { + fn validate_state( + parameters: Parameters<'static>, + state: State<'static>, + _related: RelatedContracts<'static>, + ) -> Result { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(ValidateResult::Valid); + } + match t::validate(&space_id, &member, state.as_ref()) { + Ok(_) => Ok(ValidateResult::Valid), + Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))), + } + } + + fn update_state( + parameters: Parameters<'static>, + state: State<'static>, + data: Vec>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + let mut candidates: Vec> = Vec::new(); + if !state.as_ref().is_empty() { + candidates.push(state.as_ref().to_vec()); + } + let mut incoming_valid = 0usize; + for ud in &data { + let mut push = |bytes: &[u8]| { + if bytes.is_empty() { + return; + } + if t::validate(&space_id, &member, bytes).is_ok() { + incoming_valid += 1; + } + candidates.push(bytes.to_vec()); + }; + match ud { + UpdateData::State(s) => push(s.as_ref()), + UpdateData::Delta(d) => push(d.as_ref()), + UpdateData::StateAndDelta { state, delta } => { + push(state.as_ref()); + push(delta.as_ref()); + } + _ => {} + } + } + if incoming_valid == 0 && !data.is_empty() { + return Err(ContractError::InvalidUpdate); + } + match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) { + Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))), + None => Ok(UpdateModification::valid(state)), + } + } + + fn summarize_state( + parameters: Parameters<'static>, + state: State<'static>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateSummary::from(Vec::new())); + } + let s = t::validate(&space_id, &member, state.as_ref()) + .map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?; + Ok(StateSummary::from(summary_of(state.as_ref(), &s))) + } + + fn get_state_delta( + parameters: Parameters<'static>, + state: State<'static>, + summary: StateSummary<'static>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateDelta::from(Vec::new())); + } + let s = t::validate(&space_id, &member, state.as_ref()) + .map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?; + let ours = t::rank(state.as_ref(), &s); + let theirs = { + let b = summary.as_ref(); + if b.len() == 40 { + let seq = u64::from_le_bytes(b[..8].try_into().unwrap()); + let mut h = [0u8; 32]; + h.copy_from_slice(&b[8..40]); + Some((seq, h)) + } else { + None + } + }; + let newer = match theirs { + Some(t) => ours > t, + None => true, + }; + Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() })) + } +} diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 4696894..f16c8fd 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -811,6 +811,14 @@ impl Library { Ok(rows.filter_map(|r| r.ok()).collect()) } + /// Album id → MusicBrainz release id, for the albums that have one. + pub fn album_mbids(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + /// Per-album aggregates for the Library page: what the FILES add up to. /// One call for the whole library, because the page derives its stat row, /// its Format grouping and four of its sorts (length, size, bitrate, diff --git a/crates/persona-state/src/holdings.rs b/crates/persona-state/src/holdings.rs new file mode 100644 index 0000000..aa214c9 --- /dev/null +++ b/crates/persona-state/src/holdings.rs @@ -0,0 +1,446 @@ +//! Holdings — what a member of a Space holds, as album keys and never as +//! files. One contract per (space, member): the member's latest signed list, +//! sealed to the room's epoch key in a private Space. Merge is the pointer +//! contract's total order, (sequence, blake3): a withdrawal is just a newer +//! sequence with an empty body, and a stale replay cannot resurrect a list. +//! +//! An album is named by a key, not a name: blake3 over the room's id and the +//! same normalised (artist, album) the library-overlap comparison uses, so a +//! holding matches exactly when overlap would have said "you own this". The +//! room id salts it — the same album hashes differently in every room, so +//! two rooms' lists cannot be joined. A member can still learn whether you +//! hold any album they can NAME, by hashing it and looking; that is the +//! feature, and the switch's disclosure says so. +//! +//! Nothing here reads a library or a network; the app does both. + +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: u32 = 1; +pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1"; +/// The Space family's domain, not one of its own: the identity delegate +/// signs only under an allowlist, and a new domain would be a delegate +/// generation (a migration event) for the one case — an owner signing their +/// own list — that needs it. The signed payload's shape keeps a holdings +/// signature from ever reading as a Space message or a checkpoint. +pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN; +/// The contract's gate: envelope plus body, sealed or not. +pub const MAX_STATE_BYTES: usize = 1024 * 1024; +/// Entries per list. A `Holding` is at most 66 bytes on the wire, so this +/// keeps a full list — with its username and the envelope — under the gate +/// with room to spare. A library above it publishes its newest entries. +pub const MAX_ENTRIES: usize = 14_000; +pub const MAX_USERNAME_BYTES: usize = 64; + +pub const TIER_UNKNOWN: u8 = 0; +pub const TIER_LOSSY: u8 = 1; +pub const TIER_LOSSLESS: u8 = 2; + +/// One album, as the room sees it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Holding { + /// `album_key` of the normalised (artist, album), salted with the room. + pub key: [u8; 32], + /// `mbid_key` when the library knows the release's MusicBrainz id. + pub mbid: Option<[u8; 32]>, + /// One word about quality: lossless, lossy, or unknown. Never a format, + /// a bitrate, or a size. + pub tier: u8, +} + +/// The plaintext a list carries. Sealed in a private Space, so the Soulseek +/// name inside it is readable by members of that epoch only — it is what +/// lets "who has this" become "get it from them". +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HoldingsBody { + pub schema_version: u32, + pub slsk_username: Option, + /// Sorted by key, no duplicates — `encode_body` enforces it. + pub entries: Vec, +} + +/// The contract state: the member's latest list, signed by the key they +/// wear in the room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HoldingsState { + pub schema_version: u32, + pub space_id: [u8; 16], + pub member: [u8; 32], + pub sequence: u64, + /// The epoch whose key sealed `body`; readers look their key up by it. + pub epoch: u64, + /// False in a public Space: `body` is the plain encoding. + pub sealed: bool, + pub published_at: u64, + /// Withdrawn: the list is gone from current state (`body` empty). Copies + /// members already read stay with them — never claim erasure. + pub withdrawn: bool, + pub body: Vec, + pub sig: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invalid { + Oversized(usize), + Malformed, + WrongSchema(u32), + WrongAddress, + BadSignature, + TooManyEntries(usize), + FieldTooLong(&'static str, usize), +} + +impl core::fmt::Display for Invalid { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"), + Invalid::Malformed => write!(f, "does not decode"), + Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"), + Invalid::WrongAddress => write!(f, "names another room or member"), + Invalid::BadSignature => write!(f, "the signature does not verify"), + Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"), + Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"), + } + } +} + +// ---------------------------------------------------------------- params + +pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); + p.extend_from_slice(PARAMS_FAMILY); + p.extend_from_slice(space_id); + p.extend_from_slice(member); + p +} + +pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> { + let t = PARAMS_FAMILY.len(); + if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { + return None; + } + Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap())) +} + +// ---------------------------------------------------------------- keys + +/// The key an album is held under in ONE room. `artist` and `album` are +/// the overlap normalisation's output (lowercase alphanumerics, brackets +/// gone, a leading "the" and a filing year dropped) — the caller's job, so +/// a wasm build of this module never carries the normaliser. +pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] { + let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1"); + h.update(space_id); + h.update(artist.as_bytes()); + h.update(&[0]); + h.update(album.as_bytes()); + *h.finalize().as_bytes() +} + +/// The same album by its MusicBrainz release id, for the day identity is +/// anchored rather than named. +pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] { + let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1"); + h.update(space_id); + h.update(mbid.trim().to_lowercase().as_bytes()); + *h.finalize().as_bytes() +} + +// ---------------------------------------------------------------- body + +/// Encode a list for the wire: sorted by key, duplicates folded (the better +/// tier wins), caps enforced. +pub fn encode_body(mut b: HoldingsBody) -> Result, Invalid> { + if let Some(u) = &b.slsk_username { + if u.len() > MAX_USERNAME_BYTES { + return Err(Invalid::FieldTooLong("slsk_username", u.len())); + } + } + b.schema_version = SCHEMA_VERSION; + b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier))); + b.entries.dedup_by(|later, earlier| { + if later.key != earlier.key { + return false; + } + // The first of a run has the higher tier already; keep its mbid if + // the later one had none. + if earlier.mbid.is_none() { + earlier.mbid = later.mbid; + } + true + }); + if b.entries.len() > MAX_ENTRIES { + return Err(Invalid::TooManyEntries(b.entries.len())); + } + bincode::serialize(&b).map_err(|_| Invalid::Malformed) +} + +pub fn decode_body(bytes: &[u8]) -> Option { + let b: HoldingsBody = bincode::deserialize(bytes).ok()?; + if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES { + return None; + } + Some(b) +} + +// ---------------------------------------------------------------- sealing + +fn body_key(space_key: &[u8; 32]) -> [u8; 32] { + blake3::derive_key("soulstream.holdings-body.v1", space_key) +} + +/// The AAD binds a body to its envelope: another member's sealed bytes +/// under this address, or this member's older sequence, do not open. +fn aad(s: &HoldingsState) -> Vec { + let mut v = Vec::with_capacity(64); + v.extend_from_slice(&s.space_id); + v.extend_from_slice(&s.member); + v.extend_from_slice(&s.sequence.to_le_bytes()); + v.extend_from_slice(&s.epoch.to_le_bytes()); + v +} + +/// Unique per (member, sequence) under one room — a sequence is never +/// published twice by the same key, which is the nonce's whole contract. +fn nonce(s: &HoldingsState) -> [u8; 12] { + blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap() +} + +/// Seal `s.body` (the plain encoding going in) under the room's epoch key. +/// Call BEFORE signing; the signature covers the sealed bytes. +pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) + .expect("seal") +} + +/// Open a sealed body. None = wrong key for this epoch, or tampered. +pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) + .ok() +} + +// ---------------------------------------------------------------- signing + +#[derive(Serialize)] +struct SigBody<'a> { + schema_version: u32, + space_id: &'a [u8; 16], + member: &'a [u8; 32], + sequence: u64, + epoch: u64, + sealed: bool, + published_at: u64, + withdrawn: bool, + body: &'a [u8], +} + +/// What the member signs: blake3 of the envelope, the sealed body included. +pub fn sign_payload(s: &HoldingsState) -> Vec { + let body = bincode::serialize(&SigBody { + schema_version: s.schema_version, + space_id: &s.space_id, + member: &s.member, + sequence: s.sequence, + epoch: s.epoch, + sealed: s.sealed, + published_at: s.published_at, + withdrawn: s.withdrawn, + body: &s.body, + }) + .expect("sig body serializes"); + blake3::hash(&body).as_bytes().to_vec() +} + +/// Sign as the key worn in the room (a room-local member key, or an +/// owner's persona through the delegate — the app decides which). +pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec { + crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s)) +} + +// ---------------------------------------------------------------- validate / merge + +/// Strict, in the plan's order: size → parse → schema → address → caps → +/// signature. A state that passes is exactly what its member published. +pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result { + if bytes.len() > MAX_STATE_BYTES { + return Err(Invalid::Oversized(bytes.len())); + } + let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; + if s.schema_version != SCHEMA_VERSION { + return Err(Invalid::WrongSchema(s.schema_version)); + } + if &s.space_id != space_id || &s.member != member { + return Err(Invalid::WrongAddress); + } + if s.withdrawn && !s.body.is_empty() { + return Err(Invalid::Malformed); + } + if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) { + return Err(Invalid::BadSignature); + } + Ok(s) +} + +/// The order every replica agrees on: highest sequence, then the byte hash. +pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) { + (s.sequence, *blake3::hash(bytes).as_bytes()) +} + +/// The winning candidate's bytes, or None when nothing valid was offered. +pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator) -> Option> { + let mut best: Option<((u64, [u8; 32]), &[u8])> = None; + for bytes in candidates { + let Ok(s) = validate(space_id, member, bytes) else { continue }; + let r = rank(bytes, &s); + if best.as_ref().is_none_or(|(b, _)| r > *b) { + best = Some((r, bytes)); + } + } + best.map(|(_, b)| b.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secret(n: u8) -> [u8; 32] { + [n; 32] + } + fn state(secret: &[u8; 32], seq: u64, body: Vec, withdrawn: bool) -> HoldingsState { + let mut s = HoldingsState { + schema_version: SCHEMA_VERSION, + space_id: [3; 16], + member: crate::public_of(secret), + sequence: seq, + epoch: 4, + sealed: false, + published_at: 1_700_000_000, + withdrawn, + body, + sig: vec![], + }; + s.sig = sign_with(secret, &s); + s + } + fn bytes(s: &HoldingsState) -> Vec { + bincode::serialize(s).unwrap() + } + fn holding(n: u8, tier: u8) -> Holding { + Holding { key: [n; 32], mbid: None, tier } + } + + #[test] + fn a_list_round_trips_and_refuses_tampering() { + let k = secret(7); + let member = crate::public_of(&k); + let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap(); + let s = state(&k, 1, body.clone(), false); + let b = bytes(&s); + let back = validate(&[3; 16], &member, &b).unwrap(); + assert_eq!(back, s); + let d = decode_body(&back.body).unwrap(); + assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::>(), [1, 2], "sorted by key"); + assert_eq!(d.slsk_username.as_deref(), Some("zoocone")); + + // another room, another member, a flipped byte, a foreign signature + assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress)); + assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress)); + let mut t = s.clone(); + t.sequence = 9; + assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature)); + let mut f = s.clone(); + f.sig = sign_with(&secret(8), &f); + assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature)); + let mut w = state(&k, 2, body, true); + w.sig = sign_with(&k, &w); + assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body"); + } + + #[test] + fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() { + let k = secret(5); + let member = crate::public_of(&k); + let one = bytes(&state(&k, 1, vec![1], false)); + let two = bytes(&state(&k, 2, vec![2], false)); + let gone = bytes(&state(&k, 3, vec![], true)); + let stranger = bytes(&state(&secret(6), 9, vec![9], false)); + let sid = [3; 16]; + assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone())); + assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone())); + assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone())); + assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None); + assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone())); + // same sequence twice: the hash breaks the tie the same way in any order + let a = bytes(&state(&k, 4, vec![1, 1], false)); + let b = bytes(&state(&k, 4, vec![2, 2], false)); + let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]); + let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]); + assert_eq!(ab, ba); + assert!(ab == Some(a.clone()) || ab == Some(b.clone())); + } + + #[test] + fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() { + let k = secret(2); + let room_key = [11u8; 32]; + let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap(); + let mut s = state(&k, 1, plain.clone(), false); + s.sealed = true; + s.body = seal(&room_key, &s); + s.sig = sign_with(&k, &s); + assert_ne!(s.body, plain); + assert_eq!(open(&room_key, &s), Some(plain.clone())); + assert_eq!(open(&[12u8; 32], &s), None); + // the same ciphertext under another sequence is not this list + let mut moved = s.clone(); + moved.sequence = 2; + assert_eq!(open(&room_key, &moved), None); + // validate does not need the key: the envelope stands on its own + assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok()); + } + + #[test] + fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() { + let a = album_key(&[1; 16], "broadcast", "tenderbuttons"); + let b = album_key(&[2; 16], "broadcast", "tenderbuttons"); + assert_ne!(a, b); + assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons")); + assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart"); + assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC")); + assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC")); + + let body = encode_body(HoldingsBody { + schema_version: 0, + slsk_username: None, + entries: vec![ + Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY }, + Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }, + Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN }, + ], + }) + .unwrap(); + let d = decode_body(&body).unwrap(); + assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]); + + let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() }; + assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_)))); + let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] }; + assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65)))); + } + + #[test] + fn params_round_trip() { + let p = make_params(&[3; 16], &[4; 32]); + assert_eq!(params_parts(&p), Some(([3; 16], [4; 32]))); + assert_eq!(params_parts(&p[..p.len() - 1]), None); + assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None); + } +} diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index cd755da..995709b 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -21,6 +21,7 @@ pub mod playlist; pub mod inbox; pub mod space; pub mod proposals; +pub mod holdings; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/src-tauri/assets/holdings_contract.wasm b/src-tauri/assets/holdings_contract.wasm new file mode 100644 index 0000000000000000000000000000000000000000..25e4eb992ac43b7c70bedec2f7ae4f9e32b74bc2 GIT binary patch literal 185389 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 X|CQ(eXZ-}1qK)K@rYHV35TAbmMzfze literal 0 HcmV?d00001 diff --git a/src-tauri/src/holdings.rs b/src-tauri/src/holdings.rs new file mode 100644 index 0000000..29b876b --- /dev/null +++ b/src-tauri/src/holdings.rs @@ -0,0 +1,809 @@ +//! Holdings — "who has this, and get it from them". +//! +//! Each member of a Space may publish what they HOLD: the albums of their +//! library as room-salted keys (`persona_state::holdings::album_key` over +//! `overlap::norm`), one quality word each, and their Soulseek name — sealed +//! to the room's epoch key in a private Space, signed by the key they wear +//! there, one contract per (room, member). Never files, paths, sizes or +//! bitrates: the same rule the overlap shelf lives by, and the switch's +//! disclosure says exactly what a member can learn. +//! +//! Reading a room caches each member's list on disk (`holdings.json`, the +//! overlap ledger's pattern), so the ASK is local and instant and works with +//! the holders offline: hash the album per room, look it up, name the +//! members. "Get it" is the hub's album grab aimed at that member's share — +//! `find_folder` walks their browse tree with overlap's `pair_of` and hands +//! the files back for the frontend to queue; a holder who is offline goes on +//! a local wait list the frontend drains when their presence flips. +//! +//! Nothing is ever pushed to a holder. The only new network object is the +//! list, and only the room's members can read it. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use freenet_adapter::FreenetAdapter; +use persona_state::holdings as hd; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; + +use crate::pipeline::LibraryState; +use crate::state::AppState; +use crate::{ledger, overlap, persona, slsk_link, spaces}; + +const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm"); +/// The ledger changed (a publish, a read, a wait): the page re-reads. +pub const EVENT: &str = "community:holdings"; +const LEDGER_FILE: &str = "holdings.json"; +/// A member's cached list is re-read after this long, or on Refresh. +const FRESH_SECS: u64 = 10 * 60; +/// A member with no list is asked again after this long: a GET for a +/// contract nobody put is a ring search that costs seconds. +const ABSENT_SECS: u64 = 60 * 60; +const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"]; +const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"]; + +// ---------------------------------------------------------------- ledger + +/// What this device last published into one room. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Mine { + pub sequence: u64, + pub epoch: u64, + pub published_at: u64, + pub count: u32, + /// blake3 over the sorted keys, so a library that has not changed does + /// not republish. + pub digest: String, + #[serde(default)] + pub withdrawn: bool, +} + +/// One member's list as this device last read it. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct Cached { + sequence: u64, + epoch: u64, + read_at: u64, + /// No contract under that address when last asked. + absent: bool, + /// Sealed to an epoch this device has no key for. + unreadable: bool, + withdrawn: bool, + slsk_username: Option, + /// (key b58, mbid key b58, tier) — the same three the wire carries. + entries: Vec<(String, Option, u8)>, +} + +/// An album to fetch from someone the moment they come online. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Wait { + pub username: String, + pub artist: String, + pub album: String, + pub since: u64, + /// The room the holder was found in, for the row's provenance. + #[serde(default)] + pub via: Option, +} + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + mine: BTreeMap, + /// room record id → member key (b58) → the list. + #[serde(default)] + cache: BTreeMap>, + #[serde(default)] + waits: Vec, +} + +fn path(app: &tauri::AppHandle) -> Result { + Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE)) +} +fn load(app: &tauri::AppHandle) -> Ledger { + path(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} +fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { + let p = path(app)?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + let _ = app.emit(EVENT, ()); + Ok(()) +} +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} +fn b58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} + +// ---------------------------------------------------------------- my list + +/// The tier a dominant extension speaks for. +fn tier_of(ext: &str) -> u8 { + let e = ext.to_lowercase(); + if LOSSLESS.contains(&e.as_str()) { + hd::TIER_LOSSLESS + } else if LOSSY.contains(&e.as_str()) { + hd::TIER_LOSSY + } else { + hd::TIER_UNKNOWN + } +} + +/// The library as one room would hold it: keyed, tiered, sorted. Reads the +/// database, so it runs off the async runtime. +fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec, u32), String> { + let albums = lib.list_albums().map_err(|e| e.to_string())?; + let mbids = lib.album_mbids().unwrap_or_default(); + let tiers: BTreeMap = lib + .album_stats(0) + .map_err(|e| e.to_string())? + .into_iter() + .map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN))) + .collect(); + let total = albums.len() as u32; + // Newest first is the library's own order, so a list over the cap keeps + // what was added last. + let entries: Vec = albums + .iter() + .filter_map(|a| { + let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title)); + if na.is_empty() || nt.is_empty() { + return None; + } + Some(hd::Holding { + key: hd::album_key(space_id, &na, &nt), + mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)), + tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN), + }) + }) + .take(hd::MAX_ENTRIES) + .collect(); + Ok((entries, total)) +} + +fn digest_of(entries: &[hd::Holding]) -> String { + let mut buf = Vec::with_capacity(entries.len() * 33); + for e in entries { + buf.extend_from_slice(&e.key); + buf.push(e.tier); + } + b58(&persona_state::content_hash(&buf)) +} + +// ---------------------------------------------------------------- network + +fn adapter(app: &tauri::AppHandle) -> FreenetAdapter { + app.state::().freenet.clone() +} + +/// The list at one address, verified. None when nothing was ever put there. +async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result, String> { + let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member)); + match adapter(app).contract_get(&ids.instance, false).await { + Ok(bytes) if bytes.is_empty() => Ok(None), + Ok(bytes) => hd::validate(space_id, member, &bytes) + .map(Some) + .map_err(|e| format!("holdings failed verification: {e}")), + Err(e) if e.to_string().contains("not found") => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> { + let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; + let params = hd::make_params(space_id, member); + let a = adapter(app); + if first { + a.contract_put(HOLDINGS_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) + } else { + a.get_then_update(HOLDINGS_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) + } + .map_err(|e| e.to_string()) +} + +/// Sign as the key worn in the room: the room secret for a member, the +/// delegate for an owner (their persona is their key there). +async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result, String> { + match room_secret { + Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)), + _ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await, + } +} + +/// Everything a publish or a withdrawal needs to know about where it goes. +struct Seat { + me: [u8; 32], + worn: [u8; 32], + room_secret: Option<[u8; 32]>, + space_id: [u8; 16], + epoch: u64, + epoch_key: Option<[u8; 32]>, +} + +async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let me = spaces::my_key(app).await?; + let owner = spaces::key_of(owner_b58)?; + let space_id = spaces::id_of(space_b58)?; + spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me); + let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + if !c.members.iter().any(|m| m.key == worn) { + return Err("you are not in this Space's roster".into()); + } + let epoch_key = if c.private { + Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?) + } else { + None + }; + Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key }) +} + +/// Publish this device's list into one room. Read-and-bump: the sequence +/// follows whatever is on the network, so a device that lost its ledger +/// cannot publish under a sequence the room has already seen. +pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let st = seat(app, owner_b58, space_b58).await?; + let lib = app.state::().library.clone(); + let sid = st.space_id; + let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) + .await + .map_err(|e| e.to_string())??; + let count = entries.len() as u32; + let digest = digest_of(&entries); + let body = hd::encode_body(hd::HoldingsBody { + schema_version: hd::SCHEMA_VERSION, + slsk_username: slsk_link::own_linked_username(app), + entries, + }) + .map_err(|e| e.to_string())?; + let rid = spaces::record_id(owner_b58, space_b58); + let on_net = fetch(app, &st.space_id, &st.worn).await?; + let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0); + let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1; + let mut s = hd::HoldingsState { + schema_version: hd::SCHEMA_VERSION, + space_id: st.space_id, + member: st.worn, + sequence, + epoch: st.epoch, + sealed: st.epoch_key.is_some(), + published_at: now_secs(), + withdrawn: false, + body, + sig: vec![], + }; + if let Some(k) = &st.epoch_key { + s.body = hd::seal(k, &s); + } + s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; + offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?; + let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false }; + { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.insert(rid, mine.clone()); + save(app, &l)?; + } + log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch); + Ok(mine) +} + +/// Take the list out of current state. Copies members already read stay +/// with them — never claim erasure. +pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> { + let st = seat(app, owner_b58, space_b58).await?; + let rid = spaces::record_id(owner_b58, space_b58); + let on_net = fetch(app, &st.space_id, &st.worn).await?; + let Some(current) = on_net else { + // Nothing there to withdraw; forget what the ledger thought. + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.remove(&rid); + return save(app, &l); + }; + if current.withdrawn { + return Ok(()); + } + let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1; + let mut s = hd::HoldingsState { + schema_version: hd::SCHEMA_VERSION, + space_id: st.space_id, + member: st.worn, + sequence, + epoch: st.epoch, + sealed: false, + published_at: now_secs(), + withdrawn: true, + body: vec![], + sig: vec![], + }; + s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; + offer(app, &st.space_id, &st.worn, &s, false).await?; + { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true }); + save(app, &l)?; + } + log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}"); + Ok(()) +} + +/// The switch. On: publish now. Off: withdraw. Either way the flag is set +/// first, so a publish that fails on the network is retried by the next +/// library change rather than forgotten. +pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> { + spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?; + if on { + publish(app, owner_b58, space_b58).await.map(|_| ()) + } else { + withdraw(app, owner_b58, space_b58).await + } +} + +/// The library changed: every room with the switch on gets the new list, +/// unless the list is byte-for-byte what that room already has. Quiet per +/// room — a failed republish is logged and tried again next change. +pub async fn republish_all(app: &tauri::AppHandle) -> Result { + let rooms = spaces::rooms_sharing_holdings(app); + let mut n = 0; + for (owner, space) in rooms { + let stale = { + let lib = app.state::().library.clone(); + let sid = spaces::id_of(&space)?; + let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) + .await + .map_err(|e| e.to_string())??; + let l = load(app); + l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true) + }; + if !stale { + continue; + } + match publish(app, &owner, &space).await { + Ok(_) => n += 1, + Err(e) => log::info!("holdings: could not republish into {space}: {e}"), + } + } + Ok(n) +} + +// ---------------------------------------------------------------- reading a room + +#[derive(Debug, Serialize)] +pub struct MemberHoldings { + pub key: String, + pub username: Option, + pub is_me: bool, + /// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread" + pub state: String, + pub count: u32, + pub sequence: u64, + pub read_at: u64, +} + +#[derive(Debug, Serialize)] +pub struct RoomHoldings { + pub owner: String, + pub space_id: String, + pub name: String, + pub share_holdings: bool, + pub mine: Option, + pub members: Vec, + pub sharing: u32, +} + +/// The room's lists as this device knows them, reading the network for +/// members whose cached copy is stale (or all of them, on `refresh`). The +/// reads are sequential ring searches — seconds each for a member who +/// never published — so this runs from a spawned task and the page hears +/// the result by event; it never blocks the Space opening. +pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result { + let me = spaces::my_key(app).await?; + let owner = spaces::key_of(owner_b58)?; + let space_id = spaces::id_of(space_b58)?; + let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me); + let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + let rid = spaces::record_id(owner_b58, space_b58); + let now = now_secs(); + let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect(); + for member in members { + let mb58 = b58(&member); + let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned(); + let fresh = cached.as_ref().is_some_and(|c| { + let age = now.saturating_sub(c.read_at); + if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS } + }); + if fresh && !refresh { + continue; + } + let entry = match fetch(app, &space_id, &member).await { + Ok(None) => Cached { read_at: now, absent: true, ..Default::default() }, + Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() }, + Ok(Some(s)) => { + let plain = if s.sealed { + match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) { + Some(k) => hd::open(&k, &s), + None => None, + } + } else { + Some(s.body.clone()) + }; + match plain.as_deref().and_then(hd::decode_body) { + Some(b) => Cached { + sequence: s.sequence, + epoch: s.epoch, + read_at: now, + slsk_username: b.slsk_username, + entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(), + ..Default::default() + }, + None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() }, + } + } + Err(e) => { + log::info!("holdings: could not read {mb58} in {space_b58}: {e}"); + continue; + } + }; + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.cache.entry(rid.clone()).or_default().insert(mb58, entry); + save(app, &l)?; + } + Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn)) +} + +/// The room as the cache has it — no network. +pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let l = load(app); + let rid = spaces::record_id(owner_b58, space_b58); + let mine = l.mine.get(&rid).cloned(); + let cache = l.cache.get(&rid).cloned().unwrap_or_default(); + let members = cache + .iter() + .map(|(k, c)| MemberHoldings { + key: k.clone(), + username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)), + is_me: false, + state: state_word(c).into(), + count: c.entries.len() as u32, + sequence: c.sequence, + read_at: c.read_at, + }) + .collect::>(); + let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn)); + Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing }) +} + +fn state_word(c: &Cached) -> &'static str { + if c.absent { + "none" + } else if c.withdrawn { + "withdrawn" + } else if c.unreadable { + "sealed" + } else { + "shared" + } +} + +fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings { + let l = load(app); + let rid = spaces::record_id(owner_b58, space_b58); + let mine = l.mine.get(&rid).cloned(); + let cache = l.cache.get(&rid).cloned().unwrap_or_default(); + let members: Vec = c + .members + .iter() + .map(|m| { + let k = b58(&m.key); + let is_me = &m.key == worn; + let cached = cache.get(&k); + MemberHoldings { + username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)), + is_me, + state: if is_me { + match &mine { + Some(m) if !m.withdrawn => "shared", + _ => "none", + } + } else { + cached.map(state_word).unwrap_or("unread") + } + .into(), + count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) }, + sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) }, + read_at: cached.map(|c| c.read_at).unwrap_or(0), + key: k, + } + }) + .collect(); + let sharing = members.iter().filter(|m| m.state == "shared").count() as u32; + RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing } +} + +// ---------------------------------------------------------------- the ask + +#[derive(Debug, Deserialize)] +pub struct AskItem { + pub artist: String, + pub album: String, + #[serde(default)] + pub mbid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Holder { + pub owner: String, + pub space_id: String, + pub space_name: String, + pub member: String, + pub username: Option, + pub tier: u8, + /// How the holding matched: "name" | "mbid". + pub matched_by: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct Answer { + pub artist: String, + pub album: String, + pub holders: Vec, +} + +/// Who holds each album, across every room this device is in — from the +/// cache, no network. A person in two rooms is one holder per room; the +/// frontend folds by username when it wants to. +pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec { + let l = load(app); + let rooms = spaces::rooms(app); + // Per room: member → (key set, mbid set, username, tier by key). + struct RoomIndex { + owner: String, + space_id: String, + name: String, + sid: [u8; 16], + members: Vec<(String, Option, BTreeMap, BTreeSet)>, + } + let index: Vec = rooms + .into_iter() + .filter_map(|(owner, space, name)| { + let sid = spaces::id_of(&space).ok()?; + let cache = l.cache.get(&spaces::record_id(&owner, &space))?; + let members = cache + .iter() + .filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty()) + .map(|(k, c)| { + let mut keys = BTreeMap::new(); + let mut mbids = BTreeSet::new(); + for (key, mbid, tier) in &c.entries { + keys.insert(key.clone(), *tier); + if let Some(m) = mbid { + mbids.insert(m.clone()); + } + } + (k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids) + }) + .collect(); + Some(RoomIndex { owner, space_id: space, name, sid, members }) + }) + .collect(); + items + .iter() + .map(|it| { + let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album)); + let mut holders = Vec::new(); + if !na.is_empty() && !nt.is_empty() { + for r in &index { + let key = b58(&hd::album_key(&r.sid, &na, &nt)); + let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m))); + for (member, username, keys, mbids) in &r.members { + let by_name = keys.get(&key).copied(); + let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN); + if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) { + holders.push(Holder { + owner: r.owner.clone(), + space_id: r.space_id.clone(), + space_name: r.name.clone(), + member: member.clone(), + username: username.clone(), + tier, + matched_by: how, + }); + } + } + } + } + // Lossless first, then those who can actually be asked. + holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some()))); + Answer { artist: it.artist.clone(), album: it.album.clone(), holders } + }) + .collect() +} + +// ---------------------------------------------------------------- getting it + +#[derive(Debug, Serialize)] +pub struct FileRef { + pub path: String, + pub name: String, + pub size: u64, +} + +#[derive(Debug, Serialize)] +pub struct FolderMatch { + pub username: String, + pub folder: String, + pub files: Vec, + pub tier: u8, +} + +/// Browse a holder's share and find the folder that names this album — the +/// overlap walk, so what the room said they hold is what gets grabbed. Disc +/// subfolders come along. None when their share does not name it (they +/// moved it, or the room's word was a near-name). +pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { + let (na, nt) = (overlap::norm(artist), overlap::norm(album)); + if na.is_empty() || nt.is_empty() { + return Err("that album has no name to look for".into()); + } + let fut = { + let state = app.state::(); + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.browse(username) + }; + let dirs = fut.await.map_err(|e| e.to_string())?; + overlap::note_browse(app, username, &dirs); + let mut files = Vec::new(); + let mut folder: Option = None; + for d in &dirs { + let Some((a, b)) = overlap::pair_of(&d.name) else { continue }; + let (da, db) = (overlap::norm(&a), overlap::norm(&b)); + let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str())))); + if !hit { + continue; + } + let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect(); + if audio.is_empty() { + continue; + } + if folder.is_none() { + folder = Some(d.name.clone()); + } + for f in audio { + files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size }); + } + } + let Some(folder) = folder else { return Ok(None) }; + let tier = files + .iter() + .map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default())) + .max() + .unwrap_or(hd::TIER_UNKNOWN); + Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier })) +} + +// ---------------------------------------------------------------- waits + +pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result, String> { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album); + if !l.waits.iter().any(same) { + l.waits.push(Wait { since: now_secs(), ..w }); + save(app, &l)?; + } + Ok(l.waits) +} + +pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let (na, nt) = (overlap::norm(artist), overlap::norm(album)); + l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt)); + save(app, &l)?; + Ok(l.waits) +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result { + set_share(&app, &owner, &space, on).await?; + room_now(&app, &owner, &space) +} + +/// The room's lists. `refresh` re-reads every member; otherwise only stale +/// ones, and only from a spawned task so the answer is immediate — the +/// page hears the read finish by event. +#[tauri::command] +pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result { + let now = room_now(&app, &owner, &space)?; + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = read_room(&app2, &owner, &space, refresh).await { + log::info!("holdings: reading {space}: {e}"); + } + }); + Ok(now) +} + +#[tauri::command] +pub fn holdings_ask(app: tauri::AppHandle, items: Vec) -> Vec { + ask(&app, &items) +} + +#[tauri::command] +pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { + find_folder(&app, &username, &artist, &album).await +} + +#[tauri::command] +pub async fn holdings_republish(app: tauri::AppHandle) -> Result { + republish_all(&app).await +} + +#[tauri::command] +pub fn holdings_waits(app: tauri::AppHandle) -> Vec { + load(&app).waits +} + +#[tauri::command] +pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option) -> Result, String> { + wait_add(&app, Wait { username, artist, album, since: 0, via }).await +} + +#[tauri::command] +pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { + wait_remove(&app, &username, &artist, &album).await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The bundled contract is a commitment: a changed wasm re-keys every + /// list on the network. + #[test] + fn contract_generation_is_pinned() { + let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32])); + assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q"); + } + + #[test] + fn a_dominant_format_speaks_for_the_tier() { + assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS); + assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS); + assert_eq!(tier_of("mp3"), hd::TIER_LOSSY); + assert_eq!(tier_of("opus"), hd::TIER_LOSSY); + assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN); + } + + /// An older ledger loads, and a wait is one per (person, album) however + /// the names are spelled. + #[test] + fn the_ledger_reads_older_files_and_waits_fold_by_name() { + let l: Ledger = serde_json::from_str("{}").unwrap(); + assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty()); + let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None }; + let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None }; + assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album)); + assert!(w.username.eq_ignore_ascii_case(&same.username)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e0a8d6..6e5c7a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod smtc; mod spaces; mod proposals; mod overlap; +mod holdings; mod state; mod thumbbar; mod vocal_sep; @@ -186,6 +187,14 @@ pub fn run() { overlap::overlap_list, overlap::overlap_compute, overlap::overlap_forget, + holdings::holdings_set_share, + holdings::holdings_room, + holdings::holdings_ask, + holdings::holdings_find_folder, + holdings::holdings_republish, + holdings::holdings_waits, + holdings::holdings_wait_add, + holdings::holdings_wait_remove, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/overlap.rs b/src-tauri/src/overlap.rs index 80040af..ba521db 100644 --- a/src-tauri/src/overlap.rs +++ b/src-tauri/src/overlap.rs @@ -90,7 +90,7 @@ fn key(username: &str) -> String { /// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and /// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and /// "sigur ros" are not the same string here, and that is a known miss. -fn norm(s: &str) -> String { +pub(crate) fn norm(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut depth = 0i32; for c in s.chars() { @@ -127,7 +127,7 @@ fn is_disc_folder(s: &str) -> bool { false } -fn is_audio(f: &soulseek_proto::types::File) -> bool { +pub(crate) fn is_audio(f: &soulseek_proto::types::File) -> bool { let ext = if f.extension.is_empty() { f.filename.rsplit('.').next().unwrap_or_default() } else { @@ -138,7 +138,7 @@ fn is_audio(f: &soulseek_proto::types::File) -> bool { } /// The (artist, album) a share folder names, with display spellings. -fn pair_of(dir: &str) -> Option<(String, String)> { +pub(crate) fn pair_of(dir: &str) -> Option<(String, String)> { let segs: Vec<&str> = dir .split(['\\', '/']) .map(str::trim) diff --git a/src-tauri/src/slsk_link.rs b/src-tauri/src/slsk_link.rs index a476753..65d0626 100644 --- a/src-tauri/src/slsk_link.rs +++ b/src-tauri/src/slsk_link.rs @@ -293,6 +293,12 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) { /// The Soulseek account a verified persona key belongs to, if this device /// verified one (our own included). +/// The Soulseek name this device has VERIFIED as its own through the link +/// handshake, if any — not merely the account signed in right now. +pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option { + load(app).own.map(|o| o.username) +} + pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option { let links = load(app); if let Some(o) = &links.own { diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index ee25527..ae7fef7 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -66,6 +66,11 @@ struct SpaceRecord { /// room is not consent to be watched in it. #[serde(default)] share_listens: bool, + /// Whether this device publishes what it HOLDS into this Space — album + /// keys sealed to the room, never files (`holdings.rs`). Per Space and + /// off until asked, for the same reason as listens. + #[serde(default)] + share_holdings: bool, /// The last listen posted here — what, and when. A reload or a restart /// re-reports whatever is already past the player's threshold, and this /// is the only side that knows what a room was last told. @@ -153,6 +158,8 @@ pub struct SpaceView { pub can_post: bool, /// Whether THIS device posts what it plays here. pub share_listens: bool, + /// Whether THIS device publishes its holdings here. + pub share_holdings: bool, pub has_key: bool, /// The contract exists on the network (a Space someone invited us to /// may not have propagated to this node yet). @@ -226,13 +233,13 @@ fn rand32() -> [u8; 32] { rand::thread_rng().fill_bytes(&mut n); n } -fn b58(bytes: &[u8]) -> String { +pub(crate) fn b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } -fn key_of(s: &str) -> Result<[u8; 32], String> { +pub(crate) fn key_of(s: &str) -> Result<[u8; 32], String> { inbox::key_of(s) } -fn id_of(s: &str) -> Result<[u8; 16], String> { +pub(crate) fn id_of(s: &str) -> Result<[u8; 16], String> { bs58::decode(s) .into_vec() .map_err(|e| format!("bad id: {e}"))? @@ -250,7 +257,7 @@ fn cap(s: &str, max: usize) -> String { } s[..end].to_string() } -fn record_id(owner: &str, space_id: &str) -> String { +pub(crate) fn record_id(owner: &str, space_id: &str) -> String { format!("{owner}:{space_id}") } @@ -281,7 +288,7 @@ fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { Ok(()) } -async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { +pub(crate) async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().freenet.clone(); let info = persona::current(&adapter) .await? @@ -404,6 +411,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me listens: vec![], can_post: false, share_listens: rec.share_listens, + share_holdings: rec.share_holdings, has_key: false, reachable, }; @@ -490,6 +498,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me .collect(), can_post: is_member && has_key, share_listens: rec.share_listens, + share_holdings: rec.share_holdings, has_key, reachable, } @@ -563,6 +572,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }; @@ -826,6 +836,53 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str /// Turn posting-what-I-play on or off for one Space. Nothing is published /// by flipping it; it only decides what the next track does. +/// What `holdings.rs` needs to know about a room, without the record. +pub(crate) struct RoomFacts { + pub name: String, + pub private: bool, + pub my_role: String, + pub share_holdings: bool, +} + +pub(crate) fn room_facts(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Option { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + Some(RoomFacts { name: rec.name.clone(), private: rec.private, my_role: rec.my_role.clone(), share_holdings: rec.share_holdings }) +} + +/// The room's key for one epoch, if this device was handed it. +pub(crate) fn epoch_key_for(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, epoch: u64) -> Option<[u8; 32]> { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok()) +} + +/// Every room this device is in: (owner, space id, name). +pub(crate) fn rooms(app: &tauri::AppHandle) -> Vec<(String, String, String)> { + load(app).spaces.values().map(|r| (r.owner.clone(), r.space_id.clone(), r.name.clone())).collect() +} + +/// The rooms this device publishes its holdings into. +pub(crate) fn rooms_sharing_holdings(app: &tauri::AppHandle) -> Vec<(String, String)> { + load(app).spaces.values().filter(|r| r.share_holdings).map(|r| (r.owner.clone(), r.space_id.clone())).collect() +} + +/// The Soulseek name behind a room key as this device knows it: through the +/// owner's private roster, or the key itself when it is a persona (an owner's). +pub(crate) fn username_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, member_b58: &str) -> Option { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + slsk_link::username_for_key(app, rec.roster.get(member_b58).map(String::as_str).unwrap_or(member_b58)) +} + +pub(crate) async fn set_share_holdings_flag(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> { + let _ledger = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; + rec.share_holdings = on; + save(app, &l) +} + pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result { { let _ledger = ledger::hold(LEDGER_FILE).await; @@ -946,6 +1003,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }); @@ -1197,6 +1255,7 @@ mod tests { roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }